From 810374abc2a40245f81adec99dfc9fda89b1b6e6 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Fri, 14 Aug 2026 16:09:59 +0200 Subject: [PATCH] =?UTF-8?q?Guide=20IA=20:=20traduire=20les=20formulations?= =?UTF-8?q?=20de=20repli=20=C3=A0=20l'enregistrement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'infobulle promettait que ces phrases seraient traduites « comme le reste de vos contenus », mais rien ne les traduisait jamais : BuildFallbackInstruction filtre sur la langue du visiteur et, ne trouvant rien, laisse le modèle improviser son refus. Les formulations du client ne servaient qu'en français. L'enregistrement traduit désormais vers les langues déclarées sur les canaux, en n'appelant l'IA que pour ce qui en a besoin : les langues sans traduction, et toutes les langues si une phrase source a changé depuis le chargement. Une sauvegarde qui ne touche pas aux formulations ne consomme donc pas de jetons. Les langues visées sont celles des canaux, pas les dix supportées, pour la même raison. Un échec de traduction n'empêche pas l'enregistrement : le reste est sauvé, les traductions précédentes sont conservées et l'écran le signale. Effacer toutes les formulations efface aussi leurs traductions, devenues sans objet. Corrige au passage le mot de réveil affiché : le modèle OpenWakeWord est « hey_viva » / « hey_marco », le visiteur doit donc dire « Hey Viva », pas « Viva ». Co-Authored-By: Claude Opus 5 --- lib/Screens/GuideIa/guide_ia_screen.dart | 102 +++++++++++++++++++---- lib/l10n/app_en.arb | 11 ++- lib/l10n/app_fr.arb | 11 ++- lib/l10n/app_localizations.dart | 14 +++- lib/l10n/app_localizations_en.dart | 10 ++- lib/l10n/app_localizations_fr.dart | 10 ++- lib/l10n/app_localizations_nl.dart | 10 ++- lib/l10n/app_nl.arb | 11 ++- 8 files changed, 158 insertions(+), 21 deletions(-) diff --git a/lib/Screens/GuideIa/guide_ia_screen.dart b/lib/Screens/GuideIa/guide_ia_screen.dart index 4603bcf..84cbcae 100644 --- a/lib/Screens/GuideIa/guide_ia_screen.dart +++ b/lib/Screens/GuideIa/guide_ia_screen.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:manager_api_new/api.dart'; @@ -9,6 +10,7 @@ import 'package:manager_app/constants.dart'; import 'package:manager_app/l10n/app_localizations.dart'; import 'package:manager_app/Models/managerContext.dart'; import 'package:manager_app/Screens/Applications/app_configuration_link_screen.dart'; +import 'package:manager_app/Services/ai_translate_service.dart'; import 'package:manager_app/Screens/GuideIa/visitor_questions_tab.dart'; import 'package:provider/provider.dart'; @@ -38,6 +40,10 @@ class _GuideIaScreenState extends State { /// Ids des canaux dont l'activation est en cours — chaque ligne bascule indépendamment. final Set _togglingChannels = {}; + /// Formulations de repli telles que chargées, pour repérer une modification : + /// une traduction ne vaut plus rien dès que sa phrase source change. + List _loadedFallbacks = []; + int _tab = 0; final _previewController = TextEditingController(); @@ -76,9 +82,18 @@ class _GuideIaScreenState extends State { ManagerAppContext get _managerContext => _appContext.getContext() as ManagerAppContext; /// Langue dans laquelle le gestionnaire saisit ses messages de repli. - /// Les autres langues sont produites par la traduction automatique, comme le reste du contenu. + /// Les autres langues sont produites par le bouton de traduction, comme le reste du contenu. String get _editingLanguage => 'FR'; + /// Langues réellement servies aux visiteurs : l'union de celles déclarées par les canaux. + /// Traduire vers les dix langues supportées gaspillerait le quota IA du client. + List get _visitorLanguages => (_instance?.applicationInstanceDTOs ?? []) + .expand((app) => app.languages ?? const []) + .map((lang) => lang.toUpperCase()) + .where((lang) => lang != _editingLanguage) + .toSet() + .toList(); + Future _load() async { final ctx = _managerContext; final instanceId = ctx.instanceId; @@ -104,6 +119,7 @@ class _GuideIaScreenState extends State { if (_fallbackControllers.isEmpty) { _fallbackControllers.add(TextEditingController()); } + _loadedFallbacks = _fallbackControllers.map((c) => c.text.trim()).where((t) => t.isNotEmpty).toList(); setState(() { _instance = instance; @@ -176,6 +192,44 @@ class _GuideIaScreenState extends State { } } + /// Traduit les formulations de repli vers les langues des visiteurs. Sans elles, + /// `BuildFallbackInstruction` côté serveur ne trouve rien dans la langue du visiteur + /// et laisse le modèle improviser son refus — les phrases du client sont perdues. + /// Traduit les formulations de repli vers les langues des visiteurs, en ne rappelant + /// l'IA que pour les langues qui en ont besoin : celles qui n'ont encore rien, et + /// toutes les autres si une phrase source a changé depuis le chargement. + /// Renvoie null si l'appel a échoué, pour que l'enregistrement le signale. + Future?> _translateFallbacks(List sources, List existing) async { + final ctx = _managerContext; + final sourcesChanged = !listEquals(sources, _loadedFallbacks); + + final targets = _visitorLanguages + .where((lang) => sourcesChanged || !existing.any((m) => m.language == lang)) + .toList(); + + if (sources.isEmpty || targets.isEmpty) return const []; + + // Une requête par formulation : l'endpoint traduit un texte vers N langues. + final translated = []; + for (final source in sources) { + final result = await AiTranslateService.translate( + host: ctx.host!, + accessToken: ctx.accessToken!, + instanceId: ctx.instanceId!, + text: source, + sourceLang: _editingLanguage, + targetLangs: targets, + ); + for (final lang in targets) { + final value = result[lang]?.trim(); + if (value != null && value.isNotEmpty) { + translated.add(TranslationDTO(language: lang, value: value)); + } + } + } + return translated; + } + Future _save() async { final instance = _instance; if (instance == null) return; @@ -183,26 +237,46 @@ class _GuideIaScreenState extends State { setState(() => _saving = true); - // Les messages des autres langues sont préservés tels quels : on ne réécrit que la langue d'édition. - final otherLanguages = (instance.guideFallbackMessages) - .where((m) => m.language != _editingLanguage) - .toList(); - final edited = _fallbackControllers - .map((c) => c.text.trim()) - .where((t) => t.isNotEmpty) - .map((t) => TranslationDTO(language: _editingLanguage, value: t)) - .toList(); + final sources = _fallbackControllers.map((c) => c.text.trim()).where((t) => t.isNotEmpty).toList(); + final existing = instance.guideFallbackMessages.where((m) => m.language != _editingLanguage).toList(); + + // Sans traduction dans la langue du visiteur, le serveur laisse le modèle improviser + // son refus : les formulations du client seraient perdues hors du français. + List? translated; + String? translationError; + try { + translated = await _translateFallbacks(sources, existing); + } on AiTranslateException catch (e) { + translationError = e.message; + } catch (_) { + translationError = l.guideIaFallbackTranslateError; + } + if (!mounted) return; + + // Une traduction produite remplace celle de sa langue ; les autres restent en place. + // Plus aucune formulation source : les traductions n'ont plus d'objet et disparaissent. + final refreshedLanguages = (translated ?? []).map((m) => m.language).toSet(); + final preserved = sources.isEmpty + ? [] + : existing.where((m) => !refreshedLanguages.contains(m.language)).toList(); + final edited = sources.map((t) => TranslationDTO(language: _editingLanguage, value: t)).toList(); instance.guideName = _nameController.text.trim(); instance.guidePersonaPrompt = _personaController.text.trim(); instance.guideVoiceId = _voiceId; instance.isVisitorQuestionCollectionEnabled = _collectionEnabled; - instance.guideFallbackMessages = [...otherLanguages, ...edited]; + instance.guideFallbackMessages = [...preserved, ...edited, ...(translated ?? [])]; try { await _managerContext.clientAPI!.instanceApi!.instanceUpdateinstance(instance); if (!mounted) return; - showNotification(kSuccess, kWhite, l.guideIaSaved, context, null); + _loadedFallbacks = List.of(sources); + // Le reste est bien enregistré : l'échec de traduction se signale sans masquer ça. + if (translationError != null) { + showNotification(Colors.orange, kWhite, l.guideIaSavedTranslationFailed(translationError), context, null); + } else { + showNotification(kSuccess, kWhite, l.guideIaSaved, context, null); + } } catch (_) { if (!mounted) return; showNotification(kError, kWhite, l.guideIaSaveError, context, null); @@ -900,7 +974,7 @@ class _GuideIaScreenState extends State { } Widget _voiceCard(AppLocalizations l) { - final wakeword = _voiceId == kGuideVoiceMarco ? 'Marco' : 'Viva'; + final wakeword = _voiceId == kGuideVoiceMarco ? 'Hey Marco' : 'Hey Viva'; return _card( title: l.guideIaVoiceTitle, @@ -944,7 +1018,7 @@ class _GuideIaScreenState extends State { const SizedBox(height: 2), Text(description, style: TextStyle(fontSize: 12.5, color: kBodyTextColor)), const SizedBox(height: 8), - Text(l.guideIaVoiceWakeword(name), + Text(l.guideIaVoiceWakeword('Hey $name'), style: TextStyle(fontSize: 11.5, color: kBodyTextColor.withValues(alpha: 0.7))), ], ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5e75602..aab0418 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -80,7 +80,16 @@ "guideIaFallbackLabel": "When the guide doesn't know", "guideIaFallbackHint": "The guide picks one at random. With a single sentence it repeats it word for word, and that shows immediately.", "guideIaFallbackAdd": "Add a wording", - "guideIaFallbackTranslated": "Visitors see these sentences: they will be translated into their language, like the rest of your content.", + "guideIaFallbackTranslated": "Visitors see these sentences: on save, they are translated into your channels' languages.", + "guideIaFallbackTranslateError": "AI translation failed", + "guideIaSavedTranslationFailed": "Saved, but translating the wordings failed: {reason}", + "@guideIaSavedTranslationFailed": { + "placeholders": { + "reason": { + "type": "String" + } + } + }, "guideIaVoiceTitle": "Your guide out loud", "guideIaVoiceSub": "Visitors ask out loud and hear the answer — in the app, or in their connected glasses.", "guideIaVoiceFemale": "Female voice, warm", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index af22d7f..8956bb3 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -80,7 +80,16 @@ "guideIaFallbackLabel": "Quand le guide ne sait pas répondre", "guideIaFallbackHint": "Le guide en choisit une au hasard. Avec une seule phrase, il la répète à l'identique et ça se remarque tout de suite.", "guideIaFallbackAdd": "Ajouter une formulation", - "guideIaFallbackTranslated": "Ces phrases sont vues par vos visiteurs : elles seront traduites dans leur langue, comme le reste de vos contenus.", + "guideIaFallbackTranslated": "Ces phrases sont vues par vos visiteurs : à l'enregistrement, elles sont traduites dans les langues de vos canaux.", + "guideIaFallbackTranslateError": "Erreur lors de la traduction IA", + "guideIaSavedTranslationFailed": "Enregistré, mais la traduction des formulations a échoué : {reason}", + "@guideIaSavedTranslationFailed": { + "placeholders": { + "reason": { + "type": "String" + } + } + }, "guideIaVoiceTitle": "Votre guide à voix haute", "guideIaVoiceSub": "Le visiteur pose sa question à voix haute et entend la réponse — dans l'application, ou dans ses lunettes connectées.", "guideIaVoiceFemale": "Voix féminine, chaleureuse", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6e6cc9c..a74a25b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -553,9 +553,21 @@ abstract class AppLocalizations { /// No description provided for @guideIaFallbackTranslated. /// /// In fr, this message translates to: - /// **'Ces phrases sont vues par vos visiteurs : elles seront traduites dans leur langue, comme le reste de vos contenus.'** + /// **'Ces phrases sont vues par vos visiteurs : à l\'enregistrement, elles sont traduites dans les langues de vos canaux.'** String get guideIaFallbackTranslated; + /// No description provided for @guideIaFallbackTranslateError. + /// + /// In fr, this message translates to: + /// **'Erreur lors de la traduction IA'** + String get guideIaFallbackTranslateError; + + /// No description provided for @guideIaSavedTranslationFailed. + /// + /// In fr, this message translates to: + /// **'Enregistré, mais la traduction des formulations a échoué : {reason}'** + String guideIaSavedTranslationFailed(String reason); + /// No description provided for @guideIaVoiceTitle. /// /// In fr, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 99ded49..33c0b71 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -255,7 +255,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get guideIaFallbackTranslated => - 'Visitors see these sentences: they will be translated into their language, like the rest of your content.'; + 'Visitors see these sentences: on save, they are translated into your channels\' languages.'; + + @override + String get guideIaFallbackTranslateError => 'AI translation failed'; + + @override + String guideIaSavedTranslationFailed(String reason) { + return 'Saved, but translating the wordings failed: $reason'; + } @override String get guideIaVoiceTitle => 'Your guide out loud'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 25fdb37..e626d84 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -259,7 +259,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get guideIaFallbackTranslated => - 'Ces phrases sont vues par vos visiteurs : elles seront traduites dans leur langue, comme le reste de vos contenus.'; + 'Ces phrases sont vues par vos visiteurs : à l\'enregistrement, elles sont traduites dans les langues de vos canaux.'; + + @override + String get guideIaFallbackTranslateError => 'Erreur lors de la traduction IA'; + + @override + String guideIaSavedTranslationFailed(String reason) { + return 'Enregistré, mais la traduction des formulations a échoué : $reason'; + } @override String get guideIaVoiceTitle => 'Votre guide à voix haute'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index fc81918..59785f3 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -259,7 +259,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get guideIaFallbackTranslated => - 'Bezoekers zien deze zinnen: ze worden vertaald naar hun taal, net als de rest van uw inhoud.'; + 'Bezoekers zien deze zinnen: bij het opslaan worden ze vertaald naar de talen van uw kanalen.'; + + @override + String get guideIaFallbackTranslateError => 'Fout bij de AI-vertaling'; + + @override + String guideIaSavedTranslationFailed(String reason) { + return 'Opgeslagen, maar het vertalen van de formuleringen is mislukt: $reason'; + } @override String get guideIaVoiceTitle => 'Uw gids hardop'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 0d456b0..3fbb1f1 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -80,7 +80,16 @@ "guideIaFallbackLabel": "Wanneer de gids het niet weet", "guideIaFallbackHint": "De gids kiest er willekeurig een. Met één zin herhaalt hij die letterlijk, en dat valt meteen op.", "guideIaFallbackAdd": "Formulering toevoegen", - "guideIaFallbackTranslated": "Bezoekers zien deze zinnen: ze worden vertaald naar hun taal, net als de rest van uw inhoud.", + "guideIaFallbackTranslated": "Bezoekers zien deze zinnen: bij het opslaan worden ze vertaald naar de talen van uw kanalen.", + "guideIaFallbackTranslateError": "Fout bij de AI-vertaling", + "guideIaSavedTranslationFailed": "Opgeslagen, maar het vertalen van de formuleringen is mislukt: {reason}", + "@guideIaSavedTranslationFailed": { + "placeholders": { + "reason": { + "type": "String" + } + } + }, "guideIaVoiceTitle": "Uw gids hardop", "guideIaVoiceSub": "De bezoeker stelt zijn vraag hardop en hoort het antwoord — in de app of in zijn verbonden bril.", "guideIaVoiceFemale": "Vrouwelijke stem, warm",