fix(meetings): populate Recent calls for backend meet flow (owner, duration, participants) (#3710)

This commit is contained in:
YellowSnnowmann
2026-06-17 13:31:30 +05:30
committed by GitHub
parent a1b7c29338
commit eaad68ecaa
24 changed files with 591 additions and 96 deletions
+12
View File
@@ -61,6 +61,18 @@ impl EventHandler for MeetingEventSubscriber {
"{LOG_PREFIX} transcript received — creating meeting thread"
);
// Record a recent-calls entry (meet id, duration, owner,
// participants) so the meeting-bots panel shows call history.
// Done first (before the heavier thread-creation path) so the
// record is on disk by the time the panel refetches at call-end.
// Best-effort: never blocks; logs on failure internally.
super::recent_calls::record_backend_call(
turns,
*duration_ms,
correlation_id.as_deref(),
)
.await;
// Create the meeting thread with transcript.
if let Err(e) = create_meeting_thread_with_transcript(
turns,
+1
View File
@@ -18,6 +18,7 @@ pub mod bus;
pub mod calendar;
pub mod in_call;
pub mod ops;
pub mod recent_calls;
pub mod schemas;
pub mod store;
pub mod types;
+20
View File
@@ -327,6 +327,26 @@ pub async fn handle_join(params: Map<String, Value>) -> Result<Value, String> {
.await
.map_err(|e| format!("[agent_meetings] emit failed: {e}"))?;
// Snapshot join context so the post-call recent-calls record can show who
// launched the bot, into which meeting. Keyed by correlation_id; consumed
// when the `BackendMeetTranscript` event arrives at call-end. No-op when
// the caller didn't supply a correlation_id.
super::recent_calls::remember_join(
req.correlation_id.as_deref(),
super::recent_calls::JoinMeta {
meet_url: normalized_url.to_string(),
// "Your Name in This Meeting" — the human who launched the bot and
// whom it answers to. This is the owner shown in the recent-calls list.
owner_display_name: req.respond_to_participant.clone().unwrap_or_default(),
// The bot's tile name in the meeting (persona display name).
bot_display_name: display_name.clone(),
started_at_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0),
},
);
// Active mode (listen_only = false, the modal's "respond when addressed"
// toggle) enables in-call agency for just this meeting, so the toggle
// "just works" without flipping the global config. Passive joins leave
@@ -0,0 +1,358 @@
//! Recent-calls recording for the **backend meet** flow.
//!
//! The "Send OpenHuman to a meeting" card drives the backend bot via
//! `agent_meetings_join` → `bot:join`, and the call ends with a
//! `BackendMeetTranscript` event carrying only `turns` / `duration_ms` /
//! `correlation_id`. None of the join context (who launched the bot, which
//! URL, the bot's display name) survives to call-end on its own.
//!
//! To give the Recent-calls panel real detail we:
//! 1. snapshot the join inputs in an in-memory registry keyed by
//! `correlation_id` at [`remember_join`] time, and
//! 2. at transcript time [`record_backend_call`] looks that snapshot back
//! up, mines the participant names out of the transcript, and appends a
//! [`MeetCallRecord`] to the shared recent-calls store the UI reads.
//!
//! The registry is intentionally in-memory: a call lives for minutes and the
//! snapshot is only needed for that window. If the app restarts mid-call we
//! simply fall back to a leaner record (duration + participants, no owner/URL)
//! rather than failing — recording is best-effort and never blocks the call.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use crate::core::event_bus::BackendMeetTurn;
use crate::openhuman::meet_agent::store::{self, MeetCallRecord};
const LOG_PREFIX: &str = "[agent_meetings::recent_calls]";
/// Join-time context snapshotted so the call record can be enriched at
/// transcript time. Keyed by `correlation_id` in the pending registry.
#[derive(Debug, Clone)]
pub struct JoinMeta {
pub meet_url: String,
/// Display name the user gave themselves in the call ("who added the bot").
pub owner_display_name: String,
/// The bot's tile name in the meeting.
pub bot_display_name: String,
pub started_at_ms: u64,
}
/// Evict a pending join this long after its `started_at_ms` if no transcript
/// ever claimed it (failed join, crash, bot never admitted, …). Generous —
/// a real call's transcript lands within minutes — but bounded so abandoned
/// joins can't accumulate in a long-lived process.
const PENDING_JOIN_TTL_MS: u64 = 6 * 60 * 60 * 1000; // 6h
/// Backstop hard cap on retained pending joins, independent of the TTL, so
/// pathological churn within the window still can't grow without bound.
/// Oldest entries are evicted first.
const MAX_PENDING_JOINS: usize = 256;
fn registry() -> &'static Mutex<HashMap<String, JoinMeta>> {
static REG: OnceLock<Mutex<HashMap<String, JoinMeta>>> = OnceLock::new();
REG.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Snapshot join context for `correlation_id`. No-op when the id is absent
/// (we can't key the later lookup without it). Opportunistically prunes stale
/// entries on each insert so a transcript that never arrives can't leak.
pub fn remember_join(correlation_id: Option<&str>, meta: JoinMeta) {
let Some(cid) = correlation_id.map(str::trim).filter(|c| !c.is_empty()) else {
return;
};
let mut map = registry().lock().unwrap();
map.insert(cid.to_string(), meta);
prune_stale(&mut map, now_ms());
log::debug!(
"{LOG_PREFIX} remembered join correlation_id={cid} pending={}",
map.len()
);
}
/// Drop entries past their TTL, then enforce `MAX_PENDING_JOINS` by evicting
/// the oldest. Pure over its inputs (takes `now_ms`) so it is unit-testable.
fn prune_stale(map: &mut HashMap<String, JoinMeta>, now_ms: u64) {
map.retain(|_, m| now_ms.saturating_sub(m.started_at_ms) <= PENDING_JOIN_TTL_MS);
if map.len() > MAX_PENDING_JOINS {
let mut by_age: Vec<(String, u64)> = map
.iter()
.map(|(k, m)| (k.clone(), m.started_at_ms))
.collect();
by_age.sort_by_key(|(_, ts)| *ts);
for (k, _) in by_age.into_iter().take(map.len() - MAX_PENDING_JOINS) {
map.remove(&k);
}
}
}
/// Take (and remove) the snapshot for `correlation_id`, if any.
fn take_join(correlation_id: Option<&str>) -> Option<JoinMeta> {
let cid = correlation_id.map(str::trim).filter(|c| !c.is_empty())?;
registry().lock().unwrap().remove(cid)
}
/// Build and persist a [`MeetCallRecord`] for a finished backend-meet call.
///
/// Best-effort: any failure is logged and swallowed — the call is already
/// over and the UI degrades to "no record" rather than erroring.
pub async fn record_backend_call(
turns: &[BackendMeetTurn],
duration_ms: u64,
correlation_id: Option<&str>,
) {
let meta = take_join(correlation_id);
let record = build_record(meta.as_ref(), turns, duration_ms, correlation_id, now_ms());
match store::append_record(&record).await {
Ok(()) => log::info!(
"{LOG_PREFIX} recorded call request_id={} participants={} duration_s={:.0}",
record.request_id,
record.participants.len(),
record.listened_seconds
),
Err(e) => log::warn!("{LOG_PREFIX} append_record failed: {e}"),
}
}
/// Map a finished call's inputs to a [`MeetCallRecord`]. Pure (takes `now_ms`,
/// no I/O) so the field-mapping and fallback logic is unit-testable without a
/// store or a workspace.
fn build_record(
meta: Option<&JoinMeta>,
turns: &[BackendMeetTurn],
duration_ms: u64,
correlation_id: Option<&str>,
now_ms: u64,
) -> MeetCallRecord {
// Prefer the snapshotted start; otherwise derive it from the duration so
// the row still sorts roughly correctly in the newest-first list.
let started_at_ms = meta
.map(|m| m.started_at_ms)
.unwrap_or_else(|| now_ms.saturating_sub(duration_ms));
MeetCallRecord {
request_id: correlation_id
.map(str::trim)
.filter(|c| !c.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("backend-{started_at_ms}")),
meet_url: meta.map(|m| m.meet_url.clone()).unwrap_or_default(),
bot_display_name: meta.map(|m| m.bot_display_name.clone()).unwrap_or_default(),
owner_display_name: meta
.map(|m| m.owner_display_name.clone())
.unwrap_or_default(),
started_at_ms,
ended_at_ms: now_ms,
// The backend flow reports a single wall-clock duration rather than
// split listen/speak meters; surface it as "listened" so the existing
// UI ("Ns on call" = listened + spoken) shows the real call length.
listened_seconds: (duration_ms as f32) / 1000.0,
spoken_seconds: 0.0,
turn_count: turns.len() as u32,
participants: extract_participants(turns),
}
}
/// Extract distinct human participant names from a transcript.
///
/// Backend transcript lines look like `[00:51] [Shanu Goyanka] your time` or
/// `[00:00] [System] Tiny joined the meeting`. The speaker is the first
/// bracketed token that is **not** a `MM:SS` timestamp. We skip:
/// - assistant turns (that's the bot, not a participant),
/// - the synthetic `System` speaker (join/leave/presence noise),
/// - blank/duplicate names (first-seen order preserved).
fn extract_participants(turns: &[BackendMeetTurn]) -> Vec<String> {
let mut seen = Vec::new();
for turn in turns {
if turn.role.eq_ignore_ascii_case("assistant") {
continue;
}
let Some(name) = speaker_name(&turn.content) else {
continue;
};
if name.eq_ignore_ascii_case("system") {
continue;
}
if !seen.iter().any(|n: &String| n == &name) {
seen.push(name);
}
}
seen
}
/// Pull the speaker name from a single transcript line: the first
/// `[...]` group whose contents are not a clock timestamp.
fn speaker_name(content: &str) -> Option<String> {
let mut rest = content.trim_start();
while let Some(stripped) = rest.strip_prefix('[') {
let close = stripped.find(']')?;
let inner = stripped[..close].trim();
rest = stripped[close + 1..].trim_start();
if inner.is_empty() || is_timestamp(inner) {
continue;
}
return Some(inner.to_string());
}
None
}
/// True for `M:SS` / `MM:SS` / `H:MM:SS` clock stamps.
fn is_timestamp(s: &str) -> bool {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() < 2 || parts.len() > 3 {
return false;
}
parts
.iter()
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn turn(role: &str, content: &str) -> BackendMeetTurn {
BackendMeetTurn {
role: role.to_string(),
content: content.to_string(),
}
}
#[test]
fn speaker_name_skips_timestamp_and_takes_name() {
assert_eq!(
speaker_name("[00:51] [Shanu Goyanka] your time").as_deref(),
Some("Shanu Goyanka")
);
assert_eq!(
speaker_name("[System] Tiny joined").as_deref(),
Some("System")
);
// No bracketed speaker → none.
assert_eq!(speaker_name("just text"), None);
assert_eq!(speaker_name("[12:00]"), None);
}
#[test]
fn is_timestamp_matches_clock_only() {
assert!(is_timestamp("0:00"));
assert!(is_timestamp("00:51"));
assert!(is_timestamp("1:02:03"));
assert!(!is_timestamp("Shanu"));
assert!(!is_timestamp("12"));
assert!(!is_timestamp("a:bb"));
}
#[test]
fn extract_participants_dedups_and_excludes_system_and_bot() {
let turns = vec![
turn("user", "[00:00] [System] Tiny joined the meeting"),
turn("user", "[00:51] [Shanu Goyanka] your time"),
turn("assistant", "[00:55] [Tiny] On it."),
turn("user", "[02:09] [Shanu Goyanka] hey hello"),
turn("user", "[02:20] [Alex Rivera] sounds good"),
];
let names = extract_participants(&turns);
assert_eq!(names, vec!["Shanu Goyanka", "Alex Rivera"]);
}
#[test]
fn extract_participants_empty_when_no_speakers() {
let turns = vec![turn("user", "no brackets here"), turn("assistant", "hi")];
assert!(extract_participants(&turns).is_empty());
}
fn meta(started_at_ms: u64) -> JoinMeta {
JoinMeta {
meet_url: "https://meet.google.com/abc-defg-hij".into(),
owner_display_name: "Shanu".into(),
bot_display_name: "Tiny".into(),
started_at_ms,
}
}
#[test]
fn remember_then_take_round_trips_and_consumes() {
let cid = "corr-rc-test-1";
// Fresh timestamp so the opportunistic prune in remember_join keeps it.
remember_join(Some(cid), meta(now_ms()));
let got = take_join(Some(cid)).expect("present");
assert_eq!(got.owner_display_name, "Shanu");
// Consumed — a second take is empty.
assert!(take_join(Some(cid)).is_none());
}
#[test]
fn prune_stale_evicts_expired_keeps_fresh() {
let now = 10 * PENDING_JOIN_TTL_MS;
let mut map = HashMap::new();
map.insert("fresh".to_string(), meta(now)); // age 0
map.insert("old".to_string(), meta(now - PENDING_JOIN_TTL_MS - 1)); // just past TTL
prune_stale(&mut map, now);
assert!(map.contains_key("fresh"));
assert!(!map.contains_key("old"));
}
#[test]
fn prune_stale_enforces_size_cap_evicting_oldest() {
let now = 10 * PENDING_JOIN_TTL_MS;
let mut map = HashMap::new();
// All within TTL, but more than the cap — oldest must be evicted.
for i in 0..(MAX_PENDING_JOINS + 5) as u64 {
map.insert(format!("c{i}"), meta(now - i)); // c0 newest … higher i older
}
prune_stale(&mut map, now);
assert_eq!(map.len(), MAX_PENDING_JOINS);
assert!(map.contains_key("c0")); // newest kept
assert!(!map.contains_key(&format!("c{}", MAX_PENDING_JOINS + 4))); // oldest evicted
}
#[test]
fn build_record_uses_join_meta_when_present() {
let turns = vec![turn("user", "[00:10] [Shanu Goyanka] hi")];
let rec = build_record(Some(&meta(1000)), &turns, 30_000, Some("corr-42"), 50_000);
assert_eq!(rec.request_id, "corr-42");
assert_eq!(rec.meet_url, "https://meet.google.com/abc-defg-hij");
assert_eq!(rec.owner_display_name, "Shanu");
assert_eq!(rec.bot_display_name, "Tiny");
assert_eq!(rec.started_at_ms, 1000); // from meta, not derived
assert_eq!(rec.ended_at_ms, 50_000);
assert_eq!(rec.listened_seconds, 30.0);
assert_eq!(rec.turn_count, 1);
assert_eq!(rec.participants, vec!["Shanu Goyanka"]);
}
#[test]
fn build_record_falls_back_without_meta() {
let turns = vec![turn("user", "[00:00] [System] joined")];
let rec = build_record(None, &turns, 8_000, None, 100_000);
// No correlation id → synthesised request_id from the derived start.
assert_eq!(rec.started_at_ms, 92_000); // now - duration
assert_eq!(rec.request_id, "backend-92000");
assert!(rec.meet_url.is_empty());
assert!(rec.owner_display_name.is_empty());
// Only a System turn → no human participants.
assert!(rec.participants.is_empty());
}
#[test]
fn remember_join_noop_without_correlation_id() {
remember_join(
None,
JoinMeta {
meet_url: "x".into(),
owner_display_name: "y".into(),
bot_display_name: "z".into(),
started_at_ms: 0,
},
);
assert!(take_join(None).is_none());
assert!(take_join(Some(" ")).is_none());
}
}
+4
View File
@@ -232,6 +232,10 @@ pub async fn handle_stop_session(params: Map<String, Value>) -> Result<Value, St
listened_seconds: session.listened_seconds(),
spoken_seconds: session.spoken_seconds(),
turn_count: session.turn_count,
// Local meet-agent calls expose no per-speaker transcript here,
// so we leave participants empty. The backend-meet flow
// (agent_meetings) populates this from the transcript.
participants: Vec::new(),
};
if let Err(err) = store::append_record(&record).await {
log::warn!(
+8
View File
@@ -62,6 +62,13 @@ pub struct MeetCallRecord {
pub spoken_seconds: f32,
/// Completed agent turns during the call.
pub turn_count: u32,
/// Distinct human participant display names observed in the
/// transcript (excludes the bot and system/presence lines). Empty
/// for the local meet-agent flow, which has no transcript to mine.
/// `#[serde(default)]` keeps older JSONL lines (written before this
/// field existed) parseable.
#[serde(default)]
pub participants: Vec<String>,
}
/// Hard cap on the rows returned from `read_recent`. The UI shows ~20
@@ -177,6 +184,7 @@ mod tests {
listened_seconds: 12.5,
spoken_seconds: 4.2,
turn_count: 3,
participants: vec!["Alice".into(), "Bob".into()],
}
}