Valdeuck-final / app.py
bsalvan's picture
Update app.py
b6a8d5a verified
Raw History Blame Contribute Delete
23.6 kB
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, StreamingResponse
from pydantic import BaseModel
from openai import OpenAI
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_chroma import Chroma
import os
import json
import re
import time
import io
import asyncio
import edge_tts
import requests
app = FastAPI(title="Valdeuck Maître Deuck V13")
# =========================
# VOICE — voix masculine française Microsoft
# =========================
TTS_VOICE = "fr-FR-RemyMultilingualNeural"
# FREE CHAT MODELS (for valdeuck.fr)
# model="HuggingFaceH4/zephyr-7b-gemma-v0.1", # Modèle Chat natif et gratuit
# model="meta-llama/Llama-3.2-3B-Instruct", # Modèle Chat natif
# =========================
# HUGGING FACE INFERENCE PROVIDERS
# =========================
# Dans les Secrets du Space, définir HF_TOKEN.
# HF_CHAT_MODEL peut être changé sans modifier le code.
# :preferred demande au routeur HF le premier fournisseur disponible.
HF_TOKEN_VALUE = (
os.getenv("HF_TOKEN")
or os.getenv("HUGGINGFACEHUB_API_TOKEN")
or ""
).strip()
#• Qwen/Qwen2.5-Coder-7B-Instruct (Le choix idéal : très intelligent, rapide, gère parfaitement le français)
#• deepseek-ai/DeepSeek-R1-Distill-Qwen-7B (Excellent modèle de raisonnement logique basé sur l'architecture ouverte de DeepSeek)
#• Qwen/Qwen2.5-Coder-3B-Instruct (Ultra-léger, s'exécute instantanément sur les serveurs CPU de Hugging Face)
############################# Ne fonctionne plus sur huggingface, version avec abonnement uniquement
#HF_CHAT_MODEL = os.getenv(
# "HF_CHAT_MODEL",
# "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B",
#)
#
#if not HF_TOKEN_VALUE:
# print("ERREUR : aucun jeton HF_TOKEN / HUGGINGFACEHUB_API_TOKEN détecté.")
#else:
# print("Jeton Hugging Face détecté :", True)
# print("Modèle configuré :", HF_CHAT_MODEL)
#
#client = OpenAI(
# base_url="https://router.huggingface.co/v1",
# api_key=HF_TOKEN_VALUE or "missing-token"
#)
#
# ==========================================================================================
# CONFIGURATION DE L'IA (Contournement gratuit via Pollinations AI)
# via interface (bibliothèque) openai versus bibliothgèque standard : exclusif avec standard
# ==========================================================================================
# On utilise 'qwen' qui est performant, gratuit et gère parfaitement le français
#HF_CHAT_MODEL = os.getenv("HF_CHAT_MODEL", "qwen")
#print("Jeton d'authentification : Non requis (API Libre)")
#print("Modèle configuré :", HF_CHAT_MODEL)
# CORRECTION : On utilise la racine de l'URL sans le /v1 pour éviter l'erreur 405
#client = OpenAI(
# base_url="https://pollinations.ai",
# api_key="not-needed"
#)
#===========================================================================================
####################################################################
# DEBUG - Liste des modeles disponibles aucune garantie de gratuité
#try:
# models = client.models.list()
# print("=== MODELES DISPONIBLES VIA LE ROUTEUR HF ===")
# for model in models.data:
# print(model.id)
#except Exception as e:
# print("ERREUR LISTE DES MODELES :", repr(e))
#####################################################################
########################## Si Utilisation bibliothèque standard vs openai : exclusif avec openai
HF_CHAT_MODEL = os.getenv("HF_CHAT_MODEL", "openai-fast")
print("Jeton d'authentification : Non requis (API Libre)")
print("Modèle configuré :", HF_CHAT_MODEL)
# Utilisation de l'endpoint OpenAI officiel de Pollinations
client = OpenAI(
base_url="https://text.pollinations.ai/openai",
api_key="not-needed"
)
# =========================
# VECTOR DB
# =========================
embeddings = HuggingFaceEmbeddings(
model_name="intfloat/multilingual-e5-base"
)
beer_db = Chroma(
persist_directory="vectorstore_beers",
embedding_function=embeddings
)
knowledge_db = Chroma(
persist_directory="vectorstore_knowledge",
embedding_function=embeddings
)
beer_retriever = beer_db.as_retriever(search_kwargs={"k": 6})
knowledge_retriever = knowledge_db.as_retriever(search_kwargs={"k": 3})
# =========================
# REQUEST
# =========================
class Query(BaseModel):
query: str
# =========================
# CLEAN
# =========================
def clean(t):
if not t:
return ""
return re.sub(r"\s+", " ", t).strip()
# =========================
# NETTOYAGE STRICT ANTI-ASTÉRISQUE ET ACCENT US
# =========================
def clean_tts_text(text: str) -> str:
if not text:
return ""
# 1. Supprime définitivement la syntaxe Markdown (astérisques, tirets, accents graves)
t = text.replace("**", "").replace("*", "").replace("`", "")
# 2. Remplacer les noms anglais par leur équivalent phonétique français
# évite que le moteur vocal Remy ne bascule sur un accent américain forcé
phonetic_map = {
"Bongo Fury": "Bongo Fiouri",
"Concrete Jungle": "Concrète Jeunguel",
"China Girl": "Tchaïna Geurl",
"Red Hot Kampot Pepper": "Red Hot Kampotte Pèpeur",
"Elder Foot Beat": "Elder Foute Bite",
"Melody Nelson": "Mélodi Nelson",
"White Rabbit": "Ouaïte Rabitte",
"IPA": "I P A",
"Stout": "Stoute",
"Sour": "Safeur",
"Craft Beer": "Crafte Bière"
}
for eng, fr in phonetic_map.items():
t = re.sub(r'\b' + re.escape(eng) + r'\b', fr, t, flags=re.IGNORECASE)
return t
# =========================
# TTS ENDPOINT SÉCURISÉ CONTRE LES ERREURS RÉSEAU
# Génère un fichier audio MP3 côté serveur avec edge-tts
# =========================
@app.get("/tts")
async def tts(text: str):
try:
# On applique le filtre anti-astérisque et phonétique avant l'envoi de la voix
text_clean = clean_tts_text(text)
if not text_clean.strip():
return StreamingResponse(io.BytesIO(), media_type="audio/mpeg")
communicate = edge_tts.Communicate(text_clean, TTS_VOICE)
buf = io.BytesIO()
# Flux sécurisé asynchrone par morceaux (evite les coupures de proxy)
async for chunk in communicate.stream():
if chunk["type"] == "audio":
buf.write(chunk["data"])
buf.seek(0)
# Validation du contenu généré
if buf.getbuffer().nbytes == 0:
return StreamingResponse(io.BytesIO(), media_type="audio/mpeg")
return StreamingResponse(buf, media_type="audio/mpeg")
except Exception as e:
# Capture l'erreur réseau proprement sans faire crasher l'interface utilisateur
print(f"❌ Erreur réseau interceptée sur le module Voix (edge-tts): {e}")
return StreamingResponse(io.BytesIO(), media_type="audio/mpeg")
# =========================
# UI
# =========================
@app.get("/", response_class=HTMLResponse)
def home():
return """<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Maître Deuck</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Lato:wght@300;400;700&display=swap" rel="stylesheet">
<style>
:root {
--orange: #ff6b35;
--dark: #0d0d0d;
--card: #1a1a1a;
--border: #2a2a2a;
--text: #f0e6d3;
--muted: #777;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--dark);
color: var(--text);
font-family: 'Lato', sans-serif;
font-size: 13px;
display: flex;
flex-direction: column;
min-height: 100vh;
}
header {
text-align: center;
padding: 14px 12px 10px;
border-bottom: 1px solid var(--border);
background: #111;
}
header .logo {
font-family: 'Playfair Display', serif;
font-size: 1.3rem;
font-weight: 700;
color: var(--orange);
letter-spacing: 2px;
text-transform: uppercase;
}
header .sub {
font-size: 0.65rem;
color: var(--muted);
letter-spacing: 3px;
text-transform: uppercase;
margin-top: 2px;
}
main {
flex: 1;
display: flex;
flex-direction: column;
padding: 12px;
gap: 10px;
overflow-y: auto;
}
.welcome-card {
background: var(--card);
border: 1px solid var(--border);
border-left: 3px solid var(--orange);
border-radius: 8px;
padding: 12px 14px;
animation: fadeIn 0.6s ease;
}
.welcome-card .name {
font-family: 'Playfair Display', serif;
font-size: 0.85rem;
color: var(--orange);
font-weight: 700;
margin-bottom: 4px;
}
.welcome-card .msg {
font-size: 0.8rem;
color: var(--text);
line-height: 1.5;
}
#chat {
display: flex;
flex-direction: column;
gap: 8px;
}
.bubble {
max-width: 88%;
padding: 9px 13px;
border-radius: 10px;
font-size: 0.8rem;
line-height: 1.5;
animation: slideUp 0.25s ease;
}
.bubble.user {
align-self: flex-end;
background: var(--orange);
color: #fff;
border-bottom-right-radius: 3px;
}
.bubble.bot {
align-self: flex-start;
background: var(--card);
border: 1px solid var(--border);
color: var(--text);
border-bottom-left-radius: 3px;
}
.bubble.bot .bubble-name {
font-size: 0.65rem;
color: var(--orange);
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 4px;
}
.thinking {
align-self: flex-start;
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
border-bottom-left-radius: 3px;
padding: 10px 14px;
display: none;
}
.dots span {
display: inline-block;
width: 6px; height: 6px;
background: var(--orange);
border-radius: 50%;
margin: 0 2px;
animation: bounce 1.2s infinite;
}
.dots span:nth-child(2) { animation-delay: 0.2s; }
.dots span:nth-child(3) { animation-delay: 0.4s; }
.input-zone {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
display: flex;
gap: 8px;
align-items: flex-end;
}
textarea {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-family: 'Lato', sans-serif;
font-size: 0.8rem;
resize: none;
min-height: 36px;
max-height: 90px;
line-height: 1.4;
}
textarea::placeholder { color: var(--muted); }
#btn-mic {
width: 34px; height: 34px;
border-radius: 50%;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
}
#btn-mic:hover { border-color: var(--orange); color: var(--orange); }
#btn-mic.active { background: var(--orange); border-color: var(--orange); color: #fff; animation: pulse 1s infinite; }
#btn-send {
padding: 8px 18px;
background: var(--orange);
border: none;
border-radius: 6px;
color: #fff;
font-family: 'Lato', sans-serif;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
height: 34px;
}
#btn-send:hover { background: #e05520; }
#btn-send:disabled { background: #444; cursor: not-allowed; }
@keyframes fadeIn { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }
@keyframes slideUp { from{opacity:0;transform:translateY(5px)} to{opacity:1;transform:translateY(0)} }
@keyframes bounce { 0%,80%,100%{transform:scale(0)} 40%{transform:scale(1)} }
@keyframes pulse { 0%,100%{box-shadow:0 0 0 0 rgba(255,107,53,.4)} 50%{box-shadow:0 0 0 6px rgba(255,107,53,0)} }
</style>
</head>
<body>
<header>
<div class="logo">🍺 Valdeuck</div>
<div class="sub">Bincherie Fabrik — Salon-de-Provence</div>
</header>
<main>
<div class="welcome-card">
<div class="name">Maître Deuck</div>
<div class="msg">
Bonjour&nbsp;! Je suis Maître Deuck, l'expert brassicole de la Valdeuck.<br>
Comment puis-je vous aider&nbsp;?
</div>
</div>
<div id="chat"></div>
<div class="thinking" id="thinking">
<div class="dots"><span></span><span></span><span></span></div>
</div>
</main>
<div style="padding:8px 12px 12px; background:var(--dark); border-top:1px solid var(--border);">
<div class="input-zone">
<textarea
id="input"
placeholder="Votre question… ou 🎤 pour parler"
rows="1"
></textarea>
<button id="btn-mic" title="Parler">🎤</button>
<button id="btn-send">Envoyer</button>
</div>
</div>
<script>
let currentAudio = null;
async function speak(text) {
try {
if (currentAudio) {
currentAudio.pause();
currentAudio = null;
}
const url = "/tts?text=" + encodeURIComponent(text);
const audio = new Audio(url);
currentAudio = audio;
await audio.play();
} catch (e) {
console.warn("TTS playback error:", e);
}
}
window.addEventListener("load", () => {
setTimeout(() => speak(WELCOME), 700);
});
function addBubble(text, who) {
const chat = document.getElementById("chat");
const div = document.createElement("div");
div.className = `bubble ${who}`;
if (who === "bot") {
div.innerHTML = `<div class="bubble-name">Maître Deuck</div>${text}`;
} else {
div.textContent = text;
}
chat.appendChild(div);
div.scrollIntoView({ behavior: "smooth", block: "end" });
}
async function send(queryText) {
const input = document.getElementById("input");
const btnSend = document.getElementById("btn-send");
const thinking = document.getElementById("thinking");
const q = (queryText || input.value).trim();
if (!q) return;
addBubble(q, "user");
input.value = "";
btnSend.disabled = true;
thinking.style.display = "block";
thinking.scrollIntoView({ behavior: "smooth", block: "end" });
try {
const res = await fetch("/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: q })
});
const data = await res.json();
const answer = data.response || "Information non disponible.";
thinking.style.display = "none";
addBubble(answer, "bot");
speak(answer);
} catch {
thinking.style.display = "none";
addBubble("Souci technique… Reformulez votre question. 🍺", "bot");
}
btnSend.disabled = false;
}
document.getElementById("btn-send").addEventListener("click", () => send());
document.getElementById("input").addEventListener("keydown", e => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
});
let recognition = null;
document.getElementById("btn-mic").addEventListener("click", async () => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) {
alert("Reconnaissance vocale non supportée sur ce navigateur (utilisez Chrome).");
return;
}
if (recognition) {
recognition.stop();
return;
}
try {
await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) {
alert("Accès au microphone refusé. Autorisez le micro dans les paramètres du navigateur.");
return;
}
const btn = document.getElementById("btn-mic");
const input = document.getElementById("input");
recognition = new SR();
recognition.lang = "fr-FR";
recognition.interimResults = true;
recognition.maxAlternatives = 1;
recognition.continuous = false;
btn.classList.add("active");
input.value = "";
input.placeholder = "🎤 Parlez maintenant…";
recognition.onresult = e => {
let interim = "";
let final = "";
for (let i = e.resultIndex; i < e.results.length; i++) {
const t = e.results[i][0].transcript;
if (e.results[i].isFinal) final += t;
else interim += t;
}
input.value = final || interim;
if (final) {
setTimeout(() => {
input.placeholder = "Votre question… ou 🎤 pour parler";
send(final.trim());
}, 400);
}
};
recognition.onerror = e => {
console.error("Mic error:", e.error);
btn.classList.remove("active");
input.placeholder = "Votre question… ou 🎤 pour parler";
recognition = null;
if (e.error === "not-allowed") alert("Microphone non autorisé.");
if (e.error === "no-speech") input.value = "";
if (e.error === "network") alert("Erreur réseau sur la reconnaissance vocale.");
};
recognition.onend = () => {
btn.classList.remove("active");
input.placeholder = "Votre question… ou 🎤 pour parler";
recognition = null;
};
recognition.start();
});
</script>
</body>
</html>
"""
# =========================
# HEALTH -
# =========================
@app.get("/health")
def health():
return {
"status": "ok",
"beer": beer_db._collection.count(),
"knowledge": knowledge_db._collection.count(),
"tts_voice": TTS_VOICE,
"time": time.time()
}
# =========================
# ASK
# =========================
# Si openai sans Pollinations
#@app.post("/ask")
#def ask(q: Query):
# try:
# beer_docs = beer_retriever.invoke(q.query)
# know_docs = knowledge_retriever.invoke(q.query)
# context = "\n".join(
# clean(d.page_content) for d in beer_docs + know_docs
# )
# system = """Tu es Maître Deuck, expert de la Valdeuck.
#Règles : réponds en 2-3 phrases maximum. Sois direct et précis.
#Si l'info est absente du contexte : "Information non disponible."
#Pas de marques externes. Français uniquement.
#Remplace systématiquement "alc" et "d'alc" par "alcool" et "d'alcool" dans tes réponses."""
# user = f"CONTEXTE :\n{context}\n\nQUESTION : {q.query}"
# if not HF_TOKEN_VALUE:
# raise RuntimeError(
# "Jeton absent : configurez HF_TOKEN dans les Secrets du Space."
# )
# res = client.chat.completions.create(
# model=HF_CHAT_MODEL,
# messages=[
# {"role": "system", "content": system},
# {"role": "user", "content": user}
# ],
# temperature=0.2,
# max_tokens=300
# )
# answer = (res.choices[0].message.content or "").strip()
# if not answer:
# raise RuntimeError("Le fournisseur a renvoyé une réponse vide.")
# answer = answer.replace("vol d'alc/", "vol d'alcool/")
# answer = answer.replace("% alc", "% alcool")
# answer = answer.replace("% d'alc", "% d'alcool")
# answer = answer.replace(" alc.", " alcool")
# answer = answer.replace(" alc/", " alcool/")
# answer = answer.replace("Valdeuck Bincherie Fabrik", "Valdeuck")
# return {"response": answer}
# except Exception as e:
# print(f"❌ Erreur critique dans /ask ({type(e).__name__}) : {str(e)}")
# return {
# "response": (
# "Maître Deuck est momentanément indisponible. "
# "Réessayez dans quelques instants."
# )
# }
@app.post("/ask")
def ask(q: Query):
try:
beer_docs = beer_retriever.invoke(q.query)
know_docs = knowledge_retriever.invoke(q.query)
context = "\n".join(
clean(d.page_content) for d in beer_docs + know_docs
)
system = """Tu es Maître Deuck, expert de la Valdeuck.
Règles : réponds en 2-3 phrases maximum. Sois direct et précis.
Si l'info est absente du contexte : "Information non disponible."
Pas de marques externes. Français uniquement.
Remplace systématiquement "alc" et "d'alc" par "alcool" et "d'alcool" dans tes réponses."""
user = f"CONTEXTE :\n{context}\n\nQUESTION : {q.query}"
# Appel direct à Pollinations.ai via son endpoint text compatible OpenAI
url = "https://text.pollinations.ai/"
payload = {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user}
],
"model": HF_CHAT_MODEL,
"json": False
}
print(f"Envoi de la requête à Pollinations pour : {q.query}")
res = requests.post(url, json=payload, timeout=30)
if res.status_code == 200:
answer = res.text.strip()
print("Réponse reçue avec succès !")
else:
raise Exception(f"Erreur serveur Pollinations code {res.status_code}: {res.text}")
if not answer:
raise RuntimeError("Le fournisseur a renvoyé une réponse vide.")
# Post-traitements de nettoyage
answer = answer.replace("vol d'alc/", "vol d'alcool/")
answer = answer.replace("% alc", "% alcool")
answer = answer.replace("% d'alc", "% d'alcool")
answer = answer.replace(" alc.", " alcool")
answer = answer.replace(" alc/", " alcool/")
answer = answer.replace("Valdeuck Bincherie Fabrik", "Valdeuck")
return {"response": answer}
except Exception as e:
print(f"❌ Erreur critique dans /ask ({type(e).__name__}) : {str(e)}")
return {
"response": (
"Maître Deuck est momentanément indisponible. "
"Réessayez dans quelques instants."
)
}