- TRA-76 - Send Button color changes implemented

- TRA-72 - Translation of privacy statement and T&C
- TRA-73 - Strange characters in Tenant Make Name
- Addition of meta information in Specialist Form Fields
This commit is contained in:
Josako
2025-09-15 17:57:13 +02:00
parent 43fd4ce9c1
commit 2b04692fab
8 changed files with 280 additions and 108 deletions

View File

@@ -1,4 +1,6 @@
import json import json
import copy
import re
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from flask import session from flask import session
@@ -50,8 +52,8 @@ class TranslationServices:
if isinstance(config_data, str): if isinstance(config_data, str):
config_data = json.loads(config_data) config_data = json.loads(config_data)
# Maak een kopie van de originele data om te wijzigen # Maak een deep copy van de originele data om te wijzigen en input-mutatie te vermijden
translated_config = config_data.copy() translated_config = copy.deepcopy(config_data)
# Haal type en versie op voor de Business Event span # Haal type en versie op voor de Business Event span
config_type = config_data.get('type', 'Unknown') config_type = config_data.get('type', 'Unknown')
@@ -65,71 +67,124 @@ class TranslationServices:
if not context and 'metadata' in config_data and 'description' in config_data['metadata']: if not context and 'metadata' in config_data and 'description' in config_data['metadata']:
description_context = config_data['metadata']['description'] description_context = config_data['metadata']['description']
# Hulpfuncties
def is_nonempty_str(val: Any) -> bool:
return isinstance(val, str) and val.strip() != ''
def safe_translate(text: str, ctx: Optional[str]):
try:
res = cache_manager.translation_cache.get_translation(
text=text,
target_lang=target_language,
source_lang=source_language,
context=ctx
)
return res.translated_text if res else None
except Exception as e:
if current_event:
current_event.log_error('translation_error', {
'tenant_id': tenant_id,
'config_type': config_type,
'config_version': config_version,
'field_config': field_config,
'error': str(e)
})
return None
tag_pair_pattern = re.compile(r'<([a-zA-Z][\w-]*)>[\s\S]*?<\/\1>')
def extract_tag_counts(text: str) -> Dict[str, int]:
counts: Dict[str, int] = {}
for m in tag_pair_pattern.finditer(text or ''):
tag = m.group(1)
counts[tag] = counts.get(tag, 0) + 1
return counts
def tags_valid(source: str, translated: str) -> bool:
return extract_tag_counts(source) == extract_tag_counts(translated)
# Counters
meta_consentRich_translated_count = 0
meta_aria_translated_count = 0
meta_inline_tags_invalid_after_translation_count = 0
# Loop door elk veld in de configuratie # Loop door elk veld in de configuratie
for field_name, field_data in fields.items(): for field_name, field_data in fields.items():
# Vertaal name als het bestaat en niet leeg is # Vertaal name als het bestaat en niet leeg is (alleen strings)
if 'name' in field_data and field_data['name']: if 'name' in field_data and is_nonempty_str(field_data['name']):
# Gebruik context indien opgegeven, anders description_context
field_context = context if context else description_context field_context = context if context else description_context
translated_name = cache_manager.translation_cache.get_translation( t = safe_translate(field_data['name'], field_context)
text=field_data['name'], if t:
target_lang=target_language, translated_config[field_config][field_name]['name'] = t
source_lang=source_language,
context=field_context
)
if translated_name:
translated_config[field_config][field_name]['name'] = translated_name.translated_text
if 'title' in field_data and field_data['title']: if 'title' in field_data and is_nonempty_str(field_data.get('title')):
# Gebruik context indien opgegeven, anders description_context
field_context = context if context else description_context field_context = context if context else description_context
translated_title = cache_manager.translation_cache.get_translation( t = safe_translate(field_data['title'], field_context)
text=field_data['title'], if t:
target_lang=target_language, translated_config[field_config][field_name]['title'] = t
source_lang=source_language,
context=field_context
)
if translated_title:
translated_config[field_config][field_name]['title'] = translated_title.translated_text
# Vertaal description als het bestaat en niet leeg is # Vertaal description als het bestaat en niet leeg is
if 'description' in field_data and field_data['description']: if 'description' in field_data and is_nonempty_str(field_data.get('description')):
# Gebruik context indien opgegeven, anders description_context
field_context = context if context else description_context field_context = context if context else description_context
translated_desc = cache_manager.translation_cache.get_translation( t = safe_translate(field_data['description'], field_context)
text=field_data['description'], if t:
target_lang=target_language, translated_config[field_config][field_name]['description'] = t
source_lang=source_language,
context=field_context
)
if translated_desc:
translated_config[field_config][field_name]['description'] = translated_desc.translated_text
# Vertaal context als het bestaat en niet leeg is # Vertaal context als het bestaat en niet leeg is
if 'context' in field_data and field_data['context']: if 'context' in field_data and is_nonempty_str(field_data.get('context')):
translated_ctx = cache_manager.translation_cache.get_translation( t = safe_translate(field_data['context'], context)
text=field_data['context'], if t:
target_lang=target_language, translated_config[field_config][field_name]['context'] = t
source_lang=source_language,
context=context
)
if translated_ctx:
translated_config[field_config][field_name]['context'] = translated_ctx.translated_text
# vertaal allowed values als het veld bestaat en de waarden niet leeg zijn. # vertaal allowed_values als het veld bestaat en waarden niet leeg zijn (alleen string-items)
if 'allowed_values' in field_data and field_data['allowed_values']: if 'allowed_values' in field_data and isinstance(field_data['allowed_values'], list) and field_data['allowed_values']:
translated_allowed_values = [] translated_allowed_values = []
for allowed_value in field_data['allowed_values']: for allowed_value in field_data['allowed_values']:
translated_allowed_value = cache_manager.translation_cache.get_translation( if is_nonempty_str(allowed_value):
text=allowed_value, t = safe_translate(allowed_value, context)
target_lang=target_language, translated_allowed_values.append(t if t else allowed_value)
source_lang=source_language, else:
context=context translated_allowed_values.append(allowed_value)
)
translated_allowed_values.append(translated_allowed_value.translated_text)
if translated_allowed_values: if translated_allowed_values:
translated_config[field_config][field_name]['allowed_values'] = translated_allowed_values translated_config[field_config][field_name]['allowed_values'] = translated_allowed_values
# Vertaal meta.consentRich en meta.aria*
meta = field_data.get('meta')
if isinstance(meta, dict):
# consentRich
if is_nonempty_str(meta.get('consentRich')):
consent_ctx = (context if context else description_context) or ''
consent_ctx = f"Consent rich text with inline tags. Keep tag names intact and translate only inner text. {consent_ctx}".strip()
t = safe_translate(meta['consentRich'], consent_ctx)
if t and tags_valid(meta['consentRich'], t):
translated_config[field_config][field_name].setdefault('meta', {})['consentRich'] = t
meta_consentRich_translated_count += 1
else:
if t and not tags_valid(meta['consentRich'], t) and current_event:
src_counts = extract_tag_counts(meta['consentRich'])
dst_counts = extract_tag_counts(t)
current_event.log_error('inline_tags_validation_failed', {
'tenant_id': tenant_id,
'config_type': config_type,
'config_version': config_version,
'field_config': field_config,
'field_name': field_name,
'target_language': target_language,
'source_tag_counts': src_counts,
'translated_tag_counts': dst_counts
})
meta_inline_tags_invalid_after_translation_count += 1
# fallback: keep original (already in deep copy)
# aria*
for k, v in list(meta.items()):
if isinstance(k, str) and k.startswith('aria') and is_nonempty_str(v):
aria_ctx = (context if context else description_context) or ''
aria_ctx = f"ARIA label for accessibility. Short, imperative, descriptive. Form '{config_type} {config_version}', field '{field_name}'. {aria_ctx}".strip()
t2 = safe_translate(v, aria_ctx)
if t2:
translated_config[field_config][field_name].setdefault('meta', {})[k] = t2
meta_aria_translated_count += 1
return translated_config return translated_config
@staticmethod @staticmethod

View File

@@ -9,8 +9,11 @@ content: >
'{context}' '{context}'
Do not translate text in between double square brackets, as these are names or terms that need to remain intact. These are best practices you should follow:
Remove the triple quotes in your translation!
- Do not translate text in between double square brackets, as these are names or terms that need to remain intact. Remove the square brackets in the translation!
- We use inline tags (Custom HTML/XML-like tags). Ensure the tags themself are not translated and remain intact in the translation. The text inbetween the tags should be translated. e.g. "<terms_and_conditions>Terms & Conditions</terms_and_conditions>" translates in Dutch to <terms_and_conditions>Gebruiksvoorwaarden</terms_and_conditions>
- Remove the triple quotes in your translation!
I only want you to return the translation. No explanation, no options. I need to be able to directly use your answer I only want you to return the translation. No explanation, no options. I need to be able to directly use your answer
without further interpretation. If more than one option is available, present me with the most probable one. without further interpretation. If more than one option is available, present me with the most probable one.

View File

@@ -6,8 +6,11 @@ content: >
into '{target_language}'. into '{target_language}'.
Do not translate text in between double square brackets, as these are names or terms that need to remain intact. These are best practices you should follow:
Remove the triple quotes in your translation!
- Do not translate text in between double square brackets, as these are names or terms that need to remain intact. Remove the square brackets in the translation!
- We use inline tags (Custom HTML/XML-like tags). Ensure the tags themself are not translated and remain intact in the translation. The text inbetween the tags should be translated. e.g. "<terms_and_conditions>Terms & Conditions</terms_and_conditions>" translates in Dutch to <terms_and_conditions>Gebruiksvoorwaarden</terms_and_conditions>
- Remove the triple quotes in your translation!
I only want you to return the translation. No explanation, no options. I need to be able to directly use your answer I only want you to return the translation. No explanation, no options. I need to be able to directly use your answer
without further interpretation. If more than one option is available, present me with the most probable one. without further interpretation. If more than one option is available, present me with the most probable one.

View File

@@ -24,6 +24,11 @@ fields:
type: "boolean" type: "boolean"
description: "Consent" description: "Consent"
required: true required: true
meta:
kind: "consent"
consentRich: "Ik Agree with the <terms>Terms and Conditions</terms> and the <privacy>Privacy Statement</privacy> of Ask Eve AI"
ariaPrivacy: "Open privacyverklaring in a modal dialog"
ariaTerms: "Open algemene voorwaarden in a modal dialog"
metadata: metadata:
author: "Josako" author: "Josako"
date_added: "2025-07-29" date_added: "2025-07-29"

View File

@@ -0,0 +1,94 @@
<template>
<span class="consent-rich-text">
<template v-for="(node, idx) in nodes" :key="idx">
<component
v-if="node.type !== 'text'"
:is="linkTag"
:href="linkTag === 'a' ? '#' : undefined"
class="consent-link"
:aria-label="node.aria"
role="button"
tabindex="0"
@click.prevent="emitClick(node.type)"
@keydown.enter.prevent="emitClick(node.type)"
@keydown.space.prevent="emitClick(node.type)"
>{{ node.label }}</component>
<span v-else>{{ node.text }}</span>
</template>
</span>
</template>
<script>
export default {
name: 'ConsentRichText',
props: {
template: { type: String, required: true },
asButton: { type: Boolean, default: false },
ariaPrivacy: { type: String, default: 'Open privacy statement in a dialog' },
ariaTerms: { type: String, default: 'Open terms and conditions in a dialog' }
},
emits: ['open-privacy', 'open-terms'],
computed: {
linkTag() {
return this.asButton ? 'button' : 'a';
},
nodes() {
// Parse only allowed tags <privacy>...</privacy> and <terms>...</terms>
const source = (this.template || '');
// 2) parse only allowed tags <privacy>...</privacy> and <terms>...</terms>
const pattern = /<(privacy|terms)>([\s\S]*?)<\/\1>/gi;
const out = [];
let lastIndex = 0;
let match;
while ((match = pattern.exec(source)) !== null) {
const [full, tag, label] = match;
const start = match.index;
if (start > lastIndex) {
out.push({ type: 'text', text: source.slice(lastIndex, start) });
}
out.push({
type: tag, // 'privacy' | 'terms'
label: (label || '').trim(),
aria: tag === 'privacy' ? this.ariaPrivacy : this.ariaTerms
});
lastIndex = start + full.length;
}
if (lastIndex < source.length) {
out.push({ type: 'text', text: source.slice(lastIndex) });
}
return out;
}
},
methods: {
emitClick(kind) {
if (kind === 'privacy') this.$emit('open-privacy');
if (kind === 'terms') this.$emit('open-terms');
}
}
};
</script>
<style scoped>
.consent-link {
color: #007bff;
text-decoration: underline;
cursor: pointer;
transition: color 0.2s ease;
}
.consent-link:hover {
color: #0056b3;
}
.consent-link:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
border-radius: 2px;
}
button.consent-link {
background: none;
border: none;
padding: 0;
color: #007bff;
text-decoration: underline;
}
</style>

View File

@@ -104,14 +104,16 @@
> >
<!-- Regular checkbox label --> <!-- Regular checkbox label -->
<span v-if="!isConsentField" class="checkbox-text">{{ field.name }}</span> <span v-if="!isConsentField" class="checkbox-text">{{ field.name }}</span>
<!-- Consent field with privacy and terms links --> <!-- Consent field with privacy and terms links (rich, multilingual) -->
<span v-else class="checkbox-text consent-text"> <ConsentRichText
{{ texts.consentPrefix }} v-else
<a href="#" @click="openPrivacyModal" class="consent-link">{{ texts.privacyLink }}</a> class="checkbox-text consent-text"
{{ texts.consentMiddle }} :template="texts.consentRich"
<a href="#" @click="openTermsModal" class="consent-link">{{ texts.termsLink }}</a> :aria-privacy="texts.ariaPrivacy || 'Open privacy statement in a dialog'"
{{ texts.consentSuffix }} :aria-terms="texts.ariaTerms || 'Open terms and conditions in a dialog'"
</span> @open-privacy="openPrivacyModal"
@open-terms="openTermsModal"
/>
<span v-if="field.required" class="required" style="color: #d93025; margin-left: 2px;">*</span> <span v-if="field.required" class="required" style="color: #d93025; margin-left: 2px;">*</span>
</label> </label>
</div> </div>
@@ -180,9 +182,11 @@
<script> <script>
import { useComponentTranslations } from '../js/services/LanguageProvider.js'; import { useComponentTranslations } from '../js/services/LanguageProvider.js';
import ConsentRichText from './ConsentRichText.vue';
export default { export default {
name: 'FormField', name: 'FormField',
components: { ConsentRichText },
props: { props: {
field: { field: {
type: Object, type: Object,
@@ -201,13 +205,11 @@ export default {
}, },
emits: ['update:modelValue', 'open-privacy-modal', 'open-terms-modal', 'keydown-enter'], emits: ['update:modelValue', 'open-privacy-modal', 'open-terms-modal', 'keydown-enter'],
setup() { setup() {
// Consent text constants (English base) // Consent text constants (English base) - rich template
const consentTexts = { const consentTexts = {
consentPrefix: "I agree with the", consentRich: "I agree with the <privacy>privacy statement</privacy> and the <terms>terms and conditions</terms>",
consentMiddle: "and", ariaPrivacy: 'Open privacy statement in a dialog',
consentSuffix: "of AskEveAI", ariaTerms: 'Open terms and conditions in a dialog'
privacyLink: "privacy statement",
termsLink: "terms and conditions"
}; };
try { try {
@@ -230,24 +232,36 @@ export default {
}, },
computed: { computed: {
texts() { texts() {
// Robust consent texts that always return valid values // Validate that consentRich exists and includes both required tags; otherwise fallback to English base
// Use translated texts if available and valid, otherwise use fallback const hasValidRich = (t) => t && typeof t.consentRich === 'string'
if (this.translatedTexts && typeof this.translatedTexts === 'object') { && /<privacy>[\s\S]*?<\/privacy>/.test(t.consentRich)
const translated = this.translatedTexts; && /<terms>[\s\S]*?<\/terms>/.test(t.consentRich);
// Check if translated texts have all required properties
if (translated.consentPrefix && translated.consentMiddle && translated.consentSuffix && // 1) Prefer backend-provided rich string on the field's meta (already localized)
translated.privacyLink && translated.termsLink) { const meta = this.field && this.field.meta ? this.field.meta : (this.field.i18n || null);
return translated; if (meta && typeof meta.consentRich === 'string' && hasValidRich(meta)) {
} return {
consentRich: meta.consentRich,
ariaPrivacy: meta.ariaPrivacy || this.fallbackTexts.ariaPrivacy,
ariaTerms: meta.ariaTerms || this.fallbackTexts.ariaTerms
};
} }
// Fallback to English texts // 2) Otherwise, use client-side translated texts if available and valid
return this.fallbackTexts || { if (this.translatedTexts && typeof this.translatedTexts === 'object' && hasValidRich(this.translatedTexts)) {
consentPrefix: "I agree with the", return this.translatedTexts;
consentMiddle: "and", }
consentSuffix: "of AskEveAI",
privacyLink: "privacy statement", // 3) Fallback to English texts (rich template)
termsLink: "terms and conditions" if (this.fallbackTexts && hasValidRich(this.fallbackTexts)) {
return this.fallbackTexts;
}
// 4) Ultimate fallback (should not happen): provide a safe default
return {
consentRich: "I agree with the <privacy>privacy statement</privacy> and the <terms>terms and conditions</terms>",
ariaPrivacy: 'Open privacy statement in a dialog',
ariaTerms: 'Open terms and conditions in a dialog'
}; };
}, },
value: { value: {
@@ -317,12 +331,10 @@ export default {
this.value = file; this.value = file;
} }
}, },
openPrivacyModal(event) { openPrivacyModal() {
event.preventDefault();
this.$emit('open-privacy-modal'); this.$emit('open-privacy-modal');
}, },
openTermsModal(event) { openTermsModal() {
event.preventDefault();
this.$emit('open-terms-modal'); this.$emit('open-terms-modal');
}, },

View File

@@ -9,10 +9,10 @@
<script> <script>
// Definieer chatConfig voordat componenten worden geladen // Definieer chatConfig voordat componenten worden geladen
window.chatConfig = { window.chatConfig = {
explanation: `{{ customisation.sidebar_markdown|default('') }}`, explanation: {{ customisation.sidebar_markdown|default('')|tojson }},
progress_tracker_insights: `{{ customisation.progress_tracker_insights|default('No Information') }}`, progress_tracker_insights: {{ customisation.progress_tracker_insights|default('No Information')|tojson }},
form_title_display: `{{ customisation.form_title_display|default('Full Title') }}`, form_title_display: {{ customisation.form_title_display|default('Full Title')|tojson }},
conversationId: '{{ conversation_id|default("default") }}', conversationId: {{ conversation_id|default('default')|tojson }},
messages: {{ messages|tojson|safe }}, messages: {{ messages|tojson|safe }},
settings: { settings: {
maxMessageLength: {{ settings.max_message_length|default(2000) }}, maxMessageLength: {{ settings.max_message_length|default(2000) }},
@@ -22,15 +22,15 @@
allowReactions: {{ settings.allow_reactions|default('true')|lower }} allowReactions: {{ settings.allow_reactions|default('true')|lower }}
}, },
apiPrefix: '/chat-client/chat', apiPrefix: '/chat-client/chat',
language: '{{ session.magic_link.specialist_args.language|default("en") }}', language: {{ session.magic_link.specialist_args.language|default('en')|tojson }},
supportedLanguageDetails: {{ config.SUPPORTED_LANGUAGE_DETAILS|tojson|safe }}, supportedLanguageDetails: {{ config.SUPPORTED_LANGUAGE_DETAILS|tojson|safe }},
allowedLanguages: {{ tenant_make.allowed_languages|tojson|safe }}, allowedLanguages: {{ tenant_make.allowed_languages|tojson|safe }},
tenantMake: { tenantMake: {{ {
name: "{{ tenant_make.name|default('EveAI') }}", 'name': tenant_make.name or 'EveAI',
logo_url: "{{ tenant_make.logo_url|default('') }}" 'logo_url': tenant_make.logo_url or ''
}, }|tojson|safe }},
// Environment-aware static base provided by Flask's overridden url_for // Environment-aware static base provided by Flask's overridden url_for
staticBase: '{{ static_url }}' staticBase: {{ static_url|tojson }}
}; };
// Debug info om te controleren of chatConfig correct is ingesteld // Debug info om te controleren of chatConfig correct is ingesteld

View File

@@ -10,8 +10,8 @@
try { globalThis.staticUrl = window.staticUrl; } catch (e) {} try { globalThis.staticUrl = window.staticUrl; } catch (e) {}
} else { } else {
// Prefer runtime chatConfig.staticBase; else fallback to server-provided base or default // Prefer runtime chatConfig.staticBase; else fallback to server-provided base or default
var serverStaticBase = '{{ static_url|default("") }}' || ''; var serverStaticBase = {{ static_url|default('')|tojson }} || '';
if (!serverStaticBase) { serverStaticBase = '{{ url_for("static", filename="") }}'; } if (!serverStaticBase) { serverStaticBase = {{ url_for('static', filename='')|tojson }}; }
var base = (window.chatConfig && window.chatConfig.staticBase) ? window.chatConfig.staticBase : (serverStaticBase || '/static/'); var base = (window.chatConfig && window.chatConfig.staticBase) ? window.chatConfig.staticBase : (serverStaticBase || '/static/');
var normalizedBase = String(base).replace(/\/+$/, '/'); var normalizedBase = String(base).replace(/\/+$/, '/');
window.staticUrl = function(path) { window.staticUrl = function(path) {
@@ -34,19 +34,19 @@
window.chatConfig.supportedLanguages = [ window.chatConfig.supportedLanguages = [
{% for lang_code in config.SUPPORTED_LANGUAGES %} {% for lang_code in config.SUPPORTED_LANGUAGES %}
{ {
code: "{{ lang_code }}", code: {{ lang_code|tojson }},
name: "{{ config.SUPPORTED_LANGUAGE_DETAILS[config.SUPPORTED_LANGUAGES_FULL[loop.index0]]['iso 639-1'] }}", name: {{ config.SUPPORTED_LANGUAGE_DETAILS[config.SUPPORTED_LANGUAGES_FULL[loop.index0]]['iso 639-1']|tojson }},
flag: "{{ config.SUPPORTED_LANGUAGE_DETAILS[config.SUPPORTED_LANGUAGES_FULL[loop.index0]]['flag'] }}" flag: {{ config.SUPPORTED_LANGUAGE_DETAILS[config.SUPPORTED_LANGUAGES_FULL[loop.index0]]['flag']|tojson }}
}{% if not loop.last %},{% endif %} }{% if not loop.last %},{% endif %}
{% endfor %} {% endfor %}
]; ];
// Voeg tenantMake toe aan chatConfig als die nog niet bestaat // Voeg tenantMake toe aan chatConfig als die nog niet bestaat
if (!window.chatConfig.tenantMake) { if (!window.chatConfig.tenantMake) {
window.chatConfig.tenantMake = { window.chatConfig.tenantMake = {{ {
name: "{{ tenant_make.name|default('EveAI') }}", 'name': tenant_make.name or 'EveAI',
logo_url: "{{ tenant_make.logo_url|default('') }}" 'logo_url': tenant_make.logo_url or ''
}; }|tojson|safe }};
} }
console.log('Taalinstellingen toegevoegd aan chatConfig'); console.log('Taalinstellingen toegevoegd aan chatConfig');