Add soft French voice and contact saving

This commit is contained in:
2026-04-27 08:31:10 +02:00
parent f876b3a635
commit 94e14a3674
8 changed files with 99 additions and 19 deletions

View File

@@ -3,3 +3,4 @@ GMAIL_EMAIL=opensquaredgeneva@gmail.com
GMAIL_PASSWORD=your_gmail_app_password GMAIL_PASSWORD=your_gmail_app_password
APP_SECRET=change_me_to_a_long_random_string APP_SECRET=change_me_to_a_long_random_string
TTS_PROVIDER=browser TTS_PROVIDER=browser
CONTACTS_FILE=/root/openbot/data/contacts.json

View File

@@ -25,7 +25,10 @@ Derniers changements locaux a deployer:
- Raccourci optionnel `/usr/local/bin/openbot`. - Raccourci optionnel `/usr/local/bin/openbot`.
- Mode mains libres: `Demarrer` ecoute en continu, envoi apres 2 secondes de silence. - 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. - 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 ## VPS
@@ -176,6 +179,9 @@ Correctif applique:
- Le bouton `Demarrer` lance le mode mains libres. - Le bouton `Demarrer` lance le mode mains libres.
- Le message vocal est envoye automatiquement apres 2 secondes de silence. - 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 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: Si le bouton ne reagit pas apres un deploiement:

View File

@@ -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". - Interface mobile installable sur Android via "Ajouter a l'ecran d'accueil".
- Bouton micro avec transcription Groq Whisper. - Bouton micro avec transcription Groq Whisper.
- Reponse LLM Groq. - 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`. - 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 ## Configuration locale
@@ -25,6 +26,7 @@ GMAIL_EMAIL=...
GMAIL_PASSWORD=... GMAIL_PASSWORD=...
APP_SECRET=... APP_SECRET=...
TTS_PROVIDER=browser TTS_PROVIDER=browser
CONTACTS_FILE=/root/openbot/data/contacts.json
``` ```
Installer : 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. 6. Parler naturellement : apres 2 secondes de silence, le message est envoye.
7. Appuyer sur `Arreter` pour couper l'ecoute continue. 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 ## Notes
- Groq Orpheus TTS doit etre accepte dans la console Groq pour fonctionner : - Groq Orpheus TTS doit etre accepte dans la console Groq pour fonctionner :
https://console.groq.com/playground?model=canopylabs%2Forpheus-v1-english 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. - 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`. - 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. - 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.

41
app.py
View File

@@ -15,7 +15,7 @@ load_dotenv()
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
STATIC_DIR = ROOT / "static" 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", "") GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
GMAIL_EMAIL = os.getenv("GMAIL_EMAIL", "") 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 : Quand tu as toutes les informations pour envoyer un email, reponds uniquement avec ce JSON :
{"action": "envoyer_email", "destinataire": "...", "sujet": "...", "corps": "..."} {"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. Pour tout le reste, reponds en francais naturel, avec des phrases courtes.
N'utilise pas d'anglais sauf si l'utilisateur le demande explicitement. N'utilise pas d'anglais sauf si l'utilisateur le demande explicitement.
En mode voiture, sois bref : une ou deux phrases maximum.""" En mode voiture, sois bref : une ou deux phrases maximum."""
@@ -54,6 +58,14 @@ def charger_contacts():
return {"laurent": "barontil@hotmail.com"} 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(): def construire_system_prompt():
contacts = charger_contacts() contacts = charger_contacts()
lignes = [f"- {nom.title()} : {email}" for nom, email in contacts.items()] 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) return contacts.get(valeur.lower(), valeur)
def extraire_action_email(reponse): def extraire_action(reponse):
decodeur = json.JSONDecoder() decodeur = json.JSONDecoder()
for index, caractere in enumerate(reponse): for index, caractere in enumerate(reponse):
if caractere != "{": if caractere != "{":
@@ -75,11 +87,24 @@ def extraire_action_email(reponse):
data, _ = decodeur.raw_decode(reponse[index:]) data, _ = decodeur.raw_decode(reponse[index:])
except json.JSONDecodeError: except json.JSONDecodeError:
continue 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 data
return None 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): def envoyer_email(destinataire, sujet, corps):
destinataire = resoudre_contact(destinataire) destinataire = resoudre_contact(destinataire)
msg = MIMEMultipart() msg = MIMEMultipart()
@@ -163,13 +188,18 @@ def generer_audio(texte):
def traiter_message(session_id, texte, avec_audio=False): def traiter_message(session_id, texte, avec_audio=False):
reponse = demander_au_llm(session_id, texte) reponse = demander_au_llm(session_id, texte)
action = extraire_action_email(reponse) action = extraire_action(reponse)
email_sent = False 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"]) destinataire = envoyer_email(action["destinataire"], action["sujet"], action["corps"])
reponse = f"Email envoye a {destinataire}." reponse = f"Email envoye a {destinataire}."
email_sent = True 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 audio_url = None
if avec_audio and TTS_PROVIDER == "groq": if avec_audio and TTS_PROVIDER == "groq":
@@ -179,6 +209,7 @@ def traiter_message(session_id, texte, avec_audio=False):
"reply": reponse, "reply": reponse,
"audioUrl": audio_url, "audioUrl": audio_url,
"emailSent": email_sent, "emailSent": email_sent,
"contactSaved": contact_saved,
} }

View File

@@ -5,6 +5,7 @@ After=network.target
[Service] [Service]
WorkingDirectory=/root/openbot WorkingDirectory=/root/openbot
EnvironmentFile=/root/openbot/.env 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 ExecStart=/root/openbot/.venv/bin/gunicorn -w 2 -b 0.0.0.0:8080 app:app
Restart=always Restart=always
RestartSec=3 RestartSec=3

View File

@@ -323,7 +323,8 @@ async function sendVoiceBlob(blob) {
async function handleAssistantResponse(data) { async function handleAssistantResponse(data) {
const reply = data.reply || ""; const reply = data.reply || "";
addBubble(reply, "assistant"); 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; if (!audioToggle.checked || !reply) return;
@@ -350,26 +351,45 @@ function speakInFrench(text) {
window.speechSynthesis.cancel(); window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text); const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "fr-FR"; utterance.lang = "fr-FR";
utterance.rate = 0.98; utterance.rate = 0.9;
utterance.pitch = 1; utterance.pitch = 1.08;
utterance.voice = pickFrenchVoice(); utterance.volume = 0.92;
utterance.voice = pickSoftFrenchVoice();
utterance.onend = resolve; utterance.onend = resolve;
utterance.onerror = resolve; utterance.onerror = resolve;
window.speechSynthesis.speak(utterance); window.speechSynthesis.speak(utterance);
}); });
} }
function pickFrenchVoice() { function pickSoftFrenchVoice() {
const voices = window.speechSynthesis.getVoices(); 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 ( return (
voices.find((voice) => voice.lang === "fr-FR" && /google|microsoft/i.test(voice.name)) || frenchVoices.find((voice) => preferredNames.some((name) => voice.name.toLowerCase().includes(name))) ||
voices.find((voice) => voice.lang === "fr-FR") || frenchVoices.find((voice) => voice.lang === "fr-FR" && /female|femme|google|microsoft/i.test(voice.name)) ||
voices.find((voice) => voice.lang && voice.lang.toLowerCase().startsWith("fr")) || frenchVoices.find((voice) => voice.lang === "fr-FR") ||
frenchVoices[0] ||
null null
); );
} }
window.speechSynthesis?.addEventListener?.("voiceschanged", pickFrenchVoice); window.speechSynthesis?.addEventListener?.("voiceschanged", pickSoftFrenchVoice);
async function resetConversation() { async function resetConversation() {
await fetch("/api/reset", { await fetch("/api/reset", {

View File

@@ -50,6 +50,6 @@
</section> </section>
</main> </main>
<script src="/app.js?v=5" type="module"></script> <script src="/app.js?v=6" type="module"></script>
</body> </body>
</html> </html>

View File

@@ -1,8 +1,8 @@
const CACHE_NAME = "opensquared-assistant-v5"; const CACHE_NAME = "opensquared-assistant-v6";
const ASSETS = [ const ASSETS = [
"/", "/",
"/styles.css", "/styles.css",
"/app.js?v=5", "/app.js?v=6",
"/manifest.webmanifest", "/manifest.webmanifest",
"/icons/icon-v2.svg" "/icons/icon-v2.svg"
]; ];