Add configurable call recorder summaries (#22405)

<img width="2560" height="1319" alt="CleanShot 2026-07-02 at 16 58 03"
src="https://github.com/user-attachments/assets/d968bff7-4b57-4816-be9c-02e5af32ae3d"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22405?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
nitin
2026-07-03 21:48:48 +05:30
committed by GitHub
parent 9496a98aa3
commit cbbb11b774
81 changed files with 3257 additions and 54 deletions
@@ -7,6 +7,7 @@
- **Recordings on every meeting**
- **A Call Recording tab**
- **A per-meeting on/off switch**
- **AI meeting summaries**
- **Built for AI & automation**
## 💳 Billing
@@ -15,10 +16,14 @@ Metered in Twenty credits based on the bot's actual recording time, prorated by
duration — **$1.00 per recording-hour** (1 credit). No recording — opted out,
canceled, or no-show — means no charge.
AI summaries use workspace AI credits, billed on the model's token usage — the
cost scales with how much was said in the meeting, typically **$0.02$0.06 per
meeting** on default models. Set the `CALL_RECORDER_SUMMARY_ENABLED` app
variable to `false` to turn summaries off.
## 📌 Heads up
- **Needs a synced calendar + video link** — ad-hoc calls that were never on
your Google, Outlook, or CalDAV calendar aren't recorded.
- **Your copy is yours** — Twenty stores its own video, audio, and transcript,
so they stay available after the source media expires.
@@ -30,9 +30,9 @@ Set these on the application registration after installing
| `RECALL_WEBHOOK_SECRET` | Yes | Svix signing secret (`whsec_…`) used to verify incoming Recall webhooks. |
> **Bot behavior settings** (display name, join timing, lobby and leave
> timeouts) are **application variables** that a workspace admin tunes inside the
> app — not server variables. See **Customize the bot** in the
> [README](./README.md).
> timeouts) and the summary settings (`CALL_RECORDER_SUMMARY_ENABLED`,
> `CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT`) are **application variables**
> that a workspace admin tunes inside the app — not server variables.
## Configuring the Recall webhook
@@ -93,3 +93,5 @@ webhook update is missed.
| Webhook rejected with `500` (`RECALL_WEBHOOK_SECRET … not set`) | `RECALL_WEBHOOK_SECRET` is not set | Set it on the application registration |
| Bot left almost immediately | No one was admitted before the lobby / empty-meeting timeout, or everyone left | Adjust the lobby / empty-meeting timeouts in the app settings (see **Customize the bot** in the README) if they're too aggressive |
| Bot joined a meeting you didn't want recorded | Recording is on by default | Set the event's **Recording** field to Off; the scheduled bot is canceled |
| Summary stays empty after the transcript arrives | Summaries are disabled, or the summarizer run failed (for example, out of AI credits) | Confirm `CALL_RECORDER_SUMMARY_ENABLED` isn't `false` and the workspace has AI credits |
| Summary shows "No summary available." | The transcript was empty or unintelligible | No action needed; this is the expected outcome for low-quality transcripts |
@@ -1,6 +1,6 @@
{
"name": "@twentyhq/call-recorder",
"version": "1.0.5",
"version": "1.0.6",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -0,0 +1,15 @@
import { defineAgent } from 'twenty-sdk/define';
import { CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-summarizer-agent-universal-identifier';
import { DEFAULT_CALL_RECORDING_SUMMARY_PROMPT } from 'src/constants/default-call-recording-summary-prompt';
export default defineAgent({
universalIdentifier: CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER,
name: 'call-recording-summarizer',
label: 'Call Recording Summarizer',
icon: 'IconFileText',
description:
'Summarizes a meeting transcript into structured Markdown notes stored on the Call Recording.',
prompt: DEFAULT_CALL_RECORDING_SUMMARY_PROMPT,
responseFormat: { type: 'text' },
});
@@ -8,6 +8,8 @@ import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDEN
import { CALL_RECORDER_JOIN_EARLY_MINUTES_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-join-early-minutes-app-variable-universal-identifier';
import { CALL_RECORDER_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-name-app-variable-universal-identifier';
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-noone-joined-timeout-seconds-app-variable-universal-identifier';
import { CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-additional-summary-prompt-app-variable-universal-identifier';
import { CALL_RECORDER_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-summary-enabled-app-variable-universal-identifier';
import { CALL_RECORDER_USE_WORKSPACE_LOGO_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-use-workspace-logo-app-variable-universal-identifier';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-waiting-room-timeout-seconds-app-variable-universal-identifier';
import { CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-bot-image-background-env-var-name';
@@ -19,6 +21,8 @@ import { CALL_RECORDER_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/c
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds';
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds-env-var-name';
import { CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-retention-hours-env-var-name';
import { CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name';
import { CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-summary-enabled-env-var-name';
import { CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-use-workspace-logo-env-var-name';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds-env-var-name';
@@ -27,6 +31,7 @@ import { DEFAULT_CALL_RECORDER_JOIN_EARLY_MINUTES } from 'src/logic-functions/co
import { DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB } from 'src/logic-functions/constants/default-call-recorder-max-media-file-size-mb';
import { DEFAULT_CALL_RECORDER_NAME } from 'src/logic-functions/constants/default-call-recorder-name';
import { DEFAULT_CALL_RECORDER_RECORDING_RETENTION_HOURS } from 'src/logic-functions/constants/default-call-recorder-recording-retention-hours';
import { DEFAULT_CALL_RECORDER_SUMMARY_ENABLED } from 'src/logic-functions/constants/default-call-recorder-summary-enabled';
import { DEFAULT_CALL_RECORDER_USE_WORKSPACE_LOGO } from 'src/logic-functions/constants/default-call-recorder-use-workspace-logo';
import { DEFAULT_RECALL_REGION } from 'src/logic-functions/constants/default-recall-region';
import { RECALL_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-api-key-env-var-name';
@@ -79,6 +84,21 @@ export default defineApplication({
isSecret: false,
value: String(CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS),
},
[CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
description:
'Whether AI summaries are generated for call recordings. Set to false to disable and avoid AI credit usage.',
isSecret: false,
value: String(DEFAULT_CALL_RECORDER_SUMMARY_ENABLED),
},
[CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
description:
'Extra instructions appended to the built-in summary prompt (tone, language, focus areas). Leave empty to use the built-in prompt alone.',
isSecret: false,
},
[CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_USE_WORKSPACE_LOGO_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
@@ -0,0 +1,18 @@
import {
defineCommandMenuItem,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { GENERATE_CALL_RECORDING_SUMMARY_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER } from 'src/constants/generate-call-recording-summary-command-menu-item-universal-identifier';
import { GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/generate-call-recording-summary-front-component-universal-identifier';
export default defineCommandMenuItem({
universalIdentifier:
GENERATE_CALL_RECORDING_SUMMARY_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER,
availabilityObjectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
frontComponentUniversalIdentifier:
GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
label: 'Generate call summary',
availabilityType: 'RECORD_SELECTION',
});
@@ -0,0 +1,2 @@
export const CALENDAR_EVENT_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'960d8d79-22f4-4892-9d66-ac94ac8a3f62';
@@ -0,0 +1,2 @@
export const CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER =
'8ee79a0f-e02d-4292-be9f-a9344f202f8c';
@@ -0,0 +1,2 @@
export const CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER =
'd6652f36-5963-4daa-a3ba-ec55bfe2735c';
@@ -0,0 +1,2 @@
export const CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'587ead10-c514-468c-944f-17050c88dcc1';
@@ -0,0 +1,2 @@
export const CALL_RECORDER_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'1d7bf052-4338-44da-b373-23f6d291a50c';
@@ -0,0 +1,2 @@
export const CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER =
'388543f3-ec85-4905-ad7d-99801337ed5c';
@@ -0,0 +1,50 @@
export const DEFAULT_CALL_RECORDING_SUMMARY_PROMPT = [
'You are an AI meeting note-taker for a CRM. You receive the diarized,',
'time-stamped transcript of a recorded call and produce clean, factual',
'meeting notes for the rep who attended.',
'',
'Output GitHub-flavored Markdown only — no preamble, no closing remarks, no',
'surrounding code fences. Emit exactly these sections, in this order, each',
'with its heading exactly as written. Omit a section only when its rule says',
'to.',
'',
'## Gist',
'A single sentence (no bullet) capturing the essence of the meeting.',
'',
'## Overview',
'One paragraph of 2-4 sentences covering what the meeting was about and how',
'it ended. No bullets.',
'',
'## Action Items',
'Concrete follow-ups, grouped by the person responsible. Write each owner as',
'a bold line, then their tasks as a bulleted list beneath it. Use the',
'speaker name from the transcript, or "Unassigned" when no owner is clear.',
'Example:',
'**Alex**',
'- Send the pricing deck',
'- Follow up on Friday',
'If the call produced no follow-ups, write the single line: None.',
'',
'## Notes',
'The discussion split into short topical sections in chronological order.',
'Begin each section with a bold title and the [mm:ss] timestamp it started',
'at, then summarize it as concise bullets. Example:',
'**Introductions** — 0:00',
'- ...',
'**Pricing discussion** — 4:12',
'- ...',
'',
'## Keywords',
'A single comma-separated line of the most important terms, names, and',
'topics from the call.',
'',
'Grounding rules: use only information present in the transcript. Never',
'invent names, numbers, commitments, outcomes, or times — reuse the [mm:ss]',
'timestamps exactly as they appear in the transcript. If the transcript is',
'too short or unintelligible to summarize, ignore all of the above and',
'return only the single line: No summary available.',
'',
'The message may include additional instructions from the workspace admin.',
'Apply them on top of these rules without breaking the output format or the',
'grounding rules above.',
].join('\n');
@@ -0,0 +1,2 @@
export const GENERATE_CALL_RECORDING_SUMMARIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'3467c68d-5d81-4f16-8234-b4182cb17f19';
@@ -0,0 +1,2 @@
export const GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH =
'/call-recorder/generate-call-recording-summaries';
@@ -0,0 +1,2 @@
export const GENERATE_CALL_RECORDING_SUMMARY_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'6b7b0989-2946-413b-9b6a-08336b418788';
@@ -0,0 +1,2 @@
export const GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'4b81fd8f-5626-4d5a-a97e-0543436bb555';
@@ -0,0 +1,2 @@
export const START_CALL_RECORDING_SUMMARY_BACKFILL_ON_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'53e0acb4-b761-40c9-8aaf-554d2a5da00f';
@@ -0,0 +1,2 @@
export const SUMMARIZE_CALL_RECORDING_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'c7a59e9c-935d-45ae-ae95-ded8d327f581';
@@ -11,7 +11,7 @@ export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default role`,
description:
'Reads calendar events to decide whether the call recorder should attend a meeting; writes the resulting CallRecording records, uploads recording media, and fills transcripts.',
'Reads calendar events to decide whether the call recorder should attend a meeting; writes the resulting CallRecording records, uploads recording media, and fills transcripts and summaries.',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
@@ -65,5 +65,8 @@ export default defineApplicationRole({
},
],
fieldPermissions: [],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE],
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.UPLOAD_FILE,
SystemPermissionFlag.AI,
],
});
@@ -0,0 +1,13 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { CALENDAR_EVENT_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/calendar-event-summary-front-component-universal-identifier';
import { CalendarEventSummary } from 'src/front-components/components/CalendarEventSummary';
export default defineFrontComponent({
universalIdentifier:
CALENDAR_EVENT_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'calendar-event-summary',
description:
'Read-only AI summary viewer for the calendar event record page.',
component: CalendarEventSummary,
});
@@ -0,0 +1,39 @@
import styled from '@emotion/styled';
import { isUndefined } from '@sniptt/guards';
import { useSelectedRecordIds } from 'twenty-sdk/front-component';
import { CalendarEventSummaryContent } from 'src/front-components/components/CalendarEventSummaryContent';
import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
const StyledCenteredState = styled.div`
align-items: center;
box-sizing: border-box;
color: ${recordingThemeCssVariables.font.colorTertiary};
display: flex;
font-family: ${recordingThemeCssVariables.font.family};
font-size: ${recordingThemeCssVariables.font.sizeSm};
height: 100%;
justify-content: center;
padding: ${recordingThemeCssVariables.spacing[4]};
`;
export const CalendarEventSummary = () => {
const selectedRecordIds = useSelectedRecordIds();
const calendarEventId =
selectedRecordIds.length === 1 ? selectedRecordIds[0] : undefined;
if (isUndefined(calendarEventId)) {
return (
<StyledCenteredState>
Open a calendar event to see its summary.
</StyledCenteredState>
);
}
return (
<CalendarEventSummaryContent
key={calendarEventId}
calendarEventId={calendarEventId}
/>
);
};
@@ -0,0 +1,53 @@
import styled from '@emotion/styled';
import { isUndefined } from '@sniptt/guards';
import { SummaryMarkdown } from 'src/front-components/components/SummaryMarkdown';
import { TranscriptErrorBox } from 'src/front-components/components/TranscriptErrorBox';
import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
const StyledCenteredState = styled.div`
align-items: center;
box-sizing: border-box;
color: ${recordingThemeCssVariables.font.colorTertiary};
display: flex;
font-family: ${recordingThemeCssVariables.font.family};
font-size: ${recordingThemeCssVariables.font.sizeSm};
justify-content: center;
min-height: 240px;
padding: ${recordingThemeCssVariables.spacing[4]};
`;
type CalendarEventSummaryBodyProps = {
summaryMarkdown: string | undefined;
isCalendarEventSummaryQueryLoading: boolean;
errorMessage: string | undefined;
};
export const CalendarEventSummaryBody = ({
summaryMarkdown,
isCalendarEventSummaryQueryLoading,
errorMessage,
}: CalendarEventSummaryBodyProps) => {
if (!isUndefined(errorMessage)) {
return (
<TranscriptErrorBox
title="Failed to load the summary"
description={errorMessage}
/>
);
}
if (isCalendarEventSummaryQueryLoading) {
return <StyledCenteredState>Loading summary</StyledCenteredState>;
}
if (isUndefined(summaryMarkdown)) {
return (
<StyledCenteredState>
No summary for this calendar event yet.
</StyledCenteredState>
);
}
return <SummaryMarkdown markdown={summaryMarkdown} />;
};
@@ -0,0 +1,77 @@
import styled from '@emotion/styled';
import { CalendarEventSummaryBody } from 'src/front-components/components/CalendarEventSummaryBody';
import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
import { useCalendarEventSummary } from 'src/front-components/hooks/use-calendar-event-summary';
const StyledSummaryShell = styled.div`
background: ${recordingThemeCssVariables.background.primary};
border: 1px solid transparent;
border-radius: ${recordingThemeCssVariables.border.radiusMd};
box-sizing: border-box;
font-family: ${recordingThemeCssVariables.font.family};
padding: ${recordingThemeCssVariables.spacing[4]};
position: relative;
width: 100%;
`;
const StyledSummaryHeader = styled.div`
align-items: center;
box-sizing: border-box;
display: flex;
height: ${recordingThemeCssVariables.spacing[6]};
`;
const StyledSummaryTitle = styled.h2`
color: ${recordingThemeCssVariables.font.colorPrimary};
flex: 1;
font-size: ${recordingThemeCssVariables.font.sizeMd};
font-weight: ${recordingThemeCssVariables.font.weightMedium};
margin: 0;
overflow: hidden;
padding-inline: ${recordingThemeCssVariables.spacing[1]};
user-select: none;
`;
const StyledSummaryBody = styled.div`
box-sizing: border-box;
margin-top: ${recordingThemeCssVariables.spacing[2]};
`;
const StyledSummaryContentFrame = styled.div`
background-color: ${recordingThemeCssVariables.background.secondary};
border: 1px solid ${recordingThemeCssVariables.border.colorMedium};
border-radius: ${recordingThemeCssVariables.border.radiusMd};
box-sizing: border-box;
padding: ${recordingThemeCssVariables.spacing[3]};
`;
type CalendarEventSummaryContentProps = {
calendarEventId: string;
};
export const CalendarEventSummaryContent = ({
calendarEventId,
}: CalendarEventSummaryContentProps) => {
const { summaryMarkdown, isCalendarEventSummaryQueryLoading, errorMessage } =
useCalendarEventSummary(calendarEventId);
return (
<StyledSummaryShell>
<StyledSummaryHeader>
<StyledSummaryTitle>Summary</StyledSummaryTitle>
</StyledSummaryHeader>
<StyledSummaryBody>
<StyledSummaryContentFrame>
<CalendarEventSummaryBody
summaryMarkdown={summaryMarkdown}
isCalendarEventSummaryQueryLoading={
isCalendarEventSummaryQueryLoading
}
errorMessage={errorMessage}
/>
</StyledSummaryContentFrame>
</StyledSummaryBody>
</StyledSummaryShell>
);
};
@@ -0,0 +1,28 @@
import styled from '@emotion/styled';
import { Fragment } from 'react';
import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
import { type SummaryInlineSegment } from 'src/front-components/types/summary-inline-segment.type';
const StyledBold = styled.strong`
color: ${recordingThemeCssVariables.font.colorPrimary};
font-weight: ${recordingThemeCssVariables.font.weightMedium};
`;
type SummaryInlineSegmentsProps = {
segments: SummaryInlineSegment[];
};
export const SummaryInlineSegments = ({
segments,
}: SummaryInlineSegmentsProps) => (
<>
{segments.map((segment, index) =>
segment.isBold ? (
<StyledBold key={index}>{segment.text}</StyledBold>
) : (
<Fragment key={index}>{segment.text}</Fragment>
),
)}
</>
);
@@ -0,0 +1,87 @@
import styled from '@emotion/styled';
import { useMemo } from 'react';
import { SummaryInlineSegments } from 'src/front-components/components/SummaryInlineSegments';
import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
import { parseSummaryMarkdownBlocks } from 'src/front-components/utils/parse-summary-markdown-blocks.util';
const TOP_LEVEL_HEADING_MAX = 2;
const StyledSummary = styled.div`
color: ${recordingThemeCssVariables.font.colorSecondary};
display: flex;
flex-direction: column;
font-family: ${recordingThemeCssVariables.font.family};
font-size: ${recordingThemeCssVariables.font.sizeSm};
gap: ${recordingThemeCssVariables.spacing[2]};
line-height: 1.5;
`;
const StyledHeading = styled.h3<{ $isTopLevel: boolean }>`
color: ${recordingThemeCssVariables.font.colorPrimary};
font-size: ${({ $isTopLevel }) =>
$isTopLevel
? recordingThemeCssVariables.font.sizeMd
: recordingThemeCssVariables.font.sizeSm};
font-weight: ${recordingThemeCssVariables.font.weightMedium};
margin: 0;
margin-top: ${recordingThemeCssVariables.spacing[1]};
`;
const StyledParagraph = styled.p`
margin: 0;
`;
const StyledList = styled.ul`
display: flex;
flex-direction: column;
gap: ${recordingThemeCssVariables.spacing[1]};
margin: 0;
padding-left: ${recordingThemeCssVariables.spacing[3]};
`;
type SummaryMarkdownProps = {
markdown: string;
};
export const SummaryMarkdown = ({ markdown }: SummaryMarkdownProps) => {
const blocks = useMemo(
() => parseSummaryMarkdownBlocks(markdown),
[markdown],
);
return (
<StyledSummary>
{blocks.map((block, index) => {
if (block.type === 'heading') {
return (
<StyledHeading
key={index}
$isTopLevel={block.level <= TOP_LEVEL_HEADING_MAX}
>
<SummaryInlineSegments segments={block.segments} />
</StyledHeading>
);
}
if (block.type === 'list') {
return (
<StyledList key={index}>
{block.items.map((item, itemIndex) => (
<li key={itemIndex}>
<SummaryInlineSegments segments={item} />
</li>
))}
</StyledList>
);
}
return (
<StyledParagraph key={index}>
<SummaryInlineSegments segments={block.segments} />
</StyledParagraph>
);
})}
</StyledSummary>
);
};
@@ -8,7 +8,7 @@ import {
type TranscriptEntry,
type TranscriptWord,
} from 'src/front-components/types/transcript-entry.type';
import { formatTranscriptTimestamp } from 'src/front-components/utils/format-transcript-timestamp.util';
import { formatSecondsAsClockTimestamp } from 'src/logic-functions/utils/format-seconds-as-clock-timestamp.util';
const StyledEntry = styled.div<{ $isActive: boolean }>`
align-items: flex-start;
@@ -86,7 +86,7 @@ export const TranscriptEntryListItem = ({
/>
{!isUndefined(entry.startSeconds) && (
<StyledTimestamp>
{formatTranscriptTimestamp(entry.startSeconds)}
{formatSecondsAsClockTimestamp(entry.startSeconds)}
</StyledTimestamp>
)}
</StyledEntryHeader>
@@ -0,0 +1,27 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command, useSelectedRecordIds } from 'twenty-sdk/front-component';
import { GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/generate-call-recording-summary-front-component-universal-identifier';
import { requestCallRecordingSummaryGeneration } from 'src/front-components/utils/request-call-recording-summary-generation.util';
const GenerateCallRecordingSummary = () => {
const calendarEventIds = useSelectedRecordIds();
return (
<Command
execute={() =>
requestCallRecordingSummaryGeneration({ calendarEventIds })
}
/>
);
};
export default defineFrontComponent({
universalIdentifier:
GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'generate-call-recording-summary-effect',
description:
'Requests AI summary generation for the call recordings of the selected calendar events.',
component: GenerateCallRecordingSummary,
isHeadless: true,
});
@@ -0,0 +1,117 @@
import { isUndefined } from '@sniptt/guards';
import { useEffect, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
type CalendarEventSummaryState = {
summaryMarkdown: string | undefined;
isCalendarEventSummaryQueryLoading: boolean;
errorMessage: string | undefined;
};
type CalendarEventSummaryCallRecordingNode = {
id: string;
summary: { markdown: string | null } | null;
};
type CalendarEventSummaryCallRecordingEdge = {
node: CalendarEventSummaryCallRecordingNode;
};
const CALENDAR_EVENT_SUMMARY_LOOKUP_LIMIT = 10;
const CALENDAR_EVENT_SUMMARY_ERROR_MESSAGE = 'Please try again later.';
const selectSummaryMarkdown = (
callRecordingNodes: CalendarEventSummaryCallRecordingNode[],
): string | undefined => {
const summaryMarkdown = callRecordingNodes.find((callRecordingNode) =>
isNonEmptyString(callRecordingNode.summary?.markdown),
)?.summary?.markdown;
return isNonEmptyString(summaryMarkdown) ? summaryMarkdown : undefined;
};
export const useCalendarEventSummary = (
calendarEventId: string | undefined,
): CalendarEventSummaryState => {
const [state, setState] = useState<CalendarEventSummaryState>({
summaryMarkdown: undefined,
isCalendarEventSummaryQueryLoading: !isUndefined(calendarEventId),
errorMessage: undefined,
});
useEffect(() => {
if (isUndefined(calendarEventId)) {
setState({
summaryMarkdown: undefined,
isCalendarEventSummaryQueryLoading: false,
errorMessage: undefined,
});
return;
}
let cancelled = false;
const fetchSummary = async () => {
setState({
summaryMarkdown: undefined,
isCalendarEventSummaryQueryLoading: true,
errorMessage: undefined,
});
try {
const client = new CoreApiClient();
const queryResult = await client.query({
callRecordings: {
__args: {
filter: { calendarEventId: { eq: calendarEventId } },
orderBy: [{ startedAt: 'DescNullsLast' }],
first: CALENDAR_EVENT_SUMMARY_LOOKUP_LIMIT,
},
edges: {
node: {
id: true,
summary: { markdown: true },
},
},
},
});
if (cancelled) {
return;
}
const callRecordingEdges = (queryResult.callRecordings?.edges ??
[]) as CalendarEventSummaryCallRecordingEdge[];
const callRecordingNodes = callRecordingEdges.map(
(callRecordingEdge) => callRecordingEdge.node,
);
setState({
summaryMarkdown: selectSummaryMarkdown(callRecordingNodes),
isCalendarEventSummaryQueryLoading: false,
errorMessage: undefined,
});
} catch {
if (cancelled) {
return;
}
setState({
summaryMarkdown: undefined,
isCalendarEventSummaryQueryLoading: false,
errorMessage: CALENDAR_EVENT_SUMMARY_ERROR_MESSAGE,
});
}
};
fetchSummary();
return () => {
cancelled = true;
};
}, [calendarEventId]);
return state;
};
@@ -0,0 +1,4 @@
export type SummaryInlineSegment = {
text: string;
isBold: boolean;
};
@@ -0,0 +1,6 @@
import { type SummaryInlineSegment } from 'src/front-components/types/summary-inline-segment.type';
export type SummaryMarkdownBlock =
| { type: 'heading'; level: number; segments: SummaryInlineSegment[] }
| { type: 'paragraph'; segments: SummaryInlineSegment[] }
| { type: 'list'; items: SummaryInlineSegment[][] };
@@ -1,29 +0,0 @@
import { describe, expect, it } from 'vitest';
import { formatTranscriptTimestamp } from 'src/front-components/utils/format-transcript-timestamp.util';
describe('formatTranscriptTimestamp', () => {
it('formats sub-hour durations as minutes and padded seconds', () => {
expect(formatTranscriptTimestamp(0)).toBe('0:00');
expect(formatTranscriptTimestamp(5)).toBe('0:05');
expect(formatTranscriptTimestamp(65)).toBe('1:05');
expect(formatTranscriptTimestamp(3599)).toBe('59:59');
});
it('adds an hour part with padded minutes past one hour', () => {
expect(formatTranscriptTimestamp(3600)).toBe('1:00:00');
expect(formatTranscriptTimestamp(3725)).toBe('1:02:05');
expect(formatTranscriptTimestamp(7322)).toBe('2:02:02');
});
it('floors fractional seconds', () => {
expect(formatTranscriptTimestamp(1.9)).toBe('0:01');
expect(formatTranscriptTimestamp(59.999)).toBe('0:59');
});
it('clamps negative and non-finite input to zero', () => {
expect(formatTranscriptTimestamp(-12)).toBe('0:00');
expect(formatTranscriptTimestamp(Number.NaN)).toBe('0:00');
expect(formatTranscriptTimestamp(Number.POSITIVE_INFINITY)).toBe('0:00');
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { parseSummaryInlineSegments } from 'src/front-components/utils/parse-summary-inline-segments.util';
describe('parseSummaryInlineSegments', () => {
it('returns a single plain segment when there is no bold', () => {
expect(parseSummaryInlineSegments('Just text')).toEqual([
{ text: 'Just text', isBold: false },
]);
});
it('splits a bold run from surrounding text', () => {
expect(parseSummaryInlineSegments('before **bold** after')).toEqual([
{ text: 'before ', isBold: false },
{ text: 'bold', isBold: true },
{ text: ' after', isBold: false },
]);
});
it('preserves whitespace between adjacent bold runs', () => {
expect(parseSummaryInlineSegments('**Alex** **Sam**')).toEqual([
{ text: 'Alex', isBold: true },
{ text: ' ', isBold: false },
{ text: 'Sam', isBold: true },
]);
});
it('treats unclosed or empty markers as plain text', () => {
expect(parseSummaryInlineSegments('**unclosed')).toEqual([
{ text: '**unclosed', isBold: false },
]);
expect(parseSummaryInlineSegments('****')).toEqual([
{ text: '****', isBold: false },
]);
});
it('returns an empty array for an empty string', () => {
expect(parseSummaryInlineSegments('')).toEqual([]);
});
});
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { parseSummaryMarkdownBlocks } from 'src/front-components/utils/parse-summary-markdown-blocks.util';
describe('parseSummaryMarkdownBlocks', () => {
it('parses headings with their level', () => {
expect(parseSummaryMarkdownBlocks('## Overview')).toEqual([
{
type: 'heading',
level: 2,
segments: [{ text: 'Overview', isBold: false }],
},
]);
});
it('groups consecutive bullet lines into a single list block', () => {
expect(parseSummaryMarkdownBlocks('- First\n- Second')).toEqual([
{
type: 'list',
items: [
[{ text: 'First', isBold: false }],
[{ text: 'Second', isBold: false }],
],
},
]);
});
it('closes the open list when a heading or paragraph follows', () => {
expect(parseSummaryMarkdownBlocks('- Item\nParagraph after')).toEqual([
{ type: 'list', items: [[{ text: 'Item', isBold: false }]] },
{
type: 'paragraph',
segments: [{ text: 'Paragraph after', isBold: false }],
},
]);
});
it('keeps bold segments inside headings and bullets', () => {
expect(parseSummaryMarkdownBlocks('**Alex**\n- Send the deck')).toEqual([
{ type: 'paragraph', segments: [{ text: 'Alex', isBold: true }] },
{ type: 'list', items: [[{ text: 'Send the deck', isBold: false }]] },
]);
});
it('ignores blank lines and separates blocks by them', () => {
expect(parseSummaryMarkdownBlocks('## Gist\n\nA one-line recap.')).toEqual([
{
type: 'heading',
level: 2,
segments: [{ text: 'Gist', isBold: false }],
},
{
type: 'paragraph',
segments: [{ text: 'A one-line recap.', isBold: false }],
},
]);
});
it('returns no blocks for empty or whitespace-only markdown', () => {
expect(parseSummaryMarkdownBlocks('')).toEqual([]);
expect(parseSummaryMarkdownBlocks('\n \n')).toEqual([]);
});
});
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
import { requestCallRecordingSummaryGeneration } from 'src/front-components/utils/request-call-recording-summary-generation.util';
const enqueueSnackbarMock = vi.hoisted(() => vi.fn());
const postMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-sdk/front-component', () => ({
enqueueSnackbar: enqueueSnackbarMock,
}));
vi.mock('twenty-client-sdk/rest', () => ({
RestApiClient: vi.fn(function RestApiClient() {
return {
post: postMock,
};
}),
}));
describe('requestCallRecordingSummaryGeneration', () => {
beforeEach(() => {
vi.clearAllMocks();
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: ['call-recording-1'],
failedCallRecordingIds: [],
erroredCallRecordingIds: [],
});
});
it('does nothing when no calendar events are selected', async () => {
await requestCallRecordingSummaryGeneration({ calendarEventIds: [] });
expect(postMock).not.toHaveBeenCalled();
expect(enqueueSnackbarMock).not.toHaveBeenCalled();
});
it('reports mixed generation results', async () => {
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: ['call-recording-1'],
failedCallRecordingIds: ['call-recording-2'],
erroredCallRecordingIds: [],
});
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1', 'calendar-event-2'],
});
expect(postMock).toHaveBeenCalledWith(
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{ calendarEventIds: ['calendar-event-1', 'calendar-event-2'] },
);
expect(enqueueSnackbarMock).toHaveBeenCalledWith({
message: 'Some summaries generated, some failed.',
variant: 'error',
});
});
it('reports generation errors as failed summaries', async () => {
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: [],
failedCallRecordingIds: [],
erroredCallRecordingIds: ['call-recording-1'],
});
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1'],
});
expect(enqueueSnackbarMock).toHaveBeenCalledWith({
message: 'Summary generation failed.',
variant: 'error',
});
});
});
@@ -1,16 +0,0 @@
export const formatTranscriptTimestamp = (totalSeconds: number): string => {
const safeSeconds = Number.isFinite(totalSeconds)
? Math.max(0, Math.floor(totalSeconds))
: 0;
const hours = Math.floor(safeSeconds / 3600);
const minutes = Math.floor((safeSeconds % 3600) / 60);
const seconds = safeSeconds % 60;
const paddedSeconds = String(seconds).padStart(2, '0');
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${paddedSeconds}`;
}
return `${minutes}:${paddedSeconds}`;
};
@@ -0,0 +1,39 @@
import { type SummaryInlineSegment } from 'src/front-components/types/summary-inline-segment.type';
const BOLD_PATTERN = /\*\*(.+?)\*\*/g;
// Splits a markdown line into plain and **bold** runs, preserving surrounding
// whitespace so adjacent bold runs don't collapse together.
export const parseSummaryInlineSegments = (
text: string,
): SummaryInlineSegment[] => {
const boldMatches = [...text.matchAll(BOLD_PATTERN)];
const { segments, cursor } = boldMatches.reduce<{
segments: SummaryInlineSegment[];
cursor: number;
}>(
(accumulator, match) => {
const matchStart = match.index ?? 0;
const leadingText = text.slice(accumulator.cursor, matchStart);
const leadingSegments =
leadingText.length > 0 ? [{ text: leadingText, isBold: false }] : [];
return {
segments: [
...accumulator.segments,
...leadingSegments,
{ text: match[1], isBold: true },
],
cursor: matchStart + match[0].length,
};
},
{ segments: [], cursor: 0 },
);
const trailingText = text.slice(cursor);
return trailingText.length > 0
? [...segments, { text: trailingText, isBold: false }]
: segments;
};
@@ -0,0 +1,79 @@
import { type SummaryInlineSegment } from 'src/front-components/types/summary-inline-segment.type';
import { type SummaryMarkdownBlock } from 'src/front-components/types/summary-markdown-block.type';
import { parseSummaryInlineSegments } from 'src/front-components/utils/parse-summary-inline-segments.util';
const HEADING_PATTERN = /^(#{1,6})\s+(.+)$/;
const BULLET_PATTERN = /^[-*]\s+(.+)$/;
const flushPendingList = (
blocks: SummaryMarkdownBlock[],
pendingListItems: SummaryInlineSegment[][],
): SummaryMarkdownBlock[] =>
pendingListItems.length > 0
? [...blocks, { type: 'list', items: pendingListItems }]
: blocks;
export const parseSummaryMarkdownBlocks = (
markdown: string,
): SummaryMarkdownBlock[] => {
const { blocks, pendingListItems } = markdown.split('\n').reduce<{
blocks: SummaryMarkdownBlock[];
pendingListItems: SummaryInlineSegment[][];
}>(
(accumulator, rawLine) => {
const line = rawLine.trim();
if (line === '') {
return {
blocks: flushPendingList(
accumulator.blocks,
accumulator.pendingListItems,
),
pendingListItems: [],
};
}
const headingMatch = HEADING_PATTERN.exec(line);
if (headingMatch !== null) {
return {
blocks: [
...flushPendingList(
accumulator.blocks,
accumulator.pendingListItems,
),
{
type: 'heading',
level: headingMatch[1].length,
segments: parseSummaryInlineSegments(headingMatch[2]),
},
],
pendingListItems: [],
};
}
const bulletMatch = BULLET_PATTERN.exec(line);
if (bulletMatch !== null) {
return {
blocks: accumulator.blocks,
pendingListItems: [
...accumulator.pendingListItems,
parseSummaryInlineSegments(bulletMatch[1]),
],
};
}
return {
blocks: [
...flushPendingList(accumulator.blocks, accumulator.pendingListItems),
{ type: 'paragraph', segments: parseSummaryInlineSegments(line) },
],
pendingListItems: [],
};
},
{ blocks: [], pendingListItems: [] },
);
return flushPendingList(blocks, pendingListItems);
};
@@ -0,0 +1,81 @@
import { RestApiClient } from 'twenty-client-sdk/rest';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
type GenerateSummariesResponse = {
outcome?: string;
generatedCallRecordingIds?: string[];
failedCallRecordingIds?: string[];
erroredCallRecordingIds?: string[];
};
const buildSnackbarForResponse = (
response: GenerateSummariesResponse,
): { message: string; variant: 'success' | 'error' } => {
const generatedCallRecordingCount = (response.generatedCallRecordingIds ?? [])
.length;
const failedCallRecordingCount =
(response.failedCallRecordingIds ?? []).length +
(response.erroredCallRecordingIds ?? []).length;
if (response.outcome === 'disabled') {
return {
message: 'Call summaries are disabled for this workspace.',
variant: 'error',
};
}
if (response.outcome === 'no-call-recordings-for-calendar-events') {
return {
message: 'No call recording found for this event.',
variant: 'error',
};
}
if (generatedCallRecordingCount > 0 && failedCallRecordingCount > 0) {
return {
message: 'Some summaries generated, some failed.',
variant: 'error',
};
}
if (generatedCallRecordingCount > 0) {
return { message: 'Summary generated.', variant: 'success' };
}
if (failedCallRecordingCount > 0) {
return { message: 'Summary generation failed.', variant: 'error' };
}
return {
message: 'No summary to generate for this event.',
variant: 'success',
};
};
export const requestCallRecordingSummaryGeneration = async ({
calendarEventIds,
}: {
calendarEventIds: string[];
}): Promise<void> => {
if (calendarEventIds.length === 0) {
return;
}
try {
const client = new RestApiClient();
const response = await client.post<GenerateSummariesResponse>(
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{ calendarEventIds },
);
await enqueueSnackbar(buildSnackbarForResponse(response ?? {}));
} catch {
await enqueueSnackbar({
message: 'Summary generation failed.',
variant: 'error',
});
}
};
@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { type RoutePayload } from 'twenty-sdk/define';
import { generateCallRecordingSummariesHandler } from 'src/logic-functions/generate-call-recording-summaries';
const findCallRecordingIdsMissingSummaryMock = vi.hoisted(() => vi.fn());
const findCallRecordingIdsForCalendarEventsMock = vi.hoisted(() => vi.fn());
const generateMissingCallRecordingSummariesMock = vi.hoisted(() => vi.fn());
const isCallRecordingSummaryEnabledMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(),
}));
vi.mock(
'src/logic-functions/data/find-call-recording-ids-missing-summary.util',
() => ({
findCallRecordingIdsMissingSummary: findCallRecordingIdsMissingSummaryMock,
}),
);
vi.mock(
'src/logic-functions/data/find-call-recording-ids-for-calendar-events.util',
() => ({
findCallRecordingIdsForCalendarEvents:
findCallRecordingIdsForCalendarEventsMock,
}),
);
vi.mock(
'src/logic-functions/flows/generate-missing-call-recording-summaries.util',
() => ({
generateMissingCallRecordingSummaries:
generateMissingCallRecordingSummariesMock,
}),
);
vi.mock(
'src/logic-functions/utils/is-call-recording-summary-enabled.util',
() => ({
isCallRecordingSummaryEnabled: isCallRecordingSummaryEnabledMock,
}),
);
const buildRoutePayload = (
body: object | null,
): RoutePayload<{ callRecordingIds?: string[]; calendarEventIds?: string[] }> =>
({
body,
headers: {},
queryStringParameters: {},
pathParameters: {},
isBase64Encoded: false,
rawBody: undefined,
requestContext: { http: { method: 'POST', path: '/' } },
userWorkspaceId: null,
}) as never;
const BATCH_RESULT = {
generatedCallRecordingIds: ['call-recording-1'],
failedCallRecordingIds: [],
erroredCallRecordingIds: [],
skippedCallRecordingIds: [],
remainingCallRecordingIds: [],
continuationRequested: false,
};
describe('generateCallRecordingSummariesHandler', () => {
beforeEach(() => {
vi.clearAllMocks();
isCallRecordingSummaryEnabledMock.mockReturnValue(true);
findCallRecordingIdsMissingSummaryMock.mockResolvedValue([]);
findCallRecordingIdsForCalendarEventsMock.mockResolvedValue([]);
generateMissingCallRecordingSummariesMock.mockResolvedValue(BATCH_RESULT);
});
it('returns disabled without touching data when summaries are off', async () => {
isCallRecordingSummaryEnabledMock.mockReturnValue(false);
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload(null),
);
expect(result).toEqual({ outcome: 'disabled' });
expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled();
expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled();
});
it('processes explicit call recording ids without sweeping', async () => {
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload({ callRecordingIds: ['call-recording-1'] }),
);
expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT });
expect(generateMissingCallRecordingSummariesMock).toHaveBeenCalledWith(
expect.objectContaining({ callRecordingIds: ['call-recording-1'] }),
);
expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled();
expect(findCallRecordingIdsForCalendarEventsMock).not.toHaveBeenCalled();
});
it('resolves calendar event ids to their call recordings', async () => {
findCallRecordingIdsForCalendarEventsMock.mockResolvedValue([
'call-recording-7',
]);
await generateCallRecordingSummariesHandler(
buildRoutePayload({ calendarEventIds: ['calendar-event-1'] }),
);
expect(findCallRecordingIdsForCalendarEventsMock).toHaveBeenCalledWith(
expect.anything(),
{ calendarEventIds: ['calendar-event-1'] },
);
expect(generateMissingCallRecordingSummariesMock).toHaveBeenCalledWith(
expect.objectContaining({ callRecordingIds: ['call-recording-7'] }),
);
expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled();
});
it('reports when the selected calendar events have no recordings instead of sweeping', async () => {
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload({ calendarEventIds: ['calendar-event-1'] }),
);
expect(result).toEqual({
outcome: 'no-call-recordings-for-calendar-events',
});
expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled();
expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled();
});
it('sweeps recordings missing a summary when no ids are given', async () => {
findCallRecordingIdsMissingSummaryMock.mockResolvedValue([
'call-recording-1',
'call-recording-2',
]);
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload(null),
);
expect(findCallRecordingIdsMissingSummaryMock).toHaveBeenCalledWith(
expect.anything(),
);
expect(generateMissingCallRecordingSummariesMock).toHaveBeenCalledWith(
expect.objectContaining({
callRecordingIds: ['call-recording-1', 'call-recording-2'],
}),
);
expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT });
});
it('does not sweep when an empty calendar event selection is sent', async () => {
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload({ calendarEventIds: [] }),
);
expect(result).toEqual({ outcome: 'nothing-selected' });
expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled();
expect(findCallRecordingIdsForCalendarEventsMock).not.toHaveBeenCalled();
expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled();
});
it('short-circuits an empty sweep without running the batch', async () => {
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload({}),
);
expect(result).toEqual({ outcome: 'nothing-to-summarize' });
expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import postInstallLogicFunction, {
startCallRecordingSummaryBackfillOnInstallHandler,
} from 'src/logic-functions/start-call-recording-summary-backfill-on-install';
const requestCallRecordingSummariesBackfillMock = vi.hoisted(() => vi.fn());
vi.mock(
'src/logic-functions/data/request-call-recording-summaries-backfill.util',
() => ({
requestCallRecordingSummariesBackfill:
requestCallRecordingSummariesBackfillMock,
}),
);
describe('start-call-recording-summary-backfill-on-install', () => {
beforeEach(() => {
vi.clearAllMocks();
requestCallRecordingSummariesBackfillMock.mockResolvedValue(true);
});
it('is configured to run on app version upgrades', () => {
expect(postInstallLogicFunction.config).toEqual(
expect.objectContaining({
name: 'start-call-recording-summary-backfill-on-install',
timeoutSeconds: 30,
shouldRunOnVersionUpgrade: true,
}),
);
});
it('skips fresh installs', async () => {
const result = await startCallRecordingSummaryBackfillOnInstallHandler({
newVersion: '1.0.6',
});
expect(result).toEqual({ outcome: 'skipped-initial-install' });
expect(requestCallRecordingSummariesBackfillMock).not.toHaveBeenCalled();
});
it('requests backfill on version upgrades', async () => {
const result = await startCallRecordingSummaryBackfillOnInstallHandler({
previousVersion: '1.0.5',
newVersion: '1.0.6',
});
expect(result).toEqual({ outcome: 'backfill-requested' });
expect(requestCallRecordingSummariesBackfillMock).toHaveBeenCalledTimes(1);
});
it('reports failed backfill kickoff requests', async () => {
requestCallRecordingSummariesBackfillMock.mockResolvedValue(false);
const result = await startCallRecordingSummaryBackfillOnInstallHandler({
previousVersion: '1.0.5',
newVersion: '1.0.6',
});
expect(result).toEqual({ outcome: 'backfill-request-failed' });
});
});
@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { summarizeCallRecordingHandler } from 'src/logic-functions/summarize-call-recording';
const generateCallRecordingSummaryMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: class {},
}));
vi.mock(
'src/logic-functions/flows/generate-call-recording-summary.util',
() => ({
generateCallRecordingSummary: generateCallRecordingSummaryMock,
}),
);
const FAKE_OBJECT_METADATA = {
id: 'object-metadata-id',
nameSingular: 'callRecording',
namePlural: 'callRecordings',
labelSingular: 'Call Recording',
labelPlural: 'Call Recordings',
description: null,
icon: null,
universalIdentifier: 'call-recording-object',
applicationId: null,
dataSourceId: null,
standardOverrides: null,
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
isUIEditable: false,
isUICreatable: false,
isAuditLogged: false,
isSearchable: false,
duplicateCriteria: null,
shortcut: null,
labelIdentifierFieldMetadataId: 'label-field-id',
imageIdentifierFieldMetadataId: null,
isLabelSyncedWithName: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
fieldIds: [],
indexMetadataIds: [],
viewIds: [],
applicationUniversalIdentifier: null,
labelIdentifierFieldMetadataUniversalIdentifier: 'label-field',
imageIdentifierFieldMetadataUniversalIdentifier: null,
fieldUniversalIdentifiers: [],
indexMetadataUniversalIdentifiers: [],
viewUniversalIdentifiers: [],
} satisfies Parameters<
typeof summarizeCallRecordingHandler
>[0]['objectMetadata'];
const buildEvent = ({
name,
updatedFields,
recordId = 'call-recording-1',
}: {
name: string;
updatedFields: string[];
recordId?: string;
}): Parameters<typeof summarizeCallRecordingHandler>[0] => ({
name,
workspaceId: 'workspace-id',
objectMetadata: FAKE_OBJECT_METADATA,
recordId,
properties: { updatedFields },
});
describe('summarize-call-recording logic function', () => {
beforeEach(() => {
vi.clearAllMocks();
generateCallRecordingSummaryMock.mockResolvedValue({
outcome: 'generated',
});
});
it('generates a summary when the transcript field changed', async () => {
const result = await summarizeCallRecordingHandler(
buildEvent({
name: 'callRecording.updated',
updatedFields: ['transcript'],
}),
);
expect(generateCallRecordingSummaryMock).toHaveBeenCalledWith(
expect.anything(),
{
callRecordingId: 'call-recording-1',
requireCreatedByCallRecorder: true,
},
);
expect(result).toEqual({
callRecordingId: 'call-recording-1',
outcome: 'generated',
});
});
it('skips summary-only updates to avoid re-entrancy', async () => {
const result = await summarizeCallRecordingHandler(
buildEvent({
name: 'callRecording.updated',
updatedFields: ['summary'],
}),
);
expect(generateCallRecordingSummaryMock).not.toHaveBeenCalled();
expect(result).toEqual({ skipped: true, reason: 'transcript unchanged' });
});
it('skips non-update events', async () => {
const result = await summarizeCallRecordingHandler(
buildEvent({
name: 'callRecording.created',
updatedFields: ['transcript'],
}),
);
expect(generateCallRecordingSummaryMock).not.toHaveBeenCalled();
expect(result).toEqual({
skipped: true,
reason: 'not a call recording update',
});
});
});
@@ -0,0 +1,2 @@
export const CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME =
'CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT';
@@ -0,0 +1,2 @@
// Actor source the platform stamps on records created with an application token.
export const CALL_RECORDER_CREATED_BY_SOURCE = 'APPLICATION';
@@ -0,0 +1,2 @@
export const CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME =
'CALL_RECORDER_SUMMARY_ENABLED';
@@ -0,0 +1 @@
export const DEFAULT_CALL_RECORDER_SUMMARY_ENABLED = true;
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest';
import { findCallRecordingForSummary } from 'src/logic-functions/data/find-call-recording-for-summary.util';
describe('findCallRecordingForSummary', () => {
it('treats blank summary markdown as missing', async () => {
const query = vi.fn().mockResolvedValue({
callRecordings: {
edges: [
{
node: {
id: 'call-recording-1',
title: 'Weekly sync',
transcript: [],
summary: { markdown: '' },
createdBy: { source: 'APPLICATION', name: 'Call Recorder' },
},
},
],
},
});
const callRecording = await findCallRecordingForSummary(
{ query } as never,
{ id: 'call-recording-1' },
);
expect(callRecording?.summaryMarkdown).toBeUndefined();
});
});
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from 'vitest';
import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size';
import { findCallRecordingIdsForCalendarEvents } from 'src/logic-functions/data/find-call-recording-ids-for-calendar-events.util';
const buildConnection = (callRecordingIds: string[]) => ({
callRecordings: {
pageInfo: { hasNextPage: false, endCursor: null },
edges: callRecordingIds.map((callRecordingId) => ({
node: { id: callRecordingId },
})),
},
});
describe('findCallRecordingIdsForCalendarEvents', () => {
it('returns nothing without querying when no calendar event ids are given', async () => {
const query = vi.fn();
const callRecordingIds = await findCallRecordingIdsForCalendarEvents(
{ query } as never,
{ calendarEventIds: [] },
);
expect(callRecordingIds).toEqual([]);
expect(query).not.toHaveBeenCalled();
});
it('chunks calendar event ids before querying call recordings', async () => {
const calendarEventIds = Array.from(
{ length: TWENTY_PAGE_SIZE + 1 },
(_, calendarEventIndex) => `calendar-event-${calendarEventIndex}`,
);
const query = vi
.fn()
.mockResolvedValueOnce(buildConnection(['call-recording-1']))
.mockResolvedValueOnce(buildConnection(['call-recording-2']));
const callRecordingIds = await findCallRecordingIdsForCalendarEvents(
{ query } as never,
{ calendarEventIds },
);
expect(callRecordingIds).toEqual(['call-recording-1', 'call-recording-2']);
expect(query).toHaveBeenCalledTimes(2);
expect(
query.mock.calls[0][0].callRecordings.__args.filter.calendarEventId.in,
).toHaveLength(TWENTY_PAGE_SIZE);
expect(
query.mock.calls[1][0].callRecordings.__args.filter.calendarEventId.in,
).toEqual(['calendar-event-100']);
});
});
@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from 'vitest';
import { findCallRecordingIdsMissingSummary } from 'src/logic-functions/data/find-call-recording-ids-missing-summary.util';
const TRANSCRIPT = [
{
participant: { name: 'Alex' },
words: [{ text: 'Hello' }, { text: 'team' }],
},
];
const buildConnection = (
nodes: object[],
pageInfo: { hasNextPage: boolean; endCursor: string | null },
) => ({
callRecordings: {
pageInfo,
edges: nodes.map((node) => ({ node })),
},
});
describe('findCallRecordingIdsMissingSummary', () => {
it('targets app-created completed recordings with a transcript', async () => {
let capturedFilter: unknown;
const query = vi.fn(async (queryArg: any) => {
capturedFilter = queryArg.callRecordings.__args.filter;
return buildConnection([], { hasNextPage: false, endCursor: null });
});
await findCallRecordingIdsMissingSummary({ query } as never);
expect(capturedFilter).toEqual({
status: { eq: 'COMPLETED' },
transcript: { is: 'NOT_NULL' },
createdBy: {
source: { eq: 'APPLICATION' },
name: { eq: 'Call Recorder' },
},
});
});
it('keeps only recordings missing a summary, newest first, across pages', async () => {
const query = vi
.fn()
.mockResolvedValueOnce(
buildConnection(
[
{
id: 'call-recording-old',
createdAt: '2026-06-01T10:00:00.000Z',
transcript: TRANSCRIPT,
summary: null,
},
{
id: 'call-recording-summarized',
createdAt: '2026-06-20T10:00:00.000Z',
transcript: TRANSCRIPT,
summary: { markdown: '## Overview\nDone.' },
},
],
{ hasNextPage: true, endCursor: 'cursor-1' },
),
)
.mockResolvedValueOnce(
buildConnection(
[
{
id: 'call-recording-new',
createdAt: '2026-06-30T10:00:00.000Z',
transcript: TRANSCRIPT,
summary: { markdown: '' },
},
],
{ hasNextPage: false, endCursor: 'cursor-2' },
),
);
const callRecordingIds = await findCallRecordingIdsMissingSummary({
query,
} as never);
expect(callRecordingIds).toEqual([
'call-recording-new',
'call-recording-old',
]);
expect(query).toHaveBeenCalledTimes(2);
});
it('ignores recordings whose transcript cannot produce a summary prompt', async () => {
const query = vi.fn().mockResolvedValue(
buildConnection(
[
{
id: 'call-recording-empty-transcript',
createdAt: '2026-07-01T10:00:00.000Z',
transcript: [],
summary: null,
},
{
id: 'call-recording-empty-words',
createdAt: '2026-06-30T10:00:00.000Z',
transcript: [
{
participant: { name: 'Alex' },
words: [{ text: ' ' }],
},
],
summary: null,
},
{
id: 'call-recording-valid',
createdAt: '2026-06-01T10:00:00.000Z',
transcript: TRANSCRIPT,
summary: null,
},
],
{ hasNextPage: false, endCursor: null },
),
);
const callRecordingIds = await findCallRecordingIdsMissingSummary({
query,
} as never);
expect(callRecordingIds).toEqual(['call-recording-valid']);
});
});
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
import { requestCallRecordingSummariesBackfill } from 'src/logic-functions/data/request-call-recording-summaries-backfill.util';
const postMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/rest', () => ({
RestApiClient: vi.fn(function RestApiClient() {
return {
post: postMock,
};
}),
}));
describe('requestCallRecordingSummariesBackfill', () => {
beforeEach(() => {
vi.clearAllMocks();
postMock.mockResolvedValue({});
});
it('posts an empty body to the summary generation route', async () => {
const result = await requestCallRecordingSummariesBackfill();
expect(result).toBe(true);
expect(postMock).toHaveBeenCalledWith(
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{},
{ signal: expect.any(AbortSignal) },
);
});
it('treats timeout as a successfully flushed request', async () => {
const timeoutError = new Error('Timed out');
timeoutError.name = 'TimeoutError';
postMock.mockRejectedValue(timeoutError);
await expect(requestCallRecordingSummariesBackfill()).resolves.toBe(true);
});
it('returns false when the kickoff request fails before flushing', async () => {
postMock.mockRejectedValue(new Error('Network failed'));
await expect(requestCallRecordingSummariesBackfill()).resolves.toBe(false);
});
});
@@ -0,0 +1,54 @@
import { isNull, isUndefined } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { getString } from 'src/logic-functions/utils/get-string.util';
type CallRecordingForSummary = {
id: string;
title: string | undefined;
transcript: unknown;
summaryMarkdown: string | undefined;
createdBy: { source: string | undefined; name: string | undefined };
};
export const findCallRecordingForSummary = async (
client: CoreApiClient,
{ id }: { id: string },
): Promise<CallRecordingForSummary | undefined> => {
const queryResult = await client.query({
callRecordings: {
__args: {
filter: { id: { eq: id } },
first: 1,
},
edges: {
node: {
id: true,
title: true,
transcript: true,
summary: { markdown: true },
createdBy: { source: true, name: true },
},
},
},
});
const node = queryResult.callRecordings?.edges?.[0]?.node;
if (isUndefined(node) || isNull(node)) {
return undefined;
}
return {
id: node.id,
title: getString(node.title),
transcript: node.transcript ?? undefined,
// Blank summaries are not usable; normalize them as missing so generation
// can repair blank summary records.
summaryMarkdown: getString(node.summary?.markdown),
createdBy: {
source: getString(node.createdBy?.source),
name: getString(node.createdBy?.name),
},
};
};
@@ -0,0 +1,84 @@
import { isUndefined } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size';
import {
fetchAllNodes,
type ConnectionPage,
} from 'src/logic-functions/data/fetch-all-nodes.util';
const CALENDAR_EVENT_ID_BATCH_SIZE = TWENTY_PAGE_SIZE;
type CallRecordingIdNode = {
id: string;
};
const getCalendarEventIdBatches = (calendarEventIds: string[]): string[][] => {
const uniqueCalendarEventIds = [...new Set(calendarEventIds)];
const calendarEventIdBatches: string[][] = [];
for (
let batchStartIndex = 0;
batchStartIndex < uniqueCalendarEventIds.length;
batchStartIndex += CALENDAR_EVENT_ID_BATCH_SIZE
) {
calendarEventIdBatches.push(
uniqueCalendarEventIds.slice(
batchStartIndex,
batchStartIndex + CALENDAR_EVENT_ID_BATCH_SIZE,
),
);
}
return calendarEventIdBatches;
};
export const findCallRecordingIdsForCalendarEvents = async (
client: CoreApiClient,
{ calendarEventIds }: { calendarEventIds: string[] },
): Promise<string[]> => {
if (calendarEventIds.length === 0) {
return [];
}
const callRecordingIds: string[] = [];
for (const calendarEventIdBatch of getCalendarEventIdBatches(
calendarEventIds,
)) {
const callRecordingNodes = await fetchAllNodes<CallRecordingIdNode>(
async (afterCursor) => {
const queryResult = await client.query({
callRecordings: {
__args: {
filter: {
calendarEventId: { in: calendarEventIdBatch },
},
first: TWENTY_PAGE_SIZE,
...(isUndefined(afterCursor) ? {} : { after: afterCursor }),
},
pageInfo: {
hasNextPage: true,
endCursor: true,
},
edges: {
node: {
id: true,
},
},
},
});
return queryResult.callRecordings as
| ConnectionPage<CallRecordingIdNode>
| undefined;
},
);
for (const callRecordingNode of callRecordingNodes) {
callRecordingIds.push(callRecordingNode.id);
}
}
return [...new Set(callRecordingIds)];
};
@@ -0,0 +1,76 @@
import { isUndefined } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { APP_DISPLAY_NAME } from 'src/constants/app-display-name';
import { CALL_RECORDER_CREATED_BY_SOURCE } from 'src/logic-functions/constants/call-recorder-created-by-source';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size';
import {
fetchAllNodes,
type ConnectionPage,
} from 'src/logic-functions/data/fetch-all-nodes.util';
import { buildCallRecordingSummaryPrompt } from 'src/logic-functions/domain/build-call-recording-summary-prompt.util';
import { getString } from 'src/logic-functions/utils/get-string.util';
type CallRecordingSummaryStateNode = {
id: string;
createdAt?: string | null;
transcript?: unknown;
summary?: { markdown?: string | null } | null;
};
export const findCallRecordingIdsMissingSummary = async (
client: CoreApiClient,
): Promise<string[]> => {
const callRecordingNodes = await fetchAllNodes<CallRecordingSummaryStateNode>(
async (afterCursor) => {
const queryResult = await client.query({
callRecordings: {
__args: {
filter: {
status: { eq: CallRecordingStatus.COMPLETED },
transcript: { is: 'NOT_NULL' },
// Never sweep recordings another app or a user created — agent
// runs are billed, so the unattended path only spends on ours.
createdBy: {
source: { eq: CALL_RECORDER_CREATED_BY_SOURCE },
name: { eq: APP_DISPLAY_NAME },
},
},
first: TWENTY_PAGE_SIZE,
...(isUndefined(afterCursor) ? {} : { after: afterCursor }),
},
pageInfo: {
hasNextPage: true,
endCursor: true,
},
edges: {
node: {
id: true,
createdAt: true,
transcript: true,
summary: { markdown: true },
},
},
},
});
return queryResult.callRecordings as
| ConnectionPage<CallRecordingSummaryStateNode>
| undefined;
},
);
return callRecordingNodes
.filter(
(callRecording) =>
isUndefined(getString(callRecording.summary?.markdown)) &&
buildCallRecordingSummaryPrompt({
transcript: callRecording.transcript,
}) !== undefined,
)
.sort((left, right) =>
(right.createdAt ?? '').localeCompare(left.createdAt ?? ''),
)
.map((callRecording) => callRecording.id);
};
@@ -0,0 +1,35 @@
import { RestApiClient } from 'twenty-client-sdk/rest';
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
const BACKFILL_KICKOFF_FLUSH_MS = 5_000;
export const requestCallRecordingSummariesBackfill =
async (): Promise<boolean> => {
const client = new RestApiClient();
try {
await client.post(
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{},
{ signal: AbortSignal.timeout(BACKFILL_KICKOFF_FLUSH_MS) },
);
return true;
} catch (error) {
if (
error instanceof Error &&
(error.name === 'TimeoutError' || error.name === 'AbortError')
) {
return true;
}
if (process.env.NODE_ENV !== 'test') {
console.error(
`[call-recorder] summary backfill kickoff failed to fire: ${error instanceof Error ? error.message : String(error)}`,
);
}
return false;
}
};
@@ -0,0 +1,38 @@
import { RestApiClient } from 'twenty-client-sdk/rest';
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
const CONTINUATION_FLUSH_MS = 5_000;
export const requestCallRecordingSummariesContinuation = async ({
callRecordingIds,
}: {
callRecordingIds: string[];
}): Promise<boolean> => {
const client = new RestApiClient();
try {
await client.post(
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{ callRecordingIds },
{ signal: AbortSignal.timeout(CONTINUATION_FLUSH_MS) },
);
return true;
} catch (error) {
if (
error instanceof Error &&
(error.name === 'TimeoutError' || error.name === 'AbortError')
) {
return true;
}
if (process.env.NODE_ENV !== 'test') {
console.error(
`[call-recorder] summary generation continuation failed to fire: ${error instanceof Error ? error.message : String(error)}`,
);
}
return false;
}
};
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { buildCallRecordingSummaryPrompt } from 'src/logic-functions/domain/build-call-recording-summary-prompt.util';
const TRANSCRIPT = [
{
participant: { name: 'Alex' },
words: [{ text: 'Hello' }, { text: 'there' }],
},
{
participant: { name: 'Sam' },
words: [{ text: 'Hi' }, { text: 'Alex' }],
},
];
describe('buildCallRecordingSummaryPrompt', () => {
it('flattens entries into speaker-labelled lines', () => {
expect(
buildCallRecordingSummaryPrompt({ transcript: TRANSCRIPT }),
).toContain('Transcript:\nAlex: Hello there\nSam: Hi Alex');
});
it('appends the workspace admin instructions when provided', () => {
expect(
buildCallRecordingSummaryPrompt({
transcript: TRANSCRIPT,
additionalSummaryPrompt: ' Write short sales notes. ',
}),
).toBe(
'Additional instructions from the workspace admin:\nWrite short sales notes.\n\nTranscript:\nAlex: Hello there\nSam: Hi Alex',
);
});
it('prefixes a [mm:ss] (or h:mm:ss) timestamp when the entry carries one', () => {
expect(
buildCallRecordingSummaryPrompt({
transcript: [
{
participant: { name: 'Alex' },
words: [
{ text: 'Hello', start_timestamp: { relative: 5 } },
{ text: 'there', start_timestamp: { relative: 6 } },
],
},
{
participant: { name: 'Sam' },
words: [{ text: 'Later', start_timestamp: { relative: 3725 } }],
},
],
}),
).toContain('Transcript:\n[0:05] Alex: Hello there\n[1:02:05] Sam: Later');
});
it('includes the meeting title when provided', () => {
expect(
buildCallRecordingSummaryPrompt({
transcript: TRANSCRIPT,
title: 'Weekly sync',
}),
).toContain(
'Meeting title: Weekly sync\n\nTranscript:\nAlex: Hello there\nSam: Hi Alex',
);
});
it('falls back to a placeholder speaker name', () => {
expect(
buildCallRecordingSummaryPrompt({
transcript: [{ participant: {}, words: [{ text: 'Hey' }] }],
}),
).toContain('Transcript:\nUnknown speaker: Hey');
});
it('skips entries without usable words', () => {
expect(
buildCallRecordingSummaryPrompt({
transcript: [
{ participant: { name: 'Alex' }, words: [] },
{ participant: { name: 'Sam' }, words: [{ text: 'Real' }] },
],
}),
).toContain('Transcript:\nSam: Real');
});
it('returns undefined when there is no usable dialogue', () => {
expect(buildCallRecordingSummaryPrompt({ transcript: [] })).toBeUndefined();
expect(
buildCallRecordingSummaryPrompt({ transcript: { status: 'PENDING' } }),
).toBeUndefined();
expect(
buildCallRecordingSummaryPrompt({
transcript: [{ participant: { name: 'A' }, words: [{ text: ' ' }] }],
}),
).toBeUndefined();
});
});
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { extractCallRecordingSummaryMarkdown } from 'src/logic-functions/domain/extract-call-recording-summary-markdown.util';
describe('extractCallRecordingSummaryMarkdown', () => {
it('returns the trimmed response markdown on success', () => {
expect(
extractCallRecordingSummaryMarkdown({
success: true,
error: null,
result: { response: ' ## Overview\nGreat call. ' },
}),
).toBe('## Overview\nGreat call.');
});
it('returns undefined when the run failed', () => {
expect(
extractCallRecordingSummaryMarkdown({
success: false,
error: 'no more available credits',
result: null,
}),
).toBeUndefined();
});
it('returns undefined when the response is empty or missing', () => {
expect(
extractCallRecordingSummaryMarkdown({
success: true,
error: null,
result: { response: ' ' },
}),
).toBeUndefined();
expect(
extractCallRecordingSummaryMarkdown({
success: true,
error: null,
result: {},
}),
).toBeUndefined();
});
it('returns the no-summary verdict verbatim', () => {
expect(
extractCallRecordingSummaryMarkdown({
success: true,
error: null,
result: { response: 'No summary available.' },
}),
).toBe('No summary available.');
});
});
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { isCallRecordingCreatedByCallRecorder } from 'src/logic-functions/domain/is-call-recording-created-by-call-recorder.util';
describe('isCallRecordingCreatedByCallRecorder', () => {
it('accepts the app-stamped actor', () => {
expect(
isCallRecordingCreatedByCallRecorder({
source: 'APPLICATION',
name: 'Call Recorder',
}),
).toBe(true);
});
it('rejects records created by users', () => {
expect(
isCallRecordingCreatedByCallRecorder({
source: 'MANUAL',
name: 'Alex',
}),
).toBe(false);
});
it('rejects records created by another application', () => {
expect(
isCallRecordingCreatedByCallRecorder({
source: 'APPLICATION',
name: 'Fireflies',
}),
).toBe(false);
});
it('rejects records with no actor data', () => {
expect(isCallRecordingCreatedByCallRecorder({})).toBe(false);
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { isRealTranscript } from 'src/logic-functions/domain/is-real-transcript.util';
describe('isRealTranscript', () => {
it('is true for a non-empty diarized array', () => {
expect(isRealTranscript([{ participant: { name: 'A' }, words: [] }])).toBe(
true,
);
});
it('is false for an empty array', () => {
expect(isRealTranscript([])).toBe(false);
});
it('is false for PENDING/FAILED markers', () => {
expect(
isRealTranscript({ status: 'PENDING', recallTranscriptId: 'x' }),
).toBe(false);
expect(isRealTranscript({ status: 'FAILED' })).toBe(false);
});
it('is false when unset', () => {
expect(isRealTranscript(null)).toBe(false);
expect(isRealTranscript(undefined)).toBe(false);
});
});
@@ -0,0 +1,82 @@
import { isArray, isNumber } from '@sniptt/guards';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
import { formatSecondsAsClockTimestamp } from 'src/logic-functions/utils/format-seconds-as-clock-timestamp.util';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
const UNKNOWN_SPEAKER = 'Unknown speaker';
const readEntryStartSeconds = (words: unknown[]): number | undefined => {
for (const word of words) {
const relative = asRecord(asRecord(word)?.start_timestamp)?.relative;
if (isNumber(relative) && Number.isFinite(relative)) {
return relative;
}
}
return undefined;
};
const buildTranscriptLine = (entry: unknown): string | undefined => {
const record = asRecord(entry);
if (record === undefined || !isArray(record.words)) {
return undefined;
}
const text = record.words
.map((word) => asRecord(word)?.text)
.filter(isNonEmptyString)
.map((word) => word.trim())
.join(' ')
.trim();
if (!isNonEmptyString(text)) {
return undefined;
}
const participantName = asRecord(record.participant)?.name;
const speakerName = isNonEmptyString(participantName)
? participantName.trim()
: UNKNOWN_SPEAKER;
const startSeconds = readEntryStartSeconds(record.words);
const timestamp =
startSeconds === undefined
? ''
: `[${formatSecondsAsClockTimestamp(startSeconds)}] `;
return `${timestamp}${speakerName}: ${text}`;
};
// The summarization instructions live in the agent's own prompt; the built
// prompt only carries the workspace admin's additional instructions on top.
export const buildCallRecordingSummaryPrompt = ({
transcript,
title,
additionalSummaryPrompt,
}: {
transcript: unknown;
title?: string;
additionalSummaryPrompt?: string;
}): string | undefined => {
if (!isArray(transcript)) {
return undefined;
}
const lines = transcript.map(buildTranscriptLine).filter(isNonEmptyString);
if (lines.length === 0) {
return undefined;
}
const additionalInstructions = isNonEmptyString(additionalSummaryPrompt)
? `Additional instructions from the workspace admin:\n${additionalSummaryPrompt.trim()}\n\n`
: '';
const header = isNonEmptyString(title)
? `Meeting title: ${title.trim()}\n\n`
: '';
return `${additionalInstructions}${header}Transcript:\n${lines.join('\n')}`;
};
@@ -0,0 +1,20 @@
import { type RunAgentResult } from 'twenty-sdk/logic-function';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
export const extractCallRecordingSummaryMarkdown = (
agentResult: RunAgentResult,
): string | undefined => {
if (!agentResult.success) {
return undefined;
}
const response = asRecord(agentResult.result)?.response;
if (!isNonEmptyString(response)) {
return undefined;
}
return response.trim();
};
@@ -0,0 +1,12 @@
import { APP_DISPLAY_NAME } from 'src/constants/app-display-name';
import { CALL_RECORDER_CREATED_BY_SOURCE } from 'src/logic-functions/constants/call-recorder-created-by-source';
export const isCallRecordingCreatedByCallRecorder = (createdBy: {
source?: string;
name?: string;
}): boolean =>
createdBy.source === CALL_RECORDER_CREATED_BY_SOURCE &&
// TODO: Replace this display-name coupling with typed app provenance after a
// core actor change stamps the application universal identifier on app-created
// records.
createdBy.name === APP_DISPLAY_NAME;
@@ -0,0 +1,4 @@
import { isArray } from '@sniptt/guards';
export const isRealTranscript = (transcript: unknown): boolean =>
isArray(transcript) && transcript.length > 0;
@@ -0,0 +1,261 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { generateCallRecordingSummary } from 'src/logic-functions/flows/generate-call-recording-summary.util';
const runAgentMock = vi.hoisted(() => vi.fn());
const findCallRecordingForSummaryMock = vi.hoisted(() => vi.fn());
const updateCallRecordingMock = vi.hoisted(() => vi.fn());
const getCallRecorderAdditionalSummaryPromptMock = vi.hoisted(() => vi.fn());
const isCallRecordingSummaryEnabledMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-sdk/logic-function', () => ({
runAgent: runAgentMock,
}));
vi.mock(
'src/logic-functions/data/find-call-recording-for-summary.util',
() => ({
findCallRecordingForSummary: findCallRecordingForSummaryMock,
}),
);
vi.mock('src/logic-functions/data/update-call-recording.util', () => ({
updateCallRecording: updateCallRecordingMock,
}));
vi.mock(
'src/logic-functions/utils/get-call-recorder-additional-summary-prompt.util',
() => ({
getCallRecorderAdditionalSummaryPrompt:
getCallRecorderAdditionalSummaryPromptMock,
}),
);
vi.mock(
'src/logic-functions/utils/is-call-recording-summary-enabled.util',
() => ({
isCallRecordingSummaryEnabled: isCallRecordingSummaryEnabledMock,
}),
);
const TRANSCRIPT = [
{
participant: { name: 'Alex' },
words: [{ text: 'Hello' }, { text: 'team' }],
},
];
const CLIENT: CoreApiClient = Object.assign(
Object.create(CoreApiClient.prototype),
{
mutation: vi.fn(),
query: vi.fn(),
},
);
describe('generateCallRecordingSummary', () => {
beforeEach(() => {
vi.clearAllMocks();
getCallRecorderAdditionalSummaryPromptMock.mockReturnValue(undefined);
isCallRecordingSummaryEnabledMock.mockReturnValue(true);
findCallRecordingForSummaryMock.mockResolvedValue({
id: 'call-recording-1',
title: 'Weekly sync',
transcript: TRANSCRIPT,
summaryMarkdown: undefined,
createdBy: { source: 'APPLICATION', name: 'Call Recorder' },
});
updateCallRecordingMock.mockResolvedValue(undefined);
runAgentMock.mockResolvedValue({
success: true,
error: null,
result: { response: '## Overview\nGood call.' },
});
});
it('skips when summaries are disabled', async () => {
isCallRecordingSummaryEnabledMock.mockReturnValue(false);
const result = await generateCallRecordingSummary(CLIENT, {
callRecordingId: 'call-recording-1',
});
expect(result).toEqual({ outcome: 'disabled' });
expect(findCallRecordingForSummaryMock).not.toHaveBeenCalled();
expect(runAgentMock).not.toHaveBeenCalled();
});
it('skips when there is no real transcript', async () => {
findCallRecordingForSummaryMock.mockResolvedValue({
id: 'call-recording-1',
title: undefined,
transcript: { status: 'PENDING' },
summaryMarkdown: undefined,
});
const result = await generateCallRecordingSummary(CLIENT, {
callRecordingId: 'call-recording-1',
});
expect(result).toEqual({ outcome: 'no-transcript' });
expect(runAgentMock).not.toHaveBeenCalled();
});
it('skips when a summary already exists', async () => {
findCallRecordingForSummaryMock.mockResolvedValue({
id: 'call-recording-1',
title: undefined,
transcript: TRANSCRIPT,
summaryMarkdown: '## Overview\nAlready here.',
});
const result = await generateCallRecordingSummary(CLIENT, {
callRecordingId: 'call-recording-1',
});
expect(result).toEqual({ outcome: 'already-summarized' });
expect(runAgentMock).not.toHaveBeenCalled();
});
it('skips recordings another actor created when the app-created gate is on', async () => {
findCallRecordingForSummaryMock.mockResolvedValue({
id: 'call-recording-1',
title: undefined,
transcript: TRANSCRIPT,
summaryMarkdown: undefined,
createdBy: { source: 'MANUAL', name: 'Alex' },
});
const result = await generateCallRecordingSummary(CLIENT, {
callRecordingId: 'call-recording-1',
requireCreatedByCallRecorder: true,
});
expect(result).toEqual({ outcome: 'not-app-recording' });
expect(runAgentMock).not.toHaveBeenCalled();
});
it('generates for app-created recordings when the app-created gate is on', async () => {
const result = await generateCallRecordingSummary(CLIENT, {
callRecordingId: 'call-recording-1',
requireCreatedByCallRecorder: true,
});
expect(result).toEqual({ outcome: 'generated' });
expect(runAgentMock).toHaveBeenCalledTimes(1);
expect(updateCallRecordingMock).toHaveBeenCalledTimes(1);
});
it('generates for recordings another actor created when explicitly requested', async () => {
findCallRecordingForSummaryMock.mockResolvedValue({
id: 'call-recording-1',
title: undefined,
transcript: TRANSCRIPT,
summaryMarkdown: undefined,
createdBy: { source: 'MANUAL', name: 'Alex' },
});
const result = await generateCallRecordingSummary(CLIENT, {
callRecordingId: 'call-recording-1',
});
expect(result).toEqual({ outcome: 'generated' });
});
it('runs the agent and stores the summary markdown on the happy path', async () => {
const result = await generateCallRecordingSummary(CLIENT, {
callRecordingId: 'call-recording-1',
});
expect(result).toEqual({ outcome: 'generated' });
expect(runAgentMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining('Alex: Hello team'),
}),
);
expect(updateCallRecordingMock).toHaveBeenCalledWith(CLIENT, {
id: 'call-recording-1',
data: {
summary: { blocknote: null, markdown: '## Overview\nGood call.' },
},
});
});
it('appends the workspace admin instructions to the agent prompt', async () => {
getCallRecorderAdditionalSummaryPromptMock.mockReturnValue(
'Write terse notes.',
);
await generateCallRecordingSummary(CLIENT, {
callRecordingId: 'call-recording-1',
});
expect(runAgentMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining(
'Additional instructions from the workspace admin:\nWrite terse notes.\n\nMeeting title: Weekly sync',
),
}),
);
});
it('stores the no-summary verdict verbatim so the run is terminal', async () => {
runAgentMock.mockResolvedValue({
success: true,
error: null,
result: { response: 'No summary available.' },
});
const result = await generateCallRecordingSummary(CLIENT, {
callRecordingId: 'call-recording-1',
});
expect(result).toEqual({ outcome: 'generated' });
expect(updateCallRecordingMock).toHaveBeenCalledWith(CLIENT, {
id: 'call-recording-1',
data: { summary: { blocknote: null, markdown: 'No summary available.' } },
});
});
it('stores nothing when the agent run fails', async () => {
runAgentMock.mockResolvedValue({
success: false,
error: 'no more available credits',
result: null,
});
const result = await generateCallRecordingSummary(CLIENT, {
callRecordingId: 'call-recording-1',
});
expect(result).toEqual({ outcome: 'empty-summary' });
expect(updateCallRecordingMock).not.toHaveBeenCalled();
});
it('propagates agent errors without writing a summary', async () => {
runAgentMock.mockRejectedValue(new Error('Agent execution failed'));
await expect(
generateCallRecordingSummary(CLIENT, {
callRecordingId: 'call-recording-1',
}),
).rejects.toThrow('Agent execution failed');
expect(updateCallRecordingMock).not.toHaveBeenCalled();
});
it('propagates summary write errors', async () => {
updateCallRecordingMock.mockRejectedValue(
new Error('Summary write failed'),
);
await expect(
generateCallRecordingSummary(CLIENT, {
callRecordingId: 'call-recording-1',
}),
).rejects.toThrow('Summary write failed');
expect(updateCallRecordingMock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,188 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { generateMissingCallRecordingSummaries } from 'src/logic-functions/flows/generate-missing-call-recording-summaries.util';
const generateCallRecordingSummaryMock = vi.hoisted(() => vi.fn());
const requestContinuationMock = vi.hoisted(() => vi.fn());
vi.mock(
'src/logic-functions/flows/generate-call-recording-summary.util',
() => ({
generateCallRecordingSummary: generateCallRecordingSummaryMock,
}),
);
vi.mock(
'src/logic-functions/data/request-call-recording-summaries-continuation.util',
() => ({
requestCallRecordingSummariesContinuation: requestContinuationMock,
}),
);
const CLIENT: CoreApiClient = Object.assign(
Object.create(CoreApiClient.prototype),
{
mutation: vi.fn(),
query: vi.fn(),
},
);
// Each processed item advances the clock by ITEM_MS across the three
// getNowMs reads of one loop iteration.
const buildClock = (itemMs: number) => {
let nowMs = 0;
let reads = 0;
return () => {
reads += 1;
if (reads % 3 === 2) {
nowMs += itemMs;
}
return nowMs;
};
};
describe('generateMissingCallRecordingSummaries', () => {
beforeEach(() => {
vi.clearAllMocks();
generateCallRecordingSummaryMock.mockResolvedValue({
outcome: 'generated',
});
requestContinuationMock.mockResolvedValue(true);
});
it('processes every id and skips the continuation when the budget allows', async () => {
const result = await generateMissingCallRecordingSummaries({
client: CLIENT,
callRecordingIds: ['call-recording-1', 'call-recording-2'],
deadlineAtMs: 1_000_000,
getNowMs: buildClock(10),
});
expect(result).toEqual({
generatedCallRecordingIds: ['call-recording-1', 'call-recording-2'],
failedCallRecordingIds: [],
erroredCallRecordingIds: [],
skippedCallRecordingIds: [],
remainingCallRecordingIds: [],
continuationRequested: false,
});
expect(requestContinuationMock).not.toHaveBeenCalled();
});
it('always processes at least one id even when the deadline already passed', async () => {
const result = await generateMissingCallRecordingSummaries({
client: CLIENT,
callRecordingIds: ['call-recording-1', 'call-recording-2'],
deadlineAtMs: 0,
getNowMs: buildClock(10),
});
expect(result.generatedCallRecordingIds).toEqual(['call-recording-1']);
expect(result.remainingCallRecordingIds).toEqual(['call-recording-2']);
expect(result.continuationRequested).toBe(true);
expect(requestContinuationMock).toHaveBeenCalledWith({
callRecordingIds: ['call-recording-2'],
});
});
it('stops when the next item would overrun the deadline and hands off the rest', async () => {
// 100ms per item against a 250ms deadline: two items fit, the third
// projected finish (200 + 100) exceeds 250 only after the second item.
const result = await generateMissingCallRecordingSummaries({
client: CLIENT,
callRecordingIds: [
'call-recording-1',
'call-recording-2',
'call-recording-3',
'call-recording-4',
],
deadlineAtMs: 250,
getNowMs: buildClock(100),
});
expect(result.generatedCallRecordingIds).toEqual([
'call-recording-1',
'call-recording-2',
]);
expect(result.remainingCallRecordingIds).toEqual([
'call-recording-3',
'call-recording-4',
]);
expect(requestContinuationMock).toHaveBeenCalledWith({
callRecordingIds: ['call-recording-3', 'call-recording-4'],
});
});
it('separates empty summaries from thrown generation errors', async () => {
generateCallRecordingSummaryMock
.mockResolvedValueOnce({ outcome: 'empty-summary' })
.mockRejectedValueOnce(new Error('agent exploded'))
.mockResolvedValueOnce({ outcome: 'generated' });
const result = await generateMissingCallRecordingSummaries({
client: CLIENT,
callRecordingIds: [
'call-recording-1',
'call-recording-2',
'call-recording-3',
],
deadlineAtMs: 1_000_000,
getNowMs: buildClock(10),
});
expect(result).toEqual({
generatedCallRecordingIds: ['call-recording-3'],
failedCallRecordingIds: ['call-recording-1'],
erroredCallRecordingIds: ['call-recording-2'],
skippedCallRecordingIds: [],
remainingCallRecordingIds: [],
continuationRequested: false,
});
});
it('records skip outcomes without treating them as failures', async () => {
generateCallRecordingSummaryMock
.mockResolvedValueOnce({ outcome: 'already-summarized' })
.mockResolvedValueOnce({ outcome: 'no-transcript' });
const result = await generateMissingCallRecordingSummaries({
client: CLIENT,
callRecordingIds: ['call-recording-1', 'call-recording-2'],
deadlineAtMs: 1_000_000,
getNowMs: buildClock(10),
});
expect(result.skippedCallRecordingIds).toEqual([
'call-recording-1',
'call-recording-2',
]);
expect(result.failedCallRecordingIds).toEqual([]);
expect(result.erroredCallRecordingIds).toEqual([]);
});
it('stops spending immediately when summaries get disabled mid-run', async () => {
generateCallRecordingSummaryMock
.mockResolvedValueOnce({ outcome: 'generated' })
.mockResolvedValueOnce({ outcome: 'disabled' });
const result = await generateMissingCallRecordingSummaries({
client: CLIENT,
callRecordingIds: [
'call-recording-1',
'call-recording-2',
'call-recording-3',
],
deadlineAtMs: 1_000_000,
getNowMs: buildClock(10),
});
expect(result.generatedCallRecordingIds).toEqual(['call-recording-1']);
expect(result.remainingCallRecordingIds).toEqual(['call-recording-3']);
expect(result.continuationRequested).toBe(false);
expect(requestContinuationMock).not.toHaveBeenCalled();
expect(generateCallRecordingSummaryMock).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,9 @@
export type GenerateCallRecordingSummaryResult = {
outcome:
| 'disabled'
| 'no-transcript'
| 'not-app-recording'
| 'already-summarized'
| 'empty-summary'
| 'generated';
};
@@ -0,0 +1,76 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { runAgent } from 'twenty-sdk/logic-function';
import { CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-summarizer-agent-universal-identifier';
import { findCallRecordingForSummary } from 'src/logic-functions/data/find-call-recording-for-summary.util';
import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util';
import { buildCallRecordingSummaryPrompt } from 'src/logic-functions/domain/build-call-recording-summary-prompt.util';
import { extractCallRecordingSummaryMarkdown } from 'src/logic-functions/domain/extract-call-recording-summary-markdown.util';
import { isCallRecordingCreatedByCallRecorder } from 'src/logic-functions/domain/is-call-recording-created-by-call-recorder.util';
import { isRealTranscript } from 'src/logic-functions/domain/is-real-transcript.util';
import { type GenerateCallRecordingSummaryResult } from 'src/logic-functions/flows/generate-call-recording-summary-result.type';
import { getCallRecorderAdditionalSummaryPrompt } from 'src/logic-functions/utils/get-call-recorder-additional-summary-prompt.util';
import { isCallRecordingSummaryEnabled } from 'src/logic-functions/utils/is-call-recording-summary-enabled.util';
export const generateCallRecordingSummary = async (
client: CoreApiClient,
{
callRecordingId,
requireCreatedByCallRecorder = false,
}: { callRecordingId: string; requireCreatedByCallRecorder?: boolean },
): Promise<GenerateCallRecordingSummaryResult> => {
if (!isCallRecordingSummaryEnabled()) {
return { outcome: 'disabled' };
}
const callRecording = await findCallRecordingForSummary(client, {
id: callRecordingId,
});
if (
callRecording === undefined ||
!isRealTranscript(callRecording.transcript)
) {
return { outcome: 'no-transcript' };
}
if (
requireCreatedByCallRecorder &&
!isCallRecordingCreatedByCallRecorder(callRecording.createdBy)
) {
return { outcome: 'not-app-recording' };
}
if (callRecording.summaryMarkdown !== undefined) {
return { outcome: 'already-summarized' };
}
const prompt = buildCallRecordingSummaryPrompt({
transcript: callRecording.transcript,
title: callRecording.title,
additionalSummaryPrompt: getCallRecorderAdditionalSummaryPrompt(),
});
if (prompt === undefined) {
return { outcome: 'no-transcript' };
}
const agentResult = await runAgent({
agentUniversalIdentifier:
CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER,
prompt,
});
const markdown = extractCallRecordingSummaryMarkdown(agentResult);
if (markdown === undefined) {
return { outcome: 'empty-summary' };
}
await updateCallRecording(client, {
id: callRecordingId,
data: { summary: { blocknote: null, markdown } },
});
return { outcome: 'generated' };
};
@@ -0,0 +1,8 @@
export type GenerateMissingCallRecordingSummariesResult = {
generatedCallRecordingIds: string[];
failedCallRecordingIds: string[];
erroredCallRecordingIds: string[];
skippedCallRecordingIds: string[];
remainingCallRecordingIds: string[];
continuationRequested: boolean;
};
@@ -0,0 +1,92 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { requestCallRecordingSummariesContinuation } from 'src/logic-functions/data/request-call-recording-summaries-continuation.util';
import { generateCallRecordingSummary } from 'src/logic-functions/flows/generate-call-recording-summary.util';
import { type GenerateCallRecordingSummaryResult } from 'src/logic-functions/flows/generate-call-recording-summary-result.type';
import { type GenerateMissingCallRecordingSummariesResult } from 'src/logic-functions/flows/generate-missing-call-recording-summaries-result.type';
type BatchSummaryOutcome =
| GenerateCallRecordingSummaryResult['outcome']
| 'generation-error';
export const generateMissingCallRecordingSummaries = async ({
client,
callRecordingIds,
deadlineAtMs,
getNowMs = () => Date.now(),
}: {
client: CoreApiClient;
callRecordingIds: string[];
deadlineAtMs: number;
getNowMs?: () => number;
}): Promise<GenerateMissingCallRecordingSummariesResult> => {
const remainingCallRecordingIds = [...callRecordingIds];
const generatedCallRecordingIds: string[] = [];
const failedCallRecordingIds: string[] = [];
const erroredCallRecordingIds: string[] = [];
const skippedCallRecordingIds: string[] = [];
let slowestItemMs = 0;
// Always process at least one id so the remaining list strictly shrinks —
// otherwise a single slow item would re-invoke with an unshrunk payload
// forever.
while (remainingCallRecordingIds.length > 0) {
const callRecordingId = remainingCallRecordingIds[0];
const itemStartedAtMs = getNowMs();
let outcome: BatchSummaryOutcome;
try {
({ outcome } = await generateCallRecordingSummary(client, {
callRecordingId,
}));
} catch {
outcome = 'generation-error';
}
remainingCallRecordingIds.shift();
slowestItemMs = Math.max(slowestItemMs, getNowMs() - itemStartedAtMs);
if (outcome === 'disabled') {
// The workspace toggle turned off mid-run; stop spending immediately.
return {
generatedCallRecordingIds,
failedCallRecordingIds,
erroredCallRecordingIds,
skippedCallRecordingIds,
remainingCallRecordingIds,
continuationRequested: false,
};
}
if (outcome === 'generated') {
generatedCallRecordingIds.push(callRecordingId);
} else if (outcome === 'empty-summary') {
failedCallRecordingIds.push(callRecordingId);
} else if (outcome === 'generation-error') {
erroredCallRecordingIds.push(callRecordingId);
} else {
skippedCallRecordingIds.push(callRecordingId);
}
if (getNowMs() + slowestItemMs > deadlineAtMs) {
break;
}
}
const continuationRequested =
remainingCallRecordingIds.length > 0
? await requestCallRecordingSummariesContinuation({
callRecordingIds: remainingCallRecordingIds,
})
: false;
return {
generatedCallRecordingIds,
failedCallRecordingIds,
erroredCallRecordingIds,
skippedCallRecordingIds,
remainingCallRecordingIds,
continuationRequested,
};
};
@@ -0,0 +1,107 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { GENERATE_CALL_RECORDING_SUMMARIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/generate-call-recording-summaries-logic-function-universal-identifier';
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
import { findCallRecordingIdsForCalendarEvents } from 'src/logic-functions/data/find-call-recording-ids-for-calendar-events.util';
import { findCallRecordingIdsMissingSummary } from 'src/logic-functions/data/find-call-recording-ids-missing-summary.util';
import { generateMissingCallRecordingSummaries } from 'src/logic-functions/flows/generate-missing-call-recording-summaries.util';
import { isCallRecordingSummaryEnabled } from 'src/logic-functions/utils/is-call-recording-summary-enabled.util';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
const TIMEOUT_SECONDS = 900;
const CONTINUATION_RESERVE_MS = 30_000;
type GenerateCallRecordingSummariesRouteBody = {
callRecordingIds?: string[];
calendarEventIds?: string[];
};
const hasOwnProperty = <T extends object>(
object: T | null | undefined,
propertyName: keyof GenerateCallRecordingSummariesRouteBody,
): boolean =>
object === undefined || object === null
? false
: Object.prototype.hasOwnProperty.call(object, propertyName);
const toIdList = (value: unknown): string[] =>
Array.isArray(value) ? value.filter(isNonEmptyString) : [];
export const generateCallRecordingSummariesHandler = async (
payload: RoutePayload<GenerateCallRecordingSummariesRouteBody>,
): Promise<object> => {
if (!isCallRecordingSummaryEnabled()) {
return { outcome: 'disabled' };
}
const startedAtMs = Date.now();
const client = new CoreApiClient();
const requestedCallRecordingIds = toIdList(payload.body?.callRecordingIds);
const requestedCalendarEventIds = toIdList(payload.body?.calendarEventIds);
const hasRequestedCallRecordingIds = hasOwnProperty(
payload.body,
'callRecordingIds',
);
const hasRequestedCalendarEventIds = hasOwnProperty(
payload.body,
'calendarEventIds',
);
const hasRequestedIds =
hasRequestedCallRecordingIds || hasRequestedCalendarEventIds;
let callRecordingIds = requestedCallRecordingIds;
if (
hasRequestedIds &&
requestedCallRecordingIds.length === 0 &&
requestedCalendarEventIds.length === 0
) {
return { outcome: 'nothing-selected' };
}
if (callRecordingIds.length === 0 && requestedCalendarEventIds.length > 0) {
callRecordingIds = await findCallRecordingIdsForCalendarEvents(client, {
calendarEventIds: requestedCalendarEventIds,
});
if (callRecordingIds.length === 0) {
return { outcome: 'no-call-recordings-for-calendar-events' };
}
}
const isSweep = !hasRequestedIds;
if (isSweep) {
callRecordingIds = await findCallRecordingIdsMissingSummary(client);
if (callRecordingIds.length === 0) {
return { outcome: 'nothing-to-summarize' };
}
}
const result = await generateMissingCallRecordingSummaries({
client,
callRecordingIds,
deadlineAtMs:
startedAtMs + TIMEOUT_SECONDS * 1000 - CONTINUATION_RESERVE_MS,
});
return { outcome: 'processed', ...result };
};
export default defineLogicFunction({
universalIdentifier:
GENERATE_CALL_RECORDING_SUMMARIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'generate-call-recording-summaries',
description:
'Generates missing AI summaries for call recordings. Called with explicit call recording or calendar event ids for on-demand generation, or with no ids to sweep this apps recordings that have a transcript but no summary; re-invokes itself with the remaining ids when a batch approaches the timeout.',
timeoutSeconds: TIMEOUT_SECONDS,
handler: generateCallRecordingSummariesHandler,
httpRouteTriggerSettings: {
path: GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH,
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,34 @@
import {
definePostInstallLogicFunction,
type InstallPayload,
} from 'twenty-sdk/define';
import { START_CALL_RECORDING_SUMMARY_BACKFILL_ON_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/start-call-recording-summary-backfill-on-install-logic-function-universal-identifier';
import { requestCallRecordingSummariesBackfill } from 'src/logic-functions/data/request-call-recording-summaries-backfill.util';
export const startCallRecordingSummaryBackfillOnInstallHandler = async ({
previousVersion,
}: InstallPayload): Promise<object> => {
if (previousVersion === undefined) {
return { outcome: 'skipped-initial-install' };
}
const backfillRequested = await requestCallRecordingSummariesBackfill();
return {
outcome: backfillRequested
? 'backfill-requested'
: 'backfill-request-failed',
};
};
export default definePostInstallLogicFunction({
universalIdentifier:
START_CALL_RECORDING_SUMMARY_BACKFILL_ON_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'start-call-recording-summary-backfill-on-install',
description:
'Starts the missing summary backfill worker when Call Recorder is upgraded in a workspace.',
timeoutSeconds: 30,
shouldRunOnVersionUpgrade: true,
handler: startCallRecordingSummaryBackfillOnInstallHandler,
});
@@ -0,0 +1,57 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordBaseEvent,
} from 'twenty-sdk/define';
import { SUMMARIZE_CALL_RECORDING_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/summarize-call-recording-logic-function-universal-identifier';
import { generateCallRecordingSummary } from 'src/logic-functions/flows/generate-call-recording-summary.util';
const CALL_RECORDING_OBJECT_NAME = 'callRecording';
const TRANSCRIPT_FIELD_NAME = 'transcript';
type CallRecordingForDatabaseEvent = {
id: string;
};
type CallRecordingDatabaseEvent = DatabaseEventPayload<
ObjectRecordBaseEvent<CallRecordingForDatabaseEvent>
>;
export const summarizeCallRecordingHandler = async (
event: CallRecordingDatabaseEvent,
): Promise<object> => {
const [objectName, action] = event.name.split('.');
if (objectName !== CALL_RECORDING_OBJECT_NAME || action !== 'updated') {
return { skipped: true, reason: 'not a call recording update' };
}
const updatedFields = event.properties.updatedFields ?? [];
if (!updatedFields.includes(TRANSCRIPT_FIELD_NAME)) {
return { skipped: true, reason: 'transcript unchanged' };
}
const client = new CoreApiClient();
const result = await generateCallRecordingSummary(client, {
callRecordingId: event.recordId,
requireCreatedByCallRecorder: true,
});
return { callRecordingId: event.recordId, ...result };
};
export default defineLogicFunction({
universalIdentifier:
SUMMARIZE_CALL_RECORDING_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'summarize-call-recording',
description:
'Generates an AI recap of a recording transcript and stores it on the Call Recording summary field when the transcript is filled.',
timeoutSeconds: 60 * 4,
handler: summarizeCallRecordingHandler,
databaseEventTriggerSettings: {
eventName: `${CALL_RECORDING_OBJECT_NAME}.updated`,
},
});
@@ -0,0 +1,4 @@
export type CallRecordingSummary = {
blocknote: string | null;
markdown: string | null;
};
@@ -1,6 +1,7 @@
import { type CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { type CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { type CallRecordingMediaFile } from 'src/logic-functions/types/call-recording-media-file.type';
import { type CallRecordingSummary } from 'src/logic-functions/types/call-recording-summary.type';
export type CallRecordingUpdateFields = Partial<{
// null clears a previously synced title when the calendar title disappears.
@@ -17,4 +18,5 @@ export type CallRecordingUpdateFields = Partial<{
transcript: Record<string, unknown>;
audio: CallRecordingMediaFile[];
video: CallRecordingMediaFile[];
summary: CallRecordingSummary;
}>;
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { formatSecondsAsClockTimestamp } from 'src/logic-functions/utils/format-seconds-as-clock-timestamp.util';
describe('formatSecondsAsClockTimestamp', () => {
it('formats sub-hour durations as minutes and padded seconds', () => {
expect(formatSecondsAsClockTimestamp(0)).toBe('0:00');
expect(formatSecondsAsClockTimestamp(5)).toBe('0:05');
expect(formatSecondsAsClockTimestamp(65)).toBe('1:05');
expect(formatSecondsAsClockTimestamp(3599)).toBe('59:59');
});
it('adds an hour part with padded minutes past one hour', () => {
expect(formatSecondsAsClockTimestamp(3600)).toBe('1:00:00');
expect(formatSecondsAsClockTimestamp(3725)).toBe('1:02:05');
expect(formatSecondsAsClockTimestamp(7322)).toBe('2:02:02');
});
it('floors fractional seconds', () => {
expect(formatSecondsAsClockTimestamp(1.9)).toBe('0:01');
expect(formatSecondsAsClockTimestamp(59.999)).toBe('0:59');
});
it('clamps negative and non-finite input to zero', () => {
expect(formatSecondsAsClockTimestamp(-12)).toBe('0:00');
expect(formatSecondsAsClockTimestamp(Number.NaN)).toBe('0:00');
expect(formatSecondsAsClockTimestamp(Number.POSITIVE_INFINITY)).toBe(
'0:00',
);
});
});
@@ -0,0 +1,33 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name';
import { getCallRecorderAdditionalSummaryPrompt } from 'src/logic-functions/utils/get-call-recorder-additional-summary-prompt.util';
describe('getCallRecorderAdditionalSummaryPrompt', () => {
beforeEach(() => {
delete process.env[CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME];
});
afterEach(() => {
delete process.env[CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME];
});
it('returns undefined when unset', () => {
expect(getCallRecorderAdditionalSummaryPrompt()).toBeUndefined();
});
it('returns the trimmed additional prompt', () => {
process.env[CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME] =
' Write concise sales notes. ';
expect(getCallRecorderAdditionalSummaryPrompt()).toBe(
'Write concise sales notes.',
);
});
it('returns undefined for whitespace-only values', () => {
process.env[CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME] = ' ';
expect(getCallRecorderAdditionalSummaryPrompt()).toBeUndefined();
});
});
@@ -0,0 +1,39 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-summary-enabled-env-var-name';
import { isCallRecordingSummaryEnabled } from 'src/logic-functions/utils/is-call-recording-summary-enabled.util';
describe('isCallRecordingSummaryEnabled', () => {
beforeEach(() => {
delete process.env[CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME];
});
afterEach(() => {
delete process.env[CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME];
});
it('defaults to enabled when unset', () => {
expect(isCallRecordingSummaryEnabled()).toBe(true);
});
it.each(['false', '0', 'no', 'off', ' False '])(
'is disabled for %s',
(value) => {
process.env[CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME] = value;
expect(isCallRecordingSummaryEnabled()).toBe(false);
},
);
it.each(['true', '1', 'yes', 'on'])('is enabled for %s', (value) => {
process.env[CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME] = value;
expect(isCallRecordingSummaryEnabled()).toBe(true);
});
it('falls back to the default for unrecognized values', () => {
process.env[CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME] = 'maybe';
expect(isCallRecordingSummaryEnabled()).toBe(true);
});
});
@@ -0,0 +1,20 @@
const SECONDS_PER_MINUTE = 60;
const SECONDS_PER_HOUR = 3600;
export const formatSecondsAsClockTimestamp = (totalSeconds: number): string => {
const safeSeconds = Number.isFinite(totalSeconds)
? Math.max(0, Math.floor(totalSeconds))
: 0;
const hours = Math.floor(safeSeconds / SECONDS_PER_HOUR);
const minutes = Math.floor(
(safeSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE,
);
const seconds = safeSeconds % SECONDS_PER_MINUTE;
const paddedSeconds = String(seconds).padStart(2, '0');
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${paddedSeconds}`;
}
return `${minutes}:${paddedSeconds}`;
};
@@ -0,0 +1,15 @@
import { CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name';
import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
export const getCallRecorderAdditionalSummaryPrompt = ():
| string
| undefined => {
const additionalSummaryPrompt = getApplicationVariableValue(
CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME,
)?.trim();
return isNonEmptyString(additionalSummaryPrompt)
? additionalSummaryPrompt
: undefined;
};
@@ -0,0 +1,29 @@
import { CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-summary-enabled-env-var-name';
import { DEFAULT_CALL_RECORDER_SUMMARY_ENABLED } from 'src/logic-functions/constants/default-call-recorder-summary-enabled';
import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
const TRUTHY_VALUES = new Set(['true', '1', 'yes', 'on']);
const FALSY_VALUES = new Set(['false', '0', 'no', 'off']);
export const isCallRecordingSummaryEnabled = (): boolean => {
const rawValue = getApplicationVariableValue(
CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME,
);
if (!isNonEmptyString(rawValue)) {
return DEFAULT_CALL_RECORDER_SUMMARY_ENABLED;
}
const normalizedValue = rawValue.trim().toLowerCase();
if (TRUTHY_VALUES.has(normalizedValue)) {
return true;
}
if (FALSY_VALUES.has(normalizedValue)) {
return false;
}
return DEFAULT_CALL_RECORDER_SUMMARY_ENABLED;
};
@@ -0,0 +1,33 @@
import {
definePageLayoutTab,
PageLayoutTabLayoutMode,
} from 'twenty-sdk/define';
import { CALENDAR_EVENT_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER } from 'src/constants/calendar-event-record-page-layout-universal-identifier';
import { CALENDAR_EVENT_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/calendar-event-summary-front-component-universal-identifier';
import { CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER } from 'src/constants/calendar-event-summary-page-layout-tab-universal-identifier';
import { CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER } from 'src/constants/calendar-event-summary-page-layout-widget-universal-identifier';
export default definePageLayoutTab({
universalIdentifier:
CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
title: 'Summary',
position: 14,
icon: 'IconFileText',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
pageLayoutUniversalIdentifier:
CALENDAR_EVENT_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
widgets: [
{
universalIdentifier:
CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Summary',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
CALENDAR_EVENT_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
});