From b9e25dd425c37a71bb88d166064b980eaf24b655 Mon Sep 17 00:00:00 2001 From: YOMXXX Date: Wed, 1 Jul 2026 00:39:46 +0800 Subject: [PATCH] fix(meet): honor auto summarize policy (#4353) --- src/openhuman/agent_meetings/bus.rs | 181 ++++++++- src/openhuman/agent_meetings/ops.rs | 363 ++++++++++++++++++- src/openhuman/agent_meetings/recent_calls.rs | 2 +- src/openhuman/agent_meetings/schemas.rs | 54 +++ src/openhuman/agent_meetings/summary.rs | 49 ++- src/openhuman/agent_meetings/types.rs | 15 + tests/json_rpc_e2e.rs | 36 ++ 7 files changed, 673 insertions(+), 27 deletions(-) diff --git a/src/openhuman/agent_meetings/bus.rs b/src/openhuman/agent_meetings/bus.rs index 5d635b997..ba3bf19f0 100644 --- a/src/openhuman/agent_meetings/bus.rs +++ b/src/openhuman/agent_meetings/bus.rs @@ -11,7 +11,10 @@ use async_trait::async_trait; use crate::core::event_bus::{DomainEvent, EventHandler, SubscriptionHandle}; -use super::ops::{create_meeting_thread_with_transcript, ingest_backend_meeting_transcript}; +use super::ops::{ + append_summary_prompt_message, create_meeting_thread_with_transcript_with_summary_mode, + ingest_backend_meeting_transcript, SummaryGenerationMode, +}; static MEETING_EVENT_HANDLE: OnceLock = OnceLock::new(); @@ -81,14 +84,44 @@ impl EventHandler for MeetingEventSubscriber { // patched in by step 4 once it's ready. super::recent_calls::record_backend_call_detail(&request_id, turns, None).await; - // 3. Generate the post-call summary once. Shared by the call-detail - // store (step 4) and the meeting thread (step 5) so the - // summarisation LLM call isn't paid for twice. - let generated = super::summary::generate_meeting_summary_bounded( - turns, - correlation_id.as_deref(), - ) - .await; + let (policy, summary_decision) = + match crate::openhuman::config::Config::load_or_init().await { + Ok(config) => { + let policy = config.meet.auto_summarize_policy; + ( + Some(policy), + super::summary::post_call_summary_decision(policy), + ) + } + Err(e) => { + tracing::warn!( + "{LOG_PREFIX} config load failed while resolving summary policy; skipping auto-summary: {e}" + ); + (None, super::summary::PostCallSummaryDecision::Skip) + } + }; + tracing::info!( + policy = ?policy, + decision = ?summary_decision, + request_id = %request_id, + "{LOG_PREFIX} resolved post-call summary policy" + ); + + // 3. Generate the post-call summary only when the user chose + // Always. Ask/Never keep the transcript durable without + // spending an LLM call. + let generated = if matches!( + summary_decision, + super::summary::PostCallSummaryDecision::Generate + ) { + super::summary::generate_meeting_summary_bounded( + turns, + correlation_id.as_deref(), + ) + .await + } else { + None + }; // 4. Upgrade the stored detail with the summary once it's ready. // Skipped when summarisation failed/timed out — the transcript @@ -104,15 +137,30 @@ impl EventHandler for MeetingEventSubscriber { // 5. Create the meeting thread with transcript, reusing the // summary generated in step 3. - if let Err(e) = create_meeting_thread_with_transcript( + match create_meeting_thread_with_transcript_with_summary_mode( turns, *duration_ms, correlation_id.clone(), generated.as_ref(), + SummaryGenerationMode::UseProvidedOnly, ) .await { - tracing::warn!("{LOG_PREFIX} meeting thread creation failed: {e}"); + Ok(thread_id) => { + if matches!( + summary_decision, + super::summary::PostCallSummaryDecision::Prompt + ) { + if let Err(e) = + append_summary_prompt_message(&thread_id, &request_id).await + { + tracing::warn!("{LOG_PREFIX} summary prompt append failed: {e}"); + } + } + } + Err(e) => { + tracing::warn!("{LOG_PREFIX} meeting thread creation failed: {e}"); + } } // Also ingest into memory tree (existing pipeline). @@ -244,6 +292,8 @@ impl EventHandler for MeetingEventSubscriber { mod tests { use super::*; + use crate::openhuman::config::AutoSummarizePolicy; + #[test] fn subscriber_name_is_correct() { let subscriber = MeetingEventSubscriber; @@ -255,4 +305,113 @@ mod tests { let subscriber = MeetingEventSubscriber; assert_eq!(subscriber.domains(), Some(&["agent_meetings"][..])); } + + struct EnvGuard { + previous: Option, + } + + impl EnvGuard { + fn set_workspace(path: &std::path::Path) -> Self { + let previous = std::env::var_os("OPENHUMAN_WORKSPACE"); + std::env::set_var("OPENHUMAN_WORKSPACE", path); + Self { previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => std::env::set_var("OPENHUMAN_WORKSPACE", value), + None => std::env::remove_var("OPENHUMAN_WORKSPACE"), + } + } + } + + async fn save_summary_policy(policy: AutoSummarizePolicy) { + let mut config = crate::openhuman::config::Config::load_or_init() + .await + .expect("config loads in temp workspace"); + config.meet.auto_summarize_policy = policy; + config.meet.ingest_backend_transcripts = false; + config.save().await.expect("config saves policy"); + } + + async fn send_transcript_for_policy(policy: AutoSummarizePolicy, correlation_id: &str) { + save_summary_policy(policy).await; + let event = DomainEvent::BackendMeetTranscript { + turns: vec![crate::core::event_bus::BackendMeetTurn { + role: "user".to_string(), + content: "[00:01] [Alice] ship it".to_string(), + }], + duration_ms: 60_000, + correlation_id: Some(correlation_id.to_string()), + }; + MeetingEventSubscriber.handle(&event).await; + } + + async fn has_summary_prompt_marker(meeting_id: &str) -> bool { + use crate::openhuman::memory::rpc_models::{ConversationMessagesRequest, EmptyRequest}; + + let threads = crate::openhuman::threads::ops::threads_list(EmptyRequest {}) + .await + .expect("list threads") + .value + .data + .expect("threads data") + .threads; + for thread in threads { + let messages = + crate::openhuman::threads::ops::messages_list(ConversationMessagesRequest { + thread_id: thread.id, + }) + .await + .expect("list messages") + .value + .data + .expect("messages data") + .messages; + if messages.iter().any(|message| { + message + .extra_metadata + .get("kind") + .and_then(serde_json::Value::as_str) + == Some("meeting_summary_prompt") + && message + .extra_metadata + .get("meeting_id") + .and_then(serde_json::Value::as_str) + == Some(meeting_id) + }) { + return true; + } + } + false + } + + #[tokio::test] + async fn transcript_policy_respects_never_and_ask_without_summary() { + let _env_lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::TempDir::new().unwrap(); + let _env = EnvGuard::set_workspace(tmp.path()); + + send_transcript_for_policy(AutoSummarizePolicy::Never, "policy-never").await; + let never_detail = crate::openhuman::meet_agent::store::read_detail("policy-never") + .await + .expect("read never detail") + .expect("never detail exists"); + assert!(never_detail.summary.is_none()); + + send_transcript_for_policy(AutoSummarizePolicy::Ask, "policy-ask").await; + let ask_detail = crate::openhuman::meet_agent::store::read_detail("policy-ask") + .await + .expect("read ask detail") + .expect("ask detail exists"); + assert!(ask_detail.summary.is_none()); + assert!( + has_summary_prompt_marker("policy-ask").await, + "Ask policy should append a summary prompt marker" + ); + } } diff --git a/src/openhuman/agent_meetings/ops.rs b/src/openhuman/agent_meetings/ops.rs index 5b9fd25fb..352a3263c 100644 --- a/src/openhuman/agent_meetings/ops.rs +++ b/src/openhuman/agent_meetings/ops.rs @@ -17,7 +17,8 @@ use crate::rpc::RpcOutcome; use super::types::{ BackendMeetHarnessResponseRequest, BackendMeetJoinRequest, BackendMeetJoinResponse, - BackendMeetLeaveRequest, BackendMeetSpeakRequest, MeetingSessionStatus, + BackendMeetLeaveRequest, BackendMeetSpeakRequest, GenerateSummaryRequest, + GenerateSummaryResponse, MeetingSessionStatus, }; const ALLOWED_HOSTS: &[(&str, &str)] = &[ @@ -217,17 +218,25 @@ pub async fn ingest_backend_meeting_transcript( "[agent_meetings] transcript ingested into memory tree" ); - // Create a meeting thread with the transcript for the thread system. This - // path has no pre-generated summary, so the thread generates its own. - if let Err(e) = - create_meeting_thread_with_transcript(&turns, duration_ms, correlation_id, None).await - { - tracing::warn!("[agent_meetings] meeting thread creation failed: {e}"); - } - Ok(()) } +/// Whether thread creation may perform its own summary generation when the +/// caller did not pass a pre-generated summary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SummaryGenerationMode { + /// Preserve the legacy behavior: enrich transcript threads when possible. + GenerateIfMissing, + /// Append only a summary supplied by the caller; never invoke the LLM. + UseProvidedOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ThreadAppendMode { + BestEffort, + Strict, +} + /// Create a conversation thread labelled "Meetings" containing the transcript. /// /// The correlation_id (when present) is embedded in the transcript body as an @@ -242,7 +251,61 @@ pub async fn create_meeting_thread_with_transcript( duration_ms: u64, correlation_id: Option, generated: Option<&super::summary::GeneratedSummary>, -) -> Result<(), String> { +) -> Result { + create_meeting_thread_with_transcript_with_summary_mode( + turns, + duration_ms, + correlation_id, + generated, + SummaryGenerationMode::GenerateIfMissing, + ) + .await +} + +pub async fn create_meeting_thread_with_transcript_with_summary_mode( + turns: &[BackendMeetTurn], + duration_ms: u64, + correlation_id: Option, + generated: Option<&super::summary::GeneratedSummary>, + summary_mode: SummaryGenerationMode, +) -> Result { + create_meeting_thread_with_transcript_inner( + turns, + duration_ms, + correlation_id, + generated, + summary_mode, + ThreadAppendMode::BestEffort, + ) + .await +} + +async fn create_meeting_thread_with_transcript_with_summary_mode_strict( + turns: &[BackendMeetTurn], + duration_ms: u64, + correlation_id: Option, + generated: Option<&super::summary::GeneratedSummary>, + summary_mode: SummaryGenerationMode, +) -> Result { + create_meeting_thread_with_transcript_inner( + turns, + duration_ms, + correlation_id, + generated, + summary_mode, + ThreadAppendMode::Strict, + ) + .await +} + +async fn create_meeting_thread_with_transcript_inner( + turns: &[BackendMeetTurn], + duration_ms: u64, + correlation_id: Option, + generated: Option<&super::summary::GeneratedSummary>, + summary_mode: SummaryGenerationMode, + append_mode: ThreadAppendMode, +) -> Result { use crate::openhuman::memory::{ AppendConversationMessageRequest, ConversationMessageRecord, CreateConversationThreadRequest, UpdateConversationThreadTitleRequest, @@ -250,7 +313,9 @@ pub async fn create_meeting_thread_with_transcript( use crate::openhuman::threads::ops; if turns.is_empty() { - return Ok(()); + return Err( + "[agent_meetings] cannot create a meeting thread without transcript turns".to_string(), + ); } // Format the transcript body first — this is the durable artifact and must @@ -311,9 +376,13 @@ pub async fn create_meeting_thread_with_transcript( message: msg, }; if let Err(e) = ops::message_append(append_req).await { + let message = format!("[agent_meetings] failed to append transcript message: {e}"); + if matches!(append_mode, ThreadAppendMode::Strict) { + return Err(message); + } tracing::warn!( thread_id = %thread_id, - "[agent_meetings] failed to append transcript message: {e}" + "{message}" ); } @@ -322,7 +391,9 @@ pub async fn create_meeting_thread_with_transcript( // and this thread); otherwise generate one here, bounded so a slow/flaky // provider can never dominate the path. Any failure or timeout leaves the // plain-transcript thread untouched. - let owned_generated = if generated.is_none() { + let owned_generated = if generated.is_none() + && matches!(summary_mode, SummaryGenerationMode::GenerateIfMissing) + { super::summary::generate_meeting_summary_bounded(turns, correlation_id.as_deref()).await } else { None @@ -363,9 +434,13 @@ pub async fn create_meeting_thread_with_transcript( message: summary_msg, }; if let Err(e) = ops::message_append(summary_req).await { + let message = format!("[agent_meetings] failed to append summary message: {e}"); + if matches!(append_mode, ThreadAppendMode::Strict) { + return Err(message); + } tracing::warn!( thread_id = %thread_id, - "[agent_meetings] failed to append summary message: {e}" + "{message}" ); } } @@ -376,9 +451,140 @@ pub async fn create_meeting_thread_with_transcript( summarized = generated.is_some(), "[agent_meetings] meeting thread created" ); + Ok(thread_id) +} + +pub async fn append_summary_prompt_message( + thread_id: &str, + meeting_id: &str, +) -> Result<(), String> { + use crate::openhuman::memory::{AppendConversationMessageRequest, ConversationMessageRecord}; + use crate::openhuman::threads::ops; + + let content = super::summary::format_summary_prompt_markdown(meeting_id); + let req = AppendConversationMessageRequest { + thread_id: thread_id.to_string(), + message: ConversationMessageRecord { + id: uuid::Uuid::new_v4().to_string(), + content, + message_type: "system".to_string(), + extra_metadata: serde_json::json!({ + "kind": "meeting_summary_prompt", + "meeting_id": meeting_id, + }), + sender: "system".to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + }, + }; + ops::message_append(req) + .await + .map_err(|e| format!("[agent_meetings] append summary prompt failed: {e}"))?; + tracing::info!( + thread_id = %thread_id, + meeting_id = %meeting_id, + "[agent_meetings] summary prompt appended" + ); Ok(()) } +fn detail_transcript_to_turns( + detail: &crate::openhuman::meet_agent::store::MeetCallDetail, +) -> Vec { + detail + .transcript + .iter() + .filter(|line| !line.content.trim().is_empty()) + .map(|line| BackendMeetTurn { + role: if line.role.eq_ignore_ascii_case("assistant") { + "assistant".to_string() + } else { + "user".to_string() + }, + content: line.content.trim().to_string(), + }) + .collect() +} + +async fn recorded_meeting_duration_ms(meeting_id: &str) -> Result { + let records = crate::openhuman::meet_agent::store::read_recent( + crate::openhuman::meet_agent::store::MAX_RECENT_CALLS, + ) + .await?; + let Some(record) = records + .into_iter() + .find(|record| record.request_id == meeting_id) + else { + tracing::warn!( + meeting_id = %meeting_id, + "[agent_meetings] no recent call row found for manual summary duration" + ); + return Ok(0); + }; + + let wall_ms = record.ended_at_ms.saturating_sub(record.started_at_ms); + let audio_ms = seconds_to_millis(record.listened_seconds + record.spoken_seconds); + Ok(wall_ms.max(audio_ms)) +} + +fn seconds_to_millis(seconds: f32) -> u64 { + if !seconds.is_finite() || seconds <= 0.0 { + return 0; + } + let millis = (seconds as f64 * 1000.0).round(); + if millis >= u64::MAX as f64 { + u64::MAX + } else { + millis as u64 + } +} + +pub async fn handle_generate_summary(params: Map) -> Result { + let req: GenerateSummaryRequest = serde_json::from_value(Value::Object(params)) + .map_err(|e| format!("[agent_meetings] invalid generate_summary params: {e}"))?; + let meeting_id = req.meeting_id.trim(); + if meeting_id.is_empty() { + return Err("[agent_meetings] meeting_id must not be empty".to_string()); + } + + tracing::info!( + meeting_id = %meeting_id, + "[agent_meetings] manual summary requested" + ); + + let detail = crate::openhuman::meet_agent::store::read_detail(meeting_id) + .await? + .ok_or_else(|| format!("[agent_meetings] no recorded meeting detail for {meeting_id}"))?; + let turns = detail_transcript_to_turns(&detail); + if turns.is_empty() { + return Err(format!( + "[agent_meetings] meeting {meeting_id} has no transcript lines to summarize" + )); + } + + let generated = super::summary::generate_meeting_summary_bounded(&turns, Some(meeting_id)) + .await + .ok_or_else(|| format!("[agent_meetings] summary generation failed for {meeting_id}"))?; + + let updated = super::recent_calls::build_detail(meeting_id, &turns, Some(&generated)); + crate::openhuman::meet_agent::store::write_detail(&updated).await?; + let duration_ms = recorded_meeting_duration_ms(meeting_id).await?; + + let thread_id = create_meeting_thread_with_transcript_with_summary_mode_strict( + &turns, + duration_ms, + Some(meeting_id.to_string()), + Some(&generated), + SummaryGenerationMode::UseProvidedOnly, + ) + .await?; + + serde_json::to_value(GenerateSummaryResponse { + ok: true, + thread_id, + }) + .map_err(|e| format!("[agent_meetings] serialize generate_summary response: {e}")) +} + // --------------------------------------------------------------------------- // Canonical URL / host helpers (single source of truth) // @@ -1747,4 +1953,133 @@ mod tests { "auto" ); } + + struct CountingProvider { + calls: std::sync::Arc, + } + + #[async_trait::async_trait] + impl crate::openhuman::inference::provider::Provider for CountingProvider { + async fn chat_with_system( + &self, + _system_prompt: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok("{\"label\":\"Policy Test\",\"headline\":\"Done\",\"key_points\":[],\"action_items\":[]}" + .to_string()) + } + } + + struct EnvGuard { + previous: Option, + } + + impl EnvGuard { + fn set_workspace(path: &std::path::Path) -> Self { + let previous = std::env::var_os("OPENHUMAN_WORKSPACE"); + std::env::set_var("OPENHUMAN_WORKSPACE", path); + Self { previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => std::env::set_var("OPENHUMAN_WORKSPACE", value), + None => std::env::remove_var("OPENHUMAN_WORKSPACE"), + } + } + } + + #[tokio::test] + async fn recorded_meeting_duration_uses_recent_call_row() { + let _env_lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::TempDir::new().unwrap(); + let _env = EnvGuard::set_workspace(tmp.path()); + + crate::openhuman::meet_agent::store::append_record( + &crate::openhuman::meet_agent::store::MeetCallRecord { + request_id: "duration-call".to_string(), + meet_url: "https://meet.google.com/abc-defg-hij".to_string(), + bot_display_name: "OpenHuman".to_string(), + owner_display_name: "Alice".to_string(), + started_at_ms: 10_000, + ended_at_ms: 130_000, + listened_seconds: 30.0, + spoken_seconds: 2.0, + turn_count: 1, + participants: vec!["Alice".to_string()], + }, + ) + .await + .expect("record call row"); + + let duration_ms = recorded_meeting_duration_ms("duration-call") + .await + .expect("duration reads from recent calls"); + + assert_eq!(duration_ms, 120_000); + } + + #[tokio::test] + async fn thread_creation_rejects_empty_transcript_turns() { + let err = create_meeting_thread_with_transcript_with_summary_mode( + &[], + 60_000, + Some("empty-transcript".to_string()), + None, + SummaryGenerationMode::UseProvidedOnly, + ) + .await + .expect_err("empty transcript should not return a successful empty thread ID"); + + assert!( + err.contains("without transcript turns"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn thread_creation_use_provided_only_does_not_generate_missing_summary() { + let _env_lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::TempDir::new().unwrap(); + let _env = EnvGuard::set_workspace(tmp.path()); + + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let _provider = + crate::openhuman::inference::provider::factory::test_provider_override::install( + std::sync::Arc::new(CountingProvider { + calls: calls.clone(), + }), + ); + + let turns = vec![BackendMeetTurn { + role: "user".to_string(), + content: "[00:01] [Alice] ship it".to_string(), + }]; + + let thread_id = create_meeting_thread_with_transcript_with_summary_mode( + &turns, + 60_000, + Some("policy-never".to_string()), + None, + SummaryGenerationMode::UseProvidedOnly, + ) + .await + .expect("thread created without generated summary"); + + assert!(!thread_id.is_empty()); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "UseProvidedOnly must not call the summarization provider" + ); + } } diff --git a/src/openhuman/agent_meetings/recent_calls.rs b/src/openhuman/agent_meetings/recent_calls.rs index 63ccfac4f..90d1b159d 100644 --- a/src/openhuman/agent_meetings/recent_calls.rs +++ b/src/openhuman/agent_meetings/recent_calls.rs @@ -150,7 +150,7 @@ pub async fn record_backend_call_detail( /// [`MeetCallDetail`]. Pure (no I/O) so the field-mapping is unit-testable. /// Blank turns are dropped so the stored transcript matches what the summary /// was generated from. -fn build_detail( +pub(crate) fn build_detail( request_id: &str, turns: &[BackendMeetTurn], generated: Option<&GeneratedSummary>, diff --git a/src/openhuman/agent_meetings/schemas.rs b/src/openhuman/agent_meetings/schemas.rs index 6ab33927b..413134c47 100644 --- a/src/openhuman/agent_meetings/schemas.rs +++ b/src/openhuman/agent_meetings/schemas.rs @@ -56,6 +56,11 @@ const DEFS: &[BackendMeetControllerDef] = &[ schema: schema_get_event_policies, handler: handle_get_event_policies_wrap, }, + BackendMeetControllerDef { + function: "generate_summary", + schema: schema_generate_summary, + handler: handle_generate_summary_wrap, + }, ]; pub fn all_controller_schemas() -> Vec { @@ -412,6 +417,39 @@ fn handle_get_event_policies_wrap(params: Map) -> ControllerFutur Box::pin(async move { super::ops::handle_get_event_policies(params).await }) } +fn schema_generate_summary() -> ControllerSchema { + ControllerSchema { + namespace: "agent_meetings", + function: "generate_summary", + description: "Generate a post-call summary for a recorded meeting transcript and create a \ + summary thread on demand. Used by Ask/manual flows.", + inputs: vec![FieldSchema { + name: "meeting_id", + ty: TypeSchema::String, + comment: "Recorded meeting id / recent-call request_id to summarize.", + required: true, + }], + outputs: vec![ + FieldSchema { + name: "ok", + ty: TypeSchema::Bool, + comment: "True when summary generation and thread creation succeeded.", + required: true, + }, + FieldSchema { + name: "thread_id", + ty: TypeSchema::String, + comment: "Thread containing the transcript and generated summary.", + required: true, + }, + ], + } +} + +fn handle_generate_summary_wrap(params: Map) -> ControllerFuture { + Box::pin(async move { super::ops::handle_generate_summary(params).await }) +} + #[cfg(test)] mod tests { use super::*; @@ -438,6 +476,7 @@ mod tests { "list_upcoming", "set_event_policy", "get_event_policies", + "generate_summary", ] ); } @@ -448,4 +487,19 @@ mod tests { assert_eq!(s.namespace, "agent_meetings"); assert_eq!(s.function, "join"); } + + #[test] + fn generate_summary_schema_is_agent_meetings_rpc() { + let s = schema_generate_summary(); + assert_eq!(s.namespace, "agent_meetings"); + assert_eq!(s.function, "generate_summary"); + assert!(s + .inputs + .iter() + .any(|f| f.name == "meeting_id" && f.required)); + assert!(s + .outputs + .iter() + .any(|f| f.name == "thread_id" && f.required)); + } } diff --git a/src/openhuman/agent_meetings/summary.rs b/src/openhuman/agent_meetings/summary.rs index c6b1913e0..3cf6fc10e 100644 --- a/src/openhuman/agent_meetings/summary.rs +++ b/src/openhuman/agent_meetings/summary.rs @@ -14,7 +14,7 @@ use serde::Deserialize; use crate::core::event_bus::BackendMeetTurn; -use crate::openhuman::config::Config; +use crate::openhuman::config::{AutoSummarizePolicy, Config}; use crate::openhuman::inference::provider::create_chat_provider; use super::types::{ActionItem, ActionItemKind, MeetingSummary}; @@ -98,6 +98,35 @@ pub struct GeneratedSummary { pub summary: MeetingSummary, } +/// Call-end action derived from the user's post-call summary policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PostCallSummaryDecision { + /// Generate immediately after the transcript is recorded. + Generate, + /// Leave the transcript intact and surface an explicit prompt instead. + Prompt, + /// Do not generate or prompt automatically. + Skip, +} + +/// Map the persisted setting to the call-end behavior. Kept pure so the bus +/// and manual paths can share the policy contract without duplicating matches. +pub fn post_call_summary_decision(policy: AutoSummarizePolicy) -> PostCallSummaryDecision { + match policy { + AutoSummarizePolicy::Always => PostCallSummaryDecision::Generate, + AutoSummarizePolicy::Ask => PostCallSummaryDecision::Prompt, + AutoSummarizePolicy::Never => PostCallSummaryDecision::Skip, + } +} + +/// Render the lightweight Ask-mode prompt appended to the meeting thread. +pub fn format_summary_prompt_markdown(meeting_id: &str) -> String { + format!( + "## Meeting ended\n\nWant me to summarize this call? Use the Generate summary action for meeting `{}`.", + meeting_id.trim() + ) +} + /// Generate a structured summary + context label from a finished call's turns. /// /// `correlation_id` (when present) becomes the summary's `meeting_id`. @@ -585,4 +614,22 @@ mod tests { assert!(!md.contains("### Action items")); assert!(!md.contains("**Topic:**")); } + + #[test] + fn post_call_summary_decision_maps_user_policy() { + use crate::openhuman::config::AutoSummarizePolicy; + + assert_eq!( + post_call_summary_decision(AutoSummarizePolicy::Always), + PostCallSummaryDecision::Generate + ); + assert_eq!( + post_call_summary_decision(AutoSummarizePolicy::Ask), + PostCallSummaryDecision::Prompt + ); + assert_eq!( + post_call_summary_decision(AutoSummarizePolicy::Never), + PostCallSummaryDecision::Skip + ); + } } diff --git a/src/openhuman/agent_meetings/types.rs b/src/openhuman/agent_meetings/types.rs index b4d3f7a5e..c6ba09204 100644 --- a/src/openhuman/agent_meetings/types.rs +++ b/src/openhuman/agent_meetings/types.rs @@ -153,6 +153,21 @@ pub struct BackendMeetSpeakRequest { pub correlation_id: Option, } +/// Inputs to `openhuman.agent_meetings_generate_summary`. +#[derive(Debug, Clone, Deserialize)] +pub struct GenerateSummaryRequest { + /// Meeting/call id. For backend Meet calls this is the request_id used by + /// the recent-calls detail store. + pub meeting_id: String, +} + +/// Outputs from `openhuman.agent_meetings_generate_summary`. +#[derive(Debug, Clone, Serialize)] +pub struct GenerateSummaryResponse { + pub ok: bool, + pub thread_id: String, +} + // --------------------------------------------------------------------------- // meet_list_upcoming RPC types // --------------------------------------------------------------------------- diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 1eaadced4..e42a04b81 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -13793,3 +13793,39 @@ async fn json_rpc_meet_event_policy_round_trip() { rpc_join.abort(); } + +/// `openhuman.agent_meetings_generate_summary` — verifies the manual summary +/// RPC is registered and reaches handler validation without needing an LLM. +#[tokio::test] +async fn json_rpc_agent_meetings_generate_summary_rejects_empty_meeting_id() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + + write_min_config(&openhuman_home, "http://127.0.0.1:9"); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{rpc_addr}"); + + let resp = post_json_rpc( + &rpc_base, + 9310, + "openhuman.agent_meetings_generate_summary", + json!({ "meeting_id": "" }), + ) + .await; + let err = assert_jsonrpc_error(&resp, "agent_meetings_generate_summary empty meeting_id"); + let message = err.get("message").and_then(Value::as_str).unwrap_or(""); + assert!( + message.contains("meeting_id must not be empty"), + "expected generate_summary validation error, got: {err}" + ); + + rpc_join.abort(); +}