Skip to content

Commit aaa4395

Browse files
committed
Harden auto-translation and default AI reply language
1 parent 2592cba commit aaa4395

12 files changed

Lines changed: 565 additions & 138 deletions

File tree

apps/api/src/ai-pipeline/primary-pipeline/index.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -482,8 +482,8 @@ describe("runPrimaryPipeline generation error/skip behavior", () => {
482482
});
483483
createScopeBoundaryRedirectMock.mockResolvedValueOnce({
484484
status: "ready",
485-
message: "Je peux aider avec le support ou le produit.",
486-
language: "fr",
485+
message: "I can help with support or product questions.",
486+
language: "en",
487487
modelId: "google/gemini-2.5-flash",
488488
});
489489

@@ -507,7 +507,7 @@ describe("runPrimaryPipeline generation error/skip behavior", () => {
507507
);
508508
expect(sendPublicMessageMock).toHaveBeenCalledWith(
509509
expect.objectContaining({
510-
text: "Je peux aider avec le support ou le produit.",
510+
text: "I can help with support or product questions.",
511511
idempotencyKey: "public:msg-1:scopeBoundary",
512512
})
513513
);

apps/api/src/ai-pipeline/primary-pipeline/scope-boundary-responder.test.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import { beforeEach, describe, expect, it, mock } from "bun:test";
33
const generateTextMock = mock(async () => ({
44
output: {
55
shouldReply: true,
6-
language: "fr",
6+
language: "en",
77
message:
8-
"Je peux aider uniquement avec les questions de support ou de produit.",
8+
"I can help with support or product questions.",
99
},
1010
}));
1111

@@ -44,14 +44,14 @@ describe("createScopeBoundaryRedirect", () => {
4444
generateTextMock.mockResolvedValue({
4545
output: {
4646
shouldReply: true,
47-
language: "fr",
47+
language: "en",
4848
message:
49-
"Je peux aider uniquement avec les questions de support ou de produit.",
49+
"I can help with support or product questions.",
5050
},
5151
});
5252
});
5353

54-
it("asks the isolated responder for a redirect in the detected visitor language", async () => {
54+
it("asks the isolated responder for a redirect in the website default language", async () => {
5555
const { createScopeBoundaryRedirect } = await modulePromise;
5656
const result = await createScopeBoundaryRedirect({
5757
db: {} as never,
@@ -65,15 +65,14 @@ describe("createScopeBoundaryRedirect", () => {
6565

6666
expect(result).toEqual({
6767
status: "ready",
68-
message:
69-
"Je peux aider uniquement avec les questions de support ou de produit.",
70-
language: "fr",
68+
message: "I can help with support or product questions.",
69+
language: "en",
7170
modelId: "google/gemini-2.5-flash",
7271
});
7372
expect(generateTextMock).toHaveBeenCalledWith(
7473
expect.objectContaining({
7574
temperature: 0,
76-
prompt: expect.stringContaining("Target language: fr"),
75+
prompt: expect.stringContaining("Target language: en"),
7776
})
7877
);
7978
});

apps/api/src/ai-pipeline/primary-pipeline/scope-boundary-responder.ts

Lines changed: 2 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -61,51 +61,6 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
6161
});
6262
}
6363

64-
function getTargetLanguage(params: {
65-
triggerText: string;
66-
visitorLanguage: string | null;
67-
websiteDefaultLanguage: string;
68-
}): string | null {
69-
const scopeBoundaryLanguage = detectScopeBoundaryLanguage(params.triggerText);
70-
if (scopeBoundaryLanguage) {
71-
return scopeBoundaryLanguage;
72-
}
73-
74-
return params.visitorLanguage ?? params.websiteDefaultLanguage;
75-
}
76-
77-
function detectScopeBoundaryLanguage(text: string): string | null {
78-
const normalized = text
79-
.normalize("NFKC")
80-
.replace(/\s+/g, " ")
81-
.trim()
82-
.toLowerCase();
83-
84-
if (!normalized) {
85-
return null;
86-
}
87-
88-
if (
89-
/[¿¡áíóúñ]/iu.test(normalized) ||
90-
/\b(escribe|poema|l[ií]neas|palabras|ayuda|hola|gracias)\b/iu.test(
91-
normalized
92-
)
93-
) {
94-
return "es";
95-
}
96-
97-
if (
98-
/[àâçéèêëîïôùûüÿœ]/iu.test(normalized) ||
99-
/\b([eé]cris|[eé]crire|po[eè]me|lignes|mots|aide|bonjour|merci)\b/iu.test(
100-
normalized
101-
)
102-
) {
103-
return "fr";
104-
}
105-
106-
return null;
107-
}
108-
10964
function getFulfillmentLeakReason(message: string): string | null {
11065
const normalized = message.replace(/\s+/g, " ").trim().toLowerCase();
11166

@@ -142,11 +97,7 @@ export async function createScopeBoundaryRedirect(params: {
14297
websiteDefaultLanguage: string;
14398
}): Promise<ScopeBoundaryRedirectResult> {
14499
const triggerText = clipTriggerText(params.triggerText);
145-
const targetLanguage = getTargetLanguage({
146-
triggerText,
147-
visitorLanguage: params.visitorLanguage,
148-
websiteDefaultLanguage: params.websiteDefaultLanguage,
149-
});
100+
const targetLanguage = params.websiteDefaultLanguage;
150101

151102
if (!(triggerText && targetLanguage)) {
152103
return {
@@ -236,7 +187,7 @@ ${triggerText}`,
236187
return {
237188
status: "ready",
238189
message,
239-
language: result.output.language ?? targetLanguage,
190+
language: targetLanguage,
240191
modelId: SCOPE_BOUNDARY_REDIRECT_MODEL,
241192
};
242193
} catch (error) {
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { beforeEach, describe, expect, it, mock } from "bun:test";
2+
3+
const getConversationByIdMock = mock(async () => ({
4+
id: "conv-1",
5+
organizationId: "org-1",
6+
websiteId: "site-1",
7+
visitorId: "visitor-1",
8+
visitorLanguage: "es",
9+
translationActivatedAt: null,
10+
translationChargedAt: null,
11+
title: "Billing question",
12+
}));
13+
const getPlanForWebsiteMock = mock(async () => ({
14+
features: { "auto-translate": true },
15+
}));
16+
const prepareOutboundVisitorTranslationMock = mock(async () => ({
17+
sourceLanguage: "en",
18+
translationPart: {
19+
type: "translation" as const,
20+
text: "Respuesta en espanol",
21+
sourceLanguage: "en",
22+
targetLanguage: "es",
23+
audience: "visitor" as const,
24+
mode: "auto" as const,
25+
modelId: "test-model",
26+
},
27+
translationResult: {
28+
status: "translated" as const,
29+
text: "Respuesta en espanol",
30+
sourceLanguage: "en",
31+
targetLanguage: "es",
32+
modelId: "test-model",
33+
billingSource: "cossistant" as const,
34+
},
35+
}));
36+
const finalizeConversationTranslationMock = mock(async () => ({ status: "noop" }));
37+
const isAutomaticTranslationEnabledMock = mock(() => true);
38+
const createMessageTimelineItemMock = mock(async () => ({
39+
item: { id: "msg-1" },
40+
}));
41+
const isAiPausedForConversationMock = mock(async () => false);
42+
const recordOutboundPublicAiMessageAndMaybePauseMock = mock(async () => ({
43+
paused: false,
44+
messageCount: 1,
45+
}));
46+
47+
mock.module("@api/db/queries/conversation", () => ({
48+
getConversationById: getConversationByIdMock,
49+
}));
50+
51+
mock.module("@api/lib/plans/access", () => ({
52+
getPlanForWebsite: getPlanForWebsiteMock,
53+
}));
54+
55+
mock.module("@api/lib/translation", () => ({
56+
finalizeConversationTranslation: finalizeConversationTranslationMock,
57+
isAutomaticTranslationEnabled: isAutomaticTranslationEnabledMock,
58+
prepareOutboundVisitorTranslation: prepareOutboundVisitorTranslationMock,
59+
}));
60+
61+
mock.module("@api/redis", () => ({
62+
getRedis: mock(() => ({})),
63+
}));
64+
65+
mock.module("@api/utils/timeline-item", () => ({
66+
createMessageTimelineItem: createMessageTimelineItemMock,
67+
}));
68+
69+
mock.module("../safety/kill-switch", () => ({
70+
isAiPausedForConversation: isAiPausedForConversationMock,
71+
recordOutboundPublicAiMessageAndMaybePause:
72+
recordOutboundPublicAiMessageAndMaybePauseMock,
73+
}));
74+
75+
const sendMessageModulePath = "./send-message?translation-regression";
76+
const modulePromise = import(sendMessageModulePath) as Promise<
77+
typeof import("./send-message")
78+
>;
79+
80+
function createDbHarness() {
81+
const selectOperation = {
82+
from: mock(() => selectOperation),
83+
where: mock(() => selectOperation),
84+
limit: mock(async () => []),
85+
};
86+
const websiteFindFirstMock = mock(async () => ({
87+
id: "site-1",
88+
defaultLanguage: "en",
89+
autoTranslateEnabled: true,
90+
}));
91+
92+
return {
93+
db: {
94+
select: mock(() => selectOperation),
95+
query: {
96+
website: {
97+
findFirst: websiteFindFirstMock,
98+
},
99+
},
100+
},
101+
websiteFindFirstMock,
102+
};
103+
}
104+
105+
describe("sendMessage", () => {
106+
beforeEach(() => {
107+
getConversationByIdMock.mockClear();
108+
getPlanForWebsiteMock.mockClear();
109+
prepareOutboundVisitorTranslationMock.mockClear();
110+
finalizeConversationTranslationMock.mockClear();
111+
isAutomaticTranslationEnabledMock.mockClear();
112+
createMessageTimelineItemMock.mockClear();
113+
isAiPausedForConversationMock.mockClear();
114+
recordOutboundPublicAiMessageAndMaybePauseMock.mockClear();
115+
});
116+
117+
it("stores the default-language AI original and attaches a visitor translation part", async () => {
118+
const { sendMessage } = await modulePromise;
119+
const { db } = createDbHarness();
120+
121+
const result = await sendMessage({
122+
db: db as never,
123+
conversationId: "conv-1",
124+
organizationId: "org-1",
125+
websiteId: "site-1",
126+
visitorId: "visitor-1",
127+
aiAgentId: "ai-1",
128+
text: "Default-language reply",
129+
idempotencyKey: "public:msg-1",
130+
});
131+
132+
expect(result).toEqual({
133+
messageId: "msg-1",
134+
created: true,
135+
});
136+
expect(prepareOutboundVisitorTranslationMock).toHaveBeenCalledWith(
137+
expect.objectContaining({
138+
text: "Default-language reply",
139+
sourceLanguage: "en",
140+
visitorLanguage: "es",
141+
mode: "auto",
142+
})
143+
);
144+
expect(createMessageTimelineItemMock).toHaveBeenCalledWith(
145+
expect.objectContaining({
146+
text: "Default-language reply",
147+
aiAgentId: "ai-1",
148+
extraParts: [
149+
expect.objectContaining({
150+
type: "translation",
151+
text: "Respuesta en espanol",
152+
audience: "visitor",
153+
targetLanguage: "es",
154+
}),
155+
],
156+
})
157+
);
158+
expect(finalizeConversationTranslationMock).toHaveBeenCalledWith(
159+
expect.objectContaining({
160+
visitorLanguage: "es",
161+
hasTranslationPart: true,
162+
chargeCredits: true,
163+
aiAgentId: "ai-1",
164+
})
165+
);
166+
});
167+
});

apps/api/src/ai-pipeline/shared/generation/prompt/builder.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,4 +346,26 @@ Continue helping while the visitor waits.`,
346346
"Never localize the internal title yourself. The system derives visitorTitle separately for the visitor-facing language."
347347
);
348348
});
349+
350+
it("keeps public AI message originals in the website default language when auto-translate is enabled", () => {
351+
const prompt = buildGenerationSystemPrompt({
352+
input: createInput() as never,
353+
promptBundle,
354+
toolset: {
355+
sendMessage: { description: "Send the main response" },
356+
respond: { description: "Finish respond" },
357+
} as never,
358+
toolNames: ["sendMessage", "respond"],
359+
});
360+
361+
expect(prompt).toContain(
362+
"Use it for internal reasoning, knowledge-base searches, query rewriting, and public sendMessage text."
363+
);
364+
expect(prompt).toContain(
365+
"Write public messages in en. The system translates public AI and human messages for the visitor when needed."
366+
);
367+
expect(prompt).not.toContain(
368+
"Always answer the visitor in the visitor's language when it is known."
369+
);
370+
});
349371
});

apps/api/src/ai-pipeline/shared/generation/prompt/builder.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ triggerMessageId=${input.triggerMessageId}
6262
triggerSenderType=${input.triggerSenderType ?? "unknown"}
6363
triggerVisibility=${input.triggerVisibility ?? "unknown"}
6464
websiteDefaultLanguage=${input.websiteDefaultLanguage}
65-
visitorLanguage=${input.visitorLanguage ?? input.visitorContext?.language ?? "unknown"}
65+
visitorLanguage=${input.visitorLanguage ?? "unknown"}
6666
autoTranslateEnabled=${input.autoTranslateEnabled !== false ? "yes" : "no"}
6767
conversationEscalated=${input.conversationState.isEscalated ? "yes" : "no"}
6868
escalationReason=${input.conversationState.escalationReason ?? "none"}
@@ -102,15 +102,14 @@ Messages are labeled with [BEFORE], [TRIGGER], or [AFTER].
102102
}
103103

104104
function buildLanguagePolicyStage(input: GenerationRuntimeInput): string {
105-
const visitorLanguage =
106-
input.visitorLanguage ?? input.visitorContext?.language ?? "unknown";
105+
const visitorLanguage = input.visitorLanguage ?? "unknown";
107106
const autoTranslateEnabled = input.autoTranslateEnabled !== false;
108107

109108
return autoTranslateEnabled
110109
? `## Language Policy
111-
- The website default language is ${input.websiteDefaultLanguage}. Use it for internal reasoning, knowledge-base searches, and query rewriting.
112-
- The visitor's language is ${visitorLanguage}.
113-
- Always answer the visitor in the visitor's language when it is known.
110+
- The website default language is ${input.websiteDefaultLanguage}. Use it for internal reasoning, knowledge-base searches, query rewriting, and public sendMessage text.
111+
- The visitor's detected conversation language is ${visitorLanguage}.
112+
- Write public messages in ${input.websiteDefaultLanguage}. The system translates public AI and human messages for the visitor when needed.
114113
- If you call updateConversationTitle, write the saved internal conversation.title in ${input.websiteDefaultLanguage} only.
115114
- Never localize the internal title yourself. The system derives visitorTitle separately for the visitor-facing language.
116115
- Never switch knowledge-base search to the visitor language unless the website language search fails and you explicitly need a rewrite.`

apps/api/src/db/queries/feedback.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ export async function createFeedback(
6464
})
6565
.returning();
6666

67+
if (!inserted) {
68+
throw new Error("Failed to create feedback");
69+
}
70+
6771
return inserted;
6872
}
6973

0 commit comments

Comments
 (0)