From 0c2430ad13a8bd5131f1fd0ab70a9b4e7a3e173c Mon Sep 17 00:00:00 2001 From: AI Company Date: Tue, 21 Jul 2026 22:06:03 +0000 Subject: [PATCH] fix: connect Peg to local AI runtime --- server/.env.example | 7 ++++--- server/README.md | 3 +++ server/app/coach.py | 16 +++++++++++++++- server/app/config.py | 5 +++-- server/app/main.py | 7 ++++++- server/docker-compose.example.yml | 9 ++++++++- server/tests/test_coach.py | 11 +++++++++++ 7 files changed, 50 insertions(+), 8 deletions(-) diff --git a/server/.env.example b/server/.env.example index 46f01ed..957aa15 100644 --- a/server/.env.example +++ b/server/.env.example @@ -4,7 +4,8 @@ ENVIRONMENT=production ALLOWED_ORIGINS=https://app.example.com POSTGRES_PASSWORD=change-me # API OpenAI-compatible : Ollama local par défaut, ou fournisseur autorisé. -AI_BASE_URL=http://localhost:11434/v1 -AI_MODEL=llama3.2:3b +# Docker sur le réseau ai-company : utiliser le nom DNS du service, pas localhost. +AI_BASE_URL=http://ollama:11434/v1 +AI_MODEL=qwen2.5:1.5b AI_API_KEY= -AI_TIMEOUT_SECONDS=20 +AI_TIMEOUT_SECONDS=90 diff --git a/server/README.md b/server/README.md index f5a057e..610aed2 100644 --- a/server/README.md +++ b/server/README.md @@ -33,6 +33,7 @@ Le port n’écoute que sur localhost ; placer un reverse proxy TLS devant en pr - `GET /account/export`, `DELETE /account` - `POST /coach/chat` — conversation authentifiée avec Peg - `GET /health` +- `GET /health/ai` — joignabilité du moteur IA et nom du modèle, sans exposer de secret Envoyer `Authorization: Bearer `. La création d’entrées exige `consent_health_data=true`. Les limites de saisie préviennent les erreurs grossières mais ne constituent pas une validation médicale. @@ -42,6 +43,8 @@ Envoyer `Authorization: Bearer `. La création d’entrées exige `consen Peg utilise une API compatible OpenAI (`/v1/chat/completions`). La configuration par défaut vise Ollama local et ne nécessite aucune clé. Définir `AI_BASE_URL`, `AI_MODEL` et, si nécessaire, `AI_API_KEY`. Aucun secret n'est commité. Un refus déterministe couvre diagnostic, prescription et dosage ; les urgences renvoient vers le 15/112. Si le modèle est indisponible ou produit une réponse suspecte, l’API renvoie un conseil général sûr avec `source=fallback`. +En Docker, `localhost` désigne le conteneur API et non Ollama. Le Compose d’exemple rattache donc l’API au réseau externe `ai-company` et utilise `http://ollama:11434/v1`. Le service Ollama doit porter le nom DNS `ollama`, le modèle configuré doit être téléchargé, et `GET /health/ai` doit retourner `reachable: true`. + Exemple de corps : `{"message":"Comment reprendre doucement la marche ?","history":[]}`. L’historique est limité à 12 messages et n’est pas persisté par cet endpoint. ## Tests diff --git a/server/app/coach.py b/server/app/coach.py index 45c49d2..290bc5a 100644 --- a/server/app/coach.py +++ b/server/app/coach.py @@ -48,7 +48,8 @@ async def chat_with_peg(data: CoachChatIn, cfg: Settings) -> CoachChatOut: 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}, + # Keep answers useful but short so a local CPU model responds reliably. + json={"model": cfg.ai_model, "messages": messages, "temperature": 0.4, "max_tokens": 160}, ) response.raise_for_status() content = response.json()["choices"][0]["message"]["content"].strip() @@ -60,3 +61,16 @@ async def chat_with_peg(data: CoachChatIn, cfg: Settings) -> CoachChatOut: 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 d’eau, 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") + + +async def peg_provider_health(cfg: Settings) -> dict[str, str | bool]: + """Check the configured AI provider without leaking credentials or prompts.""" + base_url = cfg.ai_base_url.rstrip("/") + headers = {"Authorization": f"Bearer {cfg.ai_api_key}"} if cfg.ai_api_key else {} + try: + async with httpx.AsyncClient(timeout=min(cfg.ai_timeout_seconds, 5.0)) as client: + response = await client.get(f"{base_url}/models", headers=headers) + response.raise_for_status() + return {"status": "ok", "reachable": True, "model": cfg.ai_model} + except httpx.HTTPError: + return {"status": "unavailable", "reachable": False, "model": cfg.ai_model} diff --git a/server/app/config.py b/server/app/config.py index 9356467..e278de2 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -11,8 +11,9 @@ class Settings(BaseSettings): 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 + ai_model: str = "qwen2.5:1.5b" + # Local models may need extra time for their first response while loading. + ai_timeout_seconds: float = 90.0 model_config = SettingsConfigDict(env_file=".env", extra="ignore") @property diff --git a/server/app/main.py b/server/app/main.py index 632e616..976c959 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -10,7 +10,7 @@ from .config import get_settings from .db import Base, engine, get_db from .models import Entry, Profile, User from .schemas import CoachChatIn, CoachChatOut, EntryIn, EntryOut, ProfileIn, ProfileOut, Register, Token -from .coach import chat_with_peg +from .coach import chat_with_peg, peg_provider_health from .security import current_user, hash_password, make_token, verify_password cfg = get_settings() @@ -31,6 +31,11 @@ def health(): return {"status": "ok", "medical_notice": "MetaBloom does not diagnose or replace medical care."} +@app.get("/health/ai") +async def ai_health(): + return await peg_provider_health(cfg) + + @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) diff --git a/server/docker-compose.example.yml b/server/docker-compose.example.yml index 15c26ec..c1a8777 100644 --- a/server/docker-compose.example.yml +++ b/server/docker-compose.example.yml @@ -6,10 +6,15 @@ services: JWT_SECRET: ${JWT_SECRET} ENVIRONMENT: production ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:8081} + AI_BASE_URL: ${AI_BASE_URL:-http://ollama:11434/v1} + AI_MODEL: ${AI_MODEL:-qwen2.5:1.5b} + AI_API_KEY: ${AI_API_KEY:-} + AI_TIMEOUT_SECONDS: ${AI_TIMEOUT_SECONDS:-90} depends_on: db: condition: service_healthy ports: ["127.0.0.1:8000:8000"] + networks: [default, ai-company] db: image: postgres:16-alpine environment: @@ -24,4 +29,6 @@ services: retries: 10 volumes: postgres_data: - +networks: + ai-company: + external: true diff --git a/server/tests/test_coach.py b/server/tests/test_coach.py index f02c8ef..279f62a 100644 --- a/server/tests/test_coach.py +++ b/server/tests/test_coach.py @@ -88,3 +88,14 @@ def test_coach_fallback_when_provider_is_down(monkeypatch): response = client.post("/coach/chat", headers=auth(client), json={"message": "Donne-moi une idée simple"}) assert response.status_code == 200 assert response.json()["source"] == "fallback" + + +def test_ai_health_reports_provider_status(monkeypatch): + async def fake_get(self, url, **kwargs): + request = httpx.Request("GET", url) + return httpx.Response(200, request=request, json={"data": []}) + monkeypatch.setattr(httpx.AsyncClient, "get", fake_get) + with TestClient(app) as client: + response = client.get("/health/ai") + assert response.status_code == 200 + assert response.json()["reachable"] is True