Par Thales (CEO, ZeroSuite) & Claude Opus 4.7 — instance Claude Code
À 10:00 UTC le 20 mai 2026, Déblo avait des oreilles. Les utilisateurs pouvaient tenir un appel vocal avec Gemini Live native audio, parler dans l'une des sept grandes langues, et le modèle répondait dans la même langue à une latence conversationnelle humaine. À 22:00 UTC le même jour, Déblo avait aussi des yeux. Un utilisateur pouvait taper un seul bouton sur le dock pendant un appel en cours, la caméra arrière publiait une piste vidéo via LiveKit vers un worker Python, le worker consommait les frames à 0,5 frame par seconde, les poussait comme images RGBA vers la session Gemini Live, et le modèle décrivait ce qu'il voyait — un bulletin scolaire placé sous l'objectif, une page de contrat tenue en l'air, un compteur électrique, une facture détaillée — dans le même canal vocal que l'utilisateur avait déjà ouvert.
Nous appelons ce trio Voice + Eyes + Chat. Voice est audio uniquement. Eyes est voix plus caméra temps réel. Chat est texte plus uploads. La pièce architecturale qui a atterri aujourd'hui est celle du milieu. C'est aussi celle qui nous a amenés au plus près d'une panne production totale durant la semaine de lancement.
Voici le journal de construction de la Phase 14. Ce n'est pas un post marketing. Il y a eu neuf commits, deux faux départs, une panne de quatre-vingt-dix secondes qui a tué toutes les sessions vocales y compris celles qui n'utilisaient pas la caméra, et deux éléments de finition qui sont revenus FAIL au smoke test sur appareil et qui ont été reportés à des sessions dédiées plutôt que demi-corrigés sur place. Certaines des leçons les plus utiles concernent ce que nous n'avons pas livré.
Nous allons parcourir l'architecture, la validation Step 0 qui n'était pas suffisante, le hotfix qui l'a débloquée, les mathématiques d'échantillonnage de frames qui ont transformé un défaut à 1 fps en une constante optimisée à 0,5 fps, l'overlay d'aperçu caméra ajouté après le deuxième smoke du CEO et le bouton X-close rendu contextuel après le troisième, les deux bugs qui nous ont vaincus ce soir, et le feedback sur le system prompt qui méritait son propre follow-up dédié. À la fin, il y a une section sur ce que chacun de nous a bien fait et ce que chacun de nous n'a pas pu voir.
Partie 1 — Pourquoi le streaming caméra, et pourquoi maintenant
La raison pour laquelle livrer le streaming caméra temps réel n'était pas sur la feuille de route lancement il y a deux semaines. Elle est arrivée sur la feuille de route à cause d'un autre bug. En Phase 13 (19 mai, la veille de cette session) nous avions compacté les trois system prompts vocaux à ~6 kB chacun pour rester sous le seuil de dégradation Gemini Live native audio. Ce faisant, nous avons découvert un autre problème : quand un utilisateur annonce une photo (« regarde », « j'ai envoyé un truc »), il y a une fenêtre de 1 à 2 secondes avant que l'upload du data-channel se termine et que la frame arrive dans l'input du modèle. Pendant cette fenêtre, le modèle a un biais architectural fort à remplir le silence — les modèles vocaux temps réel produisent de l'audio à chaque tour utilisateur par construction, et cet audio tend à halluciner une description plausible de ce que l'utilisateur a dit qu'il envoyait, avant que le modèle n'ait rien vu.
Nous l'avons corrigé avec un bloc de prompt — VISUAL DISCIPLINE — et une garde côté worker qui intercepte les transcripts d'annonce visuelle, interrompt le modèle, et injecte un filler « loading » de 4 mots. Ça fonctionne pour le chemin photo-upload (Phase 5.B) et le chemin clip vidéo 5 secondes (Phase 5.F). Les deux sont des modes visuels à événement discret : l'utilisateur choisit explicitement d'envoyer une chose, le worker l'uploade, le modèle la voit une fois, le modèle la décrit.
Mais le cas d'usage dominant pour Déblo Eyes — une mère tenant le bulletin scolaire de son enfant en parlant au tuteur, un client tenant un contrat en parlant à Déblo Pro, un commerçant tenant une facture — est continu. L'utilisateur ne veut pas taper « envoyer photo » cinq fois pendant une conversation à propos d'un document multi-pages. Il veut pointer le téléphone vers le document, faire défiler les pages naturellement, et que l'IA suive.
L'intuition architecturale est que le streaming continu élimine structurellement la fenêtre d'hallucination visuelle. Si une frame arrive toutes les 500 à 1000 millisecondes, le modèle n'a jamais à remplir un gap de 2 secondes. La frame est toujours fraîche. Le chemin « j'annonce, tu hallucines » n'existe simplement pas pour ce mode.
Donc la Phase 14 était à la fois une fonctionnalité (une capacité produit majeure que nous voulions pour le lancement) et un correctif (la résolution structurelle la plus propre d'une classe de bugs que nous venions de patcher au niveau du prompt). La double motivation est ce qui l'a placée sur la liste critique du 20 mai.
Partie 2 — L'architecture que nous voulions
L'image complète, en un paragraphe : une application React Native sous Expo SDK 54 publie une piste vidéo via LiveKit quand l'utilisateur tape le bouton caméra sur le dock vocal. La piste arrive au worker Python tournant sur Easypanel comme un RemoteVideoTrack, capté par le listener track_subscribed sur la room LiveKit. Une tâche asyncio imbriquée par track sid consomme rtc.VideoStream(track) comme itérateur async, throttle à une frame toutes les deux secondes (nous y reviendrons), convertit chaque frame en image PIL RGBA, la thumbnail à 768 px sur le côté long, et appelle session._activity.push_video(frame) sur la session Gemini Live. Toutes les deux frames, il appelle aussi session.generate_reply(instructions=...) avec une courte directive en anglais qui pousse le modèle à narrer seulement si la scène a changé de manière significative. Un hard cap de 5 minutes et un auto-off après 3 minutes de silence préviennent les sessions emballées. Quand le bridge se termine pour quelque raison que ce soit — toggle utilisateur, durée max, silence, erreur — le worker publie un événement camera_status sur le data channel LiveKit que le client mappe vers une bannière toast localisée.
Le seul risque architectural que nous avions identifié en entrant était session_resumption(transparent=True). Les sessions Gemini Live native audio ont un cap serveur par défaut de 2 minutes. Pour un appel de type tutoriel où la mère parcourt un bulletin de 4 pages, 2 minutes est une limite hostile. Vertex AI expose SessionResumptionConfig(transparent=True) pour lever le cap silencieusement — le SDK refait transparentement le handshake sous le capot quand le serveur fermerait sinon la connexion.
Nous ne savions pas avec certitude que le client realtime livekit-plugins-google 1.5.9 honorait cette config de bout en bout. Les docs du plugin mentionnaient le paramètre ; l'API Vertex upstream documentait le comportement ; personne que nous pouvions trouver n'avait publié de confirmation qu'une vraie session Python avec le paramètre activé restait effectivement up au-delà de 7 minutes en production. La Phase 14 en dépendait : sans la resumption, tout le bridge tomberait à 2 minutes peu importe la qualité de notre code par-dessus.
Donc nous avons planifié un Step 0. Le plan était de valider que la primitive architecturale fonctionnait du tout, avant d'écrire la moindre ligne du code de bridge qui en dépendait.
Partie 3 — Le Step 0 qui n'était pas suffisant
Le Step 0 que nous avons exécuté est documenté dans session-logs/gemini-session-logs/26-05-20-phase-14-step0-resumption-validation.md. L'objectif était de confirmer trois choses, dans l'ordre : que livekit-plugins-google expose le keyword argument session_resumption sur RealtimeModel.__init__, que le plugin accepte une valeur google.genai.types.SessionResumptionConfig(transparent=True) sans lever d'exception, et que le modèle configuré peut être instancié proprement dans une session Python locale.
Le script faisait quarante lignes. Il importait livekit.plugins.google.beta.realtime as livekit_google_realtime, instanciait un RealtimeModel avec session_resumption=genai_types.SessionResumptionConfig(transparent=True), affichait le dict _opts du modèle résultant pour confirmer que la config était stockée, et sortait avec code zéro. Il a tourné proprement. Le constructeur a accepté le kwarg, la config a été stockée dans _opts.session_resumption, et l'instance RealtimeModel était valide.
Nous avons marqué le Step 0 comme GO.
Nous nous trompions sur ce que GO signifiait.
Le Step 0 avait validé le constructeur. Il n'avait pas validé le chemin d'exécution runtime. Le constructeur stocke l'objet d'option ; il n'entre jamais dans la state machine au niveau session du plugin. La state machine au niveau session du plugin est ce qui s'exécute quand un vrai appel LiveKit arrive, et c'est dans ce chemin que le plugin lit _opts.session_resumption.handle. Si _opts.session_resumption est None — ce qui est exactement ce qui arrive quand on passe None explicitement versus omettre le kwarg entièrement — le runtime tombe sur NoneType.handle et crashe tout le pipeline de session avant qu'une seule frame audio ne soit traitée.
Nous n'avons pas découvert ceci en lisant le source du plugin. Nous l'avons découvert dans les logs production quatre-vingt-dix secondes après qu'Easypanel ait fini de reconstruire le container worker.
Partie 4 — Le hotfix production-down
Le commit 785040d est sorti à 19:42 UTC. Le commit ajoutait le bridge worker (~500 lignes), la configuration session_resumption gardée derrière une nouvelle variable d'environnement DEBLO_VIDEO_BRIDGE_ENABLED, les listeners track-subscribed et track-unsubscribed, le pipeline de conversion de frames, et la télémétrie de fin de session. La variable d'environnement n'était pas définie en production, ce qui devait selon nous signifier la fonctionnalité bridge est désactivée et rien ne change.
Ce n'est pas ce que cela signifiait.
Le chemin de code pertinent ressemblait à ceci :
pythonrealtime_model_kwargs = {
"model": settings.GEMINI_LIVE_MODEL,
"instructions": system_prompt,
"voice": settings.GEMINI_LIVE_VOICE,
"language": user_lang,
"session_resumption": (
genai_types.SessionResumptionConfig(transparent=True)
if VIDEO_BRIDGE_ENABLED
else None
),
}
model = livekit_google_realtime.RealtimeModel(**realtime_model_kwargs)Quand VIDEO_BRIDGE_ENABLED valait False, le kwarg était passé comme None. Le constructeur acceptait None sans broncher (il stocke l'option telle quelle). Mais la state machine session, quand une vraie room LiveKit se connectait et tentait de démarrer le streaming, exécutait quelque chose d'équivalent à handle = self._opts.session_resumption.handle — et il n'y a pas de garde None en amont. Le traceback était :
AttributeError: 'NoneType' object has no attribute 'handle'
File ".../livekit/plugins/google/realtime/realtime_api.py", line 493, in __init__
handle = self._opts.session_resumption.handleChaque session vocale tentée sur le worker après la reconstruction crashait à la ligne 493. Les sessions audio uniquement, qui fonctionnaient parfaitement en production depuis quatre jours, étaient maintenant mortes. La fonctionnalité bridge était désactivée, mais le chemin pour la désactiver était une mine.
Le CEO l'a remarqué en environ quatre-vingt-dix secondes. Il a essayé de démarrer un appel audio uniquement, ça a échoué silencieusement du point de vue client, il a ouvert les logs Easypanel, vu le stack trace, l'a copié dans la session, et m'a pingé :
« le worker crash sur toutes les sessions, regarde le log ; je vois 'NoneType has no attribute handle' sur la 493 du plugin google ; ça n'a aucun rapport avec le bridge censé être OFF ? »
Ça avait tout rapport. Le Step 0 que nous avions passé nous avait dit que l'objet de configuration était acceptable. Il ne nous avait pas dit que la branche runtime était acceptable. La branche runtime déréférençait un attribut sur ce que nous passions, et nous avions passé None, et None n'a pas d'attributs.
Le correctif a pris trois minutes à écrire et quatre minutes à déployer.
pythonrealtime_model_kwargs = {
"model": settings.GEMINI_LIVE_MODEL,
"instructions": system_prompt,
"voice": settings.GEMINI_LIVE_VOICE,
"language": user_lang,
}
if VIDEO_BRIDGE_ENABLED:
realtime_model_kwargs["session_resumption"] = (
genai_types.SessionResumptionConfig(transparent=True)
)
model = livekit_google_realtime.RealtimeModel(**realtime_model_kwargs)Passer le kwarg conditionnellement, jamais comme None. Quand le bridge est activé, le SessionResumptionConfig est fourni ; quand il est désactivé, le kwarg est omis entièrement et le plugin utilise son chemin handle-par-défaut qui ne crashe pas. Commit 315280e. Reconstruction Easypanel. Le CEO a retesté un appel audio uniquement : PASS. La fonctionnalité bridge est restée off en production jusqu'à ce que le reste de la Phase 14 soit prêt à être livré. Fenêtre de panne totale : environ quatre minutes entre le premier crash et la récupération confirmée.
Nous avons eu de la chance. L'audio uniquement est de loin la session vocale la plus courante ; si le CEO n'avait pas été en train de tester pendant la fenêtre de reconstruction, la panne aurait pu s'étendre à dix ou vingt minutes avant que quelqu'un ne le remarque. Nous avons aussi eu de la chance que le mode de défaillance soit un AttributeError propre avec un stack trace utile pointant vers le source du plugin lui-même. Un mode de défaillance qui aurait échoué silencieusement — disons, une session qui se connecte mais ne produit pas d'audio — aurait été substantiellement plus difficile à diagnostiquer.
La leçon est l'évidente avec un raffinement important : un Step 0 doit exercer le chemin d'exécution runtime complet, pas juste le constructeur. Instancier un objet et afficher son _opts n'est pas la même chose que démarrer une session contre le vrai backend. Pour les étapes de validation SDK à venir, notre défaut est maintenant : démarrer une vraie session, envoyer une vraie frame de test, observer le vrai retour. La vérification au niveau constructeur est au mieux 20 % du travail.
Ceci est maintenant sauvegardé dans notre mémoire d'agent comme feedback_step_zero_runtime_validation.md. C'était une erreur coûteuse mais une entrée de mémoire bon marché. La prochaine fois que nous ajoutons un nouveau plugin SDK ou que nous montons en version majeure, la leçon se déclenche automatiquement.
Partie 5 — Pourquoi 0,5 fps bat 1 fps
Après que le bridge ait été câblé et que l'audio uniquement ait été restauré, nous sommes passés au tuning. La configuration initiale du bridge était de 1 frame par seconde, dimension de frame maximale 640 px. C'est le défaut évident — il correspond au rythme auquel un humain peut analyser visuellement une scène, et 640 px est la dimension à laquelle tournent la plupart des apps de démo de modèles vision-langage.
Le CEO a poussé contre les deux chiffres en une heure. Le raisonnement, travaillé au tableau de cuisine avec une arithmétique de serviette de table :
Baseline 1.0 fps × 640 px × ~85 tokens par frame
= 5 100 tokens par minute d'input caméra
= 25 500 tokens au hard cap de 5 minutes
Optimisé 0.5 fps × 768 px × ~122 tokens par frame
= 3 660 tokens par minute d'input caméra
= 18 300 tokens au hard cap de 5 minutesMoins coûteux, et crucialement, des frames plus nettes. La partie non évidente est que les frames à 768 px ne sont pas juste « marginalement meilleures » ; elles franchissent un seuil perceptuel pour les modèles vision-langage sur des documents text-heavy. À 640 px, une colonne d'un bulletin scolaire est lisible seulement pour les en-têtes et le gros texte de corps. À 768 px, les notes individuelles et les initiales des enseignants deviennent récupérables. Le cas d'usage que nous visons — mère et bulletin, client et contrat, commerçant et facture — est presque entièrement du texte sur papier. La netteté des frames sur le texte importe plus que la fréquence des frames.
L'observation plus profonde concerne le comportement des modèles vision-langage sous échantillonnage sparse versus dense. L'intuition de beaucoup d'ingénieurs est « plus de frames c'est plus d'information ». Pour des scènes à fort mouvement (un sujet en mouvement, un clip sportif), c'est vrai. Pour des scènes statiques (un document tenu en l'air, un produit statique, un tableau blanc), c'est l'inverse : l'échantillonnage dense pousse la même image quasi-identique dans la fenêtre de contexte du modèle dix fois en dix secondes, diluant l'attention sans ajouter d'information. Le contexte effectif du modèle est gaspillé sur la redondance. L'échantillonnage sparse à plus haute résolution donne au modèle un bon regard sur une scène qui change lentement, puis le temps d'intégrer avant le prochain regard.
Notre trade-off accepté : la latence perçue par l'utilisateur entre « je suis passé à la page suivante » et « le modèle voit la nouvelle page » a doublé de une seconde à deux. Pour un parcours de document au rythme conversationnel, c'est invisible. Pour un clip sportif ce serait pénible — mais la revue de clip sportif n'est pas le cas d'usage de Déblo Eyes. La Phase 5.F (le chemin clip vidéo discret de 5 secondes) gère les courtes vidéos à fort mouvement avec les 150 frames batchées, et reste le bon outil pour ce travail.
Le commit 5cf7a75 a livré 0,5 fps + 768 px. Le code_version du worker bridge a été incrémenté à phase-14-video-bridge-sparse-0.5fps-2026-05-20 pour pouvoir corréler les événements Sentry à la génération de tuning si quelque chose régressait.
La leçon plus large, sur le choix des paramètres pour les nouvelles fonctionnalités d'intégration ML, est faire correspondre les caractéristiques d'échantillonnage à la dynamique de scène, pas aux valeurs par défaut. Le défaut pour « caméra temps réel » dans la plupart des exemples SDK est 1 fps parce que c'est ce qui correspond à la moyenne. Nous ne tournons pas sur la moyenne ; nous tournons sur un cas d'usage spécifique avec des propriétés de dynamique de scène spécifiques, et le bon nombre pour nous est la moitié du défaut.
Partie 6 — Deux pièces de finition UX, et pourquoi elles comptaient
Le smoke #2 est revenu honnête : la caméra s'allumait, le worker recevait des frames, le modèle décrivait ce qu'il voyait — et l'utilisateur n'avait aucune indication visuelle que tout cela se passait. L'écran du téléphone montrait la même sphère orange et le même waveform UI qu'en mode audio uniquement. Le premier feedback du CEO était une seule ligne : « il n'y a aucun viseur, on dirait que la caméra n'est pas allumée du tout ».
Il avait raison, et l'omission était révélatrice. Nous avions construit le bridge technique mais oublié que le modèle mental de l'utilisateur pour « caméra allumée » est le viseur de caméra, visible, plein écran. Toutes les applications caméra grand public depuis 2007 entraînent cette attente. La passer parce que « c'est un appel vocal, pas une app caméra » est un mauvais raisonnement — l'utilisateur a toggle la caméra, l'utilisateur s'attend à voir ce que la caméra voit, point final.
Le commit 202511a a ajouté l'overlay d'aperçu caméra. L'implémentation mobile utilise VideoView de @livekit/react-native rendu en plein écran derrière l'UI vocale existante, avec un scrim sombre à 26 % par-dessus pour garder la sphère orange et le transcript lisibles. La parité web utilise un élément HTML5 <video> avec track.attach(videoEl) et le même scrim. Un bouton flip-camera flotte en haut à droite sous la barre supérieure existante. Le layering CSS a pris une soirée — position: absolute, inset: 0, empilement z-index soigneux pour que l'aperçu soit sous les contrôles mais au-dessus du fond dégradé.
Le facing caméra par défaut est maintenant environment (caméra arrière). L'implémentation originale avait par défaut user (caméra avant) parce que c'est ce que setCameraEnabled(true) retourne sur la plupart des appareils sans contraintes explicites. Mais le cas d'usage dominant pour Déblo Eyes est filmer quelque chose d'externe : un document, un compteur, un produit. La caméra avant par défaut aurait signifié que la première chose que les utilisateurs voient c'est eux-mêmes, ce qui à la fois embrouille le cas d'usage et est socialement gênant pour beaucoup d'utilisateurs qui ne veulent pas se regarder en parlant à une IA.
Le smoke #3 a fait remonter la deuxième pièce de feedback UX : le bouton X en haut à gauche de l'écran vocal. À l'ère audio uniquement, taper X signifiait « raccrocher l'appel ». Avec la caméra live, l'intuition du CEO (tirée de l'utilisation de l'app Google Gemini) était que taper X devait signifier « fermer la caméra, garder l'appel ». C'est le bon comportement. Le X est, dans l'esprit de l'utilisateur, en train de fermer quelle que soit la pièce modale qu'il a ouverte en dernier. Si la caméra est ouverte, X ferme la caméra. Si seulement l'audio est ouvert, X ferme l'appel.
Le commit 15241f8 a rendu le X contextuel. Le handleClose mobile vérifie l'état caméra et route vers soit toggleCamera(false) soit le handler raccrocher original. Le handleTopCloseClick web fait pareil. Même changement conceptuel d'une ligne, trois ou quatre lignes de code par plateforme.
Ces deux pièces — l'overlay d'aperçu et le X contextuel — sont le genre de chose qui n'apparaît dans aucune liste de tâches avant le smoke test. L'implémentation technique du bridge était correcte ; l'intégration UX du bridge dans la surface vocale existante ne l'était pas. Les smoke tests avec de vrais utilisateurs sur de vrais appareils sont le seul chemin pour découvrir cette classe de gap. Relire le document d'exigences une fois de plus ne l'aurait pas trouvée. Pousser le build sur un vrai téléphone et regarder un vrai humain l'utiliser, oui.
Partie 7 — Deux bugs que nous n'avons pas battus ce soir
Le smoke test a aussi été honnête sur deux choses que nous n'avons pas résolues dans cette session : le bouton flip-camera, et les chips de transcript en streaming.
Flip camera (BUG 1). Le bouton s'affiche, le tap se déclenche, un bref flash visuel se produit à l'écran, et la caméra ne change pas réellement de arrière à avant. La console montre un warning d'event-target-shim :
WARN An event listener wasn't added because it has been added already
setMediaStreamTrack (livekit-client.umd.js:1:258098)L'implémentation utilise LocalVideoTrack.restartTrack({ facingMode: 'user' }), qui est le chemin documenté pour ré-acquérir getUserMedia avec de nouvelles contraintes sur une publication existante. Sur Chrome web ce pattern fonctionne proprement. Sur React Native (utilisant react-native-webrtc sous le SDK LiveKit RN), le MediaStreamTrack sous-jacent ne semble pas honorer la nouvelle contrainte facingMode quand redémarré sur la même publication. Le fallback que nous avons essayé — désactiver la caméra, réactiver avec { facingMode, deviceId: undefined } explicite — a le même résultat sur RN.
La cause racine probable est que RN-WebRTC, quand il redémarre une track, pioche le même handle d'appareil sous-jacent depuis son cache interne plutôt que de ré-exécuter l'énumération d'appareils avec la nouvelle contrainte. La corriger proprement nécessite d'énumérer les caméras via mediaDevices.enumerateDevices(), trouver l'appareil dont le label matche /back/i versus /front/i, et appeler restartTrack({ deviceId: targetDevice.deviceId }) avec l'ID explicite plutôt qu'une contrainte facingMode. Nous ne l'avons pas implémenté encore parce que ça nécessite un petit peu de code spécifique à la plateforme et nous voulons valider le pattern sur une session d'agent fraîche plutôt que le coller à la fin de celle-ci.
Chips de transcript en streaming (BUG 2). L'UX visée est à la YouTube-Live : pendant que la caméra est active, les cinq dernières interventions utilisateur-et-IA défilent vers le haut comme de petits chips codés par rôle en bas de l'écran, donnant à l'utilisateur une ancre textuelle pour la conversation pendant que le canevas visuel est dominé par l'aperçu caméra. Le code a été ajouté dans le commit 15241f8 — un store dérivé streamTranscriptEntries, un ScrollView avec auto-scroll sur les nouvelles entrées, un styling basé rôle — mais les chips ne se rendent pas à l'écran pendant un appel caméra live.
Les causes probables sont trois, par ordre décroissant de vraisemblance : le filtre isFinal sur les entrées de transcript pourrait filtrer tout parce que les objets de transcript sous-jacents de Gemini Live arrivent sans flag isFinal réglé de la manière que le code attend ; la View cameraPreviewLayer pourrait avoir un z-index effectif plus haut que la streamTranscriptOverlay à cause des règles de contexte d'empilement de React Native qui sont subtilement différentes du web ; ou le layout flex du conteneur d'écran parent pourrait collapser la zone de transcript à une hauteur nulle quand la sphère est cachée. Chacun est testable ; aucun n'était testable à bon coût dans la fenêtre de temps que nous avions ce soir.
Les deux sont de vrais bugs et les deux sont documentés avec des chemins de débogage spécifiques dans session-logs/upcoming-prompts/28-phase-14-mobile-polish-and-homepage-3-buttons.md. La session qui reprendra ça n'a pas besoin de partir de zéro. L'espace d'hypothèses a été réduit à un ensemble traitable de choses spécifiques à tester.
La discipline ici est reporter proprement, documenter précisément. Demi-corriger un bug difficile à la queue d'un sprint de lancement, dans la même session qui a posé une fonctionnalité majeure, c'est comme ça qu'on livre un bouton flip-camera qui à peu près marche le mardi et casse à nouveau le mercredi. Les deux bugs sont documentés, les composants qui marchent autour d'eux sont stables, et la session se termine dans un état où le bridge caméra peut être livré en production avec les pièces flip-et-transcript explicitement marquées comme finition.
Partie 8 — Le system prompt était trop conservateur
La dernière pièce de feedback honnête de smoke est une que nous n'avons pas patchée ce soir, par principe. Le test d'acceptation pour la Phase 14 était un cas d'usage réel : le CEO a tenu une brochure d'école devant la caméra, demandé à Déblo Eyes de lire le numéro de téléphone de contact, puis trois minutes plus tard re-demandé pour tester la mémoire de session du modèle. Le modèle a passé les deux : il a lu le numéro correctement la première fois, et l'a re-confirmé correctement trois minutes plus tard. La session resumption fonctionnait. La mémoire fonctionnait. La vision fonctionnait.
Mais le feedback qualitatif du CEO était que le modèle était trop réservé. Il répondait à la question littérale, n'élaborait pas, ne signalait pas proactivement de détails liés sur la brochure (l'adresse de l'école, les heures d'ouverture, les langues d'enseignement), ne posait pas de questions de clarification sur ce que l'utilisateur pourrait vouloir ensuite. En termes d'IA conversationnelle, c'était passif. En termes produit, ça laissait de l'engagement sur la table — les utilisateurs ne reviennent pas vers des produits IA qui répondent à une question et se taisent.
Les mots spécifiques du CEO : « il parle pas, retient trop d'info, ne détaille pas, faut poser bcp de questions, réponses trop courtes ; risque rétention utilisateur ».
C'est un problème de system prompt, pas un problème de bridge. La réécriture ultra-compacte de prompt de Phase 13 (19 mai, la veille de cette session) avait explicitement capé la longueur de réponse à « max 2 short clear sentences per turn (more only when the question demands) ». Ce cap est le bon cap pour une conversation audio uniquement décontractée — il empêche le modèle de tartiner à chaque « Salut ». C'est le mauvais cap pour le mode caméra, où l'utilisateur présente activement un artefact visuel multi-détail et bénéficie de ce que le modèle aille légèrement au-delà de la question littérale.
La mauvaise façon d'aborder ceci aurait été d'éditer le prompt en place pendant cette session, une demi-heure avant que l'agent ne rende la main au CEO, sans le temps de valider que le nouveau prompt ne régressait pas sur les cas de conversation décontractée. Les prompts à cette longueur sont enchevêtrés — un changement peut déplacer le comportement à travers les registres, les langues, et les types d'utilisateurs de manière imprévisible. La bonne façon est de la déléguer à une session dédiée qui peut itérer soigneusement et valider à travers la matrice voice+text, K12+Pro+Companion, et camera-on+off.
Cette session est en file à session-logs/upcoming-prompts/29-system-prompt-optimization-conservativeness.md. Le brief inclut le feedback spécifique, les contraintes (préserver le bloc VISUAL DISCIPLINE de la Phase 13.B, préserver le bloc LIVE CAMERA MODE de la Phase 14), et la matrice de validation.
Le principe général : un feedback de smoke test qui est structurel — le modèle est trop conservateur, le modèle est trop verbeux, le modèle se trompe sur un registre — appartient à sa propre session. Ce n'est pas une correction de bug à la fin de la session de fonctionnalité. La tentation de « juste tweaker le prompt pendant que j'y suis » est l'équivalent prompt-engineering de « juste refactorer pendant que je corrige le bug ». Les deux produisent des régressions que tu trouves la semaine suivante.
Partie 9 — Ce que chacun de nous a bien fait
C'est Claude Code qui écrit.
Où j'ai été utile dans cette session :
- Hot-fixer la régression Step 0 en trois minutes depuis le stack production jusqu'au correctif déployé. Une fois que le CEO a copié la trace
AttributeError: 'NoneType' has no attribute 'handle'dans la session, le diagnostic était instantané : le chemin kwarg-is-None était le seul que j'avais introduit qui touchait au code session-resumption du plugin. Le correctif kwarg conditionnel est le changement à surface minimale. Le pousser sans essayer d'« améliorer » le code environnant sous pression de panne était la bonne discipline. - Commit-and-push en parallèle tout du long. Chacun des huit commits de fonctionnalité (worker bridge, hotfix, mobile, web, system prompts, FPS tuning, toasts, aperçu caméra, flip+X) a été commité et poussé indépendamment plutôt que batché. Le workflow six-terminals du CEO dépend de ce que
git pullsoit toujours un moyen fiable d'obtenir l'état actuel voulu. Batcher les commits m'aurait fait gagner peut-être dix minutes de tapage et lui aurait coûté des heures de confusion d'arbre périmé au prochain checkpoint. - Écrire les deux fichiers upcoming-prompt pour les sessions 28 et 29 avant de clore cette session. Les deux fichiers sont autonomes, nomment précisément les scénarios qui échouent, suggèrent des chemins de débogage spécifiques, et contraignent le scope pour que le prochain agent ne dépasse pas. Les cinq minutes pour écrire ces fichiers sont la différence entre reporté-et-récupérable et reporté-et-perdu.
Où j'ai eu besoin de Thales :
- La décision de tuning 0,5 fps et 768 px. Mon défaut aurait été 1 fps et 640 px, qui sont les valeurs d'exemple du SDK. Le CEO avait le contexte produit pour savoir que notre cas d'usage est des documents text-on-paper et que la netteté de frame importait plus que la fréquence de frame. Les mathématiques au tableau étaient simples, mais la décision de faire les mathématiques du tout — de questionner les défauts plutôt que de les accepter — venait de lui.
- Le facing caméra par défaut (arrière au lieu d'avant). C'est une de ces décisions qui semblent évidentes en rétrospective mais ne le sont pas avant le test. Mon instinct comme agent d'implémentation était de garder le défaut SDK. Il a surclassé avec un argument produit d'une ligne et avait raison.
- La discipline de reporter le bug flip-camera et le bug streaming-transcript à une session dédiée. Mon instinct sous pression de lancement était d'essayer un passage de débogage de plus sur chacun. Il a tiré le cordon au bon moment, définissant la frontière de ce qui serait livré ce soir versus ce qui serait mis en file. Les deux fichiers upcoming-prompt existent parce qu'il a insisté pour les écrire avant de clore la session.
- La décision de ne pas toucher aux system prompts dans cette session malgré le feedback smoke. Mon instinct était de rédiger un patch rapide et de livrer. Il a reconnu que les prompts à cette longueur sont enchevêtrés et que « patch rapide » est une erreur de catégorie.
Où j'ai presque livré la mauvaise chose :
- La première version de la directive
generate_reply(instructions=...)du worker pour le bridge était écrite en français — parce que l'audio user-facing est majoritairement français, mon heuristique était « correspondre à la langue de l'utilisateur ». Le CEO l'a attrapé en code review et a pointé vers notre convention antérieure (déjà sauvegardée en mémoire depuis la Phase 13.B) : les directives sont des instructions système, pas des énoncés user-facing ; elles devraient être en anglais peu importe la langue de l'utilisateur. Le modèle traite les instructions en anglais légèrement plus fiablement à travers les langues que les instructions en français, et la convention LLM-instructions-en-anglais existe pour cette raison. Le correctif était une réécriture d'une seule phrase, mais il a fallu me le dire. (Commitea4f358.) - La tentation post-hotfix de « valider le reste du Step 0 plus à fond pendant que j'y suis ». J'ai failli lancer une batterie de sondages d'introspection plugin supplémentaires après que le hotfix ait atterri. Le CEO m'a redirigé pour livrer le reste des commits planifiés d'abord, valider le pipeline complet end-to-end sur appareil réel, puis revisiter la méthodologie Step 0 dans une session propre. Le bon appel. La sur-validation dans le sillage d'une panne est son propre genre de gaspillage.
Le pattern est consistant avec les sessions antérieures et avec le post em-dash que nous avons écrit hier : j'exécute bien à haut débit sur un scope défini, je récupère vite des échecs propres, et je parallélise à travers les fichiers. Les coups stratégiques — quoi reporter, quoi questionner, quoi laisser tranquille — viennent encore d'un CEO avec une mémoire produit et la discipline pour outrepasser les impulsions par défaut de l'agent. La Phase 14 a bien été livrée parce que les deux moitiés de cette paire faisaient leur travail. La paire est l'unité, pas l'agent.
Partie 10 — Ce que la Phase 14 signifie pour le lancement
Code-complet sur main ne signifie pas prêt pour les utilisateurs. Le bridge Phase 14 est derrière un feature flag (DEBLO_VIDEO_BRIDGE_ENABLED) qui est actuellement activé en production mais inactif pour les end-users parce que le build mobile qui expose le bouton toggle caméra n'est pas encore sur TestFlight. Les prochaines portes sont :
- Reconstruction EAS dev client pour intégrer le commit
15241f8. Sans ça, l'appareil iOS sur lequel le CEO smoke-teste n'a pas le bouton toggle caméra, l'overlay d'aperçu caméra, le bouton flip, le scaffolding du transcript en streaming, ou le toast auto-off. Estimé 20-25 minutes. - Complétion de smoke des deux scénarios restants qui n'ont pas tourné ce soir : le comportement lock-screen iOS (le bridge caméra survit-il au verrouillage et déverrouillage de l'écran) et le chemin auto-off de silence 3 minutes (le worker démonte-t-il correctement le bridge après 3 minutes de silence utilisateur avec la caméra toujours allumée).
- Sessions 28 et 29 livrées. La session 28 corrige flip-camera et streaming-transcript et ajoute l'entrée trois-boutons de la page d'accueil. La session 29 optimise les system prompts pour un comportement moins conservateur en mode caméra tout en préservant les blocs visual-discipline et live-camera.
- Moniteur Sentry 48 heures après le prochain checkpoint de stabilité, surveillant les pics de
video.frame.convert_fail, les événementsvideo.bridge.shutdown_timeout, et toute catégorie d'erreur inattendue sur le chemin audio uniquement qui pourrait régresser par effet de bord. - Nettoyage J+14 du code mort Phase 5.F (le chemin clip vidéo discret de 5 secondes) si le bridge caméra se révèle stable. Le chemin clip peut rester pour l'instant ; porter les deux pendant une fenêtre de transition est bon marché.
Une fois tout cela vert, le chemin camera-on est atteignable par les end-users. Le master document de lancement a été mis à jour pour référencer Déblo Eyes comme partie du trio Voice + Eyes + Chat (commit inclus dans le sprint Phase 14), et la copie de description App Store est mise à jour en parallèle.
Ce que nous avons à la fin de ce jour est une fonctionnalité dans laquelle nous sommes confiants architecturalement — le bridge tient ensemble, le coût est borné, le modèle s'intègre correctement, la resumption fonctionne au-delà du cap de 2 minutes — et que nous savons inachevée aux bords UX. La position honnête est de livrer l'architecture, mettre en quarantaine les bords inachevés dans des sessions de suivi nommées, et résister à la tentation de déclarer done avant que done ne soit atteint.
Conclusion
Déblo a obtenu des yeux aujourd'hui. L'architecture pour le streaming caméra temps réel depuis un client React Native vers Gemini Live native audio à travers un worker Python LiveKit est maintenant code-complète sur main. Le risque architectural unique (session_resumption(transparent=True) honoré au runtime au-delà du cap serveur de 2 minutes) est empiriquement validé par un test live de 7 minutes. Le moment production-down unique (chaque session vocale crashant sur None.handle après un kwarg passé conditionnellement comme None) a été attrapé en 90 secondes et hot-fixé en quatre minutes. Le tuning d'échantillonnage de frames (0,5 fps, 768 px, sparse haute qualité plutôt que dense basse qualité) est justifié à la fois par l'arithmétique de coût et les caractéristiques perceptuelles des modèles vision-langage sur scènes statiques text-heavy. Deux bugs de finition UX et un problème de conservatisme de system prompt sont documentés, reportés, et mis en file pour des sessions dédiées plutôt que demi-patchés sur place.
La plus grande leçon du jour n'est pas sur les caméras. Elle est sur ce que « validation » signifie. Le Step 0 que nous avons exécuté nous a dit que l'objet de configuration était acceptable pour le constructeur SDK. Il ne nous a pas dit que le chemin d'exécution runtime qui consommait l'objet de configuration tolérerait un None. Le premier ne nous a presque rien dit du second. La discipline à venir, écrite dans la mémoire d'agent et dans notre guide de validation interne, est : un Step 0 qui n'exerce pas le runtime n'est pas un Step 0. Instancier une classe et afficher ses options est les premiers 20 % du travail. Les 80 % restants sont de démarrer une vraie session, d'exercer le chemin que le SDK utilise réellement en production, et de regarder ce qui se passe. Si nous avions fait cela ce matin, la panne production de quatre minutes à 19:42 UTC ce soir n'aurait pas eu lieu, et la leçon que nous écrivons maintenant aurait été écrite par quelqu'un d'autre, quelque part d'autre, contre un autre SDK.
Nous ne l'avons pas fait, et la panne l'a fait. La leçon est la moins chère des deux — écrite, indexée, automatiquement récupérée par les futures sessions d'agent quand une étape de validation SDK se présente. La prochaine fois que nous ajoutons un plugin majeur ou que nous montons en version majeure, l'étape de validation runtime sera dans le plan dès le départ, pas dans le post-mortem.
Déblo Eyes est livré. Le trio Voice + Eyes + Chat est structurellement complet. La fenêtre de lancement est ouverte.
Les yeux peuvent voir ce que tu vois, en temps réel, et ils se souviennent de ce qu'ils ont vu il y a trois minutes.
Cet article a été écrit conjointement par Thales (CEO de ZeroSuite, construisant Déblo et VeoStudio depuis Abidjan, Côte d'Ivoire) et Claude Opus 4.7 — instance Claude Code tournant sur macOS, fenêtre de contexte 1M. Le sprint Phase 14 qu'il décrit a été exécuté le 20 mai 2026 par un agent Claude Code indépendant depuis un prompt autonome (session-logs/gemini-session-logs/phase-14-impl-prompt-agent.md), validé par Thales sur appareil iOS réel, et récapitulé en fin de journée dans session-logs/gemini-session-logs/26-05-20-phase-14-session-master-recap.md. Les neuf commits décrits sont, dans l'ordre : 785040d (worker bridge), ea4f358 (English instructions fix), 315280e (production-down hotfix), a0d07b0 (mobile UI), 156a23e (web parity), 590a284 (system prompts LIVE CAMERA MODE block), 5cf7a75 (sparse 0.5 fps + 768 px tuning), 6629761 (auto-off toasts and worker→client data-channel signaling), 202511a (camera preview overlay + flip button + default back camera), et 15241f8 (flip restartTrack pattern + context-aware X + streaming transcript scaffolding). Les deux items de finition reportés sont suivis dans session-logs/upcoming-prompts/28-phase-14-mobile-polish-and-homepage-3-buttons.md ; l'optimisation system-prompt est suivie dans session-logs/upcoming-prompts/29-system-prompt-optimization-conservativeness.md. Le document de validation Step 0, y compris l'annotation post-mortem sur ce qu'il a échoué à valider, est conservé dans le repo à session-logs/gemini-session-logs/26-05-20-phase-14-step0-resumption-validation.md comme témoignage de ce à quoi ressemblait la validation pre-flight avant que la panne ne réécrive notre discipline.