KOHAMI Docs
Introduction

Architecture

KOHAMI est un avatar vocal conversationnel pour seniors. Le système se compose de trois grandes briques : un back-end NestJS (apps/api) qui porte le métier et l'API REST, un runtime vocal Python (apps/voice-agent) qui anime Helena en temps réel, et des clients (tablette Android en mode kiosque, dashboard administrateur, page web de visio pour les proches).

L'avatar s'appelle Helena. C'est le nom porté par le LLM principal dans le system prompt et dans tous les artefacts runtime.

Où tourne KOHAMI, et pourquoi

Toute la plateforme tourne sur un serveur dédié Scaleway Dedibox (Paris DC2, Debian, RAID1), qui héberge les trois environnements (dev, staging, prod). Ce choix a remplacé, mi 2026, une première architecture en conteneurs serverless Scaleway. Trois raisons ont motivé la bascule :

  1. Le coût. Un pipeline vocal temps réel a besoin d'instances toujours chaudes (min-scale 1 partout). Facturé en serverless, ce profil revient beaucoup plus cher qu'un dédié qui porte les trois environnements à prix fixe.
  2. La latence et le contrôle. LiveKit (WebRTC) est sensible au réseau. L'auto-héberger sur la même machine que l'API et la base supprime des sauts réseau sur le chemin critique de la voix.
  3. La conformité. INV-T02 impose que rien ne sorte de l'UE. Une machine physique à Paris, dont on maîtrise chaque service, rend cette garantie vérifiable de bout en bout, inférences Mistral comprises (La Plateforme, région EU).

Le serveur est intégralement piloté en infrastructure as code : deux playbooks Ansible (infra/ansible) décrivent l'OS, le pare-feu, Docker, les trois piles applicatives et le cluster k3s. Aucune modification manuelle n'est faite sur la machine ; on rejoue le playbook, il converge. C'est ce qui rend le serveur reconstructible à l'identique en cas de perte.

Diagramme d'infrastructure

En dev, les applications (API, voice-agent, LiveKit, Inngest) tournent dans un cluster k3s mono-nœud ; staging et prod restent en Docker Compose en attendant leur bascule. La base PostgreSQL reste volontairement hors cluster (un conteneur par environnement, volumes sur le RAID) : les données ont un cycle de vie plus long que les workloads, et les sortir du cluster simplifie sauvegardes et migrations.

Pourquoi k3s plutôt que Compose partout ? Pour obtenir des déploiements déclaratifs (manifests kustomize versionnés dans infra/k8s), des rollouts avec health-check et un ingress unique (Traefik embarqué), tout en gardant une empreinte minimale sur un seul nœud. La frontière des responsabilités est nette : Ansible pose la plateforme (namespaces, secrets issus du vault, accès base), la CI ne fait qu'appliquer les manifests applicatifs. La CI ne détient donc aucun secret d'infrastructure.

Cartographie des modules

Le AppModule de apps/api importe les modules ci-dessous (ordre réel de app.module.ts). Trois modules transverses (Observability, Database, Auth) sont chargés en premier car tout le reste en dépend ; viennent ensuite les modules métier.

ModuleRôleAnatomie code
ObservabilityModulePorts métriques OTel (KohamiMetricsPort), recorders noop / OTelmodules/observability/
DatabaseModulePool Postgres, RLS par senior_id, helpers transactionnelsdatabase/
AuthModuleJWT custom, garde de rôles, RLS middleware (DIV-005)common/auth/
ConversationModuleOrchestrateur 3 flux, sessions, mémoire, humeur, crons proactifsmodules/conversation/ (DDD + CQRS)
SecurityModuleInactivité, escalade, idéation suicidaire, AVC (OFF V1)modules/security/ (DDD)
ProfileModuleProfil senior, consentements RGPD, devices, contacts urgencemodules/profile/ (hexagonal léger)
OnboardingModuleOnboarding adaptatif J1-J5, NLU slots, machine xstatemodules/onboarding/
AlertingModuleRedAlertOrchestrator, cascade aidants, protocole douleurmodules/alerting/
AssistanceModuleRappels, description d'image (vision), lettres, assistance administrativemodules/assistance/
EntertainmentModuleSuggestion d'activité, jeux cognitifs, contenu culturel, actualitésmodules/entertainment/
VideoModuleVisio senior-proches et aidants : salles LiveKit, jetons, quota journalier, cadence anti-harcèlementmodules/video/
EntitlementsModulePlans / quotas par senior (feature gating)modules/entitlements/
NotificationsModulePush, SMS aidants, throttling anti-spammodules/notifications/
EventsModuleBus d'événements interne, ponts vers Inngestmodules/events/
VoiceInternalModuleContrôleurs HTTP /v1/voice/internal/* consommés par le voice-agentmodules/voice/voice-internal.module.ts
AdministrationModuleDashboard admin, tickets, stats, export RGPD, audit logmodules/administration/ (CRUD)

Le runtime vocal ne vit pas dans apps/api : c'est un projet Python autonome, apps/voice-agent, construit sur le SDK LiveKit Agents. Il dialogue avec le back-end exclusivement par HTTP (routes internes /v1/voice/internal/*, protégées par un jeton partagé). Cette frontière nette a remplacé un ancien worker TypeScript embarqué dans apps/api : le SDK Python de LiveKit est la référence de l'écosystème (fonctionnalités et correctifs y arrivent en premier), et la séparation en dépôt de code distinct empêche toute fuite de dépendance entre le métier NestJS et la boucle temps réel.

Comment les modules s'emboîtent

Le découplage repose sur deux mécaniques :

  1. Ports & Adapters partout. Chaque dépendance externe (LLM, STT/TTS, mémoire, météo, SMS, push) est déclarée comme un port, une interface TypeScript dans contracts/ du module, et implémentée par un adapter dans infrastructure/adapters/. Les ports sont exposés via des Symbol NestJS, les providers concrets injectés par le module. La couche domain/ n'importe jamais un SDK externe. Conséquence directe : on remplace un stub par un adapter réel (Twilio, Pixtral) sans toucher la logique métier.

  2. Bus d'événements interne vers Inngest. Les modules ne s'appellent pas en direct sur les chemins asynchrones. Un module émet un événement de domaine (par exemple mood.score.captured, battery.low, alert.created) sur le bus (EventsModule), qui fait le pont vers un workflow durable Inngest. C'est ce qui permet aux crons proactifs (séquence matinale, anniversaires, détection d'inactivité) et à la cascade d'alerte de tourner sans coupler la session vocale au timing des effets de bord. Les workflows vivent dans inngest/ du module concerné et sont enregistrés une seule fois côté apps/api/src/inngest/.

Le point de jonction central est l'orchestrateur de conversation (modules/conversation/application/services/orchestrator.service.ts) : il collecte en parallèle le flux cache, le flux mémoire et le flux classifier, puis laisse le LLM principal composer la réponse. Tous les autres modules s'y branchent par effet de bord (le classifier peut déclencher l'alerting, l'extracteur mémoire écrit après le tour, l'humeur émet un événement).

Pipeline voix bout-en-bout

Le chemin temps réel « le senior parle, Helena répond » traverse le SFU LiveKit auto-hébergé, le runtime Python et l'API. Le détail est documenté dans le guide Architecture Helena ; vue condensée ici :

  1. Tablette vers LiveKit. Le client @livekit/react-native pousse l'audio micro au SFU. Le signaling passe en wss derrière le reverse-proxy, le média WebRTC circule en UDP direct vers le serveur. Le SFU dispatche la session au worker enregistré sous l'agent name configuré (LIVEKIT_AGENT_NAME).
  2. STT Voxtral. Le voice-agent transcrit l'audio en texte via Voxtral temps réel (Mistral La Plateforme).
  3. LLM principal. Helena compose la réponse et déclenche d'éventuels appels d'outils. Les outils sont des clients HTTP vers apps/api : la mémoire, l'agenda, la météo ou les alertes restent la propriété du back-end.
  4. TTS Voxtral. La réponse est synthétisée phrase par phrase (voix fr_marie_curious) et renvoyée au senior via le SFU.
  5. Lipsync avatar. En parallèle, le worker calcule les visèmes de chaque phrase (espeak-ng) et les publie sur le data-channel kohami.transcript (messages viseme.delta) ; l'avatar 3D anime la bouche en s'y synchronisant.

L'enveloppe de latence bout-en-bout est contrainte par INV-C07 (budget partagé STT + LLM + TTS) et surveillée en continu : le pipeline de déploiement refuse de passer au vert si le worker ne s'est pas enregistré auprès de LiveKit.

Politique de modèles Mistral

Tous les appels LLM de KOHAMI tournent sur Mistral Small (mistral-small-2603), pinné sur un snapshot daté (jamais *-latest, qui dérive silencieusement). Cette politique est tranchée par l'ADR llm-model-policy.md, révisée le 2026-06-09 : Large est interdit (directive budget JUWA) et Medium a été retiré au profit d'une ligne « tout Small » orientée latence et coût.

Ce choix assume une contrainte forte : un modèle plus petit se trompe plus souvent sur la sélection d'outils et la nuance. La réponse de KOHAMI n'est pas de monter en gamme mais de compenser par l'architecture : garde-fous déterministes autour du LLM (backstops de sécurité, classifieurs dédiés, contraintes de sortie), bancs comportementaux qui mesurent les trous réels du modèle, et prompts courts calibrés. Les cas où Small reste structurellement insuffisant sont chiffrés et documentés, pour instruire un éventuel arbitrage futur avec le client.

UsageModèle
Agent Helena (conversation, jeux, outils)mistral-small-2603
Sous-agents (classifier intent, humeur, extraction mémoire, NLU onboarding, vision)mistral-small-2603
STT temps réelvoxtral-mini realtime
TTSvoxtral-mini-tts-2603, voix fr_marie_curious

Clean Architecture, DDD sélectif, CQRS limité

Le back-end applique Clean Architecture et DDD de façon graduée selon la complexité du contexte. La règle invariante : la couche domain/ ne dépend d'aucune technologie externe.

ContexteComplexitéPatternPourquoi
ConversationÉlevéeDDD complet + CQRS structurelSessions, orchestrateur 3 flux, mémoire vectorielle, humeur, détresse : invariants riches, lectures et écritures aux contraintes très différentes
SécuritéÉlevéeDDD completEscalade multi-niveaux, idéation suicidaire, cascade aidants : logique métier dense, transitions critiques à protéger
ProfilModéréeHexagonal léger (domain/ application/ infrastructure/)Onboarding xstate, consentements granulaires : assez de logique pour justifier des ports, pas assez pour des aggregates lourds
AdministrationFaibleCRUD NestJS (controllers/ services/ repositories/)Dashboard, tickets, stats, export : pas de règle métier non triviale, DDD serait du surdimensionnement

Pourquoi CQRS seulement sur Conversation ? Parce que c'est le seul contexte où la séparation lecture / écriture paie réellement. Le hot path voix exige des lectures ultra-rapides et fortement dénormalisées (retrieval mémoire, contexte environnemental, cache de réponses), tandis que les écritures (extraction de faits, capture d'humeur, transitions de session) suivent une logique transactionnelle distincte et souvent asynchrone (après le tour). Découpler les deux côtés évite que les besoins de l'un contaminent le modèle de l'autre. Ailleurs, un même service lit et écrit sans tension : y ajouter CQRS n'achèterait que de la cérémonie.

La séparation CQRS de Conversation est structurelle (organisation de la couche application/ en use-cases, queries et services dédiés), pas une mise en œuvre du module @nestjs/cqrs (pas de CommandBus / QueryBus). La discipline est dans le découpage des responsabilités, pas dans un bus d'objets commande.

Composants tiers

ServiceUsage
Mistral La PlateformeVoxtral STT/TTS, Small (agent et sous-agents), Pixtral / visionapi.mistral.ai (région EU)
LiveKit (livekit-server)WebRTC SFU, signaling voix, data-channelsauto-hébergé sur la box
InngestWorkflows durables (alertes, rappels, cascade aidants, crons proactifs)auto-hébergé, un conteneur par environnement
Twilio Voice + SMSCascade aidants (appel et SMS)api.twilio.com
Zep GraphitiMémoire long terme (graphe), doublée d'une écriture pgvector synchronecloud Zep

ADRs clés

DécisionDocument
Politique de modèles Mistral (tout Small, Large et Medium interdits)llm-model-policy.md
Modèle de processus du runtime vocalvoice-agent-process-model.md
Auth custom NestJS (vs Auth0 / Clerk)div-005-auth-custom-nest.md
Memory provider v1 (pgVector, Zep)div-002-memory-provider-v1-pgvector.md
Visio sur LiveKit (le CDC citait Jitsi)divergence DIV-VIDEO-01, présentée à la recette
Distress / AVC OFF en V1distress-v1-neutralization.md

Invariants structurants

Cinq invariants façonnent l'architecture entière. La liste complète des 28 invariants est documentée dans Invariants.

  • INV-S01 : idéation suicidaire détectée, alerte rouge dispatchée en moins de 5 secondes. C'est ce qui justifie que le classifier puisse court-circuiter l'orchestrateur et déclencher directement RedAlertOrchestrator.
  • INV-S02 : aucun ticket silencieux hors N3, toute alerte génère un retour au senior et une trace aidants. Verrouillé en tests, jamais désactivable.
  • INV-C01 : aucun fait mémorisé sans consentement actif de la catégorie correspondante. Le filtre vit côté extracteur mémoire, qui rejette avant la persistence.
  • INV-P05 : RLS Postgres par senior_id, un senior n'accède qu'à ses propres données. Appliqué au niveau base, pas seulement applicatif.
  • INV-T02 : aucune donnée hors UE (serveur à Paris, Mistral région EU). Hard-locké au niveau infrastructure.