mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(meet): honor auto summarize policy (#4353)
This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user