feat: AI description - Backend for adding translation objects to EventType model (#17657)

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

* fix types and add validation to backend

* improve log

* improve

* import type

* add EventTypeTranslationRepository

* fix replexica error

* fix replexica error

* fix

* fix test

* wip

* wip

* add createManyDescriptionTranslations method

* improve logic

* refactor

* update replexica type

* improve typing

* Renamed descriptionTranslations to fieldTranslations

* log errors in replexica service

* handle translations in background

* don't create, upsert instead

* make sure that description is updated

* simplify

* fix

* Update packages/trpc/server/routers/viewer/eventTypes/update.handler.ts

* improve filter logic in frontend

* implement background job using Tasker

* update schema

* update schema again

* rename

* rename id to uid

* add migration

* fix type

* update

* update schema and migration sql

* fix migration

* fix type check

* fix type check 2

* improve find logic in EventMeta

* add more validation

* add logger

* rename

* upgrade replexica/sdk to 0.7.0

* upgrade replexica to 0.7.0

* use batchLocalizeText

* add comments

* override browser locale if user is a signed in calcom user

* fix type in replexica

* fix migration sql

* fix type

* use dynamic import

* do not use batchLocalizeText

* fix

* add more validations

* fix type error

* address comment

* update locales

* update locales

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
Benny Joo
2024-11-25 18:37:42 +00:00
committed by GitHub
co-authored by Keith Williams
parent 93489709a8
commit 62976a4c77
14 changed files with 290 additions and 22 deletions
+1 -1
View File
@@ -64,7 +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",
"@replexica/sdk": "^0.7.0",
"@sentry/nextjs": "^8.8.0",
"@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.35.0",
@@ -1,4 +1,5 @@
import { m } from "framer-motion";
import { useSession } from "next-auth/react";
import dynamic from "next/dynamic";
import { useEffect, useMemo } from "react";
import { shallow } from "zustand/shallow";
@@ -11,6 +12,8 @@ import { EventMetaBlock } from "@calcom/features/bookings/components/event-meta/
import { useTimePreferences } from "@calcom/features/bookings/lib";
import type { BookerEvent } from "@calcom/features/bookings/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { locales as i18nLocales } from "@calcom/lib/i18n";
import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums";
import { fadeInUp } from "../config";
import { useBookerStore } from "../store";
@@ -59,6 +62,7 @@ export const EventMeta = ({
eventMetaTimezoneSelect?: string;
};
}) => {
const session = useSession();
const { setTimezone, timeFormat, timezone } = useTimePreferences();
const selectedDuration = useBookerStore((state) => state.selectedDuration);
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot);
@@ -103,9 +107,13 @@ 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)
const userLocale = session.data?.user.locale ?? navigator.language;
const translatedDescription = (event?.fieldTranslations ?? []).find(
(trans) =>
trans.field === EventTypeAutoTranslatedField.DESCRIPTION &&
i18nLocales.includes(trans.targetLocale) &&
// browser language looks like "en-US", "es-ES", "fr-FR", etc
(userLocale === trans.targetLocale || userLocale.split("-")[0] === trans.targetLocale)
)?.translatedText;
return (
@@ -71,7 +71,8 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
fieldTranslations: {
select: {
translatedText: true,
targetLang: true,
targetLocale: true,
field: true,
},
},
requiresBookerEmailVerification: true,
+3
View File
@@ -14,6 +14,9 @@ type TaskPayloads = {
triggerFormSubmittedNoEventWebhook: z.infer<
typeof import("./tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook").ZTriggerFormSubmittedNoEventWebhookPayloadSchema
>;
translateEventTypeDescription: z.infer<
typeof import("./tasks/translateEventTypeDescription").ZTranslateEventTypeDescriptionPayloadSchema
>;
};
export type TaskTypes = keyof TaskPayloads;
export type TaskHandler = (payload: string) => Promise<void>;
+2
View File
@@ -17,6 +17,8 @@ const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
(module) => module.triggerFormSubmittedNoEventWebhook
),
sendSms: () => Promise.resolve(() => Promise.reject(new Error("Not implemented"))),
translateEventTypeDescription: () =>
import("./translateEventTypeDescription").then((module) => module.translateEventTypeDescription),
};
export default tasks;
@@ -0,0 +1,77 @@
import { z } from "zod";
import { locales as i18nLocales } from "@calcom/lib/i18n";
import logger from "@calcom/lib/logger";
import { EventTypeTranslationRepository } from "@calcom/lib/server/repository/eventTypeTranslation";
export const ZTranslateEventTypeDescriptionPayloadSchema = z.object({
eventTypeId: z.number(),
userId: z.number(),
description: z.string(),
userLocale: z.string(),
});
const SUPPORTED_LOCALES = [
"en", // English
"es", // Spanish
"de", // German
"pt", // Portuguese
"pt-BR", // Portuguese Brazilian
"fr", // French
"it", // Italian
"ar", // Arabic
"ru", // Russian
"zh-CN", // Simplified Chinese
"nl", // Dutch
"zh-TW", // Traditional Chinese
"ko", // Korean
"ja", // Japanese
"sv", // Swedish
"da", // Danish
] as const;
export async function translateEventTypeDescription(payload: string): Promise<void> {
const { eventTypeId, description, userLocale, userId } = ZTranslateEventTypeDescriptionPayloadSchema.parse(
JSON.parse(payload)
);
const targetLocales = SUPPORTED_LOCALES.filter(
(locale) => locale !== userLocale && i18nLocales.includes(locale)
);
const { ReplexicaService } = await import("@calcom/lib/server/service/replexica");
try {
const translatedDescriptions = await Promise.all(
targetLocales.map((targetLocale) =>
ReplexicaService.localizeText(description, userLocale, targetLocale)
)
);
const validTranslations = translatedDescriptions
.map((translatedText, index) => ({
translatedText,
targetLocale: targetLocales[index],
}))
.filter(
(item): item is { translatedText: string; targetLocale: (typeof SUPPORTED_LOCALES)[number] } =>
item.translatedText !== null
);
if (validTranslations.length > 0) {
await EventTypeTranslationRepository.upsertManyDescriptionTranslations(
validTranslations.map(({ translatedText, targetLocale }) => ({
eventTypeId,
sourceLocale: userLocale,
targetLocale,
// Keep old fields during transition
sourceLang: userLocale, // TODO: remove after migration
targetLang: targetLocale, // TODO: remove after migration
translatedText,
userId,
}))
);
}
} catch (error) {
logger.error("Failed to process event type description translations:", error);
}
}
+2 -1
View File
@@ -450,7 +450,8 @@ export class EventTypeRepository {
fieldTranslations: {
select: {
translatedText: true,
targetLang: true,
targetLocale: true,
field: true,
},
},
recurringEvent: true,
@@ -0,0 +1,53 @@
import { prisma } from "@calcom/prisma";
import type { EventTypeTranslation } from "@calcom/prisma/client";
import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums";
export type CreateEventTypeDescriptionTranslation = Omit<
EventTypeTranslation,
| "id" // TODO: remove after migration
| "uid"
| "createdAt"
| "createdBy"
| "updatedAt"
| "updatedBy"
| "eventType"
| "field"
| "creator"
| "updater"
> & { userId: number };
export class EventTypeTranslationRepository {
static async upsertManyDescriptionTranslations(translations: Array<CreateEventTypeDescriptionTranslation>) {
return await Promise.all(
translations.map(({ userId, ...translation }) => {
const whereClause = translation.targetLang // TODO: remove after migration
? {
eventTypeId_field_targetLang: {
eventTypeId: translation.eventTypeId,
field: EventTypeAutoTranslatedField.DESCRIPTION,
targetLang: translation.targetLang,
},
}
: {
eventTypeId_field_targetLocale: {
eventTypeId: translation.eventTypeId,
field: EventTypeAutoTranslatedField.DESCRIPTION,
targetLocale: translation.targetLocale,
},
};
return prisma.eventTypeTranslation.upsert({
where: whereClause,
update: {
translatedText: translation.translatedText,
updatedBy: userId,
},
create: {
...translation,
field: EventTypeAutoTranslatedField.DESCRIPTION,
createdBy: userId,
},
});
})
);
}
}
+36 -3
View File
@@ -1,6 +1,8 @@
import { ReplexicaEngine } from "@replexica/sdk";
import type { LocaleCode } from "@replexica/spec";
import { REPLEXICA_API_KEY } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
export class ReplexicaService {
private static engine = new ReplexicaEngine({
@@ -14,9 +16,13 @@ export class ReplexicaService {
* @param targetLocale The target language locale
* @returns The localized text
*/
static async localizeText(text: string, sourceLocale: string, targetLocale: string): Promise<string> {
static async localizeText(
text: string,
sourceLocale: string,
targetLocale: string
): Promise<string | null> {
if (!text?.trim()) {
return text;
return null;
}
try {
@@ -27,7 +33,33 @@ export class ReplexicaService {
return result;
} catch (error) {
return text;
logger.error(`ReplexicaService.localizeText() failed for targetLocale: ${targetLocale} - ${error}`);
return null;
}
}
/**
* Localize a text string to multiple target locales
* @param text The text to localize
* @param sourceLocale The source language locale
* @param targetLocales Array of the target language locales
* @returns The localized texts
*/
static async batchLocalizeText(
text: string,
sourceLocale: string,
targetLocales: string[]
): Promise<string[]> {
try {
const result = await this.engine.batchLocalizeText(text, {
sourceLocale: sourceLocale as LocaleCode,
targetLocales: targetLocales as LocaleCode[],
});
return result;
} catch (error) {
logger.error(`ReplexicaService.batchLocalizeText() failed: ${error}`);
return [];
}
}
@@ -54,6 +86,7 @@ export class ReplexicaService {
return result.map((chat: { name: string; text: string }) => chat.text);
} catch (error) {
logger.error(`ReplexicaService.localizeTexts() failed: ${error}`);
return texts;
}
}
@@ -0,0 +1,20 @@
/*
Warnings:
- Added the required column `sourceLocale` to the `EventTypeTranslation` table without a default value. This is not possible if the table is not empty.
- Added the required column `targetLocale` to the `EventTypeTranslation` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "EventTypeTranslation" ADD COLUMN "sourceLocale" TEXT,
ADD COLUMN "targetLocale" TEXT;
-- Update data
UPDATE "EventTypeTranslation" SET
"sourceLocale" = "sourceLang",
"targetLocale" = "targetLang";
-- Then make NOT NULL after data is copied
ALTER TABLE "EventTypeTranslation"
ALTER COLUMN "sourceLocale" SET NOT NULL,
ALTER COLUMN "targetLocale" SET NOT NULL;
@@ -0,0 +1,24 @@
/*
Warnings:
- A unique constraint covering the columns `[eventTypeId,field,targetLocale]` on the table `EventTypeTranslation` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "EventTypeTranslation" ADD COLUMN "uid" TEXT;
-- Copy data
UPDATE "EventTypeTranslation" SET
"uid" = "id";
-- CreateIndex
CREATE INDEX "EventTypeTranslation_eventTypeId_field_targetLocale_idx" ON "EventTypeTranslation"("eventTypeId", "field", "targetLocale");
-- CreateIndex
CREATE UNIQUE INDEX "EventTypeTranslation_eventTypeId_field_targetLocale_key" ON "EventTypeTranslation"("eventTypeId", "field", "targetLocale");
-- AddForeignKey
ALTER TABLE "EventTypeTranslation" ADD CONSTRAINT "EventTypeTranslation_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventTypeTranslation" ADD CONSTRAINT "EventTypeTranslation_updatedBy_fkey" FOREIGN KEY ("updatedBy") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+11 -2
View File
@@ -355,6 +355,8 @@ model User {
referralLinkId String?
features UserFeatures[]
reassignedBookings Booking[] @relation("reassignByUser")
createdTranslations EventTypeTranslation[] @relation("CreatedEventTypeTranslations")
updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations")
@@unique([email])
@@unique([email, username])
@@ -1632,17 +1634,24 @@ enum EventTypeAutoTranslatedField {
model EventTypeTranslation {
id String @id @default(cuid())
uid String?
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
eventTypeId Int
field EventTypeAutoTranslatedField
sourceLang String
targetLang String
sourceLocale String
targetLocale String
translatedText String @db.Text
createdAt DateTime @default(now())
createdBy Int
updatedAt DateTime @updatedAt
updatedBy Int?
creator User @relation("CreatedEventTypeTranslations", fields: [createdBy], references: [id])
updater User? @relation("UpdatedEventTypeTranslations", fields: [updatedBy], references: [id], onDelete: SetNull)
@@unique([eventTypeId, field, targetLang])
@@index([eventTypeId, field, targetLang])
@@unique([eventTypeId, field, targetLang]) // TODO: remove after migration
@@unique([eventTypeId, field, targetLocale])
@@index([eventTypeId, field, targetLang]) // TODO: remove after migration
@@index([eventTypeId, field, targetLocale])
}
@@ -7,13 +7,14 @@ import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
import tasker from "@calcom/features/tasker";
import { validateIntervalLimitOrder } from "@calcom/lib";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import type { PrismaClient } from "@calcom/prisma";
import { WorkflowTriggerEvents } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { SchedulingType, EventTypeAutoTranslatedField } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -36,6 +37,7 @@ type User = {
};
selectedCalendars: SessionUser["selectedCalendars"];
organizationId: number | null;
locale: string;
};
type UpdateOptions = {
@@ -76,6 +78,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
aiPhoneCallConfig,
isRRWeightsEnabled,
autoTranslateDescriptionEnabled,
description: newDescription,
...rest
} = input;
@@ -83,6 +86,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
where: { id },
select: {
title: true,
description: true,
fieldTranslations: {
select: {
field: true,
},
},
isRRWeightsEnabled: true,
hosts: {
select: {
@@ -153,10 +162,17 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
ensureUniqueBookingFields(bookingFields);
ensureEmailOrPhoneNumberIsPresent(bookingFields);
if (autoTranslateDescriptionEnabled && !ctx.user.organizationId) {
logger.error(
"Auto-translating description requires an organization. This should not happen - UI controls should prevent this state."
);
}
const data: Prisma.EventTypeUpdateInput = {
...rest,
// autoTranslate feature is allowed for org users only
autoTranslateDescriptionEnabled: !!(ctx.user.organizationId && autoTranslateDescriptionEnabled),
description: newDescription,
bookingFields,
isRRWeightsEnabled,
rrSegmentQueryValue:
@@ -480,6 +496,27 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
}
}
// Logic for updating `fieldTranslations`
// user has no description translations OR user is changing the description
const descriptionTranslationsNeeded =
eventType.fieldTranslations.filter((trans) => trans.field === EventTypeAutoTranslatedField.DESCRIPTION)
.length === 0 || newDescription;
const description = newDescription ?? eventType.description;
if (
ctx.user.organizationId &&
autoTranslateDescriptionEnabled &&
descriptionTranslationsNeeded &&
description
) {
await tasker.create("translateEventTypeDescription", {
eventTypeId: id,
description,
userLocale: ctx.user.locale,
userId: ctx.user.id,
});
}
const updatedEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
slug: true,
schedulingType: true,
+10 -10
View File
@@ -5035,7 +5035,7 @@ __metadata:
"@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
"@replexica/sdk": ^0.7.0
"@sentry/nextjs": ^8.8.0
"@stripe/react-stripe-js": ^1.10.0
"@stripe/stripe-js": ^1.35.0
@@ -12316,28 +12316,28 @@ __metadata:
languageName: node
linkType: hard
"@replexica/sdk@npm:^0.6.0":
version: 0.6.0
resolution: "@replexica/sdk@npm:0.6.0"
"@replexica/sdk@npm:^0.7.0":
version: 0.7.2
resolution: "@replexica/sdk@npm:0.7.2"
dependencies:
"@paralleldrive/cuid2": ^2.2.2
"@replexica/spec": 0.18.0
"@replexica/spec": 0.19.0
jsdom: ^25.0.1
typescript: ^5.6.3
vitest: ^2.1.4
zod: ^3.23.8
checksum: 95a239cf24025a8ea1a03b0720c8a1bd351f73777015fd3a8b74995740fb191c87d157ac04e7b1824f9b5b8f27cef3ce1b7126deb7bf7683b74b8435d3de1238
checksum: 5199f4c7721eaefa66f9da062b11d62d79249afaa30098325d3e52550e2ac5fb2c3bdf550e61691e020023f11718a5240d38c8ac54726159e075991150620535
languageName: node
linkType: hard
"@replexica/spec@npm:0.18.0":
version: 0.18.0
resolution: "@replexica/spec@npm:0.18.0"
"@replexica/spec@npm:0.19.0":
version: 0.19.0
resolution: "@replexica/spec@npm:0.19.0"
dependencies:
typescript: ^5.6.3
vitest: ^2.1.4
zod: ^3.23.8
checksum: d9afcfd965b804d395436cc6daf9a051142e13658297e45f742a6397699360e8f28d43399c4c31077b250ca965e1733200bf8c63eae328b4960320d2dd6ae5b5
checksum: 11114313e42c43ffe5c68e709eb465f4bbd0e24c70fcbdec5e56b4c2a9c4c10b5cd5899c253ffb652b1d66db13015f9ccbcf1d11479e079361c9d6bf8170ebf5
languageName: node
linkType: hard