Guide IA : traduire les formulations de repli à l'enregistrement

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-08-14 16:09:59 +02:00
parent 7407335371
commit 810374abc2
8 changed files with 158 additions and 21 deletions

View File

@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:manager_api_new/api.dart'; 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/l10n/app_localizations.dart';
import 'package:manager_app/Models/managerContext.dart'; import 'package:manager_app/Models/managerContext.dart';
import 'package:manager_app/Screens/Applications/app_configuration_link_screen.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:manager_app/Screens/GuideIa/visitor_questions_tab.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -38,6 +40,10 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
/// Ids des canaux dont l'activation est en cours — chaque ligne bascule indépendamment. /// Ids des canaux dont l'activation est en cours — chaque ligne bascule indépendamment.
final Set<String?> _togglingChannels = {}; final Set<String?> _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<String> _loadedFallbacks = [];
int _tab = 0; int _tab = 0;
final _previewController = TextEditingController(); final _previewController = TextEditingController();
@ -76,9 +82,18 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
ManagerAppContext get _managerContext => _appContext.getContext() as ManagerAppContext; ManagerAppContext get _managerContext => _appContext.getContext() as ManagerAppContext;
/// Langue dans laquelle le gestionnaire saisit ses messages de repli. /// 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'; 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<String> get _visitorLanguages => (_instance?.applicationInstanceDTOs ?? [])
.expand<String>((app) => app.languages ?? const [])
.map((lang) => lang.toUpperCase())
.where((lang) => lang != _editingLanguage)
.toSet()
.toList();
Future<void> _load() async { Future<void> _load() async {
final ctx = _managerContext; final ctx = _managerContext;
final instanceId = ctx.instanceId; final instanceId = ctx.instanceId;
@ -104,6 +119,7 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
if (_fallbackControllers.isEmpty) { if (_fallbackControllers.isEmpty) {
_fallbackControllers.add(TextEditingController()); _fallbackControllers.add(TextEditingController());
} }
_loadedFallbacks = _fallbackControllers.map((c) => c.text.trim()).where((t) => t.isNotEmpty).toList();
setState(() { setState(() {
_instance = instance; _instance = instance;
@ -176,6 +192,44 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
} }
} }
/// 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<List<TranslationDTO>?> _translateFallbacks(List<String> sources, List<TranslationDTO> 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 = <TranslationDTO>[];
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<void> _save() async { Future<void> _save() async {
final instance = _instance; final instance = _instance;
if (instance == null) return; if (instance == null) return;
@ -183,26 +237,46 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
setState(() => _saving = true); setState(() => _saving = true);
// Les messages des autres langues sont préservés tels quels : on ne réécrit que la langue d'édition. final sources = _fallbackControllers.map((c) => c.text.trim()).where((t) => t.isNotEmpty).toList();
final otherLanguages = (instance.guideFallbackMessages) final existing = instance.guideFallbackMessages.where((m) => m.language != _editingLanguage).toList();
.where((m) => m.language != _editingLanguage)
.toList(); // Sans traduction dans la langue du visiteur, le serveur laisse le modèle improviser
final edited = _fallbackControllers // son refus : les formulations du client seraient perdues hors du français.
.map((c) => c.text.trim()) List<TranslationDTO>? translated;
.where((t) => t.isNotEmpty) String? translationError;
.map((t) => TranslationDTO(language: _editingLanguage, value: t)) try {
.toList(); 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
? <TranslationDTO>[]
: 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.guideName = _nameController.text.trim();
instance.guidePersonaPrompt = _personaController.text.trim(); instance.guidePersonaPrompt = _personaController.text.trim();
instance.guideVoiceId = _voiceId; instance.guideVoiceId = _voiceId;
instance.isVisitorQuestionCollectionEnabled = _collectionEnabled; instance.isVisitorQuestionCollectionEnabled = _collectionEnabled;
instance.guideFallbackMessages = [...otherLanguages, ...edited]; instance.guideFallbackMessages = [...preserved, ...edited, ...(translated ?? [])];
try { try {
await _managerContext.clientAPI!.instanceApi!.instanceUpdateinstance(instance); await _managerContext.clientAPI!.instanceApi!.instanceUpdateinstance(instance);
if (!mounted) return; if (!mounted) return;
_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); showNotification(kSuccess, kWhite, l.guideIaSaved, context, null);
}
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
showNotification(kError, kWhite, l.guideIaSaveError, context, null); showNotification(kError, kWhite, l.guideIaSaveError, context, null);
@ -900,7 +974,7 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
} }
Widget _voiceCard(AppLocalizations l) { Widget _voiceCard(AppLocalizations l) {
final wakeword = _voiceId == kGuideVoiceMarco ? 'Marco' : 'Viva'; final wakeword = _voiceId == kGuideVoiceMarco ? 'Hey Marco' : 'Hey Viva';
return _card( return _card(
title: l.guideIaVoiceTitle, title: l.guideIaVoiceTitle,
@ -944,7 +1018,7 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
const SizedBox(height: 2), const SizedBox(height: 2),
Text(description, style: TextStyle(fontSize: 12.5, color: kBodyTextColor)), Text(description, style: TextStyle(fontSize: 12.5, color: kBodyTextColor)),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(l.guideIaVoiceWakeword(name), Text(l.guideIaVoiceWakeword('Hey $name'),
style: TextStyle(fontSize: 11.5, color: kBodyTextColor.withValues(alpha: 0.7))), style: TextStyle(fontSize: 11.5, color: kBodyTextColor.withValues(alpha: 0.7))),
], ],
), ),

View File

@ -80,7 +80,16 @@
"guideIaFallbackLabel": "When the guide doesn't know", "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.", "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", "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", "guideIaVoiceTitle": "Your guide out loud",
"guideIaVoiceSub": "Visitors ask out loud and hear the answer — in the app, or in their connected glasses.", "guideIaVoiceSub": "Visitors ask out loud and hear the answer — in the app, or in their connected glasses.",
"guideIaVoiceFemale": "Female voice, warm", "guideIaVoiceFemale": "Female voice, warm",

View File

@ -80,7 +80,16 @@
"guideIaFallbackLabel": "Quand le guide ne sait pas répondre", "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.", "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", "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", "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.", "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", "guideIaVoiceFemale": "Voix féminine, chaleureuse",

View File

@ -553,9 +553,21 @@ abstract class AppLocalizations {
/// No description provided for @guideIaFallbackTranslated. /// No description provided for @guideIaFallbackTranslated.
/// ///
/// In fr, this message translates to: /// 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; 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. /// No description provided for @guideIaVoiceTitle.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:

View File

@ -255,7 +255,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get guideIaFallbackTranslated => 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 @override
String get guideIaVoiceTitle => 'Your guide out loud'; String get guideIaVoiceTitle => 'Your guide out loud';

View File

@ -259,7 +259,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get guideIaFallbackTranslated => 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 @override
String get guideIaVoiceTitle => 'Votre guide à voix haute'; String get guideIaVoiceTitle => 'Votre guide à voix haute';

View File

@ -259,7 +259,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get guideIaFallbackTranslated => 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 @override
String get guideIaVoiceTitle => 'Uw gids hardop'; String get guideIaVoiceTitle => 'Uw gids hardop';

View File

@ -80,7 +80,16 @@
"guideIaFallbackLabel": "Wanneer de gids het niet weet", "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.", "guideIaFallbackHint": "De gids kiest er willekeurig een. Met één zin herhaalt hij die letterlijk, en dat valt meteen op.",
"guideIaFallbackAdd": "Formulering toevoegen", "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", "guideIaVoiceTitle": "Uw gids hardop",
"guideIaVoiceSub": "De bezoeker stelt zijn vraag hardop en hoort het antwoord — in de app of in zijn verbonden bril.", "guideIaVoiceSub": "De bezoeker stelt zijn vraag hardop en hoort het antwoord — in de app of in zijn verbonden bril.",
"guideIaVoiceFemale": "Vrouwelijke stem, warm", "guideIaVoiceFemale": "Vrouwelijke stem, warm",