feat(agent_meetings): post-call meeting summary + context label (#3709)

This commit is contained in:
YellowSnnowmann
2026-06-17 17:25:35 +05:30
committed by GitHub
parent 5265525761
commit decfc5b89b
3 changed files with 640 additions and 4 deletions
+1
View File
@@ -21,6 +21,7 @@ pub mod ops;
pub mod recent_calls;
pub mod schemas;
pub mod store;
pub mod summary;
pub mod types;
pub use schemas::{
+88 -4
View File
@@ -25,6 +25,13 @@ const ALLOWED_HOSTS: &[(&str, &str)] = &[
("webex.com", "webex"),
];
/// Upper bound on the best-effort post-call summarisation call. The provider
/// has a 120s per-request timeout and the reliable wrapper retries transient
/// failures with backoff, so without a bound a slow/flaky `summarization`
/// provider could stall the post-call persistence pipeline for minutes. On
/// timeout we fall back to the plain-transcript thread.
const SUMMARY_GENERATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
fn transcript_turns_to_chat_batch(
turns: &[BackendMeetTurn],
duration_ms: u64,
@@ -124,7 +131,7 @@ pub async fn create_meeting_thread_with_transcript(
) -> Result<(), String> {
use crate::openhuman::memory::{
AppendConversationMessageRequest, ConversationMessageRecord,
CreateConversationThreadRequest,
CreateConversationThreadRequest, UpdateConversationThreadTitleRequest,
};
use crate::openhuman::threads::ops;
@@ -132,7 +139,8 @@ pub async fn create_meeting_thread_with_transcript(
return Ok(());
}
// Format transcript body.
// Format the transcript body first — this is the durable artifact and must
// not depend on (or wait on) the summarisation LLM call.
let mut body = String::new();
let duration_min = duration_ms / 60_000;
body.push_str(&format!("Duration: {duration_min} min\n\n"));
@@ -152,7 +160,13 @@ pub async fn create_meeting_thread_with_transcript(
body.push_str(&format!("**{role_label}**: {text}\n\n"));
}
// 1. Create thread with labels: ["Meetings"]
// 1. Create the thread under the shared "Meetings" label and append the
// transcript *before* any LLM work, so thread/transcript persistence (and
// the memory-tree ingest that runs after this returns) never gate on
// summarisation. The per-meeting topic is applied later as the thread
// *title* only — adding it as a second label would accrue a unique,
// never-reused label per call and pollute the shared label taxonomy,
// while the title already disambiguates calls in the list.
let create_req = CreateConversationThreadRequest {
labels: Some(vec!["Meetings".to_string()]),
personality_id: None,
@@ -168,7 +182,8 @@ pub async fn create_meeting_thread_with_transcript(
.id
.clone();
// 2. Append the transcript as a message.
// 2. Append the transcript as a message. The durable record is now complete
// regardless of whether summarisation succeeds below.
let msg = ConversationMessageRecord {
id: uuid::Uuid::new_v4().to_string(),
content: body,
@@ -188,9 +203,78 @@ pub async fn create_meeting_thread_with_transcript(
);
}
// 3. Best-effort enrichment: generate a structured post-call summary + short
// context label. Bounded by `SUMMARY_GENERATION_TIMEOUT` so a slow/flaky
// provider can never dominate the path. Any failure or timeout logs a
// warning and leaves the plain-transcript thread untouched.
let generated = match tokio::time::timeout(
SUMMARY_GENERATION_TIMEOUT,
super::summary::generate_meeting_summary(turns, correlation_id.as_deref()),
)
.await
{
Ok(Ok(g)) => Some(g),
Ok(Err(e)) => {
tracing::warn!("[agent_meetings] summary generation failed: {e}");
None
}
Err(_) => {
tracing::warn!(
timeout_secs = SUMMARY_GENERATION_TIMEOUT.as_secs(),
"[agent_meetings] summary generation timed out"
);
None
}
};
// 3a. Title the thread with the context label (e.g. "Q3 Roadmap") so the
// meeting is identifiable in the list (default title is "Chat <date>").
let context_label = generated
.as_ref()
.map(|g| g.label.trim())
.filter(|l| !l.is_empty());
if let Some(title) = context_label {
if let Err(e) = ops::thread_update_title(UpdateConversationThreadTitleRequest {
thread_id: thread_id.clone(),
title: title.to_string(),
})
.await
{
tracing::warn!(
thread_id = %thread_id,
"[agent_meetings] failed to set meeting thread title: {e}"
);
}
}
// 3b. Append the structured summary as a closing message, so the thread ends
// with the headline / key points / action items.
if let Some(g) = &generated {
let summary_body = super::summary::format_summary_markdown(&g.summary, &g.label);
let summary_msg = ConversationMessageRecord {
id: uuid::Uuid::new_v4().to_string(),
content: summary_body,
message_type: "system".to_string(),
extra_metadata: serde_json::Value::Null,
sender: "system".to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
};
let summary_req = AppendConversationMessageRequest {
thread_id: thread_id.clone(),
message: summary_msg,
};
if let Err(e) = ops::message_append(summary_req).await {
tracing::warn!(
thread_id = %thread_id,
"[agent_meetings] failed to append summary message: {e}"
);
}
}
tracing::info!(
thread_id = %thread_id,
turn_count = turns.len(),
summarized = generated.is_some(),
"[agent_meetings] meeting thread created"
);
Ok(())
+551
View File
@@ -0,0 +1,551 @@
//! Post-call meeting summarisation.
//!
//! After a Google Meet call ends, the raw transcript is turned into a
//! structured [`MeetingSummary`] (headline + key points + action items) **and**
//! a short context label (e.g. "Q3 Planning") via a single LLM call. The
//! summary is appended to the meeting thread; the label becomes the thread
//! *title* so each call is distinguishable from the others in the shared
//! "Meetings" group (it is not added as a second label, which would pollute the
//! shared label taxonomy with a unique, never-reused entry per call).
//!
//! Generation is best-effort: callers treat a failure as "no summary" and fall
//! back to the plain transcript thread rather than aborting thread creation.
use serde::Deserialize;
use crate::core::event_bus::BackendMeetTurn;
use crate::openhuman::config::Config;
use crate::openhuman::inference::provider::create_chat_provider;
use super::types::{ActionItem, ActionItemKind, MeetingSummary};
const LOG_PREFIX: &str = "[agent_meetings::summary]";
/// Cap on the transcript size (in **bytes**, matching `str::len`) fed to the
/// model so a marathon call doesn't blow the context window. The tail is kept
/// (most recent) — summaries care most about conclusions and action items,
/// which land at the end. For multibyte (e.g. CJK) transcripts the effective
/// character budget is correspondingly smaller.
const MAX_TRANSCRIPT_BYTES: usize = 24_000;
/// Workload role used to resolve the summarisation provider/model from config.
const SUMMARIZATION_ROLE: &str = "summarization";
const SYSTEM_PROMPT: &str = "You summarise meeting transcripts. The transcript lines are \
prefixed with the speaker role (\"Participant\" or \"Assistant\"). Reply with ONLY a single \
JSON object — no prose, no markdown fences — with exactly these keys:\n\
- \"label\": a short topic label for the meeting, 2-4 words, Title Case (e.g. \"Q3 Roadmap\", \
\"Hiring Sync\"). This is used as the thread title, so make it specific to THIS meeting.\n\
- \"headline\": one sentence (<= 160 chars) capturing the meeting's outcome or purpose.\n\
- \"key_points\": an array of 2-6 short strings, the most important discussion points or decisions.\n\
- \"action_items\": an array of objects, each {\"description\": string, \"kind\": \"executable\" \
or \"advisory\", \"tool_name\": string-or-null, \"assignee\": string-or-null}. Use \"executable\" \
only when a connected tool could carry it out; otherwise \"advisory\". Use [] when there are none.\n\
Base every field strictly on the transcript. Do not invent attendees, decisions, or actions.";
/// LLM-shaped JSON the summariser is asked to return.
#[derive(Debug, Deserialize)]
struct RawSummary {
#[serde(default)]
label: String,
#[serde(default)]
headline: String,
#[serde(default)]
key_points: Vec<String>,
#[serde(default)]
action_items: Vec<RawActionItem>,
}
#[derive(Debug, Deserialize)]
struct RawActionItem {
#[serde(default)]
description: String,
#[serde(default)]
kind: Option<String>,
#[serde(default)]
tool_name: Option<String>,
#[serde(default)]
assignee: Option<String>,
}
impl From<RawActionItem> for ActionItem {
fn from(raw: RawActionItem) -> Self {
let kind = match raw
.kind
.as_deref()
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("executable") => ActionItemKind::Executable,
_ => ActionItemKind::Advisory,
};
ActionItem {
description: raw.description.trim().to_string(),
kind,
tool_name: raw.tool_name.and_then(non_empty),
assignee: raw.assignee.and_then(non_empty),
}
}
}
/// A generated summary plus the short context label for the meeting thread.
#[derive(Debug)]
pub struct GeneratedSummary {
/// Short topic label, e.g. "Q3 Roadmap". May be empty if the model
/// returned nothing usable — callers should skip the label in that case.
pub label: String,
pub summary: MeetingSummary,
}
/// Generate a structured summary + context label from a finished call's turns.
///
/// `correlation_id` (when present) becomes the summary's `meeting_id`.
pub async fn generate_meeting_summary(
turns: &[BackendMeetTurn],
correlation_id: Option<&str>,
) -> Result<GeneratedSummary, String> {
let transcript = render_transcript(turns);
if transcript.trim().is_empty() {
return Err("transcript had no usable turns".to_string());
}
let config = Config::load_or_init()
.await
.map_err(|e| format!("config load failed: {e}"))?;
let (provider, model) = create_chat_provider(SUMMARIZATION_ROLE, &config)
.map_err(|e| format!("summarisation provider init failed: {e}"))?;
let reply = provider
.chat_with_system(Some(SYSTEM_PROMPT), &transcript, &model, 0.3)
.await
.map_err(|e| format!("summarisation LLM call failed: {e}"))?;
let raw = parse_summary_json(&reply)
.ok_or_else(|| "model reply did not contain parseable summary JSON".to_string())?;
let now_ms = chrono::Utc::now().timestamp_millis().max(0) as u64;
let summary = MeetingSummary {
meeting_id: correlation_id.unwrap_or("unknown").to_string(),
headline: raw.headline.trim().to_string(),
key_points: raw
.key_points
.into_iter()
.filter_map(|p| non_empty(p.trim().to_string()))
.collect(),
action_items: raw
.action_items
.into_iter()
.map(ActionItem::from)
.filter(|a| !a.description.is_empty())
.collect(),
generated_at_ms: now_ms,
};
Ok(GeneratedSummary {
label: sanitize_label(&raw.label),
summary,
})
}
/// Render a [`MeetingSummary`] as a markdown body for the thread message.
pub fn format_summary_markdown(summary: &MeetingSummary, label: &str) -> String {
let mut md = String::from("## Meeting summary\n\n");
if !label.is_empty() {
md.push_str(&format!("**Topic:** {label}\n\n"));
}
if !summary.headline.is_empty() {
md.push_str(&format!("{}\n\n", summary.headline));
}
if !summary.key_points.is_empty() {
md.push_str("### Key points\n\n");
for point in &summary.key_points {
md.push_str(&format!("- {point}\n"));
}
md.push('\n');
}
if !summary.action_items.is_empty() {
md.push_str("### Action items\n\n");
for item in &summary.action_items {
let mut line = format!("- {}", item.description);
if let Some(assignee) = &item.assignee {
line.push_str(&format!(" _(owner: {assignee})_"));
}
if matches!(item.kind, ActionItemKind::Executable) {
match &item.tool_name {
Some(tool) => line.push_str(&format!(" — actionable via `{tool}`")),
None => line.push_str(" — actionable"),
}
}
md.push_str(&line);
md.push('\n');
}
md.push('\n');
}
md
}
/// Flatten turns into `Role: text` lines, capped to the tail when very long.
fn render_transcript(turns: &[BackendMeetTurn]) -> String {
let mut out = String::new();
for turn in turns {
let text = turn.content.trim();
if text.is_empty() {
continue;
}
let role = if turn.role.eq_ignore_ascii_case("assistant") {
"Assistant"
} else {
"Participant"
};
out.push_str(role);
out.push_str(": ");
out.push_str(text);
out.push('\n');
}
cap_tail(&out, MAX_TRANSCRIPT_BYTES)
}
/// Keep the last `max` bytes of `s`, trimmed forward to a char + line boundary.
fn cap_tail(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut idx = s.len() - max;
while idx < s.len() && !s.is_char_boundary(idx) {
idx += 1;
}
// Advance to the start of the next line so we don't cut mid-sentence.
let idx = s[idx..].find('\n').map(|i| idx + i + 1).unwrap_or(idx);
format!("…(earlier transcript truncated)…\n{}", &s[idx..])
}
/// Parse the model reply as `RawSummary`, tolerating fences / surrounding prose.
fn parse_summary_json(reply: &str) -> Option<RawSummary> {
if let Ok(parsed) = serde_json::from_str::<RawSummary>(reply.trim()) {
return Some(parsed);
}
let slice = extract_json_object(reply)?;
serde_json::from_str::<RawSummary>(&slice).ok()
}
/// Return the last top-level `{ … }` object in `text`, ignoring braces inside
/// string literals. Handles models that wrap JSON in prose or ```json fences.
fn extract_json_object(text: &str) -> Option<String> {
let bytes = text.as_bytes();
let mut depth = 0usize;
let mut start: Option<usize> = None;
let mut best: Option<(usize, usize)> = None;
let mut in_string = false;
let mut escape = false;
for (i, &b) in bytes.iter().enumerate() {
if in_string {
if escape {
escape = false;
} else if b == b'\\' {
escape = true;
} else if b == b'"' {
in_string = false;
}
continue;
}
match b {
b'"' => in_string = true,
b'{' => {
if depth == 0 {
start = Some(i);
}
depth += 1;
}
b'}' if depth > 0 => {
depth -= 1;
if depth == 0 {
if let Some(s) = start {
best = Some((s, i + 1));
}
}
}
_ => {}
}
}
best.map(|(s, e)| text[s..e].to_string())
}
/// Collapse the model's label to a single trimmed line, dequoted and length-capped.
fn sanitize_label(raw: &str) -> String {
let line = raw
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim();
let cleaned = line
.trim_matches(|c: char| matches!(c, '"' | '\'' | '`'))
.trim();
cleaned.chars().take(40).collect()
}
fn non_empty(s: String) -> Option<String> {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn turn(role: &str, content: &str) -> BackendMeetTurn {
BackendMeetTurn {
role: role.to_string(),
content: content.to_string(),
}
}
#[test]
fn render_transcript_labels_roles_and_skips_blanks() {
let turns = vec![
turn("user", "Let's ship Friday."),
turn("assistant", "Noted."),
turn("user", " "),
];
let rendered = render_transcript(&turns);
assert!(rendered.contains("Participant: Let's ship Friday."));
assert!(rendered.contains("Assistant: Noted."));
// The blank turn produces no extra "Participant:" line.
assert_eq!(rendered.matches("Participant:").count(), 1);
}
#[test]
fn render_transcript_truncates_long_input_and_keeps_tail() {
// Build a transcript well over the cap so the head is dropped.
let filler: Vec<BackendMeetTurn> = (0..4_000)
.map(|i| turn("user", &format!("padding line {i}")))
.collect();
let mut turns = filler;
turns.push(turn("assistant", "FINAL DECISION: ship Friday."));
let rendered = render_transcript(&turns);
assert!(rendered.len() <= MAX_TRANSCRIPT_BYTES + 64);
assert!(rendered.starts_with("…(earlier transcript truncated)…\n"));
// The most recent turn (where decisions land) survives truncation.
assert!(rendered.contains("FINAL DECISION: ship Friday."));
}
#[test]
fn cap_tail_keeps_tail_on_char_boundary() {
let s = "héllo\nwörld\nfoo\nbar\n";
let capped = cap_tail(s, 8);
assert!(capped.starts_with("…(earlier transcript truncated)…\n"));
assert!(capped.ends_with("bar\n"));
}
#[test]
fn cap_tail_returns_input_unchanged_when_within_cap() {
let s = "short transcript\n";
assert_eq!(cap_tail(s, 1_000), s);
}
#[tokio::test]
async fn generate_meeting_summary_rejects_empty_transcript() {
// All turns blank → nothing to summarise. This guards the early-return
// path that runs *before* any config/provider work, so it stays a pure,
// network-free unit test.
let turns = vec![turn("user", " "), turn("assistant", "\n")];
let err = generate_meeting_summary(&turns, Some("m1"))
.await
.expect_err("blank transcript should error");
assert!(err.contains("no usable turns"), "unexpected error: {err}");
}
/// Scripted provider that returns a fixed reply, so the full
/// generate → parse → map path can be exercised without any network.
struct ScriptedProvider {
reply: String,
}
#[async_trait::async_trait]
impl crate::openhuman::inference::provider::Provider for ScriptedProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok(self.reply.clone())
}
}
#[tokio::test]
async fn generate_meeting_summary_parses_and_maps_provider_reply() {
// Inject a scripted provider via the factory test override so
// `create_chat_provider` hands back our mock instead of resolving a
// real provider — the call stays network-free. The guard clears the
// override on drop.
let reply = "Here you go:\n```json\n{\"label\":\"Q3 Roadmap\",\
\"headline\":\"Agreed to ship Friday.\",\
\"key_points\":[\"Ship Friday\",\"QA owns sign-off\"],\
\"action_items\":[{\"description\":\"Send release notes\",\
\"kind\":\"executable\",\"tool_name\":\"gmail\",\"assignee\":\"Sam\"},\
{\"description\":\"Book retro\",\"kind\":\"advisory\",\
\"tool_name\":null,\"assignee\":null}]}\n```";
let _guard =
crate::openhuman::inference::provider::factory::test_provider_override::install(
std::sync::Arc::new(ScriptedProvider {
reply: reply.to_string(),
}),
);
let turns = vec![
turn("user", "Let's ship Friday."),
turn("assistant", "Noted, QA owns sign-off."),
];
let generated = generate_meeting_summary(&turns, Some("meet-42"))
.await
.expect("scripted reply should parse");
// Label is sanitised from the model reply.
assert_eq!(generated.label, "Q3 Roadmap");
// correlation_id flows through to the summary's meeting_id.
assert_eq!(generated.summary.meeting_id, "meet-42");
assert_eq!(generated.summary.headline, "Agreed to ship Friday.");
assert_eq!(generated.summary.key_points.len(), 2);
// Action items are mapped to executable/advisory with tool/assignee.
assert_eq!(generated.summary.action_items.len(), 2);
let exec = &generated.summary.action_items[0];
assert!(matches!(exec.kind, ActionItemKind::Executable));
assert_eq!(exec.tool_name.as_deref(), Some("gmail"));
assert_eq!(exec.assignee.as_deref(), Some("Sam"));
let advisory = &generated.summary.action_items[1];
assert!(matches!(advisory.kind, ActionItemKind::Advisory));
assert!(advisory.tool_name.is_none());
}
#[test]
fn parse_summary_json_direct() {
let reply = r#"{"label":"Q3 Roadmap","headline":"Agreed to ship Friday.","key_points":["Ship Friday"],"action_items":[]}"#;
let parsed = parse_summary_json(reply).expect("parse");
assert_eq!(parsed.label, "Q3 Roadmap");
assert_eq!(parsed.key_points.len(), 1);
}
#[test]
fn parse_summary_json_from_fenced_block_with_prose() {
let reply = "Here is the summary:\n```json\n{\"label\":\"Hiring Sync\",\"headline\":\"Two offers approved.\",\"key_points\":[\"Approve offers\"],\"action_items\":[{\"description\":\"Send offer\",\"kind\":\"executable\",\"tool_name\":\"gmail\",\"assignee\":\"Sam\"}]}\n```\nDone.";
let parsed = parse_summary_json(reply).expect("parse");
assert_eq!(parsed.label, "Hiring Sync");
assert_eq!(parsed.action_items.len(), 1);
assert_eq!(parsed.action_items[0].kind.as_deref(), Some("executable"));
}
#[test]
fn parse_summary_json_tolerates_braces_inside_strings() {
// A key point containing literal braces must not confuse object
// boundary detection in `extract_json_object`.
let reply = "noise {not json} more\n{\"label\":\"Infra\",\"headline\":\"Migrate config { } blocks\",\"key_points\":[\"Use {env} vars\"],\"action_items\":[]} trailing";
let parsed = parse_summary_json(reply).expect("parse");
assert_eq!(parsed.label, "Infra");
assert_eq!(parsed.key_points, vec!["Use {env} vars".to_string()]);
}
#[test]
fn parse_summary_json_returns_none_for_garbage() {
assert!(parse_summary_json("no json here at all").is_none());
assert!(parse_summary_json("").is_none());
}
#[test]
fn extract_json_object_picks_last_top_level_object() {
// Prose may contain an example object before the real reply; the last
// top-level object wins.
let text = "example: {\"a\":1} answer: {\"b\":2}";
assert_eq!(extract_json_object(text).as_deref(), Some("{\"b\":2}"));
assert!(extract_json_object("no braces").is_none());
}
#[test]
fn action_item_executable_preserves_tool_and_assignee() {
let raw = RawActionItem {
description: "Send invite".to_string(),
kind: Some(" Executable ".to_string()),
tool_name: Some("gmail".to_string()),
assignee: Some(" Sam ".to_string()),
};
let item = ActionItem::from(raw);
assert!(matches!(item.kind, ActionItemKind::Executable));
assert_eq!(item.tool_name.as_deref(), Some("gmail"));
assert_eq!(item.assignee.as_deref(), Some("Sam"));
}
#[test]
fn action_item_kind_defaults_to_advisory() {
let raw = RawActionItem {
description: " Follow up ".to_string(),
kind: None,
tool_name: Some(" ".to_string()),
assignee: None,
};
let item = ActionItem::from(raw);
assert!(matches!(item.kind, ActionItemKind::Advisory));
assert_eq!(item.description, "Follow up");
assert!(item.tool_name.is_none());
}
#[test]
fn sanitize_label_dequotes_and_caps() {
assert_eq!(sanitize_label("\"Q3 Roadmap\"\nextra"), "Q3 Roadmap");
let long = "A".repeat(60);
assert_eq!(sanitize_label(&long).chars().count(), 40);
}
#[test]
fn format_summary_markdown_includes_sections() {
let summary = MeetingSummary {
meeting_id: "m1".into(),
headline: "Agreed to ship Friday.".into(),
key_points: vec!["Ship Friday".into(), "QA owns sign-off".into()],
action_items: vec![
ActionItem {
description: "Send release notes".into(),
kind: ActionItemKind::Executable,
tool_name: Some("gmail".into()),
assignee: Some("Sam".into()),
},
ActionItem {
description: "Book retro".into(),
kind: ActionItemKind::Advisory,
tool_name: None,
assignee: None,
},
],
generated_at_ms: 0,
};
let md = format_summary_markdown(&summary, "Q3 Roadmap");
assert!(md.contains("**Topic:** Q3 Roadmap"));
assert!(md.contains("### Key points"));
assert!(md.contains("- Ship Friday"));
assert!(md.contains("### Action items"));
assert!(md.contains("actionable via `gmail`"));
assert!(md.contains("_(owner: Sam)_"));
// Advisory item carries no "actionable" suffix.
assert!(md.contains("- Book retro\n"));
}
#[test]
fn format_summary_markdown_omits_empty_sections() {
let summary = MeetingSummary {
meeting_id: "m1".into(),
headline: String::new(),
key_points: vec![],
action_items: vec![],
generated_at_ms: 0,
};
let md = format_summary_markdown(&summary, "");
assert!(md.starts_with("## Meeting summary"));
assert!(!md.contains("### Key points"));
assert!(!md.contains("### Action items"));
assert!(!md.contains("**Topic:**"));
}
}