fix(meet): honor auto summarize policy (#4353)

This commit is contained in:
YOMXXX
2026-06-30 22:09:46 +05:30
committed by GitHub
parent 5dfdd7ee2e
commit b9e25dd425
7 changed files with 673 additions and 27 deletions
+170 -11
View File
@@ -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<SubscriptionHandle> = 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<std::ffi::OsString>,
}
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"
);
}
}
+349 -14
View File
@@ -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<String>,
generated: Option<&super::summary::GeneratedSummary>,
) -> Result<(), String> {
) -> Result<String, String> {
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<String>,
generated: Option<&super::summary::GeneratedSummary>,
summary_mode: SummaryGenerationMode,
) -> Result<String, String> {
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<String>,
generated: Option<&super::summary::GeneratedSummary>,
summary_mode: SummaryGenerationMode,
) -> Result<String, String> {
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<String>,
generated: Option<&super::summary::GeneratedSummary>,
summary_mode: SummaryGenerationMode,
append_mode: ThreadAppendMode,
) -> Result<String, String> {
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<BackendMeetTurn> {
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<u64, String> {
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<String, Value>) -> Result<Value, String> {
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<std::sync::atomic::AtomicUsize>,
}
#[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<String> {
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<std::ffi::OsString>,
}
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"
);
}
}
+1 -1
View File
@@ -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>,
+54
View File
@@ -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<ControllerSchema> {
@@ -412,6 +417,39 @@ fn handle_get_event_policies_wrap(params: Map<String, Value>) -> 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<String, Value>) -> 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));
}
}
+48 -1
View File
@@ -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
);
}
}
+15
View File
@@ -153,6 +153,21 @@ pub struct BackendMeetSpeakRequest {
pub correlation_id: Option<String>,
}
/// 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
// ---------------------------------------------------------------------------
+36
View File
@@ -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();
}