From 17b4665e23b52d71b2a09c346660ff22a9882205 Mon Sep 17 00:00:00 2001 From: rajchales-monito Date: Thu, 6 Aug 2026 15:52:15 +0200 Subject: [PATCH] Stabilize visitor language detection --- AGENTS.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++ server.js | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..63af66b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,53 @@ +# MaalFlows Agent + +Purpose: maintain the MaalFlows live chat widget and admin app. + +## Project Shape + +- Main application file: `server.js`. +- Frontend assets: `public/widget.js`, `public/admin.js`, `public/admin.css`, `public/admin.html`. +- Runtime: Node.js without a framework, SQLite through `better-sqlite3`. +- Local check command: `npm.cmd run check`. +- Production app is proxied by nginx on `app.black-week.cz` to `127.0.0.1:3400`. + +## Work Rules + +- Read the relevant code before editing. +- Keep changes small and focused on the user's request. +- Do not touch `.agents/sogo-mail-filter-agent.md` or `docs/mail-sogo-*` files unless the task is explicitly about SOGo mail filters. +- Preserve existing user changes. Do not reset, checkout, or delete unrelated work. +- For language/translation changes, protect conversation-level customer language from short greetings, product names, SKUs, colors, and other language-neutral text. + +## Verification + +Before finishing a code change, run: + +```powershell +npm.cmd run check +``` + +If a broader behavioral change is made, add or run the smallest practical extra verification for that area. + +## Git Workflow + +After each completed project code change: + +1. Review `git diff`. +2. Stage only files that belong to the change. +3. Commit with a concise message. +4. Push the current branch to `origin`. +5. Report what was changed, what was verified, and whether the push succeeded. + +Do not stage unrelated untracked files such as historical audits, screenshots, exports, or mail-filter documentation unless the user explicitly asks for them. + +## Production Update + +After a successful push, update the production server only when the deployment target and restart command are known for this project. + +Expected deployment shape: + +```powershell +ssh "cd && git pull --ff-only && npm install --omit=dev && " +``` + +Before running production deploy commands, confirm the real host, app directory, and restart command if they are not already documented in the repository or current task context. diff --git a/server.js b/server.js index 7153431..728e6f9 100644 --- a/server.js +++ b/server.js @@ -1114,13 +1114,16 @@ async function translateStoredMessage(conversationId, messageId, senderType, bod try { const result = await translateText(message.body, targetLanguage, settings.model, settings.openaiApiKey); - const sourceLanguage = normalizeLanguage(result.sourceLanguage); + const detectedSourceLanguage = normalizeDetectedLanguage(result.sourceLanguage); + const sourceLanguage = senderType === "visitor" + ? stableVisitorLanguage(conversation, message.body, detectedSourceLanguage) + : detectedSourceLanguage; const translatedText = String(result.translatedText || "").trim(); - const translatedLanguage = normalizeLanguage(result.translatedLanguage || targetLanguage); + const translatedLanguage = normalizeDetectedLanguage(result.translatedLanguage || targetLanguage); const sameLanguage = sourceLanguage && translatedLanguage && sourceLanguage === translatedLanguage; if (senderType === "visitor" && sourceLanguage) { - db.prepare("UPDATE conversations SET customer_language = COALESCE(?, customer_language), updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(sourceLanguage, conversationId); + db.prepare("UPDATE conversations SET customer_language = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(sourceLanguage, conversationId); } if (!translatedText || sameLanguage) { @@ -1157,7 +1160,7 @@ async function translateText(text, targetLanguage, model, apiKey) { input: [ { role: "system", - content: "You translate live chat messages for an e-commerce support team. Return only compact JSON with keys sourceLanguage, translatedLanguage, translatedText. Preserve meaning, product terms, numbers, names, URLs, and tone. Do not answer the customer." + content: "You translate live chat messages for an e-commerce support team. Return only compact JSON with keys sourceLanguage, translatedLanguage, translatedText. Preserve meaning, product terms, numbers, names, URLs, and tone. Do not answer the customer. If the source language is unclear because the text is a greeting, product name, SKU, brand, color, URL, or otherwise language-neutral, set sourceLanguage to unknown." }, { role: "user", @@ -1768,6 +1771,53 @@ function normalizeLanguage(value) { return text.split("-")[0].slice(0, 8); } +function normalizeDetectedLanguage(value) { + const language = normalizeLanguage(value); + return ["unknown", "und", "auto", "n/a", "none"].includes(language) ? "" : language; +} + +function stableVisitorLanguage(conversation, text, detectedLanguage) { + const currentLanguage = normalizeDetectedLanguage(conversation?.customer_language); + const browserLanguage = normalizeDetectedLanguage(conversation?.language); + if (!currentLanguage) { + if (browserLanguage && weakLanguageEvidence(text)) return browserLanguage; + return detectedLanguage; + } + if (!detectedLanguage || detectedLanguage === currentLanguage) return currentLanguage; + return weakLanguageEvidence(text) ? currentLanguage : detectedLanguage; +} + +function weakLanguageEvidence(value) { + const text = String(value || "").trim(); + if (!text) return true; + + const words = text.match(/[\p{L}\p{N}][\p{L}\p{N}'-]*/gu) || []; + const lowerWords = words.map((word) => word.toLowerCase()); + if (!words.length) return true; + if (hasStrongLanguageEvidence(text, lowerWords)) return false; + + const compact = lowerWords.join(" "); + const weakPhrases = new Set([ + "hi", "hello", "hey", "ok", "okay", "yes", "no", "thanks", "thank you", + "hallo", "servus", "danke", "ja", "nein", "bitte" + ]); + if (weakPhrases.has(compact)) return true; + if (words.length <= 2) return true; + + const startsLikeProductName = /^[\p{Lu}\p{N}]/u.test(words[0]); + const sentencePunctuation = /[?!.]/.test(text); + return words.length <= 6 && startsLikeProductName && !sentencePunctuation; +} + +function hasStrongLanguageEvidence(text, lowerWords) { + const strongWords = new Set([ + "i", "you", "we", "need", "want", "would", "could", "please", "what", "which", "size", "have", "has", "is", "are", + "ich", "sie", "wir", "brauche", "benotige", "möchte", "mochte", "bitte", "welche", "welcher", "welches", "größe", "groesse", "hat", "haben", "ist", "sind", "wie", "was", "kann" + ]); + const hits = lowerWords.filter((word) => strongWords.has(word)).length; + return hits >= 2 || (hits >= 1 && /[?]/.test(text)); +} + function truncateText(value, maxLength) { const text = String(value || "").trim(); if (!text) return null;