feat: Title - AI translation for booking pages (#17794)

* 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

* chore: drop sourceLang, targetLang and id columns

* fix

* wip

* add more validations

* fix type error

* address comment

* refactor

* update booking page to use translated title

* fix type error

* fix

* revert yarn.lock changes

* remove unused import

* remove duplication migrate.sql file

* fix test

* remove test tags

* fix function name

* refactor

* fix type check

* use Promise.all

* remove unneeded code

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
Benny Joo
2024-12-11 14:18:32 +01:00
committed by GitHub
co-authored by Keith Williams
parent dd3505ad8b
commit 6739ed3a87
14 changed files with 190 additions and 111 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ try {
fetchCron("/calendar-cache/cron"),
// fetchCron("/cron/calVideoNoShowWebhookTriggers"),
//
// fetchCron("/tasks/cleanup"),
fetchCron("/tasks/cron"),
]);
},
null,
@@ -180,7 +180,7 @@ test.describe("Managed Event Types", () => {
});
const MANAGED_EVENT_TABS: { slug: string; locator: (page: Page) => Locator | Promise<Locator> }[] = [
{ slug: "setup", locator: (page) => getByKey(page, "title") },
{ slug: "setup", locator: (page) => getByKey(page, "translate_description_button") },
{
slug: "team",
locator: (page) => getByKey(page, "automatically_add_all_team_members"),
+1 -6
View File
@@ -658,12 +658,7 @@ test.describe("FORM_SUBMITTED", async () => {
webhookReceiver.close();
});
test("on submitting team form, triggers team webhook @test", async ({
page,
users,
routingForms,
webhooks,
}) => {
test("on submitting team form, triggers team webhook", async ({ page, users, routingForms, webhooks }) => {
const user = await users.create(null, {
hasTeam: true,
});
@@ -2845,7 +2845,7 @@
"filter_operator_ends_with": "Ends with",
"filter_operator_is_empty": "Is empty",
"filter_operator_not_empty": "Not empty",
"translate_description_button": "Translate description to the visitor's browser language using AI",
"translate_description_button": "Translate title/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",
+1 -1
View File
@@ -52,7 +52,7 @@ function createMockTable(data: UserTableUser[]): Table<UserTableUser> {
} as unknown as Table<UserTableUser>;
}
describe("generate Csv for Org Users Table @test", () => {
describe("generate Csv for Org Users Table", () => {
const orgDomain = "https://acme.cal.com";
const mockAttributeIds = ["attr1", "attr2"];
const mockUser: UserTableUser = {
@@ -12,6 +12,7 @@ import { useTimePreferences } from "@calcom/features/bookings/lib";
import type { BookerEvent } from "@calcom/features/bookings/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { EventTypeTranslation } from "@calcom/prisma/client";
import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums";
import i18nConfigration from "../../../../../i18n.json";
@@ -26,6 +27,21 @@ const WebTimezoneSelect = dynamic(
}
);
const getTranslatedField = (
translations: Array<Pick<EventTypeTranslation, "field" | "targetLocale" | "translatedText">>,
field: EventTypeAutoTranslatedField,
userLocale: string
) => {
const i18nLocales = i18nConfigration.locale.targets.concat([i18nConfigration.locale.source]);
return translations?.find(
(trans) =>
trans.field === field &&
i18nLocales.includes(trans.targetLocale) &&
(userLocale === trans.targetLocale || userLocale.split("-")[0] === trans.targetLocale)
)?.translatedText;
};
export const EventMeta = ({
event,
isPending,
@@ -82,7 +98,6 @@ export const EventMeta = ({
() => (isPlatform ? [PlatformTimezoneSelect] : [WebTimezoneSelect]),
[isPlatform]
);
const i18nLocales = i18nConfigration.locale.targets.concat([i18nConfigration.locale.source]);
useEffect(() => {
//In case the event has lockTimeZone enabled ,set the timezone to event's attached availability timezone
@@ -110,13 +125,16 @@ export const EventMeta = ({
? "text-yellow-500"
: "text-bookinghighlight";
const userLocale = 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;
const translatedDescription = getTranslatedField(
event?.fieldTranslations ?? [],
EventTypeAutoTranslatedField.DESCRIPTION,
userLocale
);
const translatedTitle = getTranslatedField(
event?.fieldTranslations ?? [],
EventTypeAutoTranslatedField.TITLE,
userLocale
);
return (
<div className={`${classNames?.eventMetaContainer || ""} relative z-10 p-6`} data-testid="event-meta">
@@ -133,7 +151,9 @@ export const EventMeta = ({
profile={event.profile}
entity={event.entity}
/>
<EventTitle className={`${classNames?.eventMetaTitle} my-2`}>{event?.title}</EventTitle>
<EventTitle className={`${classNames?.eventMetaTitle} my-2`}>
{translatedTitle ?? event?.title}
</EventTitle>
{(event.description || translatedDescription) && (
<EventMetaBlock contentClassName="mb-8 break-words max-w-full max-h-[180px] scroll-bar pr-4">
<div
+2 -2
View File
@@ -14,8 +14,8 @@ type TaskPayloads = {
triggerFormSubmittedNoEventWebhook: z.infer<
typeof import("./tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook").ZTriggerFormSubmittedNoEventWebhookPayloadSchema
>;
translateEventTypeDescription: z.infer<
typeof import("./tasks/translateEventTypeDescription").ZTranslateEventTypeDescriptionPayloadSchema
translateEventTypeData: z.infer<
typeof import("./tasks/translateEventTypeData").ZTranslateEventDataPayloadSchema
>;
};
export type TaskTypes = keyof TaskPayloads;
+2 -2
View File
@@ -17,8 +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),
translateEventTypeData: () =>
import("./translateEventTypeData").then((module) => module.translateEventTypeData),
};
export default tasks;
@@ -0,0 +1,108 @@
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";
import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums";
export const ZTranslateEventDataPayloadSchema = z.object({
eventTypeId: z.number(),
userId: z.number(),
description: z.string().nullable().optional(),
title: z.string().optional(),
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;
async function processTranslations({
text,
userLocale,
eventTypeId,
userId,
field,
}: {
text: string;
field: EventTypeAutoTranslatedField;
} & z.infer<typeof ZTranslateEventDataPayloadSchema>) {
const { ReplexicaService } = await import("@calcom/lib/server/service/replexica");
try {
const targetLocales = SUPPORTED_LOCALES.filter(
(locale) => locale !== userLocale && i18nLocales.includes(locale)
);
const translations = await Promise.all(
targetLocales.map((targetLocale) => ReplexicaService.localizeText(text, userLocale, targetLocale))
);
// Filter out null translations and their corresponding locales
const validTranslations = translations
.filter((trans): trans is string => trans !== null)
.map((trans, index) => ({
translatedText: trans,
targetLocale: targetLocales[index],
}));
if (validTranslations.length > 0) {
const translationData = validTranslations.map(({ translatedText, targetLocale }) => ({
eventTypeId,
sourceLocale: userLocale,
targetLocale,
translatedText,
userId,
}));
const upsertMany =
field === EventTypeAutoTranslatedField.DESCRIPTION
? EventTypeTranslationRepository.upsertManyDescriptionTranslations
: EventTypeTranslationRepository.upsertManyTitleTranslations;
await upsertMany(translationData);
}
} catch (error) {
logger.error(`Failed to process ${field} translations:`, error);
}
}
export async function translateEventTypeData(payload: string): Promise<void> {
const { eventTypeId, description, title, userLocale, userId } = ZTranslateEventDataPayloadSchema.parse(
JSON.parse(payload)
);
await Promise.all([
description &&
processTranslations({
text: description,
userLocale,
eventTypeId,
userId,
field: EventTypeAutoTranslatedField.DESCRIPTION,
}),
title &&
processTranslations({
text: title,
userLocale,
eventTypeId,
userId,
field: EventTypeAutoTranslatedField.TITLE,
}),
]);
}
@@ -1,74 +0,0 @@
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,
translatedText,
userId,
}))
);
}
} catch (error) {
logger.error("Failed to process event type description translations:", error);
}
}
@@ -2,7 +2,7 @@ import { prisma } from "@calcom/prisma";
import type { EventTypeTranslation } from "@calcom/prisma/client";
import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums";
export type CreateEventTypeDescriptionTranslation = Omit<
export type CreateEventTypeTranslation = Omit<
EventTypeTranslation,
| "uid"
| "createdAt"
@@ -16,7 +16,32 @@ export type CreateEventTypeDescriptionTranslation = Omit<
> & { userId: number };
export class EventTypeTranslationRepository {
static async upsertManyDescriptionTranslations(translations: Array<CreateEventTypeDescriptionTranslation>) {
static async upsertManyTitleTranslations(translations: Array<CreateEventTypeTranslation>) {
return await Promise.all(
translations.map(({ userId, ...translation }) => {
return prisma.eventTypeTranslation.upsert({
where: {
eventTypeId_field_targetLocale: {
eventTypeId: translation.eventTypeId,
field: EventTypeAutoTranslatedField.TITLE,
targetLocale: translation.targetLocale,
},
},
update: {
translatedText: translation.translatedText,
updatedBy: userId,
},
create: {
...translation,
field: EventTypeAutoTranslatedField.TITLE,
createdBy: userId,
},
});
})
);
}
static async upsertManyDescriptionTranslations(translations: Array<CreateEventTypeTranslation>) {
return await Promise.all(
translations.map(({ userId, ...translation }) => {
return prisma.eventTypeTranslation.upsert({
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "EventTypeAutoTranslatedField" ADD VALUE 'TITLE';
+2 -1
View File
@@ -1669,7 +1669,8 @@ model AssignmentReason {
}
enum EventTypeAutoTranslatedField {
DESCRIPTION // Currently the only field we translate
DESCRIPTION
TITLE
}
model DomainWideDelegation {
@@ -79,6 +79,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
isRRWeightsEnabled,
autoTranslateDescriptionEnabled,
description: newDescription,
title: newTitle,
...rest
} = input;
@@ -173,6 +174,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
// autoTranslate feature is allowed for org users only
autoTranslateDescriptionEnabled: !!(ctx.user.organizationId && autoTranslateDescriptionEnabled),
description: newDescription,
title: newTitle,
bookingFields,
isRRWeightsEnabled,
rrSegmentQueryValue:
@@ -497,21 +499,21 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
}
// Logic for updating `fieldTranslations`
// user has no description translations OR user is changing the description
const descriptionTranslationsNeeded =
// user has no translations OR user is changing the field
const hasNoDescriptionTranslations =
eventType.fieldTranslations.filter((trans) => trans.field === EventTypeAutoTranslatedField.DESCRIPTION)
.length === 0 || newDescription;
const description = newDescription ?? eventType.description;
.length === 0;
const description = newDescription ?? (hasNoDescriptionTranslations ? eventType.description : undefined);
const hasNoTitleTranslations =
eventType.fieldTranslations.filter((trans) => trans.field === EventTypeAutoTranslatedField.TITLE)
.length === 0;
const title = newTitle ?? (hasNoTitleTranslations ? eventType.title : undefined);
if (
ctx.user.organizationId &&
autoTranslateDescriptionEnabled &&
descriptionTranslationsNeeded &&
description
) {
await tasker.create("translateEventTypeDescription", {
if (ctx.user.organizationId && autoTranslateDescriptionEnabled && (title || description)) {
await tasker.create("translateEventTypeData", {
eventTypeId: id,
description,
title,
userLocale: ctx.user.locale,
userId: ctx.user.id,
});