From cbbb11b774e867ad8a06d4aced4654b329982f3d Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:48:48 +0530 Subject: [PATCH] Add configurable call recorder summaries (#22405) CleanShot 2026-07-02 at 16 58 03 Review in cubic --- .../public/call-recorder/README.md | 7 +- .../twenty-apps/public/call-recorder/SETUP.md | 8 +- .../public/call-recorder/package.json | 2 +- .../agents/call-recording-summarizer.agent.ts | 15 + .../call-recorder/src/application-config.ts | 20 ++ .../generate-call-recording-summary.ts | 18 ++ ...ry-front-component-universal-identifier.ts | 2 + ...ry-page-layout-tab-universal-identifier.ts | 2 + ...page-layout-widget-universal-identifier.ts | 2 + ...rompt-app-variable-universal-identifier.ts | 2 + ...abled-app-variable-universal-identifier.ts | 2 + ...g-summarizer-agent-universal-identifier.ts | 2 + .../default-call-recording-summary-prompt.ts | 50 ++++ ...ies-logic-function-universal-identifier.ts | 2 + ...ate-call-recording-summaries-route-path.ts | 2 + ...-command-menu-item-universal-identifier.ts | 2 + ...ry-front-component-universal-identifier.ts | 2 + ...all-logic-function-universal-identifier.ts | 2 + ...ing-logic-function-universal-identifier.ts | 2 + .../public/call-recorder/src/default-role.ts | 7 +- ...calendar-event-summary.front-component.tsx | 13 + .../components/CalendarEventSummary.tsx | 39 +++ .../components/CalendarEventSummaryBody.tsx | 53 ++++ .../CalendarEventSummaryContent.tsx | 77 ++++++ .../components/SummaryInlineSegments.tsx | 28 ++ .../components/SummaryMarkdown.tsx | 87 ++++++ .../components/TranscriptEntryListItem.tsx | 4 +- ...cording-summary-effect.front-component.tsx | 27 ++ .../hooks/use-calendar-event-summary.ts | 117 ++++++++ .../types/summary-inline-segment.type.ts | 4 + .../types/summary-markdown-block.type.ts | 6 + .../format-transcript-timestamp.test.ts | 29 -- .../parse-summary-inline-segments.test.ts | 40 +++ .../parse-summary-markdown-blocks.test.ts | 63 +++++ ...-call-recording-summary-generation.test.ts | 78 ++++++ .../utils/format-transcript-timestamp.util.ts | 16 -- .../parse-summary-inline-segments.util.ts | 39 +++ .../parse-summary-markdown-blocks.util.ts | 79 ++++++ ...-call-recording-summary-generation.util.ts | 81 ++++++ .../generate-call-recording-summaries.test.ts | 173 ++++++++++++ ...ording-summary-backfill-on-install.test.ts | 62 +++++ .../summarize-call-recording.test.ts | 129 +++++++++ ...-additional-summary-prompt-env-var-name.ts | 2 + .../call-recorder-created-by-source.ts | 2 + ...l-recorder-summary-enabled-env-var-name.ts | 2 + .../default-call-recorder-summary-enabled.ts | 1 + .../find-call-recording-for-summary.test.ts | 30 ++ ...-recording-ids-for-calendar-events.test.ts | 52 ++++ ...call-recording-ids-missing-summary.test.ts | 128 +++++++++ ...-call-recording-summaries-backfill.test.ts | 46 +++ .../find-call-recording-for-summary.util.ts | 54 ++++ ...-recording-ids-for-calendar-events.util.ts | 84 ++++++ ...call-recording-ids-missing-summary.util.ts | 76 +++++ ...-call-recording-summaries-backfill.util.ts | 35 +++ ...l-recording-summaries-continuation.util.ts | 38 +++ ...uild-call-recording-summary-prompt.test.ts | 95 +++++++ ...ct-call-recording-summary-markdown.test.ts | 52 ++++ ...recording-created-by-call-recorder.test.ts | 36 +++ .../__tests__/is-real-transcript.test.ts | 27 ++ ...uild-call-recording-summary-prompt.util.ts | 82 ++++++ ...ct-call-recording-summary-markdown.util.ts | 20 ++ ...recording-created-by-call-recorder.util.ts | 12 + .../domain/is-real-transcript.util.ts | 4 + .../generate-call-recording-summary.test.ts | 261 ++++++++++++++++++ ...e-missing-call-recording-summaries.test.ts | 188 +++++++++++++ ...rate-call-recording-summary-result.type.ts | 9 + .../generate-call-recording-summary.util.ts | 76 +++++ ...ng-call-recording-summaries-result.type.ts | 8 + ...e-missing-call-recording-summaries.util.ts | 92 ++++++ .../generate-call-recording-summaries.ts | 107 +++++++ ...l-recording-summary-backfill-on-install.ts | 34 +++ .../summarize-call-recording.ts | 57 ++++ .../types/call-recording-summary.type.ts | 4 + .../call-recording-update-fields.type.ts | 2 + .../format-seconds-as-clock-timestamp.test.ts | 31 +++ ...recorder-additional-summary-prompt.test.ts | 33 +++ .../is-call-recording-summary-enabled.test.ts | 39 +++ .../format-seconds-as-clock-timestamp.util.ts | 20 ++ ...recorder-additional-summary-prompt.util.ts | 15 + .../is-call-recording-summary-enabled.util.ts | 29 ++ .../calendar-event-summary-tab.ts | 33 +++ 81 files changed, 3257 insertions(+), 54 deletions(-) create mode 100644 packages/twenty-apps/public/call-recorder/src/agents/call-recording-summarizer.agent.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/command-menu-items/generate-call-recording-summary.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-front-component-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-tab-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-widget-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/call-recorder-additional-summary-prompt-app-variable-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/call-recorder-summary-enabled-app-variable-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/call-recording-summarizer-agent-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/default-call-recording-summary-prompt.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-logic-function-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-route-path.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-command-menu-item-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-front-component-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/start-call-recording-summary-backfill-on-install-logic-function-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/summarize-call-recording-logic-function-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/calendar-event-summary.front-component.tsx create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummary.tsx create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryBody.tsx create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryContent.tsx create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryInlineSegments.tsx create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryMarkdown.tsx create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/generate-call-recording-summary-effect.front-component.tsx create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/hooks/use-calendar-event-summary.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/types/summary-inline-segment.type.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/types/summary-markdown-block.type.ts delete mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/format-transcript-timestamp.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-inline-segments.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-markdown-blocks.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts delete mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/format-transcript-timestamp.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-inline-segments.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-markdown-blocks.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-call-recording-summary-backfill-on-install.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-created-by-source.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-summary-enabled-env-var-name.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-summary-enabled.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-for-summary.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-for-calendar-events.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-missing-summary.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-for-summary.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-for-calendar-events.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-missing-summary.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-backfill.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-continuation.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-call-recording-summary-prompt.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/extract-call-recording-summary-markdown.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-created-by-call-recorder.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-real-transcript.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-call-recording-summary-prompt.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/extract-call-recording-summary-markdown.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-created-by-call-recorder.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-real-transcript.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-call-recording-summary.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-missing-call-recording-summaries.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary-result.type.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries-result.type.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/generate-call-recording-summaries.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/start-call-recording-summary-backfill-on-install.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/summarize-call-recording.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-summary.type.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/utils/__tests__/format-seconds-as-clock-timestamp.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/utils/__tests__/get-call-recorder-additional-summary-prompt.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/utils/__tests__/is-call-recording-summary-enabled.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/utils/format-seconds-as-clock-timestamp.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/utils/get-call-recorder-additional-summary-prompt.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/utils/is-call-recording-summary-enabled.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/page-layouts/calendar-event-summary-tab.ts diff --git a/packages/twenty-apps/public/call-recorder/README.md b/packages/twenty-apps/public/call-recorder/README.md index dd19f0ee0b..8435377a60 100644 --- a/packages/twenty-apps/public/call-recorder/README.md +++ b/packages/twenty-apps/public/call-recorder/README.md @@ -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. - diff --git a/packages/twenty-apps/public/call-recorder/SETUP.md b/packages/twenty-apps/public/call-recorder/SETUP.md index 7e554fbaee..614285ba99 100644 --- a/packages/twenty-apps/public/call-recorder/SETUP.md +++ b/packages/twenty-apps/public/call-recorder/SETUP.md @@ -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 | diff --git a/packages/twenty-apps/public/call-recorder/package.json b/packages/twenty-apps/public/call-recorder/package.json index 1f15fcf940..4ada0a95fe 100644 --- a/packages/twenty-apps/public/call-recorder/package.json +++ b/packages/twenty-apps/public/call-recorder/package.json @@ -1,6 +1,6 @@ { "name": "@twentyhq/call-recorder", - "version": "1.0.5", + "version": "1.0.6", "license": "MIT", "engines": { "node": "^24.5.0", diff --git a/packages/twenty-apps/public/call-recorder/src/agents/call-recording-summarizer.agent.ts b/packages/twenty-apps/public/call-recorder/src/agents/call-recording-summarizer.agent.ts new file mode 100644 index 0000000000..1a6d9e42f1 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/agents/call-recording-summarizer.agent.ts @@ -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' }, +}); diff --git a/packages/twenty-apps/public/call-recorder/src/application-config.ts b/packages/twenty-apps/public/call-recorder/src/application-config.ts index 2d6d2731bd..69be0d5cb0 100644 --- a/packages/twenty-apps/public/call-recorder/src/application-config.ts +++ b/packages/twenty-apps/public/call-recorder/src/application-config.ts @@ -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, diff --git a/packages/twenty-apps/public/call-recorder/src/command-menu-items/generate-call-recording-summary.ts b/packages/twenty-apps/public/call-recorder/src/command-menu-items/generate-call-recording-summary.ts new file mode 100644 index 0000000000..9d5502fcfa --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/command-menu-items/generate-call-recording-summary.ts @@ -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', +}); diff --git a/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-front-component-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-front-component-universal-identifier.ts new file mode 100644 index 0000000000..2d086dabf6 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-front-component-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALENDAR_EVENT_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER = + '960d8d79-22f4-4892-9d66-ac94ac8a3f62'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-tab-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-tab-universal-identifier.ts new file mode 100644 index 0000000000..910e375c02 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-tab-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER = + '8ee79a0f-e02d-4292-be9f-a9344f202f8c'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-widget-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-widget-universal-identifier.ts new file mode 100644 index 0000000000..a501ee8241 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-widget-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER = + 'd6652f36-5963-4daa-a3ba-ec55bfe2735c'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-additional-summary-prompt-app-variable-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-additional-summary-prompt-app-variable-universal-identifier.ts new file mode 100644 index 0000000000..69ada219f6 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-additional-summary-prompt-app-variable-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER = + '587ead10-c514-468c-944f-17050c88dcc1'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-summary-enabled-app-variable-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-summary-enabled-app-variable-universal-identifier.ts new file mode 100644 index 0000000000..e7e5236f28 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-summary-enabled-app-variable-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALL_RECORDER_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER = + '1d7bf052-4338-44da-b373-23f6d291a50c'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/call-recording-summarizer-agent-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/call-recording-summarizer-agent-universal-identifier.ts new file mode 100644 index 0000000000..119dd5286b --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/call-recording-summarizer-agent-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER = + '388543f3-ec85-4905-ad7d-99801337ed5c'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/default-call-recording-summary-prompt.ts b/packages/twenty-apps/public/call-recorder/src/constants/default-call-recording-summary-prompt.ts new file mode 100644 index 0000000000..509079f6d9 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/default-call-recording-summary-prompt.ts @@ -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'); diff --git a/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-logic-function-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-logic-function-universal-identifier.ts new file mode 100644 index 0000000000..e6f9f01354 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-logic-function-universal-identifier.ts @@ -0,0 +1,2 @@ +export const GENERATE_CALL_RECORDING_SUMMARIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + '3467c68d-5d81-4f16-8234-b4182cb17f19'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-route-path.ts b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-route-path.ts new file mode 100644 index 0000000000..fcb5bbf9ed --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-route-path.ts @@ -0,0 +1,2 @@ +export const GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH = + '/call-recorder/generate-call-recording-summaries'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-command-menu-item-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-command-menu-item-universal-identifier.ts new file mode 100644 index 0000000000..abb6b1c773 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-command-menu-item-universal-identifier.ts @@ -0,0 +1,2 @@ +export const GENERATE_CALL_RECORDING_SUMMARY_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER = + '6b7b0989-2946-413b-9b6a-08336b418788'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-front-component-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-front-component-universal-identifier.ts new file mode 100644 index 0000000000..223a07d0ce --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-front-component-universal-identifier.ts @@ -0,0 +1,2 @@ +export const GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER = + '4b81fd8f-5626-4d5a-a97e-0543436bb555'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/start-call-recording-summary-backfill-on-install-logic-function-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/start-call-recording-summary-backfill-on-install-logic-function-universal-identifier.ts new file mode 100644 index 0000000000..a54103eb3a --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/start-call-recording-summary-backfill-on-install-logic-function-universal-identifier.ts @@ -0,0 +1,2 @@ +export const START_CALL_RECORDING_SUMMARY_BACKFILL_ON_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + '53e0acb4-b761-40c9-8aaf-554d2a5da00f'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/summarize-call-recording-logic-function-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/summarize-call-recording-logic-function-universal-identifier.ts new file mode 100644 index 0000000000..49d2c652ea --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/summarize-call-recording-logic-function-universal-identifier.ts @@ -0,0 +1,2 @@ +export const SUMMARIZE_CALL_RECORDING_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + 'c7a59e9c-935d-45ae-ae95-ded8d327f581'; diff --git a/packages/twenty-apps/public/call-recorder/src/default-role.ts b/packages/twenty-apps/public/call-recorder/src/default-role.ts index 08bfdf064c..c0b9a0d01c 100644 --- a/packages/twenty-apps/public/call-recorder/src/default-role.ts +++ b/packages/twenty-apps/public/call-recorder/src/default-role.ts @@ -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, + ], }); diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/calendar-event-summary.front-component.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/calendar-event-summary.front-component.tsx new file mode 100644 index 0000000000..d3f7dc6955 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/calendar-event-summary.front-component.tsx @@ -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, +}); diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummary.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummary.tsx new file mode 100644 index 0000000000..2b26364925 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummary.tsx @@ -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 ( + + Open a calendar event to see its summary. + + ); + } + + return ( + + ); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryBody.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryBody.tsx new file mode 100644 index 0000000000..379712a5df --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryBody.tsx @@ -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 ( + + ); + } + + if (isCalendarEventSummaryQueryLoading) { + return Loading summary…; + } + + if (isUndefined(summaryMarkdown)) { + return ( + + No summary for this calendar event yet. + + ); + } + + return ; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryContent.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryContent.tsx new file mode 100644 index 0000000000..572a8f573b --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryContent.tsx @@ -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 ( + + + Summary + + + + + + + + ); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryInlineSegments.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryInlineSegments.tsx new file mode 100644 index 0000000000..eb27ac70f0 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryInlineSegments.tsx @@ -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 ? ( + {segment.text} + ) : ( + {segment.text} + ), + )} + +); diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryMarkdown.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryMarkdown.tsx new file mode 100644 index 0000000000..0d97c002ce --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryMarkdown.tsx @@ -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 ( + + {blocks.map((block, index) => { + if (block.type === 'heading') { + return ( + + + + ); + } + + if (block.type === 'list') { + return ( + + {block.items.map((item, itemIndex) => ( +
  • + +
  • + ))} +
    + ); + } + + return ( + + + + ); + })} +
    + ); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/components/TranscriptEntryListItem.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/components/TranscriptEntryListItem.tsx index 044489c48b..c8b3af6af7 100644 --- a/packages/twenty-apps/public/call-recorder/src/front-components/components/TranscriptEntryListItem.tsx +++ b/packages/twenty-apps/public/call-recorder/src/front-components/components/TranscriptEntryListItem.tsx @@ -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) && ( - {formatTranscriptTimestamp(entry.startSeconds)} + {formatSecondsAsClockTimestamp(entry.startSeconds)} )} diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/generate-call-recording-summary-effect.front-component.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/generate-call-recording-summary-effect.front-component.tsx new file mode 100644 index 0000000000..0f4eba7353 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/generate-call-recording-summary-effect.front-component.tsx @@ -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 ( + + 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, +}); diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/hooks/use-calendar-event-summary.ts b/packages/twenty-apps/public/call-recorder/src/front-components/hooks/use-calendar-event-summary.ts new file mode 100644 index 0000000000..3c1144be91 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/hooks/use-calendar-event-summary.ts @@ -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({ + 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; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/types/summary-inline-segment.type.ts b/packages/twenty-apps/public/call-recorder/src/front-components/types/summary-inline-segment.type.ts new file mode 100644 index 0000000000..474eff8e90 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/types/summary-inline-segment.type.ts @@ -0,0 +1,4 @@ +export type SummaryInlineSegment = { + text: string; + isBold: boolean; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/types/summary-markdown-block.type.ts b/packages/twenty-apps/public/call-recorder/src/front-components/types/summary-markdown-block.type.ts new file mode 100644 index 0000000000..2ba417b7d7 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/types/summary-markdown-block.type.ts @@ -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[][] }; diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/format-transcript-timestamp.test.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/format-transcript-timestamp.test.ts deleted file mode 100644 index 79eddbd8de..0000000000 --- a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/format-transcript-timestamp.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-inline-segments.test.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-inline-segments.test.ts new file mode 100644 index 0000000000..a38bb61b3a --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-inline-segments.test.ts @@ -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([]); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-markdown-blocks.test.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-markdown-blocks.test.ts new file mode 100644 index 0000000000..a548926b11 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-markdown-blocks.test.ts @@ -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([]); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts new file mode 100644 index 0000000000..a79ebf08f8 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts @@ -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', + }); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/format-transcript-timestamp.util.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/format-transcript-timestamp.util.ts deleted file mode 100644 index 1551203ece..0000000000 --- a/packages/twenty-apps/public/call-recorder/src/front-components/utils/format-transcript-timestamp.util.ts +++ /dev/null @@ -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}`; -}; diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-inline-segments.util.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-inline-segments.util.ts new file mode 100644 index 0000000000..69ef26f133 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-inline-segments.util.ts @@ -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; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-markdown-blocks.util.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-markdown-blocks.util.ts new file mode 100644 index 0000000000..71b7d995ec --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-markdown-blocks.util.ts @@ -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); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts new file mode 100644 index 0000000000..03ac72909d --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts @@ -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 => { + if (calendarEventIds.length === 0) { + return; + } + + try { + const client = new RestApiClient(); + + const response = await client.post( + `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`, + { calendarEventIds }, + ); + + await enqueueSnackbar(buildSnackbarForResponse(response ?? {})); + } catch { + await enqueueSnackbar({ + message: 'Summary generation failed.', + variant: 'error', + }); + } +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts new file mode 100644 index 0000000000..a9dace06e5 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts @@ -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(); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-call-recording-summary-backfill-on-install.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-call-recording-summary-backfill-on-install.test.ts new file mode 100644 index 0000000000..791b95f387 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-call-recording-summary-backfill-on-install.test.ts @@ -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' }); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts new file mode 100644 index 0000000000..58bd6971d6 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts @@ -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[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', + }); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name.ts new file mode 100644 index 0000000000..9a17c5724d --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name.ts @@ -0,0 +1,2 @@ +export const CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME = + 'CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-created-by-source.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-created-by-source.ts new file mode 100644 index 0000000000..5e14a34942 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-created-by-source.ts @@ -0,0 +1,2 @@ +// Actor source the platform stamps on records created with an application token. +export const CALL_RECORDER_CREATED_BY_SOURCE = 'APPLICATION'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-summary-enabled-env-var-name.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-summary-enabled-env-var-name.ts new file mode 100644 index 0000000000..4f5ad7d54c --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-summary-enabled-env-var-name.ts @@ -0,0 +1,2 @@ +export const CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME = + 'CALL_RECORDER_SUMMARY_ENABLED'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-summary-enabled.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-summary-enabled.ts new file mode 100644 index 0000000000..cc1dc6855d --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-summary-enabled.ts @@ -0,0 +1 @@ +export const DEFAULT_CALL_RECORDER_SUMMARY_ENABLED = true; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-for-summary.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-for-summary.test.ts new file mode 100644 index 0000000000..4b2f4807d6 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-for-summary.test.ts @@ -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(); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-for-calendar-events.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-for-calendar-events.test.ts new file mode 100644 index 0000000000..66059560e2 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-for-calendar-events.test.ts @@ -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']); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-missing-summary.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-missing-summary.test.ts new file mode 100644 index 0000000000..bcd262a983 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-missing-summary.test.ts @@ -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']); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts new file mode 100644 index 0000000000..2446bc101e --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-for-summary.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-for-summary.util.ts new file mode 100644 index 0000000000..d7be79c843 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-for-summary.util.ts @@ -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 => { + 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), + }, + }; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-for-calendar-events.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-for-calendar-events.util.ts new file mode 100644 index 0000000000..4397a3bec8 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-for-calendar-events.util.ts @@ -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 => { + if (calendarEventIds.length === 0) { + return []; + } + + const callRecordingIds: string[] = []; + + for (const calendarEventIdBatch of getCalendarEventIdBatches( + calendarEventIds, + )) { + const callRecordingNodes = await fetchAllNodes( + 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 + | undefined; + }, + ); + + for (const callRecordingNode of callRecordingNodes) { + callRecordingIds.push(callRecordingNode.id); + } + } + + return [...new Set(callRecordingIds)]; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-missing-summary.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-missing-summary.util.ts new file mode 100644 index 0000000000..d9aef3f815 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-missing-summary.util.ts @@ -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 => { + const callRecordingNodes = await fetchAllNodes( + 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 + | 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); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-backfill.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-backfill.util.ts new file mode 100644 index 0000000000..15a2e4b669 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-backfill.util.ts @@ -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 => { + 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; + } + }; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-continuation.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-continuation.util.ts new file mode 100644 index 0000000000..17787ca416 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-continuation.util.ts @@ -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 => { + 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; + } +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-call-recording-summary-prompt.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-call-recording-summary-prompt.test.ts new file mode 100644 index 0000000000..91dbe63dc0 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-call-recording-summary-prompt.test.ts @@ -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(); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/extract-call-recording-summary-markdown.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/extract-call-recording-summary-markdown.test.ts new file mode 100644 index 0000000000..5e3cbdecf0 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/extract-call-recording-summary-markdown.test.ts @@ -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.'); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-created-by-call-recorder.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-created-by-call-recorder.test.ts new file mode 100644 index 0000000000..0b36beef4a --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-created-by-call-recorder.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-real-transcript.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-real-transcript.test.ts new file mode 100644 index 0000000000..542792448e --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-real-transcript.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-call-recording-summary-prompt.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-call-recording-summary-prompt.util.ts new file mode 100644 index 0000000000..ea2fbd4e48 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-call-recording-summary-prompt.util.ts @@ -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')}`; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/extract-call-recording-summary-markdown.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/extract-call-recording-summary-markdown.util.ts new file mode 100644 index 0000000000..740362e106 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/extract-call-recording-summary-markdown.util.ts @@ -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(); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-created-by-call-recorder.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-created-by-call-recorder.util.ts new file mode 100644 index 0000000000..4d6096b55b --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-created-by-call-recorder.util.ts @@ -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; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-real-transcript.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-real-transcript.util.ts new file mode 100644 index 0000000000..02e526570d --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-real-transcript.util.ts @@ -0,0 +1,4 @@ +import { isArray } from '@sniptt/guards'; + +export const isRealTranscript = (transcript: unknown): boolean => + isArray(transcript) && transcript.length > 0; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-call-recording-summary.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-call-recording-summary.test.ts new file mode 100644 index 0000000000..b80d5014ec --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-call-recording-summary.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-missing-call-recording-summaries.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-missing-call-recording-summaries.test.ts new file mode 100644 index 0000000000..f3ed3f37e4 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-missing-call-recording-summaries.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary-result.type.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary-result.type.ts new file mode 100644 index 0000000000..f7c8455f6c --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary-result.type.ts @@ -0,0 +1,9 @@ +export type GenerateCallRecordingSummaryResult = { + outcome: + | 'disabled' + | 'no-transcript' + | 'not-app-recording' + | 'already-summarized' + | 'empty-summary' + | 'generated'; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary.util.ts new file mode 100644 index 0000000000..f829367b7a --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary.util.ts @@ -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 => { + 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' }; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries-result.type.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries-result.type.ts new file mode 100644 index 0000000000..abc5a72a6f --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries-result.type.ts @@ -0,0 +1,8 @@ +export type GenerateMissingCallRecordingSummariesResult = { + generatedCallRecordingIds: string[]; + failedCallRecordingIds: string[]; + erroredCallRecordingIds: string[]; + skippedCallRecordingIds: string[]; + remainingCallRecordingIds: string[]; + continuationRequested: boolean; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries.util.ts new file mode 100644 index 0000000000..25f810386a --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries.util.ts @@ -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 => { + 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, + }; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/generate-call-recording-summaries.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/generate-call-recording-summaries.ts new file mode 100644 index 0000000000..b2a0762215 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/generate-call-recording-summaries.ts @@ -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 = ( + 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, +): Promise => { + 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, + }, +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/start-call-recording-summary-backfill-on-install.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/start-call-recording-summary-backfill-on-install.ts new file mode 100644 index 0000000000..eee358a595 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/start-call-recording-summary-backfill-on-install.ts @@ -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 => { + 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, +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/summarize-call-recording.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/summarize-call-recording.ts new file mode 100644 index 0000000000..abfad0d509 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/summarize-call-recording.ts @@ -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 +>; + +export const summarizeCallRecordingHandler = async ( + event: CallRecordingDatabaseEvent, +): Promise => { + 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`, + }, +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-summary.type.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-summary.type.ts new file mode 100644 index 0000000000..47c5c4e142 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-summary.type.ts @@ -0,0 +1,4 @@ +export type CallRecordingSummary = { + blocknote: string | null; + markdown: string | null; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-update-fields.type.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-update-fields.type.ts index 6455bb2e0a..2e67fd277f 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-update-fields.type.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-update-fields.type.ts @@ -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; audio: CallRecordingMediaFile[]; video: CallRecordingMediaFile[]; + summary: CallRecordingSummary; }>; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/__tests__/format-seconds-as-clock-timestamp.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/__tests__/format-seconds-as-clock-timestamp.test.ts new file mode 100644 index 0000000000..47510ea925 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/__tests__/format-seconds-as-clock-timestamp.test.ts @@ -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', + ); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/__tests__/get-call-recorder-additional-summary-prompt.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/__tests__/get-call-recorder-additional-summary-prompt.test.ts new file mode 100644 index 0000000000..6a52ab2e87 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/__tests__/get-call-recorder-additional-summary-prompt.test.ts @@ -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(); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/__tests__/is-call-recording-summary-enabled.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/__tests__/is-call-recording-summary-enabled.test.ts new file mode 100644 index 0000000000..df755796e2 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/__tests__/is-call-recording-summary-enabled.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/format-seconds-as-clock-timestamp.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/format-seconds-as-clock-timestamp.util.ts new file mode 100644 index 0000000000..c92cae504c --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/format-seconds-as-clock-timestamp.util.ts @@ -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}`; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/get-call-recorder-additional-summary-prompt.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/get-call-recorder-additional-summary-prompt.util.ts new file mode 100644 index 0000000000..b7d23d33fd --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/get-call-recorder-additional-summary-prompt.util.ts @@ -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; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/is-call-recording-summary-enabled.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/is-call-recording-summary-enabled.util.ts new file mode 100644 index 0000000000..71037ac96c --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/is-call-recording-summary-enabled.util.ts @@ -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; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/page-layouts/calendar-event-summary-tab.ts b/packages/twenty-apps/public/call-recorder/src/page-layouts/calendar-event-summary-tab.ts new file mode 100644 index 0000000000..126ecfc93d --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/page-layouts/calendar-event-summary-tab.ts @@ -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, + }, + }, + ], +});