Add soft French voice and contact saving
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
23
README.md
23
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".
|
- 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
41
app.py
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user