feat(meet): expandable recent-call detail with transcript + summary (#4113)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
YellowSnnowmann
2026-06-25 09:31:42 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent a1c5355734
commit 5756271335
25 changed files with 1201 additions and 74 deletions
@@ -0,0 +1,169 @@
/**
* Tests for the recent-calls panel's expandable rows.
*
* Confirms a row lazily fetches its transcript + summary on first expand,
* renders both, and degrades gracefully when the core has no detail or the
* fetch fails (with a working retry).
*/
import { configureStore } from '@reduxjs/toolkit';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Provider } from 'react-redux';
import { I18nProvider } from '../../lib/i18n/I18nContext';
import type { MeetCallDetail, MeetCallRecord } from '../../services/meetCallService';
import localeReducer from '../../store/localeSlice';
import { RecentCallsSection } from './RecentCallsSection';
const getMeetCallDetail = vi.fn<(requestId: string) => Promise<MeetCallDetail | null>>();
vi.mock('../../services/meetCallService', () => ({
getMeetCallDetail: (requestId: string) => getMeetCallDetail(requestId),
}));
function call(overrides: Partial<MeetCallRecord> = {}): MeetCallRecord {
return {
request_id: 'corr-1',
meet_url: 'https://meet.google.com/yfj-hcek-zyv',
bot_display_name: 'Tiny',
owner_display_name: 'Shanu',
started_at_ms: Date.now() - 60_000,
ended_at_ms: Date.now(),
listened_seconds: 100,
spoken_seconds: 20,
turn_count: 3,
participants: ['Shanu', 'Alan'],
...overrides,
};
}
function renderSection(rows: MeetCallRecord[]) {
const store = configureStore({
reducer: { locale: localeReducer },
preloadedState: { locale: { current: 'en' as const } },
});
return render(
<Provider store={store}>
<I18nProvider>
<RecentCallsSection rows={rows} error={null} />
</I18nProvider>
</Provider>
);
}
describe('RecentCallsSection', () => {
beforeEach(() => {
getMeetCallDetail.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
it('lazily loads and renders summary + transcript on expand', async () => {
getMeetCallDetail.mockResolvedValue({
request_id: 'corr-1',
summary: {
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' },
],
},
transcript: [
{ role: 'participant', content: '[00:51] [Shanu] your time' },
{ role: 'assistant', content: '[00:55] [Tiny] On it.' },
],
});
renderSection([call()]);
// Not fetched until the row is expanded.
expect(getMeetCallDetail).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole('button'));
expect(getMeetCallDetail).toHaveBeenCalledExactlyOnceWith('corr-1');
await waitFor(() => expect(screen.getByText('Agreed to ship Friday.')).toBeInTheDocument());
expect(screen.getByText('Summary')).toBeInTheDocument();
expect(screen.getByText('Ship Friday')).toBeInTheDocument();
expect(screen.getByText('Transcript')).toBeInTheDocument();
expect(screen.getByText('[00:55] [Tiny] On it.')).toBeInTheDocument();
// Action item description + assignee/tool meta.
expect(screen.getByText('Send release notes')).toBeInTheDocument();
});
it('shows an empty state when the call has no recorded detail', async () => {
getMeetCallDetail.mockResolvedValue(null);
renderSection([call()]);
await userEvent.click(screen.getByRole('button'));
await waitFor(() =>
expect(
screen.getByText('No transcript or summary was captured for this call.')
).toBeInTheDocument()
);
});
it('surfaces an error with a working retry', async () => {
getMeetCallDetail.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce({
request_id: 'corr-1',
summary: null,
transcript: [{ role: 'participant', content: 'recovered line' }],
});
renderSection([call()]);
await userEvent.click(screen.getByRole('button'));
const retry = await screen.findByRole('button', { name: 'Retry' });
await userEvent.click(retry);
await waitFor(() => expect(screen.getByText('recovered line')).toBeInTheDocument());
expect(getMeetCallDetail).toHaveBeenCalledTimes(2);
});
it('does not refetch when collapsing and re-expanding a fully-loaded call', async () => {
getMeetCallDetail.mockResolvedValue({
request_id: 'corr-1',
summary: { headline: 'All set.', key_points: [], action_items: [] },
transcript: [{ role: 'participant', content: 'cached line' }],
});
renderSection([call()]);
const toggle = screen.getByRole('button');
await userEvent.click(toggle); // expand → fetch
await waitFor(() => expect(screen.getByText('cached line')).toBeInTheDocument());
await userEvent.click(toggle); // collapse
await userEvent.click(toggle); // re-expand → reuse cache (summary already present)
expect(getMeetCallDetail).toHaveBeenCalledTimes(1);
});
it('refetches on re-expand to pick up a summary generated after call-end', async () => {
// First load: transcript persisted, summary still generating (null).
getMeetCallDetail
.mockResolvedValueOnce({
request_id: 'corr-1',
summary: null,
transcript: [{ role: 'participant', content: 'early line' }],
})
// Second load: summary has since landed.
.mockResolvedValueOnce({
request_id: 'corr-1',
summary: { headline: 'Summary arrived.', key_points: [], action_items: [] },
transcript: [{ role: 'participant', content: 'early line' }],
});
renderSection([call()]);
const toggle = screen.getByRole('button');
await userEvent.click(toggle); // expand → first fetch (no summary yet)
await waitFor(() => expect(screen.getByText('early line')).toBeInTheDocument());
expect(screen.queryByText('Summary arrived.')).not.toBeInTheDocument();
await userEvent.click(toggle); // collapse
await userEvent.click(toggle); // re-expand → refetch (summary was missing)
await waitFor(() => expect(screen.getByText('Summary arrived.')).toBeInTheDocument());
expect(getMeetCallDetail).toHaveBeenCalledTimes(2);
});
});
+260 -26
View File
@@ -1,11 +1,24 @@
import { useCallback, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { type MeetCallRecord } from '../../services/meetCallService';
import {
getMeetCallDetail,
type MeetCallDetail,
type MeetCallRecord,
type MeetCallSummary,
type MeetCallTranscriptLine,
} from '../../services/meetCallService';
/**
* Recent-calls history shown under the meeting-bot join form. Renders the
* loading / empty / populated states and one row per completed call (meeting
* code, relative time, turn count, duration, owner, and participants).
*
* Each row is expandable: on first expand it lazily fetches the call's
* transcript + summary via `meet_agent_get_call_detail` so the list payload
* stays lean. Older calls recorded before the feature have no detail and show
* a "nothing captured" state.
*
* Extracted from `MeetingBotsCard` to keep that component within the repo's
* ~500-line file-size guideline.
*/
@@ -43,7 +56,7 @@ export function RecentCallsSection({
{t('skills.meetingBots.recentCallsEmpty')}
</p>
) : (
<ul className="mt-2 max-h-48 space-y-1 overflow-y-auto pr-1">
<ul className="mt-2 max-h-72 space-y-1 overflow-y-auto pr-1">
{rows.map(call => (
<RecentCallRow key={call.request_id} call={call} />
))}
@@ -53,8 +66,29 @@ export function RecentCallsSection({
);
}
type DetailStatus = 'idle' | 'loading' | 'loaded' | 'error';
/**
* True when `detail` carries a non-empty generated summary. The summary lands
* asynchronously after the transcript at call-end, so this gates whether a
* re-expand should refetch (still pending) or reuse the cache (already present).
*/
function hasSummaryDetail(detail: MeetCallDetail | null): boolean {
const summary = detail?.summary;
return (
!!summary &&
(summary.headline.trim().length > 0 ||
summary.key_points.length > 0 ||
summary.action_items.length > 0)
);
}
function RecentCallRow({ call }: { call: MeetCallRecord }) {
const { t } = useT();
const [expanded, setExpanded] = useState(false);
const [status, setStatus] = useState<DetailStatus>('idle');
const [detail, setDetail] = useState<MeetCallDetail | null>(null);
const meetingCode = (() => {
try {
const parsed = new URL(call.meet_url);
@@ -67,39 +101,239 @@ function RecentCallRow({ call }: { call: MeetCallRecord }) {
const duration = Math.max(0, Math.round(call.spoken_seconds + call.listened_seconds));
const owner = call.owner_display_name?.trim();
const participants = (call.participants ?? []).map(p => p.trim()).filter(Boolean);
const loadDetail = useCallback(async () => {
setStatus('loading');
try {
const result = await getMeetCallDetail(call.request_id);
setDetail(result);
setStatus('loaded');
} catch (err) {
console.error('[recent-calls] failed to load call detail', call.request_id, err);
setStatus('error');
}
}, [call.request_id]);
const toggle = useCallback(() => {
setExpanded(prev => {
const next = !prev;
// Lazy-load on first expand. The transcript is persisted at call-end but
// the summary is generated asynchronously and patched in moments later, so
// re-expanding a row whose cached detail still lacks a summary refetches to
// pick it up. A complete (summary-present) detail is reused as-is.
if (next && (status === 'idle' || (status === 'loaded' && !hasSummaryDetail(detail)))) {
void loadDetail();
}
return next;
});
}, [status, detail, loadDetail]);
return (
<li className="rounded-lg px-2 py-1.5 text-[11px] text-stone-700 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/40">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-stone-800 dark:text-neutral-200">
{meetingCode}
</span>
<span className="shrink-0 text-stone-400 dark:text-neutral-500">
{formatRelativeTime(call.started_at_ms)}
</span>
</div>
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-stone-500 dark:text-neutral-400">
<span>
{call.turn_count} turn{call.turn_count === 1 ? '' : 's'}
</span>
<span>{duration}s on call</span>
{owner && (
<span className="truncate">
{t('skills.meetingBots.recentCallAddedBy').replace('{name}', owner)}
<li className="rounded-lg text-[11px] text-stone-700 dark:text-neutral-300">
<button
type="button"
onClick={toggle}
aria-expanded={expanded}
className="w-full rounded-lg px-2 py-1.5 text-left hover:bg-stone-50 dark:hover:bg-neutral-800/40">
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-1">
<Chevron expanded={expanded} />
<span className="truncate font-mono text-stone-800 dark:text-neutral-200">
{meetingCode}
</span>
</span>
)}
</div>
{participants.length > 0 && (
<div className="mt-0.5 truncate text-[10px] text-stone-500 dark:text-neutral-400">
{t('skills.meetingBots.recentCallParticipants').replace(
'{names}',
participants.join(', ')
<span className="shrink-0 text-stone-400 dark:text-neutral-500">
{formatRelativeTime(call.started_at_ms)}
</span>
</div>
<div className="mt-0.5 flex items-center gap-3 pl-4 text-[10px] text-stone-500 dark:text-neutral-400">
<span>
{t(
call.turn_count === 1
? 'skills.meetingBots.recentCallTurnSingular'
: 'skills.meetingBots.recentCallTurnPlural'
).replace('{count}', String(call.turn_count))}
</span>
<span>
{t('skills.meetingBots.recentCallDuration').replace('{seconds}', String(duration))}
</span>
{owner && (
<span className="truncate">
{t('skills.meetingBots.recentCallAddedBy').replace('{name}', owner)}
</span>
)}
</div>
{participants.length > 0 && (
<div className="mt-0.5 truncate pl-4 text-[10px] text-stone-500 dark:text-neutral-400">
{t('skills.meetingBots.recentCallParticipants').replace(
'{names}',
participants.join(', ')
)}
</div>
)}
</button>
{expanded && (
<div className="px-2 pb-2 pl-6">
<RecentCallDetailBody status={status} detail={detail} onRetry={loadDetail} />
</div>
)}
</li>
);
}
function RecentCallDetailBody({
status,
detail,
onRetry,
}: {
status: DetailStatus;
detail: MeetCallDetail | null;
onRetry: () => void;
}) {
const { t } = useT();
if (status === 'idle' || status === 'loading') {
return (
<p className="text-[10px] text-stone-400 dark:text-neutral-500">
{t('skills.meetingBots.callDetailLoading')}
</p>
);
}
if (status === 'error') {
return (
<p className="text-[10px] text-coral-600 dark:text-coral-400">
{t('skills.meetingBots.callDetailError')}{' '}
<button
type="button"
onClick={onRetry}
className="underline underline-offset-2 hover:text-coral-700 dark:hover:text-coral-300">
{t('skills.meetingBots.callDetailRetry')}
</button>
</p>
);
}
const summary = detail?.summary ?? null;
const transcript = detail?.transcript ?? [];
const hasSummary = hasSummaryDetail(detail);
if (!hasSummary && transcript.length === 0) {
return (
<p className="text-[10px] text-stone-400 dark:text-neutral-500">
{t('skills.meetingBots.callDetailEmpty')}
</p>
);
}
return (
<div className="space-y-3">
{hasSummary && summary && <CallSummary summary={summary} />}
{transcript.length > 0 && <CallTranscript lines={transcript} />}
</div>
);
}
function CallSummary({ summary }: { summary: MeetCallSummary }) {
const { t } = useT();
return (
<div className="space-y-1.5">
<SectionLabel>{t('skills.meetingBots.callSummaryHeading')}</SectionLabel>
{summary.headline.trim() && (
<p className="text-[11px] text-stone-700 dark:text-neutral-300">{summary.headline}</p>
)}
{summary.key_points.length > 0 && (
<div>
<p className="text-[10px] font-medium text-stone-500 dark:text-neutral-400">
{t('skills.meetingBots.callKeyPointsHeading')}
</p>
<ul className="mt-0.5 list-disc space-y-0.5 pl-4 text-[10px] text-stone-600 dark:text-neutral-400">
{summary.key_points.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
</div>
)}
{summary.action_items.length > 0 && (
<div>
<p className="text-[10px] font-medium text-stone-500 dark:text-neutral-400">
{t('skills.meetingBots.callActionItemsHeading')}
</p>
<ul className="mt-0.5 space-y-0.5 text-[10px] text-stone-600 dark:text-neutral-400">
{summary.action_items.map((item, i) => {
const meta = [
item.assignee?.trim() || undefined,
item.kind === 'executable' ? item.tool_name?.trim() || undefined : undefined,
].filter(Boolean);
return (
<li key={i} className="flex gap-1">
<span aria-hidden="true"></span>
<span>
{item.description}
{meta.length > 0 && (
<span className="text-stone-400 dark:text-neutral-500">
{' '}
({meta.join(' · ')})
</span>
)}
</span>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}
function CallTranscript({ lines }: { lines: MeetCallTranscriptLine[] }) {
const { t } = useT();
return (
<div className="space-y-1">
<SectionLabel>{t('skills.meetingBots.callTranscriptHeading')}</SectionLabel>
<div className="max-h-48 space-y-0.5 overflow-y-auto rounded-md bg-stone-50 p-2 dark:bg-neutral-800/40">
{lines.map((line, i) => (
<p
key={i}
className={
line.role === 'assistant'
? 'text-[10px] text-ocean-700 dark:text-ocean-300'
: 'text-[10px] text-stone-600 dark:text-neutral-400'
}>
{line.content}
</p>
))}
</div>
</div>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<p className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{children}
</p>
);
}
function Chevron({ expanded }: { expanded: boolean }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className={`h-3 w-3 shrink-0 text-stone-400 transition-transform dark:text-neutral-500 ${
expanded ? 'rotate-90' : ''
}`}
fill="none"
stroke="currentColor"
strokeWidth="2">
<path d="M6 4l4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function formatRelativeTime(ms: number): string {
if (!ms) return '—';
const diff = Date.now() - ms;
+11
View File
@@ -4918,6 +4918,17 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'جارٍ التحميل\u2026',
'skills.meetingBots.recentCallAddedBy': 'أضافه {name}',
'skills.meetingBots.recentCallParticipants': 'مع {names}',
'skills.meetingBots.recentCallTurnSingular': '{count} دور',
'skills.meetingBots.recentCallTurnPlural': '{count} أدوار',
'skills.meetingBots.recentCallDuration': '{seconds} ث في المكالمة',
'skills.meetingBots.callDetailLoading': 'جارٍ تحميل النص…',
'skills.meetingBots.callDetailError': 'تعذّر تحميل النص.',
'skills.meetingBots.callDetailRetry': 'إعادة المحاولة',
'skills.meetingBots.callDetailEmpty': 'لم يُسجَّل أي نص أو ملخّص لهذه المكالمة.',
'skills.meetingBots.callSummaryHeading': 'الملخّص',
'skills.meetingBots.callKeyPointsHeading': 'النقاط الرئيسية',
'skills.meetingBots.callActionItemsHeading': 'بنود الإجراءات',
'skills.meetingBots.callTranscriptHeading': 'النص',
'skills.meetingBots.liveBadge': 'مباشر',
'skills.meetingBots.liveTitle': 'في اجتماع',
'skills.meetingBots.liveStatusJoining': 'جارٍ الانضمام\u2026',
+12
View File
@@ -5018,6 +5018,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'লোড হচ্ছে\u2026',
'skills.meetingBots.recentCallAddedBy': '{name} যোগ করেছেন',
'skills.meetingBots.recentCallParticipants': '{names} এর সাথে',
'skills.meetingBots.recentCallTurnSingular': '{count}টি পালা',
'skills.meetingBots.recentCallTurnPlural': '{count}টি পালা',
'skills.meetingBots.recentCallDuration': 'কলে {seconds} সেকেন্ড',
'skills.meetingBots.callDetailLoading': 'ট্রান্সক্রিপ্ট লোড হচ্ছে…',
'skills.meetingBots.callDetailError': 'ট্রান্সক্রিপ্ট লোড করা যায়নি।',
'skills.meetingBots.callDetailRetry': 'আবার চেষ্টা করুন',
'skills.meetingBots.callDetailEmpty':
'এই কলের জন্য কোনো ট্রান্সক্রিপ্ট বা সারাংশ রেকর্ড করা হয়নি।',
'skills.meetingBots.callSummaryHeading': 'সারাংশ',
'skills.meetingBots.callKeyPointsHeading': 'মূল পয়েন্ট',
'skills.meetingBots.callActionItemsHeading': 'করণীয় কাজ',
'skills.meetingBots.callTranscriptHeading': 'ট্রান্সক্রিপ্ট',
'skills.meetingBots.liveBadge': 'লাইভ',
'skills.meetingBots.liveTitle': 'মিটিংয়ে',
'skills.meetingBots.liveStatusJoining': 'যোগ দিচ্ছে\u2026',
+12
View File
@@ -5146,6 +5146,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Laden\u2026',
'skills.meetingBots.recentCallAddedBy': 'Hinzugefügt von {name}',
'skills.meetingBots.recentCallParticipants': 'Mit {names}',
'skills.meetingBots.recentCallTurnSingular': '{count} Beitrag',
'skills.meetingBots.recentCallTurnPlural': '{count} Beiträge',
'skills.meetingBots.recentCallDuration': '{seconds}s im Gespräch',
'skills.meetingBots.callDetailLoading': 'Transkript wird geladen…',
'skills.meetingBots.callDetailError': 'Transkript konnte nicht geladen werden.',
'skills.meetingBots.callDetailRetry': 'Wiederholen',
'skills.meetingBots.callDetailEmpty':
'Für diesen Anruf wurde kein Transkript und keine Zusammenfassung erfasst.',
'skills.meetingBots.callSummaryHeading': 'Zusammenfassung',
'skills.meetingBots.callKeyPointsHeading': 'Kernpunkte',
'skills.meetingBots.callActionItemsHeading': 'Aktionspunkte',
'skills.meetingBots.callTranscriptHeading': 'Transkript',
'skills.meetingBots.liveBadge': 'Live',
'skills.meetingBots.liveTitle': 'Im Meeting',
'skills.meetingBots.liveStatusJoining': 'Beitritt\u2026',
+11
View File
@@ -5667,6 +5667,17 @@ const en: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Loading\u2026',
'skills.meetingBots.recentCallAddedBy': 'Added by {name}',
'skills.meetingBots.recentCallParticipants': 'With {names}',
'skills.meetingBots.recentCallTurnSingular': '{count} turn',
'skills.meetingBots.recentCallTurnPlural': '{count} turns',
'skills.meetingBots.recentCallDuration': '{seconds}s on call',
'skills.meetingBots.callDetailLoading': 'Loading transcript…',
'skills.meetingBots.callDetailError': 'Couldnt load the transcript.',
'skills.meetingBots.callDetailRetry': 'Retry',
'skills.meetingBots.callDetailEmpty': 'No transcript or summary was captured for this call.',
'skills.meetingBots.callSummaryHeading': 'Summary',
'skills.meetingBots.callKeyPointsHeading': 'Key points',
'skills.meetingBots.callActionItemsHeading': 'Action items',
'skills.meetingBots.callTranscriptHeading': 'Transcript',
'skills.meetingBots.liveBadge': 'Live',
'skills.meetingBots.liveTitle': 'In Meeting',
'skills.meetingBots.liveStatusJoining': 'Joining\u2026',
+12
View File
@@ -5111,6 +5111,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Cargando\u2026',
'skills.meetingBots.recentCallAddedBy': 'Añadido por {name}',
'skills.meetingBots.recentCallParticipants': 'Con {names}',
'skills.meetingBots.recentCallTurnSingular': '{count} turno',
'skills.meetingBots.recentCallTurnPlural': '{count} turnos',
'skills.meetingBots.recentCallDuration': '{seconds}s en llamada',
'skills.meetingBots.callDetailLoading': 'Cargando transcripción…',
'skills.meetingBots.callDetailError': 'No se pudo cargar la transcripción.',
'skills.meetingBots.callDetailRetry': 'Reintentar',
'skills.meetingBots.callDetailEmpty':
'No se registró ninguna transcripción ni resumen de esta llamada.',
'skills.meetingBots.callSummaryHeading': 'Resumen',
'skills.meetingBots.callKeyPointsHeading': 'Puntos clave',
'skills.meetingBots.callActionItemsHeading': 'Tareas pendientes',
'skills.meetingBots.callTranscriptHeading': 'Transcripción',
'skills.meetingBots.liveBadge': 'En vivo',
'skills.meetingBots.liveTitle': 'En reunión',
'skills.meetingBots.liveStatusJoining': 'Uniéndose\u2026',
+12
View File
@@ -5130,6 +5130,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Chargement\u2026',
'skills.meetingBots.recentCallAddedBy': 'Ajouté par {name}',
'skills.meetingBots.recentCallParticipants': 'Avec {names}',
'skills.meetingBots.recentCallTurnSingular': '{count} tour',
'skills.meetingBots.recentCallTurnPlural': '{count} tours',
'skills.meetingBots.recentCallDuration': '{seconds}s en appel',
'skills.meetingBots.callDetailLoading': 'Chargement de la transcription…',
'skills.meetingBots.callDetailError': 'Impossible de charger la transcription.',
'skills.meetingBots.callDetailRetry': 'Réessayer',
'skills.meetingBots.callDetailEmpty':
'Aucune transcription ni aucun résumé na été enregistré pour cet appel.',
'skills.meetingBots.callSummaryHeading': 'Résumé',
'skills.meetingBots.callKeyPointsHeading': 'Points clés',
'skills.meetingBots.callActionItemsHeading': 'Actions à mener',
'skills.meetingBots.callTranscriptHeading': 'Transcription',
'skills.meetingBots.liveBadge': 'En direct',
'skills.meetingBots.liveTitle': 'En réunion',
'skills.meetingBots.liveStatusJoining': 'Connexion\u2026',
+12
View File
@@ -5022,6 +5022,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'लोड हो रहा है\u2026',
'skills.meetingBots.recentCallAddedBy': '{name} द्वारा जोड़ा गया',
'skills.meetingBots.recentCallParticipants': '{names} के साथ',
'skills.meetingBots.recentCallTurnSingular': '{count} बारी',
'skills.meetingBots.recentCallTurnPlural': '{count} बारियां',
'skills.meetingBots.recentCallDuration': 'कॉल पर {seconds} सेकंड',
'skills.meetingBots.callDetailLoading': 'ट्रांसक्रिप्ट लोड हो रहा है…',
'skills.meetingBots.callDetailError': 'ट्रांसक्रिप्ट लोड नहीं हो सका।',
'skills.meetingBots.callDetailRetry': 'पुनः प्रयास करें',
'skills.meetingBots.callDetailEmpty':
'इस कॉल के लिए कोई ट्रांसक्रिप्ट या सारांश दर्ज नहीं किया गया।',
'skills.meetingBots.callSummaryHeading': 'सारांश',
'skills.meetingBots.callKeyPointsHeading': 'मुख्य बिंदु',
'skills.meetingBots.callActionItemsHeading': 'कार्य आइटम',
'skills.meetingBots.callTranscriptHeading': 'ट्रांसक्रिप्ट',
'skills.meetingBots.liveBadge': 'लाइव',
'skills.meetingBots.liveTitle': 'मीटिंग में',
'skills.meetingBots.liveStatusJoining': 'शामिल हो रहा है\u2026',
+12
View File
@@ -5033,6 +5033,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Memuat\u2026',
'skills.meetingBots.recentCallAddedBy': 'Ditambahkan oleh {name}',
'skills.meetingBots.recentCallParticipants': 'Dengan {names}',
'skills.meetingBots.recentCallTurnSingular': '{count} giliran',
'skills.meetingBots.recentCallTurnPlural': '{count} giliran',
'skills.meetingBots.recentCallDuration': '{seconds}d dalam panggilan',
'skills.meetingBots.callDetailLoading': 'Memuat transkrip…',
'skills.meetingBots.callDetailError': 'Tidak dapat memuat transkrip.',
'skills.meetingBots.callDetailRetry': 'Coba lagi',
'skills.meetingBots.callDetailEmpty':
'Tidak ada transkrip atau ringkasan yang direkam untuk panggilan ini.',
'skills.meetingBots.callSummaryHeading': 'Ringkasan',
'skills.meetingBots.callKeyPointsHeading': 'Poin penting',
'skills.meetingBots.callActionItemsHeading': 'Item tindakan',
'skills.meetingBots.callTranscriptHeading': 'Transkrip',
'skills.meetingBots.liveBadge': 'Langsung',
'skills.meetingBots.liveTitle': 'Dalam Rapat',
'skills.meetingBots.liveStatusJoining': 'Bergabung\u2026',
+12
View File
@@ -5098,6 +5098,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Caricamento\u2026',
'skills.meetingBots.recentCallAddedBy': 'Aggiunto da {name}',
'skills.meetingBots.recentCallParticipants': 'Con {names}',
'skills.meetingBots.recentCallTurnSingular': '{count} turno',
'skills.meetingBots.recentCallTurnPlural': '{count} turni',
'skills.meetingBots.recentCallDuration': '{seconds}s in chiamata',
'skills.meetingBots.callDetailLoading': 'Caricamento della trascrizione…',
'skills.meetingBots.callDetailError': 'Impossibile caricare la trascrizione.',
'skills.meetingBots.callDetailRetry': 'Riprova',
'skills.meetingBots.callDetailEmpty':
'Per questa chiamata non è stata registrata alcuna trascrizione o riepilogo.',
'skills.meetingBots.callSummaryHeading': 'Riepilogo',
'skills.meetingBots.callKeyPointsHeading': 'Punti chiave',
'skills.meetingBots.callActionItemsHeading': 'Azioni da svolgere',
'skills.meetingBots.callTranscriptHeading': 'Trascrizione',
'skills.meetingBots.liveBadge': 'In diretta',
'skills.meetingBots.liveTitle': 'In Riunione',
'skills.meetingBots.liveStatusJoining': 'Partecipando\u2026',
+11
View File
@@ -4973,6 +4973,17 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': '로딩 중\u2026',
'skills.meetingBots.recentCallAddedBy': '{name}님이 추가함',
'skills.meetingBots.recentCallParticipants': '{names}님과 함께',
'skills.meetingBots.recentCallTurnSingular': '{count}회 발언',
'skills.meetingBots.recentCallTurnPlural': '{count}회 발언',
'skills.meetingBots.recentCallDuration': '통화 {seconds}초',
'skills.meetingBots.callDetailLoading': '스크립트를 불러오는 중…',
'skills.meetingBots.callDetailError': '스크립트를 불러오지 못했습니다.',
'skills.meetingBots.callDetailRetry': '다시 시도',
'skills.meetingBots.callDetailEmpty': '이 통화에 대한 스크립트나 요약이 기록되지 않았습니다.',
'skills.meetingBots.callSummaryHeading': '요약',
'skills.meetingBots.callKeyPointsHeading': '핵심 사항',
'skills.meetingBots.callActionItemsHeading': '실행 항목',
'skills.meetingBots.callTranscriptHeading': '스크립트',
'skills.meetingBots.liveBadge': '라이브',
'skills.meetingBots.liveTitle': '회의 중',
'skills.meetingBots.liveStatusJoining': '참가 중\u2026',
+12
View File
@@ -5090,6 +5090,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Ładowanie\u2026',
'skills.meetingBots.recentCallAddedBy': 'Dodane przez {name}',
'skills.meetingBots.recentCallParticipants': 'Uczestnicy: {names}',
'skills.meetingBots.recentCallTurnSingular': '{count} tura',
'skills.meetingBots.recentCallTurnPlural': '{count} tury',
'skills.meetingBots.recentCallDuration': '{seconds}s rozmowy',
'skills.meetingBots.callDetailLoading': 'Ładowanie transkrypcji…',
'skills.meetingBots.callDetailError': 'Nie udało się załadować transkrypcji.',
'skills.meetingBots.callDetailRetry': 'Spróbuj ponownie',
'skills.meetingBots.callDetailEmpty':
'Dla tego połączenia nie zarejestrowano transkrypcji ani podsumowania.',
'skills.meetingBots.callSummaryHeading': 'Podsumowanie',
'skills.meetingBots.callKeyPointsHeading': 'Kluczowe punkty',
'skills.meetingBots.callActionItemsHeading': 'Zadania do wykonania',
'skills.meetingBots.callTranscriptHeading': 'Transkrypcja',
'skills.meetingBots.liveBadge': 'Na żywo',
'skills.meetingBots.liveTitle': 'Na spotkaniu',
'skills.meetingBots.liveStatusJoining': 'Dołączanie\u2026',
+12
View File
@@ -5102,6 +5102,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Carregando\u2026',
'skills.meetingBots.recentCallAddedBy': 'Adicionado por {name}',
'skills.meetingBots.recentCallParticipants': 'Com {names}',
'skills.meetingBots.recentCallTurnSingular': '{count} turno',
'skills.meetingBots.recentCallTurnPlural': '{count} turnos',
'skills.meetingBots.recentCallDuration': '{seconds}s em chamada',
'skills.meetingBots.callDetailLoading': 'Carregando transcrição…',
'skills.meetingBots.callDetailError': 'Não foi possível carregar a transcrição.',
'skills.meetingBots.callDetailRetry': 'Tentar novamente',
'skills.meetingBots.callDetailEmpty':
'Nenhuma transcrição ou resumo foi registrado para esta chamada.',
'skills.meetingBots.callSummaryHeading': 'Resumo',
'skills.meetingBots.callKeyPointsHeading': 'Pontos principais',
'skills.meetingBots.callActionItemsHeading': 'Itens de ação',
'skills.meetingBots.callTranscriptHeading': 'Transcrição',
'skills.meetingBots.liveBadge': 'Ao vivo',
'skills.meetingBots.liveTitle': 'Em reunião',
'skills.meetingBots.liveStatusJoining': 'Entrando\u2026',
+11
View File
@@ -5063,6 +5063,17 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Загрузка\u2026',
'skills.meetingBots.recentCallAddedBy': 'Добавил {name}',
'skills.meetingBots.recentCallParticipants': 'С {names}',
'skills.meetingBots.recentCallTurnSingular': '{count} реплика',
'skills.meetingBots.recentCallTurnPlural': '{count} реплик',
'skills.meetingBots.recentCallDuration': '{seconds} с в разговоре',
'skills.meetingBots.callDetailLoading': 'Загрузка стенограммы…',
'skills.meetingBots.callDetailError': 'Не удалось загрузить стенограмму.',
'skills.meetingBots.callDetailRetry': 'Повторить',
'skills.meetingBots.callDetailEmpty': 'Для этого звонка не записаны стенограмма или сводка.',
'skills.meetingBots.callSummaryHeading': 'Сводка',
'skills.meetingBots.callKeyPointsHeading': 'Ключевые моменты',
'skills.meetingBots.callActionItemsHeading': 'Задачи',
'skills.meetingBots.callTranscriptHeading': 'Стенограмма',
'skills.meetingBots.liveBadge': 'Эфир',
'skills.meetingBots.liveTitle': 'На встрече',
'skills.meetingBots.liveStatusJoining': 'Подключение\u2026',
+11
View File
@@ -4776,6 +4776,17 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': '加载中\u2026',
'skills.meetingBots.recentCallAddedBy': '由 {name} 添加',
'skills.meetingBots.recentCallParticipants': '与 {names}',
'skills.meetingBots.recentCallTurnSingular': '{count} 轮发言',
'skills.meetingBots.recentCallTurnPlural': '{count} 轮发言',
'skills.meetingBots.recentCallDuration': '通话 {seconds} 秒',
'skills.meetingBots.callDetailLoading': '正在加载文字记录…',
'skills.meetingBots.callDetailError': '无法加载文字记录。',
'skills.meetingBots.callDetailRetry': '重试',
'skills.meetingBots.callDetailEmpty': '此通话未记录任何文字记录或摘要。',
'skills.meetingBots.callSummaryHeading': '摘要',
'skills.meetingBots.callKeyPointsHeading': '要点',
'skills.meetingBots.callActionItemsHeading': '待办事项',
'skills.meetingBots.callTranscriptHeading': '文字记录',
'skills.meetingBots.liveBadge': '直播',
'skills.meetingBots.liveTitle': '会议中',
'skills.meetingBots.liveStatusJoining': '正在加入\u2026',
+58
View File
@@ -136,12 +136,53 @@ export interface MeetCallRecord {
participants?: string[];
}
/** One transcript line of a recorded call. Mirrors `MeetCallTranscriptLine`. */
export interface MeetCallTranscriptLine {
/** Lowercased speaker role: `'participant'` or `'assistant'`. */
role: string;
/** The line as the backend delivered it (may carry a `[MM:SS] [Name]` prefix). */
content: string;
}
/** One action item mined from a call. Mirrors `MeetCallActionItem`. */
export interface MeetCallActionItem {
description: string;
/** `'executable'` or `'advisory'`. */
kind: string;
tool_name?: string | null;
assignee?: string | null;
}
/** Structured post-call summary. Mirrors `MeetCallSummary`. */
export interface MeetCallSummary {
headline: string;
key_points: string[];
action_items: MeetCallActionItem[];
}
/**
* Transcript + summary for one completed call. Mirrors `MeetCallDetail` in
* `src/openhuman/meet_agent/store.rs`. Lazy-loaded by the recent-calls panel
* when a row is expanded, so the list payload stays lean. `summary` is null
* when summarisation failed or timed out at call-end.
*/
export interface MeetCallDetail {
request_id: string;
summary?: MeetCallSummary | null;
transcript: MeetCallTranscriptLine[];
}
interface CoreListCallsResponse {
ok: boolean;
calls: MeetCallRecord[];
count: number;
}
interface CoreGetCallDetailResponse {
ok: boolean;
detail: MeetCallDetail | null;
}
/**
* Fetch the most recent completed Meet calls (newest first). Used
* by the Skills "Meeting Bots" modal to render a history list
@@ -160,6 +201,23 @@ export async function listMeetCalls(limit = 20): Promise<MeetCallRecord[]> {
return result.calls ?? [];
}
/**
* Fetch the transcript + summary for one completed call. Lazy-loaded when the
* user expands a recent-call row. Returns `null` when the core has no detail
* for this call (older calls recorded before the feature, or a failed write) —
* the panel renders a "no transcript yet" state in that case.
*/
export async function getMeetCallDetail(requestId: string): Promise<MeetCallDetail | null> {
const result = await callCoreRpc<CoreGetCallDetailResponse>({
method: 'openhuman.meet_agent_get_call_detail',
params: { request_id: requestId },
});
if (!result?.ok) {
throw new Error('Core rejected the meet_agent_get_call_detail request.');
}
return result.detail ?? null;
}
// ---------------------------------------------------------------------------
// Backend Meet Bot via Core Socket.IO bridge
// ---------------------------------------------------------------------------
+38 -7
View File
@@ -61,23 +61,54 @@ 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(
// 1. Record a lean recent-calls entry (meet id, duration, owner,
// participants) first — before any LLM work — so the row is on
// disk by the time the panel refetches at call-end. Returns the
// request_id so the detail below is keyed to the same call.
// Best-effort: never blocks; logs on failure internally.
let request_id = super::recent_calls::record_backend_call(
turns,
*duration_ms,
correlation_id.as_deref(),
)
.await;
// Create the meeting thread with transcript.
// 2. Persist the transcript immediately — decoupled from the
// (bounded, up to 30s) summary LLM call below. Without this the
// detail file wouldn't exist until summarisation returned, so a
// row expanded right after call-end showed "nothing captured"
// even though the transcript was already in hand. The summary is
// 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;
// 4. Upgrade the stored detail with the summary once it's ready.
// Skipped when summarisation failed/timed out — the transcript
// written in step 2 stands on its own.
if generated.is_some() {
super::recent_calls::record_backend_call_detail(
&request_id,
turns,
generated.as_ref(),
)
.await;
}
// 5. Create the meeting thread with transcript, reusing the
// summary generated in step 3.
if let Err(e) = create_meeting_thread_with_transcript(
turns,
*duration_ms,
correlation_id.clone(),
generated.as_ref(),
)
.await
{
+21 -36
View File
@@ -25,13 +25,6 @@ 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,
@@ -110,8 +103,10 @@ 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.
if let Err(e) = create_meeting_thread_with_transcript(&turns, duration_ms, correlation_id).await
// 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}");
}
@@ -124,10 +119,15 @@ pub async fn ingest_backend_meeting_transcript(
/// The correlation_id (when present) is embedded in the transcript body as an
/// external reference for tracing — it does not deduplicate; each call creates
/// a new thread.
/// `generated` lets the caller pass a summary it already produced so the
/// call-end pipeline can share one generation across the recent-call detail
/// store and this thread. When `None`, the summary is generated here (bounded)
/// — preserving the behaviour of callers that don't pre-generate.
pub async fn create_meeting_thread_with_transcript(
turns: &[BackendMeetTurn],
duration_ms: u64,
correlation_id: Option<String>,
generated: Option<&super::summary::GeneratedSummary>,
) -> Result<(), String> {
use crate::openhuman::memory::{
AppendConversationMessageRequest, ConversationMessageRecord,
@@ -203,36 +203,21 @@ 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
}
// 3. Best-effort enrichment: reuse a summary the caller already generated
// (the call-end pipeline shares one across the recent-call detail store
// 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() {
super::summary::generate_meeting_summary_bounded(turns, correlation_id.as_deref()).await
} else {
None
};
let generated = generated.or(owned_generated.as_ref());
// 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());
let context_label = generated.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(),
@@ -249,7 +234,7 @@ pub async fn create_meeting_thread_with_transcript(
// 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 {
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(),
+129 -3
View File
@@ -22,7 +22,13 @@ use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use crate::core::event_bus::BackendMeetTurn;
use crate::openhuman::meet_agent::store::{self, MeetCallRecord};
use crate::openhuman::meet_agent::store::{
self, MeetCallActionItem, MeetCallDetail, MeetCallRecord, MeetCallSummary,
MeetCallTranscriptLine,
};
use super::summary::GeneratedSummary;
use super::types::ActionItemKind;
const LOG_PREFIX: &str = "[agent_meetings::recent_calls]";
@@ -93,16 +99,21 @@ fn take_join(correlation_id: Option<&str>) -> Option<JoinMeta> {
}
/// Build and persist a [`MeetCallRecord`] for a finished backend-meet call.
/// Returns the `request_id` the record was keyed by so the caller can persist
/// the matching call detail under the same id.
///
/// Best-effort: any failure is logged and swallowed — the call is already
/// over and the UI degrades to "no record" rather than erroring.
/// over and the UI degrades to "no record" rather than erroring. The id is
/// returned even when the append fails, since it is deterministic and the
/// detail write should still use it.
pub async fn record_backend_call(
turns: &[BackendMeetTurn],
duration_ms: u64,
correlation_id: Option<&str>,
) {
) -> String {
let meta = take_join(correlation_id);
let record = build_record(meta.as_ref(), turns, duration_ms, correlation_id, now_ms());
let request_id = record.request_id.clone();
match store::append_record(&record).await {
Ok(()) => log::info!(
"{LOG_PREFIX} recorded call request_id={} participants={} duration_s={:.0}",
@@ -112,6 +123,73 @@ pub async fn record_backend_call(
),
Err(e) => log::warn!("{LOG_PREFIX} append_record failed: {e}"),
}
request_id
}
/// Persist the per-call detail (transcript + optional summary) under the same
/// `request_id` the lean record used, so the recent-calls panel can lazy-load
/// it when a row is expanded. Best-effort: logs and swallows failures.
pub async fn record_backend_call_detail(
request_id: &str,
turns: &[BackendMeetTurn],
generated: Option<&GeneratedSummary>,
) {
let detail = build_detail(request_id, turns, generated);
match store::write_detail(&detail).await {
Ok(()) => log::info!(
"{LOG_PREFIX} recorded call detail request_id={} transcript_lines={} has_summary={}",
request_id,
detail.transcript.len(),
detail.summary.is_some()
),
Err(e) => log::warn!("{LOG_PREFIX} write_detail failed request_id={request_id}: {e}"),
}
}
/// Map a finished call's turns + optional summary into a persisted
/// [`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(
request_id: &str,
turns: &[BackendMeetTurn],
generated: Option<&GeneratedSummary>,
) -> MeetCallDetail {
let transcript = turns
.iter()
.filter(|t| !t.content.trim().is_empty())
.map(|t| MeetCallTranscriptLine {
role: if t.role.eq_ignore_ascii_case("assistant") {
"assistant".to_string()
} else {
"participant".to_string()
},
content: t.content.trim().to_string(),
})
.collect();
let summary = generated.map(|g| MeetCallSummary {
headline: g.summary.headline.clone(),
key_points: g.summary.key_points.clone(),
action_items: g
.summary
.action_items
.iter()
.map(|a| MeetCallActionItem {
description: a.description.clone(),
kind: match a.kind {
ActionItemKind::Executable => "executable".to_string(),
ActionItemKind::Advisory => "advisory".to_string(),
},
tool_name: a.tool_name.clone(),
assignee: a.assignee.clone(),
})
.collect(),
});
MeetCallDetail {
request_id: request_id.to_string(),
summary,
transcript,
}
}
/// Map a finished call's inputs to a [`MeetCallRecord`]. Pure (takes `now_ms`,
@@ -355,4 +433,52 @@ mod tests {
assert!(take_join(None).is_none());
assert!(take_join(Some(" ")).is_none());
}
#[test]
fn build_detail_maps_transcript_and_summary() {
use super::super::types::{ActionItem, MeetingSummary};
let turns = vec![
turn("user", "[00:51] [Shanu] your time"),
turn("user", " "), // blank → dropped
turn("assistant", "[00:55] [Tiny] On it."),
];
let generated = GeneratedSummary {
label: "Q3 Roadmap".into(),
summary: MeetingSummary {
meeting_id: "corr-9".into(),
headline: "Agreed to ship Friday.".into(),
key_points: vec!["Ship Friday".into()],
action_items: vec![ActionItem {
description: "Send release notes".into(),
kind: ActionItemKind::Executable,
tool_name: Some("gmail".into()),
assignee: Some("Sam".into()),
}],
generated_at_ms: 0,
},
};
let detail = build_detail("corr-9", &turns, Some(&generated));
assert_eq!(detail.request_id, "corr-9");
// Blank turn dropped; roles normalised to participant/assistant.
assert_eq!(detail.transcript.len(), 2);
assert_eq!(detail.transcript[0].role, "participant");
assert_eq!(detail.transcript[0].content, "[00:51] [Shanu] your time");
assert_eq!(detail.transcript[1].role, "assistant");
let summary = detail.summary.expect("summary present");
assert_eq!(summary.headline, "Agreed to ship Friday.");
assert_eq!(summary.key_points, vec!["Ship Friday".to_string()]);
assert_eq!(summary.action_items.len(), 1);
assert_eq!(summary.action_items[0].kind, "executable");
assert_eq!(summary.action_items[0].tool_name.as_deref(), Some("gmail"));
}
#[test]
fn build_detail_without_summary_keeps_transcript() {
let turns = vec![turn("user", "[00:10] [Shanu] hi")];
let detail = build_detail("corr-x", &turns, None);
assert!(detail.summary.is_none());
assert_eq!(detail.transcript.len(), 1);
}
}
+37
View File
@@ -148,6 +148,43 @@ pub async fn generate_meeting_summary(
})
}
/// 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 call-end pipeline for minutes. 30s sits well above
/// a healthy summarisation latency while capping the worst case.
pub const SUMMARY_GENERATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// Generate a summary bounded by [`SUMMARY_GENERATION_TIMEOUT`], returning
/// `None` on failure or timeout. Centralises the best-effort policy so the
/// call-end pipeline can generate a single summary and share it across the
/// recent-call detail store and the meeting thread instead of paying for the
/// LLM call twice.
pub async fn generate_meeting_summary_bounded(
turns: &[BackendMeetTurn],
correlation_id: Option<&str>,
) -> Option<GeneratedSummary> {
match tokio::time::timeout(
SUMMARY_GENERATION_TIMEOUT,
generate_meeting_summary(turns, correlation_id),
)
.await
{
Ok(Ok(g)) => Some(g),
Ok(Err(e)) => {
tracing::warn!("{LOG_PREFIX} summary generation failed: {e}");
None
}
Err(_) => {
tracing::warn!(
timeout_secs = SUMMARY_GENERATION_TIMEOUT.as_secs(),
"{LOG_PREFIX} summary generation timed out"
);
None
}
}
}
/// 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");
+41 -2
View File
@@ -21,8 +21,9 @@ use super::ops::VadEvent;
use super::session::{registry, CaptionOutcome};
use super::store::{self, MeetCallRecord};
use super::types::{
ListCallsRequest, ListCallsResponse, PollSpeechRequest, PushCaptionRequest,
PushListenPcmRequest, StartSessionRequest, StopSessionRequest,
GetCallDetailRequest, GetCallDetailResponse, ListCallsRequest, ListCallsResponse,
PollSpeechRequest, PushCaptionRequest, PushListenPcmRequest, StartSessionRequest,
StopSessionRequest,
};
/// Default `limit` for `handle_list_calls` when the caller omits one.
@@ -277,6 +278,21 @@ pub async fn handle_list_calls(params: Map<String, Value>) -> Result<Value, Stri
RpcOutcome::new(value, vec![]).into_cli_compatible_json()
}
pub async fn handle_get_call_detail(params: Map<String, Value>) -> Result<Value, String> {
let req: GetCallDetailRequest = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("{LOG_PREFIX} invalid get_call_detail params: {e}"))?;
let detail = store::read_detail(&req.request_id).await?;
log::info!(
"{LOG_PREFIX} get_call_detail request_id={} found={}",
req.request_id,
detail.is_some()
);
let response = GetCallDetailResponse { ok: true, detail };
let value = serde_json::to_value(&response)
.map_err(|e| format!("{LOG_PREFIX} serialize get_call_detail response: {e}"))?;
RpcOutcome::new(value, vec![]).into_cli_compatible_json()
}
/// Decode a base64 string of PCM16LE bytes into samples. Empty input is
/// a "heartbeat" push (no audio this tick) and yields an empty Vec.
fn decode_pcm16le_b64(b64: &str) -> Result<Vec<i16>, String> {
@@ -385,6 +401,29 @@ mod tests {
handle_stop_session(stop).await.unwrap();
}
#[tokio::test]
async fn get_call_detail_missing_returns_ok_with_null() {
// A call with no recorded detail (older call, or a best-effort write
// that failed) must resolve to `ok: true, detail: null` — the contract
// the recent-calls panel relies on to render its "nothing captured"
// state rather than surfacing an error.
let mut params = Map::new();
params.insert(
"request_id".into(),
json!("rpc-detail-never-recorded-3f9c1a"),
);
let out = handle_get_call_detail(params).await.unwrap();
assert_eq!(out.get("ok"), Some(&json!(true)));
assert_eq!(out.get("detail"), Some(&json!(null)));
}
#[tokio::test]
async fn get_call_detail_rejects_missing_request_id() {
// Empty params → deserialization error, surfaced as Err rather than a
// panic, so the RPC layer can return a clean error to the caller.
assert!(handle_get_call_detail(Map::new()).await.is_err());
}
#[test]
fn decode_pcm16le_b64_handles_empty() {
assert!(decode_pcm16le_b64("").unwrap().is_empty());
+42
View File
@@ -45,6 +45,11 @@ const DEFS: &[Def] = &[
schema: schema_list_calls,
handler: handle_list_calls,
},
Def {
function: "get_call_detail",
schema: schema_get_call_detail,
handler: handle_get_call_detail,
},
];
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
@@ -344,6 +349,38 @@ fn schema_list_calls() -> ControllerSchema {
}
}
fn schema_get_call_detail() -> ControllerSchema {
ControllerSchema {
namespace: "meet_agent",
function: "get_call_detail",
description:
"Return the persisted transcript + summary for one completed Meet call, keyed by \
request_id. Used by the recent-calls panel to lazy-load detail when a row is \
expanded. `detail` is null when no detail was recorded for the call.",
inputs: vec![FieldSchema {
name: "request_id",
ty: TypeSchema::String,
comment: "request_id of the call (matches a MeetCallRecord row from list_calls).",
required: true,
}],
outputs: vec![
FieldSchema {
name: "ok",
ty: TypeSchema::Bool,
comment: "True when the read succeeded (even if no detail exists yet).",
required: true,
},
FieldSchema {
name: "detail",
ty: TypeSchema::Json,
comment:
"MeetCallDetail object (transcript + summary), or null when none recorded.",
required: false,
},
],
}
}
fn schema_unknown() -> ControllerSchema {
ControllerSchema {
namespace: "meet_agent",
@@ -383,6 +420,10 @@ fn handle_list_calls(p: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { super::rpc::handle_list_calls(p).await })
}
fn handle_get_call_detail(p: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { super::rpc::handle_get_call_detail(p).await })
}
#[cfg(test)]
mod tests {
use super::*;
@@ -407,6 +448,7 @@ mod tests {
"poll_speech",
"stop_session",
"list_calls",
"get_call_detail",
]
);
}
+222
View File
@@ -168,6 +168,136 @@ async fn read_recent_from(path: &Path, limit: usize) -> Result<Vec<MeetCallRecor
Ok(all)
}
// ---------------------------------------------------------------------------
// Per-call detail (transcript + summary)
// ---------------------------------------------------------------------------
//
// The recent-calls list (`calls.jsonl`) is deliberately lean so the list
// endpoint stays a cheap whole-file read. The transcript and generated summary
// are heavier and only needed when the user expands one row, so they live in a
// sibling per-call JSON file loaded on demand by `meet_agent_get_call_detail`.
/// One transcript line for a recorded call. `role` is the lowercased speaker
/// role ("participant" / "assistant"); `content` is the line as the backend
/// delivered it (may carry a `[MM:SS] [Name]` prefix).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MeetCallTranscriptLine {
pub role: String,
pub content: String,
}
/// One action item mined from the call. Mirrors the `agent_meetings` summary
/// type but is kept dependency-free here so this store never depends back on
/// `agent_meetings` (which already depends on it — that would be a cycle).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MeetCallActionItem {
pub description: String,
/// `"executable"` or `"advisory"`.
pub kind: String,
#[serde(default)]
pub tool_name: Option<String>,
#[serde(default)]
pub assignee: Option<String>,
}
/// Structured post-call summary persisted alongside the transcript.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MeetCallSummary {
pub headline: String,
#[serde(default)]
pub key_points: Vec<String>,
#[serde(default)]
pub action_items: Vec<MeetCallActionItem>,
}
/// Full detail for a single recorded call: the transcript and the best-effort
/// generated summary. Persisted as one JSON file per call (keyed by
/// `request_id`) so the recent-calls list stays lean and the transcript is
/// only read when a row is expanded.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MeetCallDetail {
pub request_id: String,
/// `None` when summarisation failed or timed out — the UI falls back to the
/// transcript alone. `#[serde(default)]` keeps older files parseable.
#[serde(default)]
pub summary: Option<MeetCallSummary>,
#[serde(default)]
pub transcript: Vec<MeetCallTranscriptLine>,
}
/// Directory holding per-call detail JSON files — a sibling of `calls.jsonl`.
async fn meet_call_details_dir() -> Result<PathBuf, String> {
let workspace = Config::load_or_init()
.await
.map(|c| c.workspace_dir)
.map_err(|e| format!("load config: {e}"))?;
Ok(workspace.join("meet_agent").join("call_details"))
}
/// Map a `request_id` to a filesystem-safe, **injective** file stem.
///
/// ASCII alphanumerics and `-`/`_` pass through unchanged so the common cases —
/// UUID correlation ids and `backend-<ts>` — stay human-readable; every other
/// byte, `%` included, is percent-encoded as `%XX`. Because `%` is itself
/// escaped the mapping is reversible, and therefore collision-free: distinct
/// ids like `a/b`, `a:b`, and `a_b` map to distinct stems (`a%2Fb`, `a%3Ab`,
/// `a_b`) instead of all collapsing onto one file and overwriting each other's
/// detail. It also can never escape the details directory — `/`, `\`, and the
/// `.` in `..` are all encoded.
fn sanitize_stem(request_id: &str) -> String {
if request_id.is_empty() {
return "unknown".to_string();
}
let mut out = String::with_capacity(request_id.len());
for &b in request_id.as_bytes() {
if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' {
out.push(b as char);
} else {
out.push('%');
out.push_str(&format!("{b:02X}"));
}
}
out
}
/// Persist the detail for one call. Creates the details directory on demand.
/// Best-effort at the call site: callers log and swallow failures since the
/// call is already over and the list row still renders without detail.
pub async fn write_detail(detail: &MeetCallDetail) -> Result<(), String> {
let dir = meet_call_details_dir().await?;
write_detail_to(&dir, detail).await
}
async fn write_detail_to(dir: &Path, detail: &MeetCallDetail) -> Result<(), String> {
tokio::fs::create_dir_all(dir)
.await
.map_err(|e| format!("mkdir {}: {e}", dir.display()))?;
let path = dir.join(format!("{}.json", sanitize_stem(&detail.request_id)));
let json = serde_json::to_string(detail).map_err(|e| format!("serialize detail: {e}"))?;
tokio::fs::write(&path, json.as_bytes())
.await
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(())
}
/// Read the detail for one call. Missing file → `Ok(None)` (older calls
/// recorded before this feature, or a call whose detail write failed).
pub async fn read_detail(request_id: &str) -> Result<Option<MeetCallDetail>, String> {
let dir = meet_call_details_dir().await?;
read_detail_from(&dir, request_id).await
}
async fn read_detail_from(dir: &Path, request_id: &str) -> Result<Option<MeetCallDetail>, String> {
let path = dir.join(format!("{}.json", sanitize_stem(request_id)));
match tokio::fs::read_to_string(&path).await {
Ok(s) => serde_json::from_str::<MeetCallDetail>(&s)
.map(Some)
.map_err(|e| format!("parse {}: {e}", path.display())),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(format!("read {}: {err}", path.display())),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -258,4 +388,96 @@ mod tests {
let recent = read_recent_from(&path, usize::MAX).await.unwrap();
assert_eq!(recent.len(), 1);
}
fn sample_detail(request_id: &str) -> MeetCallDetail {
MeetCallDetail {
request_id: request_id.to_string(),
summary: Some(MeetCallSummary {
headline: "Agreed to ship Friday.".into(),
key_points: vec!["Ship Friday".into(), "QA owns sign-off".into()],
action_items: vec![MeetCallActionItem {
description: "Send release notes".into(),
kind: "executable".into(),
tool_name: Some("gmail".into()),
assignee: Some("Sam".into()),
}],
}),
transcript: vec![
MeetCallTranscriptLine {
role: "participant".into(),
content: "[00:51] [Shanu] your time".into(),
},
MeetCallTranscriptLine {
role: "assistant".into(),
content: "[00:55] [Tiny] On it.".into(),
},
],
}
}
#[tokio::test]
async fn write_then_read_detail_round_trip() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("call_details");
let detail = sample_detail("corr-42");
write_detail_to(&dir, &detail).await.unwrap();
let got = read_detail_from(&dir, "corr-42").await.unwrap();
assert_eq!(got.as_ref(), Some(&detail));
}
#[tokio::test]
async fn read_detail_missing_returns_none() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("call_details");
// Directory never created → still resolves to None, not an error.
let got = read_detail_from(&dir, "never-recorded").await.unwrap();
assert!(got.is_none());
}
#[tokio::test]
async fn detail_id_with_unsafe_chars_round_trips_via_sanitized_stem() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("call_details");
// A request_id with path separators must not escape the directory and
// must read back under the same sanitized stem it was written with.
let detail = sample_detail("../weird/id:99");
write_detail_to(&dir, &detail).await.unwrap();
let got = read_detail_from(&dir, "../weird/id:99").await.unwrap();
assert_eq!(got, Some(detail));
}
#[test]
fn sanitize_stem_encodes_path_chars_and_never_empty() {
// Safe chars (alnum, -, _) pass through so UUIDs stay readable.
assert_eq!(sanitize_stem("abc-DEF_123"), "abc-DEF_123");
// Path separators / dots are percent-encoded, never bare.
assert_eq!(sanitize_stem("../a/b.json"), "%2E%2E%2Fa%2Fb%2Ejson");
assert_eq!(sanitize_stem(""), "unknown");
assert_eq!(sanitize_stem("///"), "%2F%2F%2F");
}
#[test]
fn sanitize_stem_is_injective_for_ids_that_differ_only_in_punctuation() {
// The old lossy scheme collapsed all of these onto `a_b`, so the second
// write clobbered the first. Percent-encoding keeps them distinct.
let stems = ["a/b", "a:b", "a_b", "a.b"].map(sanitize_stem);
let unique: std::collections::HashSet<_> = stems.iter().collect();
assert_eq!(unique.len(), stems.len(), "stems collided: {stems:?}");
}
#[tokio::test]
async fn details_with_punctuation_differing_ids_do_not_overwrite() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("call_details");
// Two distinct calls whose ids differ only by a separator the old
// sanitizer flattened — each must keep its own detail file.
let mut a = sample_detail("call/1");
a.transcript[0].content = "from call/1".into();
let mut b = sample_detail("call_1");
b.transcript[0].content = "from call_1".into();
write_detail_to(&dir, &a).await.unwrap();
write_detail_to(&dir, &b).await.unwrap();
assert_eq!(read_detail_from(&dir, "call/1").await.unwrap(), Some(a));
assert_eq!(read_detail_from(&dir, "call_1").await.unwrap(), Some(b));
}
}
+21
View File
@@ -160,6 +160,27 @@ pub struct ListCallsResponse {
pub count: usize,
}
/// Inputs to `openhuman.meet_agent_get_call_detail`.
///
/// Loads the transcript + generated summary for a single completed call so the
/// recent-calls panel can expand a row without bloating the list payload.
#[derive(Debug, Clone, Deserialize)]
pub struct GetCallDetailRequest {
/// request_id of the call. Matches the `request_id` field of the
/// `MeetCallRecord` rows returned by `list_calls`.
pub request_id: String,
}
/// Outputs from `openhuman.meet_agent_get_call_detail`.
#[derive(Debug, Clone, Serialize)]
pub struct GetCallDetailResponse {
pub ok: bool,
/// The persisted detail, or `null` when none exists for this call (older
/// calls recorded before the feature, or a best-effort detail write that
/// failed). The UI degrades to "no transcript yet" in that case.
pub detail: Option<super::store::MeetCallDetail>,
}
/// Inputs to `openhuman.meet_agent_stop_session`.
#[derive(Debug, Clone, Deserialize)]
pub struct StopSessionRequest {