Browse Source

Make reply translation deterministic and inline

Maciek 3 tuần trước cách đây
mục cha
commit
137c3d76c3

+ 20 - 10
public/chatwoot-ai-tools.js

@@ -61,7 +61,7 @@
       }
       .eks-inline-translate:hover { opacity: 1 !important; }
       .eks-composer-translate {
-        border: 0 !important; border-radius: 999px !important; padding: 4px 8px !important;
+        border: 0 !important; border-radius: 999px !important; padding: 3px 7px !important;
         background: #1f6feb !important; color: white !important; font: 600 11px system-ui, sans-serif !important;
         cursor: pointer !important; margin: 0 4px !important; box-shadow: 0 2px 8px rgba(15,23,42,.16) !important;
         display: inline-flex !important; align-items: center !important; min-height: 22px !important;
@@ -252,16 +252,23 @@
     const el = getReplyEditorElement();
     const text = readEditableText(el).trim();
     const token = getToken();
-    if (!token) return showPanelError('Brak Agent UI token.');
+    if (!token) {
+      showPanelError('Brak Agent UI token. Otwórz raz panel „AI tłumacz” i wklej token.');
+      return showComposerStatus(button, 'Brak tokenu — otwórz panel AI tłumacz.', true);
+    }
     if (!el) return showComposerStatus(button, 'Nie widzę okna odpowiedzi Chatwoot.', true);
     if (!text) return showComposerStatus(button, 'Najpierw wpisz tekst po polsku w oknie odpowiedzi.', true);
 
     const panel = document.getElementById(PANEL_ID);
-    panel.hidden = false;
-    panelField('source').value = 'pl';
-    panelField('text').value = text;
-    panelField('status').textContent = 'Wykrywam język klienta z pierwszej wiadomości i tłumaczę tekst z okna odpowiedzi…';
+    if (!panel.hidden) {
+      panelField('source').value = 'pl';
+      panelField('target').value = 'auto';
+      panelField('text').value = text;
+      panelField('status').textContent = 'Wykrywam język klienta z pierwszej wiadomości i tłumaczę tekst z okna odpowiedzi…';
+    }
     showComposerStatus(button, 'Tłumaczę…');
+    const originalButtonText = button?.textContent || '';
+    if (button) button.textContent = 'AI…';
 
     try {
       setBusy(true);
@@ -272,15 +279,18 @@
         conversationId: detectConversationId(),
       });
       state.result = translated.translatedText || '';
-      panelField('result').textContent = state.result + (translated.cached ? '\n\n[cached]' : '');
-      panelField('status').textContent = 'Gotowe — wstawiłem tłumaczenie do pola odpowiedzi.';
+      if (!panel.hidden) {
+        panelField('result').textContent = state.result + (translated.cached ? '\n\n[cached]' : '');
+        panelField('status').textContent = `Gotowe — wstawiłem tłumaczenie do pola odpowiedzi (${translated.targetLanguage}).`;
+      }
       setDraftText(el, state.result);
-      showComposerStatus(button, 'Gotowe');
+      showComposerStatus(button, `Gotowe (${translated.targetLanguage})`);
     } catch (err) {
       const msg = err instanceof Error ? err.message : String(err);
       showPanelError(msg);
       showComposerStatus(button, msg, true);
     } finally {
+      if (button) button.textContent = originalButtonText;
       setBusy(false);
       setTimeout(() => showComposerStatus(button, ''), 3500);
     }
@@ -412,7 +422,7 @@
       const btn = document.createElement('button');
       btn.type = 'button';
       btn.className = 'eks-composer-translate';
-      btn.textContent = 'AI: PL  język klienta';
+      btn.textContent = 'PL→klient';
       btn.title = 'Przetłumacz wpisaną odpowiedź po polsku na język wykryty z pierwszej wiadomości klienta';
       btn.addEventListener('click', (e) => {
         e.preventDefault(); e.stopPropagation();

+ 37 - 7
src/clients/translationClient.ts

@@ -13,6 +13,7 @@ export interface TranslationInput {
     messageId?: string | null;
     mode?: 'message' | 'draft';
     firstCustomerMessage?: string | null;
+    detectedCustomerLanguage?: string | null;
   };
 }
 
@@ -61,6 +62,7 @@ export class TranslationClient {
           mode: input.context?.mode ?? 'message',
           conversationId: input.context?.conversationId ?? null,
           messageId: input.context?.messageId ?? null,
+          detectedCustomerLanguage: input.context?.detectedCustomerLanguage ?? null,
         },
         metadata: {
           purpose: 'agent_ui_translation',
@@ -69,6 +71,7 @@ export class TranslationClient {
           conversationId: input.context?.conversationId ?? null,
           messageId: input.context?.messageId ?? null,
           hasFirstCustomerMessage: Boolean(input.context?.firstCustomerMessage),
+          detectedCustomerLanguage: input.context?.detectedCustomerLanguage ?? null,
         },
       },
     });
@@ -99,26 +102,32 @@ function buildTranslationQuestion(
 ): string {
   const mode = context?.mode ?? 'message';
   const firstCustomerMessage = context?.firstCustomerMessage?.trim();
+  const detectedCustomerLanguage = context?.detectedCustomerLanguage?.trim();
   if (mode === 'draft' && firstCustomerMessage) {
     return [
       '[TRANSLATION_TASK]',
-      'You are translating a support agent draft for a customer.',
-      'First detect the language used by the customer in FIRST_CUSTOMER_MESSAGE.',
-      'Translate AGENT_DRAFT_POLISH from Polish into that detected customer language.',
+      'You are translating a support agent reply typed in the Chatwoot reply box.',
+      detectedCustomerLanguage
+        ? `The customer language has been detected from FIRST_CUSTOMER_MESSAGE as: ${languageLabel(detectedCustomerLanguage)} (${detectedCustomerLanguage}).`
+        : 'First detect the language used by the customer in FIRST_CUSTOMER_MESSAGE.',
+      `Translate AGENT_REPLY_POLISH from Polish into ${detectedCustomerLanguage ? languageLabel(detectedCustomerLanguage) : 'that detected customer language'}.`,
       'Rules:',
-      '- Return only the translated agent draft, without preface or markdown fences.',
-      '- Do not answer the customer; only translate the agent draft.',
+      '- Return only the translated agent reply, without preface or markdown fences.',
+      '- Do not answer the customer; only translate the agent reply.',
       '- Preserve order numbers, product names, URLs, email addresses and quoted identifiers exactly.',
       '- Keep the support tone natural in the detected language.',
+      detectedCustomerLanguage === 'en'
+        ? '- The output MUST be English. Do not output Russian, Greek, Polish, or any other language.'
+        : '- The output language must match the detected customer language, not the UI field values.',
       '[/TRANSLATION_TASK]',
       '',
       '[FIRST_CUSTOMER_MESSAGE]',
       firstCustomerMessage,
       '[/FIRST_CUSTOMER_MESSAGE]',
       '',
-      '[AGENT_DRAFT_POLISH]',
+      '[AGENT_REPLY_POLISH]',
       text,
-      '[/AGENT_DRAFT_POLISH]',
+      '[/AGENT_REPLY_POLISH]',
     ].join('\n');
   }
 
@@ -142,6 +151,27 @@ function buildTranslationQuestion(
   ].join('\n');
 }
 
+function languageLabel(code: string): string {
+  const labels: Record<string, string> = {
+    en: 'English',
+    pl: 'Polish',
+    de: 'German',
+    fr: 'French',
+    es: 'Spanish',
+    it: 'Italian',
+    cs: 'Czech',
+    sk: 'Slovak',
+    nl: 'Dutch',
+    ro: 'Romanian',
+    hu: 'Hungarian',
+    pt: 'Portuguese',
+    el: 'Greek',
+    ru: 'Russian',
+    uk: 'Ukrainian',
+  };
+  return labels[code] ?? code;
+}
+
 function extractTranslatedText(json: unknown, rawBody: string): string {
   if (json && typeof json === 'object' && !Array.isArray(json)) {
     const obj = json as Record<string, unknown>;

+ 42 - 1
src/http/routes/agentTools.ts

@@ -67,14 +67,18 @@ agentToolsRouter.post('/agent-tools/translate-draft', async (req, res, next) =>
         'conversationId with at least one incoming customer message is required for auto target language.',
       );
     }
+    const detectedCustomerLanguage = targetLanguage === 'auto' && firstCustomerMessage
+      ? inferCustomerLanguage(firstCustomerMessage)
+      : null;
     const result = await translateWithCache({
       text: draftText,
-      targetLanguage,
+      targetLanguage: detectedCustomerLanguage ?? targetLanguage,
       sourceLanguage: body.sourceLanguage ?? 'pl',
       conversationId: body.conversationId,
       messageId: body.messageId === undefined ? undefined : String(body.messageId),
       mode: 'draft',
       firstCustomerMessage,
+      detectedCustomerLanguage,
     });
     res.json({ ok: true, ...result });
   } catch (err) {
@@ -90,6 +94,7 @@ async function translateWithCache(input: {
   messageId?: string;
   mode: 'message' | 'draft';
   firstCustomerMessage?: string | null;
+  detectedCustomerLanguage?: string | null;
 }): Promise<{
   translatedText: string;
   sourceLanguage: string | null;
@@ -125,6 +130,7 @@ async function translateWithCache(input: {
       messageId: input.messageId ?? null,
       mode: input.mode,
       firstCustomerMessage: input.firstCustomerMessage ?? null,
+      detectedCustomerLanguage: input.detectedCustomerLanguage ?? null,
     },
   });
 
@@ -163,6 +169,41 @@ function isPublicIncoming(message: ChatwootMessage): boolean {
   return message.private !== true && (message.message_type === 'incoming' || message.message_type === 0);
 }
 
+function inferCustomerLanguage(text: string): string {
+  const sample = text.toLowerCase();
+  if (/[α-ωάέήίόύώϊϋΐΰ]/i.test(text)) return 'el';
+  if (/[а-яёіїєґ]/i.test(text)) {
+    return /[іїєґ]/i.test(text) ? 'uk' : 'ru';
+  }
+
+  const scores: Record<string, number> = {
+    en: score(sample, [' the ', ' and ', ' you ', ' what ', ' where ', ' when ', ' have ', ' haven', ' ordered ', ' received ', ' going ', ' hello ', ' hey ', ' can ', ' order ']),
+    de: score(sample, [' der ', ' die ', ' das ', ' und ', ' ich ', ' nicht ', ' eine ', ' einem ', ' guten ', ' brauche ', ' bestellung ', ' paket ']) + diacriticScore(sample, /[äöüß]/g),
+    fr: score(sample, [' le ', ' la ', ' les ', ' des ', ' est ', ' pas ', ' bonjour ', ' commande ', ' colis ', ' avec ', ' pour ']) + diacriticScore(sample, /[àâçéèêëîïôûùüÿœ]/g),
+    es: score(sample, [' el ', ' la ', ' los ', ' las ', ' que ', ' una ', ' para ', ' pedido ', ' hola ', ' gracias ']) + diacriticScore(sample, /[áéíóúñ¿¡]/g),
+    it: score(sample, [' il ', ' la ', ' gli ', ' che ', ' per ', ' ordine ', ' buongiorno ', ' pacco ', ' grazie ']) + diacriticScore(sample, /[àèéìíîòóù]/g),
+    pl: score(sample, [' czy ', ' gdzie ', ' kiedy ', ' proszę ', ' zamówienie ', ' paczka ', ' człowieku ', ' sprawdź ']) + diacriticScore(sample, /[ąćęłńóśźż]/g),
+    cs: score(sample, [' kde ', ' kdy ', ' prosím ', ' objednávka ', ' balík ', ' dobrý ']) + diacriticScore(sample, /[ěščřžýáíéůúňďť]/g),
+    sk: score(sample, [' kde ', ' kedy ', ' prosím ', ' objednávka ', ' balík ', ' dobrý ']) + diacriticScore(sample, /[äôľĺŕšťžýáíéúňď]/g),
+    nl: score(sample, [' de ', ' het ', ' een ', ' niet ', ' waar ', ' bestelling ', ' pakket ', ' goedemorgen ']),
+    ro: score(sample, [' și ', ' este ', ' pentru ', ' comandă ', ' colet ', ' bună ', ' unde ']) + diacriticScore(sample, /[ăâîșț]/g),
+    hu: score(sample, [' és ', ' hogy ', ' nem ', ' rendelés ', ' csomag ', ' kérem ', ' hol ']) + diacriticScore(sample, /[áéíóöőúüű]/g),
+    pt: score(sample, [' que ', ' para ', ' pedido ', ' pacote ', ' olá ', ' obrigado ', ' você ']) + diacriticScore(sample, /[áâãàçéêíóôõú]/g),
+  };
+
+  const best = Object.entries(scores).sort((a, b) => b[1] - a[1])[0];
+  return best && best[1] > 0 ? best[0] : 'en';
+}
+
+function score(sample: string, needles: string[]): number {
+  const padded = ` ${sample.replace(/\s+/g, ' ')} `;
+  return needles.reduce((sum, n) => sum + (padded.includes(n) ? 2 : 0), 0);
+}
+
+function diacriticScore(sample: string, regex: RegExp): number {
+  return (sample.match(regex) ?? []).length;
+}
+
 function timestamp(message: ChatwootMessage): number {
   if (typeof message.created_at === 'number') return message.created_at;
   if (typeof message.created_at === 'string') {

+ 10 - 2
tests/integration/agentTools.test.ts

@@ -25,7 +25,13 @@ before(async () => {
       flowiseCalls += 1;
       const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>;
       assert.match(String(body.question), /Translate this|TRANSLATION_TASK/);
-      return new Response(JSON.stringify({ text: 'Dzień dobry' }), {
+      const question = String(body.question);
+      const text = question.includes('AGENT_REPLY_POLISH') ? 'Please provide the order number.' : 'Dzień dobry';
+      if (question.includes('AGENT_REPLY_POLISH')) {
+        assert.match(question, /English \(en\)/);
+        assert.match(question, /output MUST be English/);
+      }
+      return new Response(JSON.stringify({ text }), {
         status: 200,
         headers: { 'Content-Type': 'application/json' },
       });
@@ -36,7 +42,7 @@ before(async () => {
         JSON.stringify({
           payload: [
             { id: 1, message_type: 'outgoing', content: 'Dzień dobry' },
-            { id: 2, message_type: 0, private: false, created_at: 1, content: 'Guten Morgen, ich brauche Hilfe.' },
+            { id: 2, message_type: 0, private: false, created_at: 1, content: 'Hey, I have not received my order. What is going on?' },
           ],
         }),
         { status: 200, headers: { 'Content-Type': 'application/json' } },
@@ -93,6 +99,8 @@ test('translates draft text through the draft endpoint', async () => {
   assert.equal(res.status, 200);
   assert.equal(res.body.ok, true);
   assert.equal(res.body.provider, 'flowise');
+  assert.equal(res.body.targetLanguage, 'en');
+  assert.equal(res.body.translatedText, 'Please provide the order number.');
 });
 
 async function postTranslate(path: string, body: Record<string, unknown>) {