Second Brain documentation

ADR 0086 – Jalon 4 : capture assistée, entrées vocales et accueil « Maintenant »

Accepté (2026-10-17, Aurélien : « Parfait, le seuil et les capacités doivent être paramétrables en fichier config. Prépare le backlog pour l'implémentation et l'architecture »)

← Toutes les décisions

ADR 0086 – Jalon 4 : capture assistée, entrées vocales et accueil « Maintenant »

Statut : Accepté (2026-10-17, Aurélien : « Parfait, le seuil et les capacités doivent être paramétrables en fichier config. Prépare le backlog pour l’implémentation et l’architecture »)

Maquettes : canvas « Second Brain · Charte graphique » (https://claude.ai/artifact/Dobv3qYuxoAiSBAKSkjEdg), planches Capture rapide (Ctrl K), Inbox de tri par lots, Accueil · Maintenant (web) et Accueil · Maintenant (mobile).

Cet ADR fixe le cadre commun des trois fils du jalon 4. Chaque fil écrit ensuite ses propres ADR dans la plage qui lui est réservée (voir « Numérotation »).

Contexte

Aurélien veut deux points d’entrée légers au-dessus des vues denses, qui restent pour travailler :

  1. Capturer sans effort, y compris sur mobile (pas de @ # / à taper) et à la voix, avec un assistant qui comprend et classe, sans exposer les données (souveraineté). Exception acceptée : une capture vocale passée par Google Assistant / Google Nest, en entrée seulement.
  2. Lire en un coup d’œil, sur l’accueil web et mobile : ce que je dois faire aujourd’hui, ce qui serait bien d’expédier vite au vu des échéances et des estimations, ce qu’il faut garder dans un coin de la tête.

L’existant couvre déjà beaucoup : capture en une ligne à marques (ADR 0042, 0046), doublons et « Ajouter à… » (0059), Inbox et « Classer comme… » (0014, 0015), lots (0041, 0049), connecteurs et webhook (0047), dashboards et widgets (0038), charge du jour (0070), score d’urgence (0064), PWA et partage (0034), densité et gestes (0061).

Principes

  • L’assistant suggère, il ne décide pas. Toute valeur devinée est visible comme telle (« deviné ») et se corrige d’un geste. Rien n’est classé hors de l’Inbox sans une confiance au-dessus du seuil configuré ou un geste de l’utilisateur.
  • Local d’abord. Aucune donnée ne sort du serveur. Pas d’appel à un LLM ni à une API de transcription en ligne. Seule entrée externe : le connecteur Google Tasks, choisi explicitement par Aurélien.
  • Le domaine vit dans config/. Seuils, capacités, règles de devinette, blocs de l’accueil : tout se règle en YAML, sans code (exigence d’Aurélien du 2026-10-17). Les réglages techniques (URL d’un service, modèle, activation) vont dans application.yml / variables SB_*.
  • Tout est facultatif. Sans modèle de plongements ni service de transcription, l’application fonctionne comme aujourd’hui ; les boutons correspondants sont masqués.
  • Un seul déployable Java. L’assistant vit dans le backend Spring (pas de service Python à côté). Seule la transcription vocale est un service compose séparé, interne, sans port publié.

Décision

1. Accueil « Maintenant » (fil A)

L’accueil reste un dashboard déclaratif (ADR 0038). Nouveau dashboard config/dashboards/maintenant.yaml, default: true ; aujourdhui.yaml reste disponible (/dashboards/aujourdhui).

name: maintenant
label: Maintenant
icon: wb_sunny
default: true
layout: focus                     # nouveau : colonne principale + colonne latérale, une seule colonne sur mobile
capture: true                     # champ de capture dans l'en-tête du dashboard
widgets:
  - kind: view
    view: aujourdhui              # échéance ≤ aujourd'hui ou début = aujourd'hui, ouvertes ; retards fusionnés
    label: Aujourd'hui
    column: main
    limit: 12
    reschedule: tache.echeance
    sum: tache.estimation
    capacity: 360                 # minutes disponibles par jour (paramétrable)
    split: "tache.echeance < today"   # nouveau : part « en retard » de la barre de charge
  - kind: view
    view: a-expedier-vite         # le seuil (20 min) et l'horizon (3 j) sont dans le where de la vue
    label: À expédier vite
    column: side
    limit: 3
    sum: tache.estimation
    chain: true                   # nouveau : bouton « Enchaîner »
  - kind: digest                  # nouveau widget : plusieurs vues en une liste étiquetée
    label: Dans un coin de la tête
    column: side
    sources:
      - { view: echeance-semaine-hors-aujourdhui, tag: Cette semaine, limit: 2 }
      - { view: en-attente-a-relancer, tag: En attente, limit: 2 }
      - { view: projets-sans-suite, tag: Sans suite, limit: 2 }
      - { view: recemment-debloquees, tag: Débloqué, limit: 2 }
  - kind: links                   # nouveau widget : une ligne de puces avec compteur
    column: side
    items:
      - { inbox: true, label: Inbox }
      - { review: hebdomadaire, label: Revue hebdo }
      - { view: terminees, label: Terminées cette semaine, count: true }

Seuils et capacités paramétrables. La capacité du jour est la clé capacity du widget (minutes). Le seuil « À expédier vite » et son horizon sont dans config/views/a-expedier-vite.yaml :

where: "tache.estimation <= 20 && tache.echeance > today && tache.echeance <= today + 3d && tache.statut != 'fait' && tache.statut != 'abandonne' && tache.statut != 'plus-tard'"
sort: [tache.urgence desc, tache.estimation asc]

Le délai « en attente depuis plus de 5 jours » est dans le where de en-attente-a-relancer (updatedAt <= today - 5d). Changer un nombre se fait dans le YAML, pris en compte au rechargement de la configuration.

Nouveautés du moteur de dashboards, toutes validées au chargement comme le reste (méta-schéma, santé de la configuration) :

  • layout: focus et column: main | side ; capture: true.
  • split (exige sum et capacity) : condition du langage des vues ; le front découpe la barre en « en retard », « prévu », « libre ». La condition doit être évaluable sur les colonnes de la vue (champ en colonne).
  • chain: true : « Enchaîner » ouvre les lignes une à une dans un mode focus (titre, estimation, minuteur facultatif, « Fait » / « Passer »). Côté navigateur seulement ; « Fait » passe par l’action rapide existante.
  • Widget digest : 1 à 6 sources (vues connues), étiquette et limite par source ; une ligne n’apparaît qu’une fois (première source gagnante). Couleur de l’étiquette = couleur du type de nœud (charte C3), pas de couleur libre.
  • Widget links : puces vers l’Inbox (compteur), une revue, une vue (compteur facultatif). Le widget inbox reste disponible.

Mode allégé. Une route peut demander la coquille allégée (data: { shell: 'light' }) : arborescence et panneau de contexte repliés, barre d’icônes seule. Appliqué à l’accueil, à tout dashboard (/dashboards/:name) et à l’Inbox (amendé par l’ADR 0089). Les panneaux restent ouvrables à la main ; le choix n’est pas mémorisé pour ces pages.

Mobile. Une seule colonne dans l’ordre Aujourd’hui, À expédier vite, Dans un coin de la tête, Inbox. Barre de charge sous la date. Barre basse flottante « Capturer » (+ « Dicter » quand la transcription est active, fil C). Cibles de 44 px.

2. Assistant de capture (fil B)

Trois étages locaux, du moins cher au plus cher, chacun facultatif sauf le premier :

Étage Rôle Où Par défaut
1. Règles marques (existant) et devinettes sans marque : mots-clés, projet le plus proche, dates config/captures/*.yaml actif
1 bis. Apprentissage retient les corrections (mot → valeur de champ) table assist_hint actif
2. Plongements « ressemble à », projet proche par le sens, doublons proches ONNX Runtime dans la JVM inactif
3. LLM local reformulation, découpe en plusieurs tâches Ollama reporté

Étage 1 : devinettes déclarées. config/captures/tache.yaml gagne guess :

guess:
  minConfidence: 0.6          # en dessous, la valeur n'est pas proposée
  autoAccept: 0.85            # « Accepter les sûrs » de l'Inbox ne prend que les valeurs au-dessus
  rules:
    - { words: [appeler, téléphoner, rappeler], set: { tache.contexte: telephone } }
    - { words: [acheter, commander, racheter], set: { tache.contexte: courses } }
    - { words: [urgent, asap, "au plus vite"], set: { tache.priorite: haute } }
  project:
    nearest: true             # projet dont le titre partage le plus de mots significatifs
    minScore: 0.5
  • Contrôles au chargement : champs connus, valeurs admises, seuils entre 0 et 1.
  • L’aperçu (POST /api/capture/preview) renvoie pour chaque champ origin: typed | guessed | learned | similar et confidence. La création n’écrit que les valeurs typed et celles que l’utilisateur a acceptées (accepted: [champ…] dans POST /api/capture). Sans acceptation, une capture sans marque va dans l’Inbox avec ses suggestions gardées (node.inbox_suggestions JSONB).
  • « Projet introuvable » devient « Projet le plus proche : Rénovation chambre ? ».

Étage 1 bis : apprentissage des corrections. Quand un champ deviné est changé ou une valeur ajoutée à un élément venu de l’Inbox, on enregistre (mots significatifs du titre → champ = valeur). Comptage simple par mot (bayésien naïf), pas de modèle. Table assist_hint(word, field, value, hits, updated_at), effacement par « Oublier ce que l’assistant a appris » (page Données). Exportée avec le reste.

Étage 2 : plongements locaux. app.assistant.embeddings.enabled=false par défaut, app.assistant.embeddings.model=/data/models/multilingual-e5-small (fichier ONNX + tokenizer, téléchargé une fois par le script local, jamais au démarrage). Vecteurs dans node_embedding(node_id, model, vector real[], updated_at), calculés à l’écriture du titre ou du corps et rattrapés au démarrage (comme BodyIndexReconciler). Similarité cosinus en mémoire : suffisant au volume personnel, sans changer l’image PostgreSQL (pas de pgvector, la sauvegarde et le déploiement restent identiques). Usages : projet proche quand aucune règle ne répond, « Ressemble à » dans l’aperçu et sur la fiche, doublons proches.

Interface. Puces « deviné » (bord pointillé) et « saisi » (plein). Tab ou un toucher accepte ; un toucher sur une puce ouvre un sélecteur (projet, contexte, priorité, date) : sur mobile on ne tape jamais @ # +. Inbox : chaque élément affiche type et champs suggérés avec leur confiance, « Accepter », « Accepter les sûrs » (seuil autoAccept), par l’API des lots existante.

3. Entrées vocales et Google (fil C)

Dictée souveraine. Service compose transcription (image fedirz/faster-whisper-server ou équivalent whisper.cpp, modèle small, langue fr), profil compose voice, sans port publié. Le backend relaie : POST /api/capture/transcribe (audio webm/ogg/mp4, 2 min et 10 Mo au plus) → { text }. Réglages : app.transcription.url (SB_TRANSCRIPTION_URL), absent = fonction inactive, GET /api/capture/capabilities dit au front s’il affiche le micro. Front : bouton « Dicter » (MediaRecorder) qui remplit le champ de capture, puis l’aperçu normal. L’API vocale du navigateur et le clavier Google ne sont pas utilisés (ils envoient l’audio à Google).

Connecteur Google Tasks entrant. « Ok Google, ajoute … à ma liste » depuis un Nest arrive dans Google Tasks ; le backend relève une liste dédiée et capture chaque tâche.

# config/connectors/google-tasks.yaml
name: google-tasks
kind: google-tasks            # nouveau type de connecteur, en relève (le webhook reste pour les autres)
list: "Second Brain"
every: 2m
capture: true                 # chaque élément passe par la capture (règles, apprentissage) puis l'Inbox
after: complete               # complete | delete : ce que l'on fait de la tâche Google une fois importée
  • OAuth 2 installé : SB_GOOGLE_CLIENT_ID, SB_GOOGLE_CLIENT_SECRET, SB_GOOGLE_REFRESH_TOKEN (portée tasks seulement). Sans secret, le connecteur est inactif et la page Santé dit pourquoi. Un écran « Connecter Google Tasks » qui obtient le jeton est reporté : la première fois, le jeton s’obtient par une commande documentée.
  • Réutilise le cadre de l’ADR 0047 (état, événements sync.*, page Santé). Dédoublonnage par id Google dans l’aspect externe.
  • Google ne lit rien : aucune donnée ne repart vers Google, sauf « tâche cochée ».
  • À vérifier par Aurélien avant d’activer : la phrase dite au Nest crée bien un élément dans Google Tasks (la liste de courses de l’Assistant peut aller dans Keep, dont l’API n’est pas officielle et n’est pas retenue).

Numérotation et propriété des fichiers

Pour éviter les collisions entre fils travaillant en parallèle :

Fil ADR réservés Propriétaire de
A · Accueil Maintenant 0087 à 0089 config/dashboards/, nouvelles vues de l’accueil, features/dashboards, features/home, coquille (features/workspace, mode allégé, barre basse mobile)
B · Capture assistée et Inbox 0090 à 0093 config/captures/, Capture*.java, nouveau paquet assist, features/inbox, features/palette, composant de capture et puces
C · Voix et Google Tasks 0094 à 0096 ConnectorService et nouveaux connecteurs en relève, config/connectors/, endpoint de transcription, composant sb-dictate, compose (deploy/)
  • Migrations Flyway : A n’en prévoit pas ; B prend la prochaine version libre au moment de fusionner, C la suivante. En cas de collision au merge, renuméroter la sienne (jamais celle déjà sur main).
  • Points de contact : C intègre le bouton « Dicter » dans le champ de capture (B) et la barre basse (A) en dernier, après les fusions de A et B. B ajoute inbox_suggestions ; A ne fait qu’afficher le compteur de l’Inbox.

Alternatives écartées

  • LLM en ligne (classement, transcription) : contraire à la souveraineté.
  • Service Python pour l’assistant : un déployable de plus, une API interne à maintenir ; les besoins (règles, comptage, ONNX) tiennent dans la JVM.
  • pgvector : change l’image PostgreSQL, la sauvegarde et la restauration ; inutile au volume personnel.
  • Accueil codé en dur : contredit les dashboards déclaratifs et l’exigence « paramétrable en config ».
  • Google Keep : API non officielle.

Reporté

  • Étage 3 (LLM local Ollama) : reformulation, découpe d’une capture en plusieurs tâches.
  • Lecture vocale « mes tâches d’aujourd’hui » sur le Nest (Home Assistant → API → TTS) : plus tard, à la demande d’Aurélien.
  • Capacité par jour de la semaine ou tirée du calendrier (après la synchro CalDAV).
  • Écran de connexion OAuth Google dans l’application.