import re import httpx from .config import Settings from .schemas import CoachChatIn, CoachChatOut SYSTEM_PROMPT = """Tu es Peg, coach bien-être chaleureux. Réponds en français naturel en 2 à 4 phrases, avec une action simple et concrète. Reste sur les habitudes générales : alimentation équilibrée, sommeil, activité douce et motivation. Aucun diagnostic, interprétation médicale, médicament, dose ou changement de traitement. Pour une question clinique, recommande un médecin ou pharmacien. En cas d'urgence, indique le 15 ou le 112 en France. N'invente rien 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 t’aider à 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, # A concise prompt and answer keep local CPU inference within the UI timeout. json={"model": cfg.ai_model, "messages": messages, "temperature": 0.4, "max_tokens": 90}, ) 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 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}