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:
@@ -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",
|
||||
|
||||
+15
@@ -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,
|
||||
|
||||
+18
@@ -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',
|
||||
});
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const CALENDAR_EVENT_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
|
||||
'960d8d79-22f4-4892-9d66-ac94ac8a3f62';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER =
|
||||
'8ee79a0f-e02d-4292-be9f-a9344f202f8c';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER =
|
||||
'd6652f36-5963-4daa-a3ba-ec55bfe2735c';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
|
||||
'587ead10-c514-468c-944f-17050c88dcc1';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const CALL_RECORDER_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
|
||||
'1d7bf052-4338-44da-b373-23f6d291a50c';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER =
|
||||
'388543f3-ec85-4905-ad7d-99801337ed5c';
|
||||
+50
@@ -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');
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const GENERATE_CALL_RECORDING_SUMMARIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
|
||||
'3467c68d-5d81-4f16-8234-b4182cb17f19';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH =
|
||||
'/call-recorder/generate-call-recording-summaries';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const GENERATE_CALL_RECORDING_SUMMARY_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER =
|
||||
'6b7b0989-2946-413b-9b6a-08336b418788';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
|
||||
'4b81fd8f-5626-4d5a-a97e-0543436bb555';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const START_CALL_RECORDING_SUMMARY_BACKFILL_ON_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
|
||||
'53e0acb4-b761-40c9-8aaf-554d2a5da00f';
|
||||
+2
@@ -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,
|
||||
],
|
||||
});
|
||||
|
||||
+13
@@ -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,
|
||||
});
|
||||
+39
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+53
@@ -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} />;
|
||||
};
|
||||
+77
@@ -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>
|
||||
);
|
||||
};
|
||||
+28
@@ -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>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
+87
@@ -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>
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+27
@@ -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,
|
||||
});
|
||||
+117
@@ -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;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type SummaryInlineSegment = {
|
||||
text: string;
|
||||
isBold: boolean;
|
||||
};
|
||||
+6
@@ -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[][] };
|
||||
-29
@@ -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');
|
||||
});
|
||||
});
|
||||
+40
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+63
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+78
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
-16
@@ -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}`;
|
||||
};
|
||||
+39
@@ -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;
|
||||
};
|
||||
+79
@@ -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);
|
||||
};
|
||||
+81
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
+173
@@ -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();
|
||||
});
|
||||
});
|
||||
+62
@@ -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' });
|
||||
});
|
||||
});
|
||||
+129
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME =
|
||||
'CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
// Actor source the platform stamps on records created with an application token.
|
||||
export const CALL_RECORDER_CREATED_BY_SOURCE = 'APPLICATION';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME =
|
||||
'CALL_RECORDER_SUMMARY_ENABLED';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const DEFAULT_CALL_RECORDER_SUMMARY_ENABLED = true;
|
||||
+30
@@ -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();
|
||||
});
|
||||
});
|
||||
+52
@@ -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']);
|
||||
});
|
||||
});
|
||||
+128
@@ -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']);
|
||||
});
|
||||
});
|
||||
+46
@@ -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);
|
||||
});
|
||||
});
|
||||
+54
@@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
+84
@@ -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)];
|
||||
};
|
||||
+76
@@ -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);
|
||||
};
|
||||
+35
@@ -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;
|
||||
}
|
||||
};
|
||||
+38
@@ -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;
|
||||
}
|
||||
};
|
||||
+95
@@ -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();
|
||||
});
|
||||
});
|
||||
+52
@@ -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.');
|
||||
});
|
||||
});
|
||||
+36
@@ -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);
|
||||
});
|
||||
});
|
||||
+27
@@ -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);
|
||||
});
|
||||
});
|
||||
+82
@@ -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')}`;
|
||||
};
|
||||
+20
@@ -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();
|
||||
};
|
||||
+12
@@ -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;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { isArray } from '@sniptt/guards';
|
||||
|
||||
export const isRealTranscript = (transcript: unknown): boolean =>
|
||||
isArray(transcript) && transcript.length > 0;
|
||||
+261
@@ -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);
|
||||
});
|
||||
});
|
||||
+188
@@ -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);
|
||||
});
|
||||
});
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export type GenerateCallRecordingSummaryResult = {
|
||||
outcome:
|
||||
| 'disabled'
|
||||
| 'no-transcript'
|
||||
| 'not-app-recording'
|
||||
| 'already-summarized'
|
||||
| 'empty-summary'
|
||||
| 'generated';
|
||||
};
|
||||
+76
@@ -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' };
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export type GenerateMissingCallRecordingSummariesResult = {
|
||||
generatedCallRecordingIds: string[];
|
||||
failedCallRecordingIds: string[];
|
||||
erroredCallRecordingIds: string[];
|
||||
skippedCallRecordingIds: string[];
|
||||
remainingCallRecordingIds: string[];
|
||||
continuationRequested: boolean;
|
||||
};
|
||||
+92
@@ -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,
|
||||
};
|
||||
};
|
||||
+107
@@ -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 app’s 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,
|
||||
},
|
||||
});
|
||||
+34
@@ -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,
|
||||
});
|
||||
+57
@@ -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`,
|
||||
},
|
||||
});
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type CallRecordingSummary = {
|
||||
blocknote: string | null;
|
||||
markdown: string | null;
|
||||
};
|
||||
+2
@@ -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;
|
||||
}>;
|
||||
|
||||
+31
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+33
@@ -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();
|
||||
});
|
||||
});
|
||||
+39
@@ -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);
|
||||
});
|
||||
});
|
||||
+20
@@ -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}`;
|
||||
};
|
||||
+15
@@ -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;
|
||||
};
|
||||
+29
@@ -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;
|
||||
};
|
||||
+33
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user