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
APP_SECRET=change_me_to_a_long_random_string
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`.
- 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:

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".
- 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.

41
app.py
View File

@@ -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,
}

View File

@@ -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

View File

@@ -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", {

View File

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

View File

@@ -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"
];