DOCS/v2/guide-ia-screen-plan.md
Thomas Fransolet 83bc8c51ad Arbitrages du 12/08, D2 livré, et deux docs qui affirmaient du faux
Décidé et inscrit : thèmes du lot J complets (job + table d'agrégats, donc le
§8.4 des CGU n'est pas à amender), meterZoneGPS sur mymuseum et visitapp-web,
D3/D4 sans attendre D0, rate limiting et Customer Portal confirmés V1, lunettes
Ray-Ban ramenées en V1, K9 (repasse visuelle de la borne, bento compris),
déclenchement proactif ouvert à tous, miroir de la conversation vocale.

Trois lignes du plan étaient périmées : le nettoyage de manager_api_new et la
sécurité du lot A sont faits depuis un moment, et un PUT ApplicationInstance
existe déjà.

Deux documents affirmaient un fallback Voice vers Mobile qui n'existe pas —
guide-ia-screen-plan.md et le commentaire d'AiController se confirmaient
mutuellement. Le code fait un FirstOrDefault sur le canal exact puis Forbid() :
envoyer AppType.Voice au chat sans ApplicationInstance de ce type rendrait 403
à chaque question. Corrigé côté doc ; le commentaire reste à corriger.

Le vocal n'est donc pas un canal mais un attribut : marqué dans
VisitEvent.Metadata, colonne JSON déjà existante, aucune migration.
AppTypeDistribution compte une entrée par session et tranche sur l'événement le
plus ancien — avec le miroir, une session est mixte, et un canal Voice aurait
mesuré « sessions démarrées en vocal », pas la part du vocal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:47:34 +02:00

24 KiB

Écran « Guide IA » — manager-app

Décisions du 2026-08-07. Périmètre V1, livré avec le RAG sur contenu CMS. Maquette de référence : https://claude.ai/code/artifact/c43fe02d-3283-48c2-992f-d687e18acdfa Elle porte la mise en page, la hiérarchie et la formulation exacte des libellés — y compris les exemples de personnalité et les suggestions de messages de repli, qui ne sont pas repris ici. La lire avant d'implémenter. Dépend de : rag-pgvector-integration-plan.md (endpoint /ask, embeddings).


État d'implémentation — 2026-08-07

Livré

Backend (dotnet build : 0 erreur)

  • Instance : GuideName, GuidePersonaPrompt, GuideVoiceId, GuideFallbackMessages — plus ToDTO / FromDTO
  • InstanceDTO : mêmes champs exposés
  • Conversion JSONB de GuideFallbackMessages dans MyInfoMateDbContext
  • Migration 20260807143641_AddGuideIaConfigToInstance4 colonnes nullable, purement additive, aucun risque sur les données existantes

Client générémanager_api_new/lib/model/instance_dto.dart étendu à la main (convention du projet : pas de régénération OpenAPI)

manager-app (flutter build web : OK)

  • Entrée de menu « Guide IA », conditionnée à instance.isAssistant — même drapeau que la garde d'AiController, donc le menu et l'API disent la même chose
  • Écran Screens/GuideIa/guide_ia_screen.dart : consommation du mois, disponibilité par canal, identité (nom, personnalité avec 3 exemples cliquables, messages de repli ajoutables/supprimables), choix de voix Viva/Marco avec les 3 messages d'information
  • 35 clés i18n ajoutées en FR / EN / NL — aucune chaîne en dur

Choix d'implémentation

  • Messages de repli stockés en List<TranslationDTO> à plat : plusieurs entrées peuvent partager la même langue. Réutilise le type et le convertisseur JSONB existants, sans inventer de type imbriqué. La sauvegarde ne réécrit que la langue d'édition et préserve les autres langues intactes.
  • Consommation affichée en pourcentage + nombre de questions, jamais en jetons — le gestionnaire ne raisonne pas en tokens. Le ratio jetons → questions est encore approximatif (/1000), à caler sur des données réelles.
  • Canaux en lecture seule pour l'instant : les activer demande un endpoint de mise à jour d'ApplicationInstance. Le sous-titre renvoie explicitement vers la configuration de chaque application, plutôt que d'exposer un interrupteur qui ne ferait rien.

Configuration branchée sur AssistantService — livré le 2026-08-07 (lot 1)

C'était le point bloquant : l'écran enregistrait une configuration que personne ne lisait. Ce n'est plus le cas.

Ce qui a été trouvé en ouvrant le fichier : pas deux blocs de prompt mais quatre — scope configuration et scope instance, chacun en vocal et en texte. Le plan n'en annonçait que deux parce que le relevé s'était arrêté au scope configuration.

Les quatre écarts constatés, tous corrigés :

Constat Correction
Tout le prompt codé en dur, config?.Label pour seul élément dynamique — tous les clients avaient le même guide au même ton tutoyant BuildGuideIdentity() injecte GuideName + GuidePersonaPrompt en tête des 4 blocs
Ton imposé (« Tu es chaleureux, naturel ») en conflit avec ce que le client écrit — et c'est le prompt figé qui gagnait Retiré des 4. Les contraintes de canal restent (concision vocale, zéro markdown) : ce sont des contraintes techniques, pas du ton
Repli figé en français tutoyant (règle 12 : « Bonne question, mais là je sèche… ») BuildFallbackInstruction() reprend les GuideFallbackMessages du client
Seconde politique de repli incohérente ligne 550 (phrase unique, « Je suis uniquement là pour t'accompagner dans ta visite ») Les 4 blocs partagent désormais la même consigne, issue de la même méthode

Deux prompts sur quatre n'avaient aucune règle hors-sujet du tout — les variantes texte. Un visiteur pouvait y demander une recette de tarte. La règle leur a été ajoutée.

Corrigé au passage, même fichier : les 4 new HttpClient() dans les outils d'agenda (audit sécurité, priorité haute) → IHttpClientFactory injecté.

dotnet build : 0 erreur · dotnet test : 124/124.

Choix d'implémentation, et pourquoi

  • Le tirage aléatoire du repli est fait côté serveur (Random.Shared), pas laissé au modèle. Le plan disait « conserver le tirage aléatoire » ; le faire en C# garantit que la phrase du client est reprise mot pour mot, alors que le modèle l'aurait paraphrasée. Un tirage par appel HTTP.
  • Filtrage strict sur la langue du visiteur. Sans formulation dans cette langue, on retombe sur le comportement générique (le modèle décline en variant lui-même) plutôt que de servir une phrase française à un visiteur néerlandais. ⚠️ Corollaire pour l'écran : un client qui ne remplit ses messages qu'en FR n'a de repli personnalisé qu'en FR — c'est le bouton « traduire automatiquement » qui doit combler ça, et il faut donc qu'il soit visible sur ce champ.
  • Chargement de l'instance par Find() et non par une requête : AiController a déjà chargé l'entité pour le contrôle de quota, dans le même scope EF. Zéro requête supplémentaire.
  • Pas d'extraction plus poussée des 4 prompts. L'identité et le repli ont désormais une source unique ; ce qui reste divergent est fonctionnel (outils disponibles, navigation, cartes). Aller plus loin rendrait les prompts illisibles pour un gain nul.

Pas encore livré, et pourquoi

  • Onglet « Ce que demandent vos visiteurs » — nécessite la table VisitorQuestion et le ConversationId. Un onglet vide serait exactement le défaut dénoncé plus bas (un réglage sans effet).
  • « Ce que connaît votre guide » — nécessite le RAG.
  • Aperçu de conversation — plus rien ne le bloque depuis que le persona est injecté : c'est le prochain morceau naturel de cet écran.

Reprendre ce chantier dans une conversation neuve

Ordre de lecture :

  1. DOCS/STATUS.md §1quater — l'ordre d'exécution des 4 lots du chantier Guide IA
  2. Ce document — l'écran, ses décisions produit, et ce qui a déjà été branché
  3. ManagerService/Services/AssistantService.csBuildGuideIdentity() / BuildFallbackInstruction() et les 4 blocs de prompt qui les consomment
  4. ManagerService/Data/Instance.cs — les quatre champs Guide*, avec leurs commentaires XML

⚠️ Ce que les documents ne portent pas : le raisonnement qui a produit ces décisions. Les conclusions et leurs justifications sont écrites ; les fausses pistes écartées en chemin ne le sont pas. Concrètement, ça veut dire qu'une conversation neuve exécute bien, mais qu'elle re-débattra une décision si on la lui rouvre. Ne pas rouvrir : trancher à nouveau coûte plus cher que de suivre.


Nom retenu : « Guide IA »

« Assistant » décrit comment c'est construit ; « guide » décrit ce que le visiteur reconnaît — et dans un lieu culturel, c'est littéralement le métier. Le doc TTS emploie déjà « Configuration du guide IA ».

  • UI et client : « guide »
  • Vocabulaire technique / code : persona

V1 — un seul guide, au niveau de l'instance

Le guide se configure sur l'instance. La recherche RAG est en revanche limitée à la configuration en cours : un lieu avec plusieurs parcours ne veut pas que le guide mélange les contenus de l'un et de l'autre.

Le modèle part en V1 sous forme de liste (List<PersonaConfig>, cf. tts-pregenerated-plan.md) même si l'UI n'en expose qu'un. Les guides multiples et le rattachement à une section sont V2 — voir §V2 en bas.


Contenu de l'écran

1. Statut & quota

  • Tokens consommés / plafond du plan, avec la date de remise à zéro
  • État par canal (voir §2)

Sans ça, le client dont le quota est épuisé ouvre un ticket « mon guide ne répond plus ».

2. Activation par canal

Le gate réel dans le code est double : Instance.IsAssistant et ApplicationInstance.isAssistant par type d'app — c'est déjà ainsi qu'AiController et la bulle web fonctionnent.

L'écran doit donc montrer web / mobile / kiosk séparément. Sinon le client ne comprendra jamais pourquoi le guide répond sur un canal et pas sur l'autre.

3. Identité du guide

Champ Contenu
Nom Libre. « Léon », « Alice », « Le Gardien »…
Prompt de personnalité Ton, spécialité, façon de s'adresser aux visiteurs
Messages de repli Plusieurs (2 à 4), tirés au hasard — voir ci-dessous

Le nom libre est cohérent avec le prompt : « Tu t'appelles Léon, tu parles en belge familier, tu es spécialisé sur le Moyen-Âge. »

Les messages de repli sont une liste éditable, pas un champ unique : lignes ajoutables/supprimables, avec des formulations suggérées en un clic. Un guide qui répète mot pour mot la même phrase passe immédiatement pour un automate — et c'est précisément le moment où il faut préserver l'illusion.

Ce qui est traduit, et ce qui ne l'est pas

Distinction structurante, à ne pas confondre :

Nature Stockage
Messages de repli Texte vu par le visiteur List<TranslationDTO>, comme tout le contenu — avec le bouton « traduire automatiquement » existant
Personnalité Instruction au modèle, jamais affichée Une seule langue. Le modèle répond dans la langue du visiteur de toute façon

Traduire le prompt de personnalité en 10 langues coûterait des appels pour rien et ferait dériver le ton d'une langue à l'autre. Ce n'est pas du contenu, c'est une consigne.

Langue de l'interface

Toutes les chaînes de l'écran passent par AppLocalizations (FR / NL / EN) — la langue du gestionnaire, distincte de celle des contenus.

⚠️ L'audit manager-app relève que l'i18n est largement contournée : infra complète (373 clés) mais ~100+ chaînes françaises en dur, et seulement 52 fichiers sur 120 utilisant AppLocalizations. Ne pas ajouter à la dette sur un écran neuf.

4. Sources de connaissance (lecture seule en V1)

Liste de ce qui alimente le guide : N sections indexées, dernière indexation. En V2 s'y ajoutent les documents uploadés.

Une barre de recherche — mais vectorielle, pas un filtre texte (arbitré le 2026-08-10)

Le client se demande « est-ce que mon guide connaît X ? ». Un Ctrl+F sur les chunks ne répond pas à cette question : le guide, lui, cherche par similarité. Le client tape « tarif groupe », ne trouve rien parce que sa fiche dit « visites collectives », et conclut à tort qu'il manque une donnée que le guide aurait trouvée. Un filtre texte produit donc de faux négatifs sur la seule question qui compte.

La barre lance le même SearchAsync que le guide et affiche les extraits retournés avec leur score et leur source. Le client voit littéralement ce que le guide voit. Ça transforme le bloc en outil de diagnostic : « pourquoi mon guide ne parle pas de X » devient une question à laquelle il répond seul, sans ticket.

Coût dérisoire (un embedding de requête). Se marie avec le §5 : l'un teste le persona, l'autre teste la matière.

Pas de graphe de connaissance. Un nuage de nœuds reliés est joli en démo et illisible en usage : à 300 sections c'est une pelote, et surtout il ne répond à aucune question que le client se pose. Le RAG n'est pas un graphe — c'est une liste plate de morceaux dans un espace vectoriel ; le dessiner en réseau représenterait une structure qui n'existe pas.

S'il faut un visuel, le seul qui informe est une couverture par section : liste ou barre montrant combien d'extraits chaque section produit, avec les sections à 0 en tête. C'est ça, l'information actionnable — « ces 12 sections n'apportent rien au guide, elles sont vides ou sans texte ». Un tri, pas un graphe.

5. Aperçu de conversation

Tester le prompt sans device. Un prompt de personnalité qu'on ne peut pas essayer est inutilisable.

Le travail est déjà à moitié spécifié : todo-features.md prévoit un « Mode preview » avec une cible Assistant / Persona. Faire converger les deux plutôt que construire deux fois.

6. Voix & assistant vocal

Présent dans l'écran dès la V1, parce que c'est un argument de démo commerciale.

En V1 le client ne choisit pas une personnalité — elle vient du prompt libre — mais simplement une voix, parmi deux options pré-câblées :

Option Voix Gemini TTS Timbre Mot d'activation
Viva Sulafat Warm, middle pitch « Viva »
Marco Umbriel Easy-going, lower middle pitch « Marco »

C'est volontairement binaire : la voix et le mot d'activation forment un couple indissociable, parce que les mots d'activation sont des modèles OpenWakeWord pré-entraînés, pas des chaînes de caractères qu'on saisit.

Voix figées le 2026-08-07. Elles sont multilingues : la même voix parle les 10 langues, donc le guide garde une identité vocale constante d'une langue à l'autre. Pas de sélection de voix par langue. Critères de choix et voix écartées : tts-pregenerated-plan.md.

Reste à valider à l'oreille sur du vrai contenu long (2 min d'une fiche, sur haut-parleur de téléphone) et dans les 4 langues — une voix excellente en français peut être médiocre en néerlandais.

Trois messages d'info dans cette section :

  1. Le nom du guide (libre) et le mot d'activation (Viva/Marco) sont deux choses distinctes — le visiteur dit « Viva » pour réveiller un guide qui s'appelle Léon.
  2. Une voix ou un mot d'activation personnalisé est un add-on facturable : collecte de samples, entraînement OpenWakeWord, intégration au build de l'app.
  3. « Les lunettes connectées fonctionnent via l'application mobile MyInfoMate installée sur le téléphone du visiteur. » Un client non technique imagine des lunettes autonomes.

7. Onglet « Ce que demandent vos visiteurs » — V1

Deuxième onglet de l'écran Guide IA. Décidé le 2026-08-07 : exploité dès la V1, pas seulement journalisé. C'est un argument de vente autant qu'un outil.

Ce qu'on stocke — et ce qui existe déjà dans le code

Vérification faite le 2026-08-07 dans AiController.Chat et DTOs/AiChatDTO.cs : presque tout est déjà disponible au point d'appel.

public class VisitorQuestion
{
    public long Id { get; set; }
    public string ConversationId { get; set; }          // ← SEUL AJOUT au contrat d'API
    public string InstanceId { get; set; }              // déjà dans AiChatRequest
    public string ConfigurationId { get; set; }         // déjà
    public AppType AppType { get; set; }                // déjà — Web / Mobile / Kiosk / Voice
    public bool IsVoice { get; set; }                   // déjà
    public string Language { get; set; }                // déjà
    public string Question { get; set; }                // = request.Message
    public string Reply { get; set; }                   // = response.Reply
    public long TokensUsed { get; set; }                // déjà — response.TokensUsed
    public bool HasAnswer { get; set; }                 // ← produit par le retrieval RAG
    public double TopScore { get; set; }                // ← idem, seuil ajustable
    public List<string> CitedContentIds { get; set; }   // ← idem (JSONB)
    public string ThemeId { get; set; }                 // null jusqu'au job de regroupement
    public DateTime CreatedAt { get; set; }
}

Une ligne par tour de conversation, groupée par ConversationId — pas le History complet à chaque tour, sinon le tour 5 réécrit les tours 1 à 4.

On stocke aussi la réponse. Pour analyser une question restée sans réponse, il faut voir ce que le guide a effectivement répondu.

Le seul changement de contrat : ajouter ConversationId (GUID généré par le front, stable sur une session de conversation) à AiChatRequest. Aujourd'hui History est reconstruit côté client à chaque appel, donc rien ne relie deux questions d'un même visiteur côté serveur.

L'insertion se fait dans AiController.Chat, juste à côté du RecordUsage(instance, result.TokensUsed) existant.

Déjà tranché par le code, sans qu'on le sache

  • Guide par instance ou par configuration ? AiChatRequest.ConfigurationId porte déjà le commentaire « null = scope instance, fourni = scope configuration ». La question était déjà résolue.
  • Canal vocal : AppType.Voice existe déjà dans l'enum, et IsVoice fait déjà adapter le prompt par le backend (pas de markdown, pas de navigation, dates en toutes lettres).
    Correction du 2026-08-12 : la phrase « avec fallback sur Mobile si aucune ApplicationInstance dédiée » était fausse et figure ici depuis l'origine. Il n'y a aucun fallback : AiController:357-361 fait un FirstOrDefault sur le canal exactement demandé, puis Forbid(). Envoyer AppType.Voice au chat sans avoir créé une ApplicationInstance de ce type rend 403 à chaque question. Le commentaire d'AiController:356 répète la même erreur — les deux se confirmaient mutuellement. Décision prise dans v1-plan.md (lot F, ligne « canal Vocal ») : le chat reste en Mobile, seuls les VisitEvent portent Voice — l'endpoint de tracking, lui, ne contrôle aucun canal.

Ce qu'on affiche

Bloc Contenu Pourquoi ça vaut quelque chose
Volume N questions ce mois, évolution Preuve d'usage — utile face à un CA ou un subsidiant
Questions sans réponse Liste, les plus fréquentes en tête Le cœur. C'est un rapport de trous de contenu : la liste exacte de ce qu'il faut ajouter au CMS
Thèmes Regroupement des questions du mois « 18 % sur les horaires, 12 % sur l'accessibilité »
Langues des questions Répartition réelle Révèle l'écart entre les langues traduites et celles réellement utilisées — un lieu qui a payé 10 traductions pour 94 % de questions FR/NL a une décision à prendre
Sections les plus citées Contenus réellement mobilisés Quel contenu sert vraiment, par opposition à celui qui a coûté du temps à écrire

Le bloc « questions sans réponse » est celui à mettre en avant : il transforme le guide d'un gadget en outil de pilotage éditorial. Il alimente aussi naturellement le rapport PDF prévu dans plan-import-ia-stats-subsides.md.

Regroupement en thèmes

Job périodique qui regroupe les questions du mois via Gemini, pas de classification à la volée. Coût négligeable, et ça évite de faire porter à /ask un traitement dont le visiteur n'a que faire.

⚠️ RGPD — à cadrer avant d'implémenter. Une question de visiteur est du texte libre qui peut contenir des données personnelles, y compris sensibles (« je suis en fauteuil roulant, c'est accessible ? » = donnée de santé). Donc :

  • aucun identifiant visiteur au-delà d'un sessionId non nominatif, non relié à une personne ;
  • rétention courte sur les questions brutes (90 jours), les agrégats et thèmes étant conservés au-delà ;
  • purge automatique par job, pas « quand on y pensera » ;
  • mention explicite dans les CGU et la politique de confidentialité avant la mise en service.

Décisions produit

Les sources : visibles pour le client, pas pour le visiteur

Décision (2026-08-07) : le petit i qui révèle les sources n'apparaît que dans l'aperçu de manager-app. Côté visiteur, le guide répond en prose, point.

Le raisonnement est le bon : le visiteur se moque de savoir de quelle fiche vient la réponse — il veut la réponse. Celui qui a besoin de vérifier, c'est le client, quand il teste son guide ou quand il conteste une réponse.

À faire quand même : renvoyer les sources dans la réponse de /ask dans tous les cas. C'est gratuit, ça sert au débogage, et ça rend l'affichage visiteur activable plus tard sans toucher au backend. On ne les rend simplement pas dans visitapp.

Quand le guide ne trouve rien : un message, pas un booléen

Pas de bascule « répondre avec ses connaissances générales ».

Le client l'activerait — ça paraît plus serviable — et le guide inventerait alors des dates sur ses œuvres. L'erreur retomberait sur MyInfoMate, pas sur le modèle. Pour un lieu culturel, un guide qui invente est un défaut produit, pas une commodité.

Comportement fixe : le guide dit qu'il ne sait pas et renvoie vers l'accueil. Ce que le client personnalise, c'est le message, pas le comportement.

La serviabilité non factuelle (politesse, navigation dans l'app, orientation) se règle dans le prompt de personnalité — elle ne nécessite aucun accès à des connaissances externes.

Ce qui alimente le guide en V1

Tout ce qui est déjà visible par les visiteurs, et rien d'autre. Pas de case à cocher : un contenu CMS publié est par définition destiné au public.

⚠️ Corollaire à implémenter : le pipeline doit respecter IsActive. Une section désactivée ou non publiée ne doit pas être indexée, et la désactiver doit purger ses embeddings. Sinon le guide révèle du contenu que le client prépare — le même incident de confidentialité que le PDF interne, mais dès la V1.

Pourquoi IncludeInAiKnowledge n'apparaît pas encore

La colonne part avec la migration (schéma). Le contrôle dans l'UI n'apparaît qu'en V2, avec l'ingestion documentaire — parce qu'en V1 aucun fichier n'alimente le guide : la case ne gaterait rien.

Ce projet a déjà trois occurrences du même défaut — isHiddenInitially, meterZoneGPS, mapProvider en web : des réglages exposés au client sans aucun effet. Ne pas en ajouter un quatrième. Le contrôle apparaît le jour où il contrôle quelque chose.



Écran Statistiques — onglet « Vocal » (V1)

L'écran de statistiques segmente aujourd'hui par canal (web, mobile, kiosk). Ajouter un onglet « Vocal » au même endroit.

Le travail est plus petit qu'il n'y paraît : AppType.Voice existe déjà dans le modèle, et AiChatRequest.IsVoice distingue déjà les interactions vocales. À vérifier : que VisitEvent porte bien l'AppType, sinon c'est le seul ajout.

Ce que le client doit y voir :

  • Nombre de visiteurs ayant utilisé le guide vocal, et volume de questions
  • Ce qui a été réellement écouté : contenus déclenchés à la voix, POI atteints, durée d'écoute — l'équivalent vocal du « ce que le visiteur a consulté »
  • Part du vocal dans l'usage total, comparée aux autres canaux

À inclure dans l'export PDF (plan-import-ia-stats-subsides.md) au même titre que les autres canaux. C'est exactement le genre de chiffre qu'un lieu subventionné met dans son rapport d'activité — « X % de nos visiteurs ont utilisé le guide vocal » est un argument de renouvellement de subside.


V2 — guides multiples & guide thématique

Plusieurs guides, assignables à une section ou une configuration (guide thématique).

Reporté non pas par charge d'UI, mais parce que deux questions de conception ne sont pas tranchées :

  1. Qui répond depuis l'accueil, quand aucune section n'est ouverte ?
  2. Sur quel périmètre de contenu cherche un guide thématique — sa seule section, ou tout, avec un ton différent ?

Mal répondre en V1 donnerait un guide qui a l'air cassé. Le modèle en liste étant déjà en place, l'ajout ne coûtera rien de plus plus tard.


À faire côté commercial quand les lunettes sortent

Ajouter à myinfomate-landing, en langage client non technique :

  • « Un guide vocal qui connaît vos contenus » — le RAG expliqué sans le mot : le guide répond à partir de vos fiches, pas d'Internet.
  • « Mains libres avec les lunettes connectées » — le visiteur pose sa question à voix haute, la réponse arrive dans ses oreilles, le téléphone reste dans la poche.

⚠️ La landing affiche aujourd'hui req/mois pour le quota IA (translations.ts, clé features.reqPerMonth) alors que le backend compte en tokens. À corriger avant de reprendre les CGU, sinon les CGU héritent de l'erreur.