Webapp de pratique orale d'anglais pour un ami de Matt (niveau B1). L'ami ouvre
un lien, appuie une fois sur le cercle, et il a une vraie conversation à l'oral
avec un partenaire IA bienveillant. Rien à installer, rien à taper, rien à lire.
Boucle mains libres :
```
micro navigateur -> POST /api/turn -> STT (lib/stt_client)
-> coach (coach.py, 1 appel LLM : réponse + notation)
-> TTS (lib/tts_client)
-> mp3 joué automatiquement -> le micro se réarme tout seul
```
La pédagogie reprend la compétence /fluent-speaking du repo
github.com/m98/fluent (communication avant grammaire, une question à la fois,
changement de sujet après 3-4 échanges, notation communication 0-5 / grammaire
0-3 / vocabulaire 0-2), adaptée à l'oral : **aucune correction n'est lue à voix
haute**. Le coach utilise un *recast* (il redit l'idée correctement dans sa
propre réponse, sans leçon de grammaire) et la notation reste silencieuse
jusqu'au récap de fin de session.
expose-webapp).fluent-voice.service/root/workspace/apps/fluent-voice//var/log/fluent-voice.log/root/workspace/apps/fluent-voice/data/fluent.db (WAL)```bash
systemctl status fluent-voice
systemctl restart fluent-voice
tail -f /var/log/fluent-voice.log
```
Pas de login : la clé voyage dans le lien et le navigateur la garde en
localStorage. Lien à envoyer à l'ami :
```
https://<domaine>/?k=<FLUENT_VOICE_KEY>&u=<prenom>
```
k = clé d'accès, dans /root/aios/.env sous FLUENT_VOICE_KEY.u = identifiant du learner (facultatif, défaut friend). C'est la clé de sa mémoire de progression, un seul et même u d'une session à l'autre.
en-tête X-Fluent-Key sur chaque appel API.
/ reste public (sinon un retour sans la clé donnerait un 403 avant que le JS puisse la relire) ; **tout /api/* est gardé**. Si FLUENT_VOICE_KEY est
vide, l'app est entièrement ouverte.
| Méthode | Route | Rôle |
|---|---|---|
| GET | /health | {"ok":true,"service":"fluent-voice"}, sans auth |
| GET | / | l'app (HTML statique) |
| POST | /api/session | démarre une session, renvoie la phrase d'accueil + son audio |
| POST | /api/turn | multipart session_id + audio : un échange complet |
| POST | /api/nudge | relance parlée après un silence prolongé (audio mis en cache) |
| POST | /api/session/end | récap parlé + écrit, écrit les notes de progression |
| GET | /api/progress?learner= | historique léger et points à travailler |
| GET | /api/audio/{token}.mp3 | audio en cache mémoire (TTL 30 min, jeton uuid4) |
gpt-4o-mini (JSON mode : réponse parlée + notation en un seul appel), repli automatique sur Gemini gemini-flash-latest.
lib/stt_client.transcribe(..., language="en"). La langue est forcée à l'anglais dans le code : config/preferences.yaml de l'AIOS
pilote le français, il ne doit pas décider ici.
lib/tts_client.synthesize(..., voice="nova"). L'app force TTS_PROVIDER=openai + OPENAI_TTS_MODEL=tts-1 dans son propre process
(le défaut AIOS est Gemini, qui repasse par ffmpeg : plus lent, et voix calée
pour le français). Rien n'est modifié pour le reste de la flotte.
caller="fluent_voice" (data/tts_usage.jsonl, data/stt_usage.jsonl).
Toutes lues depuis /root/aios/.env (systemd donne un environnement vide, le
serveur charge le .env lui-même). Aucun secret dans le code ni côté client.
| Variable | Défaut | Rôle |
|---|---|---|
FLUENT_VOICE_KEY | (vide = ouvert) | clé d'accès du lien |
FLUENT_PORT | 8367 | port uvicorn |
FLUENT_TTS_VOICE | nova | voix du coach |
FLUENT_TTS_MODEL | tts-1 | modèle TTS (tts-1-hd = plus beau, plus lent) |
FLUENT_TTS_PROVIDER | openai | gemini pour basculer |
FLUENT_LLM_MODEL | gpt-4o-mini | modèle du coach |
FLUENT_COACH_NAME | Emma | prénom du partenaire |
FLUENT_DB_PATH | data/fluent.db | base SQLite |
| Clés réutilisées | OPENAI_STT_API_KEY, GEMINI_TTS_API_KEY |
Trois tables dans fluent.db :
sessions : une session (début, fin, nombre d'échanges, moyenne /10, récap).turns : un échange (ce qu'il a dit, ce que le coach a répondu, sujet,communication/grammaire/vocabulaire, latence).
learner_notes : la mémoire longue. kind = weakness | win | topic. Une note identique n'est pas dupliquée, son seen_count est incrémenté :
c'est ce qui fait remonter une faiblesse récurrente en tête de prompt.
À la fin d'une session, un appel LLM produit le récap parlé et en extrait les
notes. Elles sont réinjectées dans le prompt du coach à la session suivante
(« points à travailler, sans jamais les nommer à voix haute » + sujets déjà
couverts à éviter). Pas de répétition espacée type SM-2, volontairement.
Session abandonnée (l'ami ferme l'onglet sans finir) : au démarrage de la
session suivante, un thread de fond ferme les sessions ouvertes de plus de 10
minutes et récupère leurs notes. Rien n'est perdu, et le démarrage n'attend pas.
Un seul point focal : le cercle. Ses états sont portés par la couleur et
l'animation, jamais par du texte à lire.
| État | Couleur | Sens |
|---|---|---|
idle | gris | appuyer pour commencer |
speaking | bleu | le coach parle |
listening | vert/menthe + niveau micro | à toi de parler |
thinking | ambre | ça travaille |
paused | gris | trop de silence, appuyer pour reprendre |
Détection de parole locale (WebAudio, seuil adaptatif au bruit de la pièce) :
rien n'est envoyé tant que personne ne parle. Fin de tour après 1,2 s de
silence, 45 s max par réponse, relance parlée après 15 s de silence, mise en
pause après 30 s. Les sous-titres existent (bouton « Show text ») mais sont
désactivés par défaut : l'app est faite pour être utilisée écran éteint.
Le cercle sert aussi de télécommande : pendant que le coach parle, un appui
coupe et redonne la parole ; pendant l'écoute, un appui veut dire « j'ai fini ».
un slug [a-z0-9-_] avant toute requête.
hmac.compare_digest).unsafe-inline (le JS et le CSS sont des fichiers externes), plus nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer,
Permissions-Policy: microphone=(self). robots.txt interdit tout.
20 sessions / heure / IP. Jetons audio en uuid4 non devinables, TTL 30 min,
gardés en mémoire (rien sur disque).
app.
```bash
cd /root/workspace/apps/fluent-voice
export FLUENT_VOICE_KEY=$(grep '^FLUENT_VOICE_KEY=' /root/aios/.env | cut -d= -f2)
python3 smoke_test.py # local
python3 smoke_test.py https://<domaine> # après publication
```
Coûte quelques centimes d'appels API. Sort en non-zéro à la première erreur.
incompressible sans streaming ; l'UI passe en « thinking » instantanément pour
que l'attente reste lisible. Ne pas passer en tts-1-hd sans accepter ~1 s de
plus par tour.
rotation a d'abord été mise dans le prompt système : le modèle l'ignorait
systématiquement au profit de la règle « rebondis sur ce qu'il vient de dire »
et creusait le même sujet indéfiniment. Elle est maintenant envoyée en message
system après le tour du learner (coach._topic_change_directive), et
elle est suivie. Ne pas la redéplacer dans le prompte système.
consécutifs portant le même libellé. Le prompt est réglé « exactitude d'abord,
stabilité ensuite » : le libellé décrit le sujet de la question du coach, et
doit être réécrit à l'identique tant que le sujet ne change pas. Trop flou, le
compteur ne monte jamais à 4 et la rotation ne part pas ; trop collant (une
version l'a fait), le libellé reste figé sur le premier sujet et force un
changement alors que la conversation variait déjà toute seule. Si on retouche
ce passage, refaire les deux tests : 4 sujets différents d'affilée (le libellé
doit suivre) et 5 tours sur le même sujet (rotation au 5e).
static/silence.mp3 jouédans le geste du premier tap. Sans ce fichier, plus aucun son sur iPhone.
(visibilitychange), sinon on téléverse du bruit.
pkill -f "python3 server.py" tue le shell qui le lance (le motif se trouve dans sa propre ligne de commande). Passer par systemctl restart.
nova est féminine et anglophone ; changer FLUENT_TTS_VOICE change aussi le personnage perçu, ajuster FLUENT_COACH_NAME en même temps.
```bash
sqlite3 /root/workspace/apps/fluent-voice/data/fluent.db \
"DELETE FROM turns WHERE session_id IN (SELECT id FROM sessions WHERE learner='friend');
DELETE FROM sessions WHERE learner='friend';
DELETE FROM learner_notes WHERE learner='friend';"
```
Le learner smoketest est créé par smoke_test.py, il n'affecte pas l'ami.
expose-webapp (nginx + DNS + Let's Encrypt), puismettre l'URL en haut de ce manuel et l'ajouter au portail
(apps/portail/index.html, règle « portail new-tab »).
?k=...&u=<prenom> et lui dire d'ajouter la pageà son écran d'accueil (le manifest PWA est en place).
coach LLM → TTS AIOS), suivi de session SQLite avec notes reportées d'une
session à l'autre, récap parlé de fin, UI à un bouton avec détection de parole
locale, garde d'accès par clé dans le lien, service systemd sur le port 8367,
suite smoke_test.py de bout en bout.