Skip to content

Commit

Permalink
feat: AI description - DB model + frontend + backend (fetch only) (ca…
Browse files Browse the repository at this point in the history
…lcom#17651)

* feat: AI description - DB model + frontend + backend (fetch only)

* fix types and add validation to backend

* improve log

* improve

* import type

* fix replexica error

* fix

* fix test

* update replexica type

* Renamed descriptionTranslations to fieldTranslations

* Moved the eventTypeId column to 2nd

---------

Co-authored-by: Keith Williams <[email protected]>
  • Loading branch information
hbjORbj and keithwillcode authored Nov 18, 2024
1 parent f9afca2 commit 5401bcc
Show file tree
Hide file tree
Showing 20 changed files with 621 additions and 9 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -408,3 +408,6 @@ NEXT_PUBLIC_WEBSITE_TERMS_URL=
# NEXT_PUBLIC_LOGGER_LEVEL=3 sets to log info, warn, error and fatal logs.
# [0: silly & upwards, 1: trace & upwards, 2: debug & upwards, 3: info & upwards, 4: warn & upwards, 5: error & fatal, 6: fatal]
NEXT_PUBLIC_LOGGER_LEVEL=

# Used to use Replexica SDK, a tool for real-time AI-powered localization
REPLEXICA_API_KEY=
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"@radix-ui/react-switch": "^1.0.0",
"@radix-ui/react-toggle-group": "^1.0.0",
"@radix-ui/react-tooltip": "^1.0.0",
"@replexica/sdk": "^0.6.0",
"@sentry/nextjs": "^8.8.0",
"@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.35.0",
Expand Down
1 change: 1 addition & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -2791,6 +2791,7 @@
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
"salesforce_option": "Salesforce Option",
"lookup_field_name": "Lookup Field Name",
"translate_description_button": "Translate description to the visitor's browser language using AI",
"rr_distribution_method": "Distribution",
"rr_distribution_method_description": "Allows for optimising distribution for maximum availability or to aim for a more balanced assignment.",
"rr_distribution_method_availability_title": "Maximize availability",
Expand Down
7 changes: 5 additions & 2 deletions apps/web/test/lib/handleChildrenEventTypes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ describe("handleChildrenEventTypes", () => {
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
...evType
} = mockFindFirstEventType({
id: 123,
Expand Down Expand Up @@ -182,7 +183,7 @@ describe("handleChildrenEventTypes", () => {
bookingLimits: undefined,
},
});
const { profileId, ...rest } = evType;
const { profileId, autoTranslateDescriptionEnabled, ...rest } = evType;
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...rest,
Expand Down Expand Up @@ -265,6 +266,7 @@ describe("handleChildrenEventTypes", () => {
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
...evType
} = mockFindFirstEventType({
id: 123,
Expand Down Expand Up @@ -337,7 +339,7 @@ describe("handleChildrenEventTypes", () => {
length: 30,
},
});
const { profileId, ...rest } = evType;
const { profileId, autoTranslateDescriptionEnabled, ...rest } = evType;
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...rest,
Expand Down Expand Up @@ -378,6 +380,7 @@ describe("handleChildrenEventTypes", () => {
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
Expand Down
10 changes: 8 additions & 2 deletions packages/features/bookings/Booker/components/EventMeta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export const EventMeta = ({
| "recurringEvent"
| "price"
| "isDynamic"
| "fieldTranslations"
| "autoTranslateDescriptionEnabled"
> | null;
isPending: boolean;
isPlatform?: boolean;
Expand Down Expand Up @@ -101,6 +103,10 @@ export const EventMeta = ({
: isHalfFull
? "text-yellow-500"
: "text-bookinghighlight";
const browserLocale = navigator.language; // e.g. "en-US", "es-ES", "fr-FR"
const translatedDescription = (event?.fieldTranslations ?? []).find((translation) =>
browserLocale.startsWith(translation.targetLang)
)?.translatedText;

return (
<div className={`${classNames?.eventMetaContainer || ""} relative z-10 p-6`} data-testid="event-meta">
Expand All @@ -120,9 +126,9 @@ export const EventMeta = ({
/>
)}
<EventTitle className={`${classNames?.eventMetaTitle} my-2`}>{event?.title}</EventTitle>
{event.description && (
{(event.description || translatedDescription) && (
<EventMetaBlock contentClassName="mb-8 break-words max-w-full max-h-[180px] scroll-bar pr-4">
<div dangerouslySetInnerHTML={{ __html: event.description }} />
<div dangerouslySetInnerHTML={{ __html: translatedDescription ?? event.description }} />
</EventMetaBlock>
)}
<div className="space-y-4 font-medium rtl:-mr-2">
Expand Down
2 changes: 2 additions & 0 deletions packages/features/bookings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export type BookerEvent = Pick<
| "bookingFields"
| "seatsShowAvailabilityCount"
| "isInstantEvent"
| "fieldTranslations"
| "autoTranslateDescriptionEnabled"
> & { users: BookerEventUser[]; showInstantEventConnectNowModal: boolean } & { profile: BookerEventProfile };

export type ValidationErrors<T extends object> = { key: FieldPath<T>; error: ErrorOption }[];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useSession } from "next-auth/react";
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
Expand All @@ -20,6 +21,7 @@ export type EventSetupTabProps = Pick<
>;
export const EventSetupTab = (props: EventSetupTabProps & { urlPrefix: string; hasOrgBranding: boolean }) => {
const { t } = useLocale();
const session = useSession();
const isPlatform = useIsPlatform();
const formMethods = useFormContext<FormValues>();
const { eventType, team, urlPrefix, hasOrgBranding } = props;
Expand All @@ -29,6 +31,7 @@ export const EventSetupTab = (props: EventSetupTabProps & { urlPrefix: string; h
const [firstRender, setFirstRender] = useState(true);

const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
const autoTranslateDescriptionEnabled = formMethods.watch("autoTranslateDescriptionEnabled");

const multipleDurationOptions = [
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
Expand Down Expand Up @@ -95,6 +98,17 @@ export const EventSetupTab = (props: EventSetupTabProps & { urlPrefix: string; h
</>
)}
</div>
<div className="[&_label]:my-1 [&_label]:font-normal">
<SettingsToggle
title={t("translate_description_button")}
checked={!!autoTranslateDescriptionEnabled}
onCheckedChange={(value) => {
formMethods.setValue("autoTranslateDescriptionEnabled", value, { shouldDirty: true });
}}
disabled={!session.data?.user.org?.id}
tooltip={!session.data?.user.org?.id ? t("orgs_upgrade_to_enable_feature") : undefined}
/>
</div>
<TextField
required
label={isPlatform ? "Slug" : t("URL")}
Expand Down
9 changes: 9 additions & 0 deletions packages/features/eventtypes/lib/getPublicEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
metadata: true,
lockTimeZoneToggleOnBookingPage: true,
requiresConfirmation: true,
autoTranslateDescriptionEnabled: true,
fieldTranslations: {
select: {
translatedText: true,
targetLang: true,
},
},
requiresBookerEmailVerification: true,
recurringEvent: true,
price: true,
Expand Down Expand Up @@ -293,6 +300,8 @@ export const getPublicEvent = async (
},
isInstantEvent: false,
showInstantEventConnectNowModal: false,
autoTranslateDescriptionEnabled: false,
fieldTranslations: [],
};
}

Expand Down
3 changes: 3 additions & 0 deletions packages/features/eventtypes/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { z } from "zod";

import type { EventLocationType } from "@calcom/core/location";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import type { EventTypeTranslation } from "@calcom/prisma/client";
import type { PeriodType, SchedulingType } from "@calcom/prisma/enums";
import type { BookerLayoutSettings, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { customInputSchema } from "@calcom/prisma/zod-utils";
Expand Down Expand Up @@ -109,6 +110,8 @@ export type FormValues = {
seatsShowAttendees: boolean | null;
seatsShowAvailabilityCount: boolean | null;
seatsPerTimeSlotEnabled: boolean;
autoTranslateDescriptionEnabled: boolean;
fieldTranslations: EventTypeTranslation[];
scheduleName: string;
minimumBookingNotice: number;
minimumBookingNoticeInDurationType: number;
Expand Down
2 changes: 2 additions & 0 deletions packages/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export const CALCOM_PRIVATE_API_ROUTE = process.env.CALCOM_PRIVATE_API_ROUTE ||
export const WEBSITE_PRIVACY_POLICY_URL =
process.env.NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL || "https://cal.com/privacy";
export const WEBSITE_TERMS_URL = process.env.NEXT_PUBLIC_WEBSITE_TERMS_URL || "https://cal.com/terms";
export const REPLEXICA_API_KEY = process.env.REPLEXICA_API_KEY;

/**
* The maximum number of days we should check for if we don't find all required bookable days
Expand All @@ -190,3 +191,4 @@ export const RECORDING_DEFAULT_ICON = IS_PRODUCTION
export const RECORDING_IN_PROGRESS_ICON = IS_PRODUCTION
? `${WEBAPP_URL}/stop-recording.svg`
: `https://app.cal.com/stop-recording.svg`;

2 changes: 2 additions & 0 deletions packages/lib/defaultEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ const commons = {
useEventTypeDestinationCalendarEmail: false,
secondaryEmailId: null,
secondaryEmail: null,
autoTranslateDescriptionEnabled: false,
fieldTranslations: [],
maxLeadThreshold: null,
};

Expand Down
1 change: 1 addition & 0 deletions packages/lib/server/eventTypeSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
title: true,
requiresConfirmation: true,
requiresConfirmationWillBlockSlot: true,
autoTranslateDescriptionEnabled: true,
position: true,
offsetStart: true,
profileId: true,
Expand Down
7 changes: 7 additions & 0 deletions packages/lib/server/repository/eventType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,13 @@ export class EventTypeRepository {
requiresConfirmation: true,
requiresConfirmationWillBlockSlot: true,
requiresBookerEmailVerification: true,
autoTranslateDescriptionEnabled: true,
fieldTranslations: {
select: {
translatedText: true,
targetLang: true,
},
},
recurringEvent: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
Expand Down
60 changes: 60 additions & 0 deletions packages/lib/server/service/replexica.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ReplexicaEngine } from "@replexica/sdk";

import { REPLEXICA_API_KEY } from "@calcom/lib/constants";

export class ReplexicaService {
private static engine = new ReplexicaEngine({
apiKey: REPLEXICA_API_KEY,
});

/**
* Localizes text from one language to another
* @param text The text to localize
* @param sourceLocale The source language locale
* @param targetLocale The target language locale
* @returns The localized text
*/
static async localizeText(text: string, sourceLocale: string, targetLocale: string): Promise<string> {
if (!text?.trim()) {
return text;
}

try {
const result = await this.engine.localizeText(text, {
sourceLocale,
targetLocale,
});

return result;
} catch (error) {
return text;
}
}

/**
* Localizes an array of texts from one language to another
* @param texts Array of texts to localize
* @param sourceLocale The source language locale
* @param targetLocale The target language locale
* @returns The localized texts array
*/
static async localizeTexts(texts: string[], sourceLocale: string, targetLocale: string): Promise<string[]> {
if (!texts.length) {
return texts;
}

try {
const result = await this.engine.localizeChat(
texts.map((text) => ({ name: "NO_NAME", text: text.trim() })),
{
sourceLocale,
targetLocale,
}
);

return result.map((chat: { name: string; text: string }) => chat.text);
} catch (error) {
return texts;
}
}
}
1 change: 1 addition & 0 deletions packages/lib/test/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
secondaryEmailId: null,
isRRWeightsEnabled: false,
eventTypeColor: null,
autoTranslateDescriptionEnabled: false,
...eventType,
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const useEventTypeForm = ({
},
})),
seatsPerTimeSlotEnabled: eventType.seatsPerTimeSlot,
autoTranslateDescriptionEnabled: eventType.autoTranslateDescriptionEnabled,
rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost,
assignAllTeamMembers: eventType.assignAllTeamMembers,
aiPhoneCallConfig: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- CreateEnum
CREATE TYPE "EventTypeAutoTranslatedField" AS ENUM ('DESCRIPTION');

-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "autoTranslateDescriptionEnabled" BOOLEAN NOT NULL DEFAULT false;

-- CreateTable
CREATE TABLE "EventTypeTranslation" (
"id" TEXT NOT NULL,
"eventTypeId" INTEGER NOT NULL,
"field" "EventTypeAutoTranslatedField" NOT NULL,
"sourceLang" TEXT NOT NULL,
"targetLang" TEXT NOT NULL,
"translatedText" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdBy" INTEGER NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedBy" INTEGER,

CONSTRAINT "EventTypeTranslation_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "EventTypeTranslation_eventTypeId_field_targetLang_idx" ON "EventTypeTranslation"("eventTypeId", "field", "targetLang");

-- CreateIndex
CREATE UNIQUE INDEX "EventTypeTranslation_eventTypeId_field_targetLang_key" ON "EventTypeTranslation"("eventTypeId", "field", "targetLang");

-- AddForeignKey
ALTER TABLE "EventTypeTranslation" ADD CONSTRAINT "EventTypeTranslation_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;
23 changes: 23 additions & 0 deletions packages/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ model EventType {
requiresConfirmation Boolean @default(false)
requiresConfirmationWillBlockSlot Boolean @default(false)
requiresBookerEmailVerification Boolean @default(false)
autoTranslateDescriptionEnabled Boolean @default(false)
/// @zod.custom(imports.recurringEventType)
recurringEvent Json?
disableGuests Boolean @default(false)
Expand Down Expand Up @@ -149,6 +150,7 @@ model EventType {
useEventTypeDestinationCalendarEmail Boolean @default(false)
aiPhoneCallConfig AIPhoneCallConfiguration?
isRRWeightsEnabled Boolean @default(false)
fieldTranslations EventTypeTranslation[]
maxLeadThreshold Int?
/// @zod.custom(imports.eventTypeColor)
Expand Down Expand Up @@ -1590,3 +1592,24 @@ model AttributeToUser {
@@unique([memberId, attributeOptionId])
}

enum EventTypeAutoTranslatedField {
DESCRIPTION // Currently the only field we translate
}

model EventTypeTranslation {
id String @id @default(cuid())
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
eventTypeId Int
field EventTypeAutoTranslatedField
sourceLang String
targetLang String
translatedText String @db.Text
createdAt DateTime @default(now())
createdBy Int
updatedAt DateTime @updatedAt
updatedBy Int?
@@unique([eventTypeId, field, targetLang])
@@index([eventTypeId, field, targetLang])
}
Loading

0 comments on commit 5401bcc

Please sign in to comment.