From 94e14a3674df142b7b9d59cb02c585ab49ba25e4 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Mon, 27 Apr 2026 08:31:10 +0200 Subject: [PATCH] Add soft French voice and contact saving --- .env.example | 1 + DEPLOY_NOTES.md | 8 +++++- README.md | 23 +++++++++++++++- app.py | 41 ++++++++++++++++++++++++---- deploy/opensquared-assistant.service | 1 + static/app.js | 38 ++++++++++++++++++++------ static/index.html | 2 +- static/sw.js | 4 +-- 8 files changed, 99 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index 3737001..e90608e 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,4 @@ GMAIL_EMAIL=opensquaredgeneva@gmail.com GMAIL_PASSWORD=your_gmail_app_password APP_SECRET=change_me_to_a_long_random_string TTS_PROVIDER=browser +CONTACTS_FILE=/root/openbot/data/contacts.json diff --git a/DEPLOY_NOTES.md b/DEPLOY_NOTES.md index 0ae3a8f..c2987ea 100644 --- a/DEPLOY_NOTES.md +++ b/DEPLOY_NOTES.md @@ -25,7 +25,10 @@ Derniers changements locaux a deployer: - Raccourci optionnel `/usr/local/bin/openbot`. - Mode mains libres: `Demarrer` ecoute en continu, envoi apres 2 secondes de silence. - TTS par defaut via voix navigateur `fr-FR` pour eviter l'accent anglais Groq. -- Cache PWA: opensquared-assistant-v5. +- Voix navigateur adoucie avec preference pour voix feminine francaise. +- Ajout de contacts email par conversation. +- Carnet de contacts prod dans `/root/openbot/data/contacts.json`. +- Cache PWA: opensquared-assistant-v6. ``` ## VPS @@ -176,6 +179,9 @@ Correctif applique: - Le bouton `Demarrer` lance le mode mains libres. - Le message vocal est envoye automatiquement apres 2 secondes de silence. - La voix par defaut est celle du navigateur en `fr-FR`, via `TTS_PROVIDER=browser`. +- La version v6 privilegie une voix feminine francaise si disponible sur l'appareil. +- Les contacts peuvent etre ajoutes en disant par exemple: `Ajoute Sophie avec l'adresse sophie@example.com`. +- Le service systemd definit `CONTACTS_FILE=/root/openbot/data/contacts.json`. Si le bouton ne reagit pas apres un deploiement: diff --git a/README.md b/README.md index d9d0bde..d6a6d64 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ POC de PWA mobile pour parler a l'assistant depuis un telephone. - Interface mobile installable sur Android via "Ajouter a l'ecran d'accueil". - Bouton micro avec transcription Groq Whisper. - Reponse LLM Groq. -- Lecture audio automatique de la reponse apres interaction utilisateur. +- Lecture audio automatique de la reponse avec une voix francaise douce cote navigateur. - Envoi d'emails Gmail avec carnet de contacts `contacts.json`. +- Ajout de contacts par conversation, par exemple : `Ajoute Sophie avec l'adresse sophie@example.com`. ## Configuration locale @@ -25,6 +26,7 @@ GMAIL_EMAIL=... GMAIL_PASSWORD=... APP_SECRET=... TTS_PROVIDER=browser +CONTACTS_FILE=/root/openbot/data/contacts.json ``` Installer : @@ -121,10 +123,29 @@ sudo certbot --nginx -d assistant.ton-domaine.com 6. Parler naturellement : apres 2 secondes de silence, le message est envoye. 7. Appuyer sur `Arreter` pour couper l'ecoute continue. +## Contacts email + +Les contacts sont stockes dans `contacts.json` en local. Sur le VPS, le service utilise `/root/openbot/data/contacts.json` pour eviter de modifier les fichiers suivis par Git. + +Tu peux les ajouter directement dans l'assistant : + +```text +Ajoute Sophie avec l'adresse sophie@example.com +``` + +Ensuite tu peux dire : + +```text +Envoie un email a Sophie +``` + +L'assistant remplacera `Sophie` par son adresse email. + ## Notes - Groq Orpheus TTS doit etre accepte dans la console Groq pour fonctionner : https://console.groq.com/playground?model=canopylabs%2Forpheus-v1-english - Par defaut `TTS_PROVIDER=browser` utilise la voix francaise du telephone pour eviter l'accent anglais du modele Groq Orpheus. +- La PWA privilegie les voix feminines francaises disponibles sur le telephone, avec un debit plus lent et un ton plus doux. - Pour revenir au TTS Groq, mettre `TTS_PROVIDER=groq` dans `.env`. - La lecture audio automatique depend toujours des regles du navigateur mobile, mais elle marche mieux dans une PWA apres un tap utilisateur que dans Telegram. diff --git a/app.py b/app.py index ba8eff7..3606295 100644 --- a/app.py +++ b/app.py @@ -15,7 +15,7 @@ load_dotenv() ROOT = Path(__file__).resolve().parent STATIC_DIR = ROOT / "static" -CONTACTS_FILE = ROOT / "contacts.json" +CONTACTS_FILE = Path(os.getenv("CONTACTS_FILE", ROOT / "contacts.json")) GROQ_API_KEY = os.getenv("GROQ_API_KEY", "") GMAIL_EMAIL = os.getenv("GMAIL_EMAIL", "") @@ -42,6 +42,10 @@ destinataire, sujet, contenu. Quand tu as toutes les informations pour envoyer un email, reponds uniquement avec ce JSON : {"action": "envoyer_email", "destinataire": "...", "sujet": "...", "corps": "..."} +Quand l'utilisateur veut ajouter, memoriser ou modifier un contact email, collecte le nom et l'adresse email. +Quand tu as les deux informations, reponds uniquement avec ce JSON : +{"action": "ajouter_contact", "nom": "...", "email": "..."} + Pour tout le reste, reponds en francais naturel, avec des phrases courtes. N'utilise pas d'anglais sauf si l'utilisateur le demande explicitement. En mode voiture, sois bref : une ou deux phrases maximum.""" @@ -54,6 +58,14 @@ def charger_contacts(): return {"laurent": "barontil@hotmail.com"} +def sauvegarder_contacts(contacts): + CONTACTS_FILE.parent.mkdir(parents=True, exist_ok=True) + CONTACTS_FILE.write_text( + json.dumps(contacts, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + def construire_system_prompt(): contacts = charger_contacts() lignes = [f"- {nom.title()} : {email}" for nom, email in contacts.items()] @@ -66,7 +78,7 @@ def resoudre_contact(destinataire): return contacts.get(valeur.lower(), valeur) -def extraire_action_email(reponse): +def extraire_action(reponse): decodeur = json.JSONDecoder() for index, caractere in enumerate(reponse): if caractere != "{": @@ -75,11 +87,24 @@ def extraire_action_email(reponse): data, _ = decodeur.raw_decode(reponse[index:]) except json.JSONDecodeError: continue - if isinstance(data, dict) and data.get("action") == "envoyer_email": + if isinstance(data, dict) and data.get("action") in {"envoyer_email", "ajouter_contact"}: return data return None +def ajouter_contact(nom, email): + contacts = charger_contacts() + nom_normalise = " ".join(nom.strip().lower().split()) + email_normalise = email.strip() + + if not nom_normalise or "@" not in email_normalise: + raise ValueError("Contact incomplet") + + contacts[nom_normalise] = email_normalise + sauvegarder_contacts(contacts) + return nom_normalise, email_normalise + + def envoyer_email(destinataire, sujet, corps): destinataire = resoudre_contact(destinataire) msg = MIMEMultipart() @@ -163,13 +188,18 @@ def generer_audio(texte): def traiter_message(session_id, texte, avec_audio=False): reponse = demander_au_llm(session_id, texte) - action = extraire_action_email(reponse) + action = extraire_action(reponse) email_sent = False + contact_saved = False - if action: + if action and action.get("action") == "envoyer_email": destinataire = envoyer_email(action["destinataire"], action["sujet"], action["corps"]) reponse = f"Email envoye a {destinataire}." email_sent = True + elif action and action.get("action") == "ajouter_contact": + nom, email = ajouter_contact(action["nom"], action["email"]) + reponse = f"Contact ajoute : {nom.title()}." + contact_saved = True audio_url = None if avec_audio and TTS_PROVIDER == "groq": @@ -179,6 +209,7 @@ def traiter_message(session_id, texte, avec_audio=False): "reply": reponse, "audioUrl": audio_url, "emailSent": email_sent, + "contactSaved": contact_saved, } diff --git a/deploy/opensquared-assistant.service b/deploy/opensquared-assistant.service index 87ce434..30342c2 100644 --- a/deploy/opensquared-assistant.service +++ b/deploy/opensquared-assistant.service @@ -5,6 +5,7 @@ After=network.target [Service] WorkingDirectory=/root/openbot EnvironmentFile=/root/openbot/.env +Environment=CONTACTS_FILE=/root/openbot/data/contacts.json ExecStart=/root/openbot/.venv/bin/gunicorn -w 2 -b 0.0.0.0:8080 app:app Restart=always RestartSec=3 diff --git a/static/app.js b/static/app.js index b01886d..9cbbdf7 100644 --- a/static/app.js +++ b/static/app.js @@ -323,7 +323,8 @@ async function sendVoiceBlob(blob) { async function handleAssistantResponse(data) { const reply = data.reply || ""; addBubble(reply, "assistant"); - setStatus(data.emailSent ? "Email envoye" : "Pret", data.emailSent ? "live" : "idle"); + const status = data.emailSent ? "Email envoye" : data.contactSaved ? "Contact ajoute" : "Pret"; + setStatus(status, data.emailSent || data.contactSaved ? "live" : "idle"); if (!audioToggle.checked || !reply) return; @@ -350,26 +351,45 @@ function speakInFrench(text) { window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); utterance.lang = "fr-FR"; - utterance.rate = 0.98; - utterance.pitch = 1; - utterance.voice = pickFrenchVoice(); + utterance.rate = 0.9; + utterance.pitch = 1.08; + utterance.volume = 0.92; + utterance.voice = pickSoftFrenchVoice(); utterance.onend = resolve; utterance.onerror = resolve; window.speechSynthesis.speak(utterance); }); } -function pickFrenchVoice() { +function pickSoftFrenchVoice() { const voices = window.speechSynthesis.getVoices(); + const frenchVoices = voices.filter((voice) => voice.lang && voice.lang.toLowerCase().startsWith("fr")); + const preferredNames = [ + "audrey", + "aurelie", + "aurélie", + "marie", + "julie", + "celine", + "céline", + "lea", + "léa", + "amelie", + "amélie", + "sylvie", + "hortense" + ]; + return ( - voices.find((voice) => voice.lang === "fr-FR" && /google|microsoft/i.test(voice.name)) || - voices.find((voice) => voice.lang === "fr-FR") || - voices.find((voice) => voice.lang && voice.lang.toLowerCase().startsWith("fr")) || + frenchVoices.find((voice) => preferredNames.some((name) => voice.name.toLowerCase().includes(name))) || + frenchVoices.find((voice) => voice.lang === "fr-FR" && /female|femme|google|microsoft/i.test(voice.name)) || + frenchVoices.find((voice) => voice.lang === "fr-FR") || + frenchVoices[0] || null ); } -window.speechSynthesis?.addEventListener?.("voiceschanged", pickFrenchVoice); +window.speechSynthesis?.addEventListener?.("voiceschanged", pickSoftFrenchVoice); async function resetConversation() { await fetch("/api/reset", { diff --git a/static/index.html b/static/index.html index fdad8ad..1075acc 100644 --- a/static/index.html +++ b/static/index.html @@ -50,6 +50,6 @@ - + diff --git a/static/sw.js b/static/sw.js index 844ef2f..20d98b0 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,8 +1,8 @@ -const CACHE_NAME = "opensquared-assistant-v5"; +const CACHE_NAME = "opensquared-assistant-v6"; const ASSETS = [ "/", "/styles.css", - "/app.js?v=5", + "/app.js?v=6", "/manifest.webmanifest", "/icons/icon-v2.svg" ];