KOHAMI Docs
Guides

Distress

Les contextes Sécurité (apps/api/src/modules/security/) et Alerting (apps/api/src/modules/alerting/) orchestrent la détection et l'escalade des situations à risque pour le senior : idéation suicidaire, AVC suspecté, douleur intense, bouton d'alerte physique, inactivité prolongée, batterie critique.

Deux invariants gouvernent tout ce périmètre, quels que soient les flags d'activation :

  1. INV-S01, idéation suicidaire détectée, alerte rouge dispatchée en moins de 5 secondes.
  2. INV-S02, aucun ticket silencieux hors N3, toute alerte génère un retour vocal au senior et une trace dans alert_trail.

Statut V1 : détresse psychologique et AVC sont OFF

La détection sémantique de détresse psychologique (N1/N2/N3) et la suspicion d'AVC sont désactivées par défaut en V1, par feature flag (DISTRESS_CLASSIFIER_ENABLED=false, DISTRESS_DETECTION_ENABLED=false, AVC_DETECTION_ENABLED=false). Le code de production est entièrement câblé (scaffold prêt) mais aucun adapter Mistral n'est instancié, aucune cascade ne se déclenche sur ces chemins. La mise en sécurité réelle en V1 repose sur le bouton SOS (SOP §4.1) + le fallback 112/15 verbalisé par Helena.

Décision : DIV-017 (Grégory Fort, 21/04/2026), ADR distress-v1-neutralization.md

Les chemins actifs en V1 sont : le bouton d'alerte tablette, le protocole douleur (vocal, déclenché par mention de douleur), l'inactivité, la batterie critique. Tous convergent vers le même RedAlertOrchestrator et la même cascade aidants détaillée dans Alerts cascade.

Le pivot d'escalade : RedAlertOrchestrator

apps/api/src/modules/alerting/application/services/red-alert-orchestrator.service.ts est le point unique par lequel passe toute alerte rouge avant d'atteindre la cascade. Il expose deux entrées :

  • handle(context) : chemin piloté par le classifier. Exécute le SuicidalIdeationDetectorPort, et ne publie une Alert que si detected === true. Utilisé par le pipeline NLU chat où le texte n'a pas encore été classifié.
  • publishClassifiedAlert(alert) : chemin déjà classifié. Saute le détecteur et publie une Alert construite en amont (par exemple par le fork voice du protocole douleur). C'est cette méthode que le contrôleur interne du protocole douleur appelle pour severity = strong.

Les deux entrées appliquent la même garde INV-S01 : après publication, enforceBudget(elapsedMs) lève une InvariantS01BreachError si le délai dépasse INV_S01_BUDGET_MS = 5000. La mesure court de l'horloge startedMs (avant détection/publication) au retour du publisher.

Pourquoi un budget mesuré post-publication

La breach se déclenche après la publication réussie : l'alerte est toujours émise, l'exception sert d'observabilité (log + métrique), pas de garde-fou bloquant. Couper la publication parce que le budget est dépassé violerait INV-S02 (ticket silencieux). Le contrôleur douleur traite explicitement ce cas : si publishClassifiedAlert jette une InvariantS01BreachError, il considère la cascade comme armée (cascadeEmitted = true) et logue un error forensique, sans annuler la trace.

Déduplication in-memory

publishClassifiedAlert maintient une latch inflight: Map<string, Promise<void>> clé sur alert.id. Un retry de contrôleur qui republie le même alert.id attend la publication en cours au lieu de courir une seconde cascade en parallèle. L'entrée s'auto-évince dans le finally. C'est un cache de dédup per-call, exempté de la règle d'invalidation via le marqueur inline // no-invalidation-needed:.

Idéation suicidaire (N3)

Le port SuicidalIdeationDetectorPort (apps/api/src/modules/alerting/contracts/suicidal-ideation-detector.port.ts) reçoit le texte du tour et retourne { detected, confidence }. L'implémentation injectée par défaut est StubSuicidalIdeationDetector (scripté pour les tests). L'adapter de production MistralDistressClassifierAdapter n'est branché que lorsque DISTRESS_CLASSIFIER_ENABLED=true, donc jamais en V1.

Quand handle détecte un N3, il construit une Alert { kind: "distress", level: "red", payload.source: "suicidal_ideation" } et la publie via AlertPublisherPort, sous budget INV-S01. La cascade aidants prioritaire (N1 et N2 en parallèle) prend le relais, voir Alerts cascade.

Le classifier N3 (scaffold V2)

MistralDistressClassifierAdapter (apps/api/src/modules/conversation/infrastructure/adapters/mistral-distress-classifier.adapter.ts) produit un objet structuré Zod { level: "none" | "N1" | "N2" | "N3", confidence, triggers[] }. Le prompt système impose une sensibilité maximale sur N3 (en cas de doute N2/N3, choisir N3) tout en filtrant l'ironie et l'humour noir. La consigne de calibration : préférer baisser la confidence plutôt que d'inventer un niveau.

Modèle : Mistral Small, avec un point de vigilance V2

Le classifier N3 tourne sur mistral-small-2603 (snapshot pinné), piloté par LLM_DISTRESS_MODEL. La politique de modèles (ADR llm-model-policy.md, révision 2026-06-09) place tous les agents en Small ; Large reste interdit (directive budget JUWA, opposable de niveau CDC, qui prévaut sur SOP §7.8) et Medium l'a rejoint.

L'impact runtime aujourd'hui est nul puisque le flag est OFF (DIV-017). Le point de vigilance est acté dans l'ADR : avant toute activation V2, la sensibilité N3 devra être re-benchée en Small ; si elle s'avère insuffisante, le rollback vers mistral-medium-2604 se fait par simple variable d'environnement (LLM_DISTRESS_MODEL), sans changement de code. Un faux négatif d'idéation suicidaire étant potentiellement fatal (INV-S01), cette re-validation n'est pas optionnelle.

AVC suspecté

L'application service StrokeSuspicionOrchestrator (apps/api/src/modules/security/application/services/stroke-suspicion-orchestrator.service.ts) est consommé depuis le hook post-tour du voice agent. Son contrat est volontairement minimal :

  • detector.detect(input) retourne null → no-op.
  • detector.detect(input) retourne une détection → audit (StrokeAuditSink) puis dispatch cascade (StrokeCascadeDispatcher, via le workflow Inngest alert-avc-cascade-handler).

L'orchestrateur ne lit jamais le flag AVC_DETECTION_ENABLED. La fabrique DI de security.module.ts choisit l'adapter au boot : quand le flag est false (défaut V1), c'est StubStrokeDetector qui est injecté, et il retourne toujours null : le dispatcher n'est jamais appelé. Le conditionnel reste hors du hot path, centralisé dans la fabrique. C'est une application directe du pattern Strategy + sélection par configuration.

Détecteur de production (scaffold V2)

MistralNluStrokeDetector (apps/api/src/modules/security/infrastructure/adapters/mistral-nlu-stroke-detector.adapter.ts) combine trois critères indépendants, et ne signale une suspicion que si triggeredCriteria.length >= criteriaMinCount (défaut 2-sur-3, AVC_CRITERIA_MIN_COUNT) :

CritèreMécanismeConfidence
speech_confusedClassifier Mistral NLU sur le transcript (aphasie / dysarthrie)sortie LLM, seuil AVC_CONFIDENCE_THRESHOLD (0.8)
arm_weaknessRegex de verbalisations françaises (« mon bras est lourd / engourdi / paralysé »)1.0 (déterministe)
mood_palier_4plusComparaison du score humeur (échelle R-05 0-100) sous AVC_MOOD_PALIER_4PLUS_BOUNDARY (50)1.0 (déterministe)

Le détecteur est délibérément conservateur : échec ou timeout Mistral → speech_confused traité comme négatif, jamais comme positif (biais false-negative, principe médical « primum non nocere » tant que la validation médicale est en attente). Un retry transport unique reste sous INV-S01 (2 × 1500 ms = 3000 ms max).

Modèle : Mistral Small, même règle que le N3

Comme le classifier N3, le critère speech_confused tourne sur mistral-small-2603 (AVC_NLU_MODEL). Même vigilance : le flag est OFF en V1, et l'activation V2 exigera une re-validation de sensibilité, avec rollback Medium possible par variable d'environnement. Le breaker 1500 ms par critère reste largement sous l'enveloppe INV-S01.

Schéma événement, AVC suspecté

Référence ADR : avc-suspicion-event-contract.md

avc-suspicion-event.ts
import type { AvcSuspicionDetectedEvent } from "@kohami/shared";

const event: AvcSuspicionDetectedEvent = {
  topic: "kohami.alert.avc.suspicion.detected",
  occurredAt: "2026-05-19T14:30:12.345Z",
  payload: {
    seniorId: "uuid",
    triggeredCriteria: ["face_asymmetry", "speech_difficulty"],
    confidence: 0.82,
    rawSignals: { },
  },
};

Activation conditionnée à la validation médicale

Le flag AVC_DETECTION_ENABLED ne doit pas être basculé sans signature médicale de Grégory Fort + JUWA (point ouvert PO-AVC-VALIDATION-MEDICALE, SOP §8). Une mauvaise calibration des seuils porte une exposition médico-légale critique. La logique de scoring des critères est déclarative tant que ce gate n'est pas levé.

Protocole douleur (actif V1)

Lorsque Helena détecte une mention de douleur, elle conduit un protocole en 3 questions (zone, intensité 1-10, souhait d'alerter un aidant), validé par Grégory Fort + JUWA. La classification de sévérité est une fonction pure, classifyPainSeverity dans apps/api/src/modules/alerting/application/services/pain-classification.service.ts :

  • zone sévère (cardiaque / abdo / tête) + intensité haute (≥ 7) → strong
  • demande explicite d'alerter un aidant (wantsAlert=true) → strong (toujours)
  • intensité très haute (≥ 8) → strong (même hors zone sévère)
  • intensité faible (≤ 3) sans demande d'alerte → weak
  • sinon → moderate

Le booléen samuRecommended n'est vrai que si severity = strong et zone cardiaque (poitrine, cœur). C'est sur cette branche que Helena verbalise un « composez le 15 ».

Chemin runtime

Le fork voice (BridgedPainProtocol) appelle le contrôleur interne apps/api/src/modules/alerting/infrastructure/controllers/pain-protocol-internal.controller.ts, protégé par header X-Internal-Token (@Public() côté JWT). Deux routes :

  • POST /v1/voice/internal/pain-protocol/complete persiste l'audit du protocole (PainProtocolRepositoryPort) et publie PainProtocolCompletedEvent (PainProtocolPublisherPort). Best-effort : une panne repo/publisher n'interrompt pas la réponse vocale, mais émet un warn structuré pour préserver la trace INV-S02. Réponse 202 avec recorded / event_published.
  • POST /v1/voice/internal/pain-protocol/strong-alert : appelé quand severity = strong. Construit une Alert { kind: "distress", level: "red", payload.source: "pain_protocol" }, la route via redAlertOrchestrator.publishClassifiedAlert (donc sous budget INV-S01), puis seed alert_trails.cascade_started. Réponse 202 avec alert_id.

Le passage par l'orchestrateur (plutôt que AlertPublisherPort.publish direct) garantit que la garde INV-S01 s'applique aussi au chemin douleur. En cas d'échec transitoire de publication, le voice fork a déjà produit la réponse vocale (recommandation SAMU 15) et le bouton SOS V1 couvre toujours le senior.

import type { PainProtocolCompletedEvent, PainSeverity } from "@kohami/shared";

// PainSeverity ∈ "weak" | "moderate" | "strong"

Bouton d'alerte tablette (actif V1)

L'utilisateur peut presser un bouton physique ou virtuel à tout moment. La tablette publie sur le DataChannel :

{
  kind: "kohami.user.alert_button_pressed",
  pressedAt: "2026-05-19T14:30:12.345Z",
  context: "voluntary" | "fall_detected" | "extended_inactivity",
}

Le back-end ingère via ButtonPressIngress, crée une entrée alert_trail, publie l'event de retour au senior (AlertButtonAcknowledgedEvent), et dispatch la cascade aidants. C'est le chemin de mise en sécurité de référence en V1 (SOP §4.1). Cf. Alerts cascade.

Traçabilité

Toutes les alertes sont stockées dans alert_trail :

  • id (UUID), senior_id, kind (énum), severity (énum), occurred_at
  • cascade_state (pending, dispatched_n1, dispatched_n2, escalated, resolved)
  • resolved_at, resolved_by, resolution_notes

L'API REST GET /v1/alerts/trail/:seniorId (scope aidant, RLS) retourne l'historique paginé.

INV-S02 reste honoré sur tous les chemins, y compris ceux gardés par flag : le contrôleur douleur, l'orchestrateur AVC (StrokeAuditSink) et le log forensique de dispatchDistressAlert écrivent une trace même quand la cascade n'est pas émise.

Aller plus loin