From 5756271335dfefb2ba28b2575ad6554a8b2ffdff Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:01:42 +0530 Subject: [PATCH] feat(meet): expandable recent-call detail with transcript + summary (#4113) Co-authored-by: Claude Opus 4.8 (1M context) --- .../skills/RecentCallsSection.test.tsx | 169 +++++++++++ .../components/skills/RecentCallsSection.tsx | 286 ++++++++++++++++-- app/src/lib/i18n/ar.ts | 11 + app/src/lib/i18n/bn.ts | 12 + app/src/lib/i18n/de.ts | 12 + app/src/lib/i18n/en.ts | 11 + app/src/lib/i18n/es.ts | 12 + app/src/lib/i18n/fr.ts | 12 + app/src/lib/i18n/hi.ts | 12 + app/src/lib/i18n/id.ts | 12 + app/src/lib/i18n/it.ts | 12 + app/src/lib/i18n/ko.ts | 11 + app/src/lib/i18n/pl.ts | 12 + app/src/lib/i18n/pt.ts | 12 + app/src/lib/i18n/ru.ts | 11 + app/src/lib/i18n/zh-CN.ts | 11 + app/src/services/meetCallService.ts | 58 ++++ src/openhuman/agent_meetings/bus.rs | 45 ++- src/openhuman/agent_meetings/ops.rs | 57 ++-- src/openhuman/agent_meetings/recent_calls.rs | 132 +++++++- src/openhuman/agent_meetings/summary.rs | 37 +++ src/openhuman/meet_agent/rpc.rs | 43 ++- src/openhuman/meet_agent/schemas.rs | 42 +++ src/openhuman/meet_agent/store.rs | 222 ++++++++++++++ src/openhuman/meet_agent/types.rs | 21 ++ 25 files changed, 1201 insertions(+), 74 deletions(-) create mode 100644 app/src/components/skills/RecentCallsSection.test.tsx diff --git a/app/src/components/skills/RecentCallsSection.test.tsx b/app/src/components/skills/RecentCallsSection.test.tsx new file mode 100644 index 000000000..c2b013a20 --- /dev/null +++ b/app/src/components/skills/RecentCallsSection.test.tsx @@ -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>(); + +vi.mock('../../services/meetCallService', () => ({ + getMeetCallDetail: (requestId: string) => getMeetCallDetail(requestId), +})); + +function call(overrides: Partial = {}): 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( + + + + + + ); +} + +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); + }); +}); diff --git a/app/src/components/skills/RecentCallsSection.tsx b/app/src/components/skills/RecentCallsSection.tsx index 22e85bffe..b04e30932 100644 --- a/app/src/components/skills/RecentCallsSection.tsx +++ b/app/src/components/skills/RecentCallsSection.tsx @@ -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')}

) : ( -
    +
      {rows.map(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('idle'); + const [detail, setDetail] = useState(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 ( -
    • -
      - - {meetingCode} - - - {formatRelativeTime(call.started_at_ms)} - -
      -
      - - {call.turn_count} turn{call.turn_count === 1 ? '' : 's'} - - {duration}s on call - {owner && ( - - {t('skills.meetingBots.recentCallAddedBy').replace('{name}', owner)} +
    • + + + {expanded && ( +
      + +
      )}
    • ); } +function RecentCallDetailBody({ + status, + detail, + onRetry, +}: { + status: DetailStatus; + detail: MeetCallDetail | null; + onRetry: () => void; +}) { + const { t } = useT(); + + if (status === 'idle' || status === 'loading') { + return ( +

      + {t('skills.meetingBots.callDetailLoading')} +

      + ); + } + + if (status === 'error') { + return ( +

      + {t('skills.meetingBots.callDetailError')}{' '} + +

      + ); + } + + const summary = detail?.summary ?? null; + const transcript = detail?.transcript ?? []; + const hasSummary = hasSummaryDetail(detail); + + if (!hasSummary && transcript.length === 0) { + return ( +

      + {t('skills.meetingBots.callDetailEmpty')} +

      + ); + } + + return ( +
      + {hasSummary && summary && } + {transcript.length > 0 && } +
      + ); +} + +function CallSummary({ summary }: { summary: MeetCallSummary }) { + const { t } = useT(); + return ( +
      + {t('skills.meetingBots.callSummaryHeading')} + {summary.headline.trim() && ( +

      {summary.headline}

      + )} + {summary.key_points.length > 0 && ( +
      +

      + {t('skills.meetingBots.callKeyPointsHeading')} +

      +
        + {summary.key_points.map((point, i) => ( +
      • {point}
      • + ))} +
      +
      + )} + {summary.action_items.length > 0 && ( +
      +

      + {t('skills.meetingBots.callActionItemsHeading')} +

      +
        + {summary.action_items.map((item, i) => { + const meta = [ + item.assignee?.trim() || undefined, + item.kind === 'executable' ? item.tool_name?.trim() || undefined : undefined, + ].filter(Boolean); + return ( +
      • + + + {item.description} + {meta.length > 0 && ( + + {' '} + ({meta.join(' · ')}) + + )} + +
      • + ); + })} +
      +
      + )} +
      + ); +} + +function CallTranscript({ lines }: { lines: MeetCallTranscriptLine[] }) { + const { t } = useT(); + return ( +
      + {t('skills.meetingBots.callTranscriptHeading')} +
      + {lines.map((line, i) => ( +

      + {line.content} +

      + ))} +
      +
      + ); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

      + {children} +

      + ); +} + +function Chevron({ expanded }: { expanded: boolean }) { + return ( + + ); +} + function formatRelativeTime(ms: number): string { if (!ms) return '—'; const diff = Date.now() - ms; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 0d8fd9411..2bbf695e2 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 8a777053e..21ff7d350 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 726ca9656..41f2bbe09 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index c2664a74d..53412ac6d 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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': 'Couldn’t 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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 9cc55210c..f8b4aa53d 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index e1aea1dfc..3636e4b55 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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é n’a é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', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 1dfba806e..56f61fc92 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index d28b59192..e896cc4b3 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 3174e2dab..d3a5e65d9 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 6cdb0db51..e23b41e3f 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index f603e130a..2f2bdf210 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 0ef31b242..29935cc5f 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 52abf4a11..c82e15881 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index aeaa68e5c..a0a2281dc 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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', diff --git a/app/src/services/meetCallService.ts b/app/src/services/meetCallService.ts index 37ea4ed90..24115d7a7 100644 --- a/app/src/services/meetCallService.ts +++ b/app/src/services/meetCallService.ts @@ -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 { 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 { + const result = await callCoreRpc({ + 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 // --------------------------------------------------------------------------- diff --git a/src/openhuman/agent_meetings/bus.rs b/src/openhuman/agent_meetings/bus.rs index f93ad8108..5d635b997 100644 --- a/src/openhuman/agent_meetings/bus.rs +++ b/src/openhuman/agent_meetings/bus.rs @@ -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 { diff --git a/src/openhuman/agent_meetings/ops.rs b/src/openhuman/agent_meetings/ops.rs index 72bbb964f..6aa3f24f7 100644 --- a/src/openhuman/agent_meetings/ops.rs +++ b/src/openhuman/agent_meetings/ops.rs @@ -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, + 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 "). - 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(), diff --git a/src/openhuman/agent_meetings/recent_calls.rs b/src/openhuman/agent_meetings/recent_calls.rs index 902ba1506..63ccfac4f 100644 --- a/src/openhuman/agent_meetings/recent_calls.rs +++ b/src/openhuman/agent_meetings/recent_calls.rs @@ -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 { } /// 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); + } } diff --git a/src/openhuman/agent_meetings/summary.rs b/src/openhuman/agent_meetings/summary.rs index e367ac01b..c6b1913e0 100644 --- a/src/openhuman/agent_meetings/summary.rs +++ b/src/openhuman/agent_meetings/summary.rs @@ -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 { + 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"); diff --git a/src/openhuman/meet_agent/rpc.rs b/src/openhuman/meet_agent/rpc.rs index efdb6b88d..319f7e609 100644 --- a/src/openhuman/meet_agent/rpc.rs +++ b/src/openhuman/meet_agent/rpc.rs @@ -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) -> Result) -> Result { + 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, 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()); diff --git a/src/openhuman/meet_agent/schemas.rs b/src/openhuman/meet_agent/schemas.rs index 87b9d0098..7a62ccfd1 100644 --- a/src/openhuman/meet_agent/schemas.rs +++ b/src/openhuman/meet_agent/schemas.rs @@ -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 { @@ -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) -> ControllerFuture { Box::pin(async move { super::rpc::handle_list_calls(p).await }) } +fn handle_get_call_detail(p: Map) -> 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", ] ); } diff --git a/src/openhuman/meet_agent/store.rs b/src/openhuman/meet_agent/store.rs index a1e9cfa04..600f9223f 100644 --- a/src/openhuman/meet_agent/store.rs +++ b/src/openhuman/meet_agent/store.rs @@ -168,6 +168,136 @@ async fn read_recent_from(path: &Path, limit: usize) -> Result, + #[serde(default)] + pub assignee: Option, +} + +/// 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, + #[serde(default)] + pub action_items: Vec, +} + +/// 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, + #[serde(default)] + pub transcript: Vec, +} + +/// Directory holding per-call detail JSON files — a sibling of `calls.jsonl`. +async fn meet_call_details_dir() -> Result { + 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-` — 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, 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, 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::(&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)); + } } diff --git a/src/openhuman/meet_agent/types.rs b/src/openhuman/meet_agent/types.rs index 35b4bb6b7..ceadd85b3 100644 --- a/src/openhuman/meet_agent/types.rs +++ b/src/openhuman/meet_agent/types.rs @@ -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, +} + /// Inputs to `openhuman.meet_agent_stop_session`. #[derive(Debug, Clone, Deserialize)] pub struct StopSessionRequest {