mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
Claude Opus 4.8
parent
a1c5355734
commit
5756271335
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user