feat: integrate Peg AI wellness coach
MetaBloom CI / app (push) Canceled after 0s
MetaBloom CI / api (push) Canceled after 0s

This commit is contained in:
AI Company
2026-07-21 21:15:19 +00:00
parent ca70688dd5
commit 16dccd2f76
10 changed files with 1970 additions and 63 deletions
+62
View File
@@ -0,0 +1,62 @@
import re
import httpx
from .config import Settings
from .schemas import CoachChatIn, CoachChatOut
SYSTEM_PROMPT = """Tu es Peg, coach bien-être chaleureux de MetaBloom. Réponds en français, brièvement et concrètement.
Tu fournis uniquement des conseils généraux sur les habitudes, l'alimentation équilibrée, le sommeil, l'activité douce et la motivation.
Tu ne poses jamais de diagnostic, n'interprètes pas une mesure comme une conclusion médicale, ne prescris aucun traitement et ne proposes jamais de dose, d'arrêt ou de modification de médicament. Ne remplace jamais un médecin.
Si une personne décrit une urgence, des symptômes graves, une hypoglycémie sévère, une détresse ou un danger immédiat, invite-la à appeler immédiatement les urgences locales (15 ou 112 en France) et à ne pas rester seule. Ne poursuis pas le coaching.
Pour toute question clinique ou médicamenteuse, refuse clairement et recommande un médecin ou pharmacien. N'invente aucune donnée. Pose au plus une question utile. Respecte la vie privée et ne demande aucune identité."""
EMERGENCY = re.compile(
r"\b(urgence|inconscient(?:e)?|ne respire|douleur thoracique|convulsion|"
r"suicide|suicidaire|me suicider|me tuer|en finir|overdose|"
r"hypoglyc[eé]mie (?:grave|s[eé]v[eè]re))\b",
re.I,
)
CLINICAL = re.compile(
r"\b(diagnosti\w*|quelle dose|dosage|augment(?:e|er).*dose|diminu(?:e|er).*dose|"
r"arr[eê]t(?:e|er)?.*m[eé]dicament|arr[eê]t(?:e|er)?.*traitement|"
r"(?:prendre|injecter|mettre|faut-il).*\d+(?:[.,]\d+)?\s*(?:unit[eé]s?|mg|ml)|"
r"insuline.*unit[eé]|prescri\w*)\b",
re.I,
)
def safety_reply(text: str) -> CoachChatOut | None:
if EMERGENCY.search(text):
return CoachChatOut(message="Cela peut être une urgence. Appelle immédiatement les urgences locales — le 15 ou le 112 en France — et ne reste pas seul·e. Peg ne peut pas évaluer ni prendre en charge une urgence.", source="safety")
if CLINICAL.search(text):
return CoachChatOut(message="Je ne peux pas poser de diagnostic ni conseiller une dose ou une modification de traitement. Contacte ton médecin ou ton pharmacien. Je peux en revanche taider à préparer les questions à lui poser.", source="safety")
return None
async def chat_with_peg(data: CoachChatIn, cfg: Settings) -> CoachChatOut:
guarded = safety_reply(data.message)
if guarded:
return guarded
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
messages.extend(item.model_dump() for item in data.history)
messages.append({"role": "user", "content": data.message})
headers = {"Content-Type": "application/json"}
if cfg.ai_api_key:
headers["Authorization"] = f"Bearer {cfg.ai_api_key}"
try:
async with httpx.AsyncClient(timeout=cfg.ai_timeout_seconds) as client:
response = await client.post(
f"{cfg.ai_base_url.rstrip('/')}/chat/completions",
headers=headers,
json={"model": cfg.ai_model, "messages": messages, "temperature": 0.4, "max_tokens": 350},
)
response.raise_for_status()
content = response.json()["choices"][0]["message"]["content"].strip()
if not content:
raise ValueError("empty model response")
# A model response is re-guarded in case it attempts prohibited prescribing.
if CLINICAL.search(content):
raise ValueError("unsafe model response")
return CoachChatOut(message=content, source="model")
except (httpx.HTTPError, KeyError, IndexError, TypeError, ValueError):
return CoachChatOut(message="Je suis momentanément indisponible. En attendant, choisis une petite action sans risque : boire un verre deau, marcher quelques minutes si tu te sens bien, ou noter ton prochain repas. Pour une question médicale, contacte un professionnel de santé.", source="fallback")
+4 -1
View File
@@ -9,6 +9,10 @@ class Settings(BaseSettings):
access_token_minutes: int = 30
allowed_origins: str = "http://localhost:8081,http://localhost:19006"
environment: str = "development"
ai_base_url: str = "http://localhost:11434/v1"
ai_api_key: str = ""
ai_model: str = "llama3.2:3b"
ai_timeout_seconds: float = 20.0
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@property
@@ -19,4 +23,3 @@ class Settings(BaseSettings):
@lru_cache
def get_settings() -> Settings:
return Settings()
+23 -2
View File
@@ -1,4 +1,6 @@
from datetime import datetime, timezone
import secrets
import uuid
from fastapi import Depends, FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordRequestForm
@@ -7,7 +9,8 @@ from sqlalchemy.orm import Session
from .config import get_settings
from .db import Base, engine, get_db
from .models import Entry, Profile, User
from .schemas import EntryIn, EntryOut, ProfileIn, ProfileOut, Register, Token
from .schemas import CoachChatIn, CoachChatOut, EntryIn, EntryOut, ProfileIn, ProfileOut, Register, Token
from .coach import chat_with_peg
from .security import current_user, hash_password, make_token, verify_password
cfg = get_settings()
@@ -28,6 +31,11 @@ def health():
return {"status": "ok", "medical_notice": "MetaBloom does not diagnose or replace medical care."}
@app.post("/coach/chat", response_model=CoachChatOut)
async def coach_chat(data: CoachChatIn, user: User = Depends(current_user)):
return await chat_with_peg(data, cfg)
@app.post("/auth/register", response_model=Token, status_code=201)
def register(data: Register, db: Session = Depends(get_db)):
email = data.email.lower()
@@ -39,6 +47,20 @@ def register(data: Register, db: Session = Depends(get_db)):
return Token(access_token=make_token(user.id))
@app.post("/auth/guest", response_model=Token, status_code=201)
def guest_session(db: Session = Depends(get_db)):
"""Create a private pseudonymous session for a first app launch."""
user = User(
email=f"guest-{uuid.uuid4()}@local.invalid",
password_hash=hash_password(secrets.token_urlsafe(32)),
)
db.add(user)
db.flush()
db.add(Profile(user_id=user.id))
db.commit()
return Token(access_token=make_token(user.id))
@app.post("/auth/token", response_model=Token)
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.scalar(select(User).where(User.email == form.username.lower()))
@@ -106,4 +128,3 @@ def export_account(user: User = Depends(current_user)):
def delete_account(user: User = Depends(current_user), db: Session = Depends(get_db)):
db.delete(user); db.commit()
return Response(status_code=204)
+17
View File
@@ -62,3 +62,20 @@ class EntryOut(EntryIn):
recorded_at: datetime
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class CoachMessage(BaseModel):
role: str = Field(pattern="^(user|assistant)$")
content: str = Field(min_length=1, max_length=2000)
class CoachChatIn(BaseModel):
message: str = Field(min_length=1, max_length=2000)
history: list[CoachMessage] = Field(default_factory=list, max_length=12)
class CoachChatOut(BaseModel):
assistant: str = "Peg"
message: str
source: str = Field(description="model, safety, or fallback")
medical_notice: str = "Conseils généraux uniquement — Peg ne remplace pas un professionnel de santé."