""" app.py - Hauptanwendung (FastAPI) für den Mama Bot Läuft auf Hugging Face Spaces (Docker). Nutzt Hugging Face Inference API für das LLM. WhatsApp-Anbindung über WAHA (WhatsApp HTTP API). Enthält: - WhatsApp Webhook-Handler mit Intent-Erkennung - Keep-Alive System (Self-Ping + externe Endpoints) - Integrierter Scheduler (Morgennachrichten, Check-in, Wochenprogramm) - Reminder-System - Benutzerprofil-Verwaltung """ import os import json import random import logging import threading import time as _time from datetime import datetime import httpx from fastapi import FastAPI, Request, HTTPException from fastapi.responses import JSONResponse, PlainTextResponse from bot_config import ( EMERGENCY_KEYWORDS, AUTHORITY_KEYWORDS, HEALTH_KEYWORDS, REMINDER_KEYWORDS, EMERGENCY_NUMBERS, FALLBACK_MESSAGES, get_system_prompt, load_user_profile, save_user_profile, MAX_HISTORY, TOPICS, MORNING_TEMPLATES, CHECKIN_MESSAGES, WEEKLY_PROGRAM_MESSAGES ) # ============================================================ # LOGGING # ============================================================ logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger("mama-bot") # ============================================================ # FASTAPI # ============================================================ app = FastAPI( title="Mama Bot", version="3.0.0", description="Persönlicher KI-Assistent für ältere Personen – Hugging Face Spaces Edition" ) # ============================================================ # KONFIGURATION # ============================================================ HF_API_TOKEN = os.getenv("HF_API_TOKEN", os.getenv("HF_TOKEN", "")) HF_MODEL = os.getenv("HF_MODEL", "mistralai/Mistral-7B-Instruct-v0.3") # Green API (WhatsApp) GREEN_API_URL = os.getenv("GREEN_API_URL", "https://7107.api.greenapi.com") GREEN_API_ID_INSTANCE = os.getenv("GREEN_API_ID_INSTANCE", "7107662501") GREEN_API_TOKEN = os.getenv("GREEN_API_TOKEN", "") # Legacy: WAHA (fallback) WAHA_API_URL = os.getenv("WAHA_API_URL", "http://waha:3000") WAHA_API_KEY = os.getenv("WAHA_API_KEY", "") WAHA_SESSION = os.getenv("WAHA_SESSION", "default") USER_NAME = os.getenv("USER_NAME", "Sabine") USER_PHONE = os.getenv("USER_PHONE", "") # Scheduler-Konfiguration MORNING_HOUR = int(os.getenv("MORNING_HOUR", 8)) MORNING_MINUTE = int(os.getenv("MORNING_MINUTE", 0)) CHECKIN_DAYS = int(os.getenv("CHECKIN_DAYS", 3)) # Persistenz-Datei für Scheduler-Zustand SCHEDULER_STATE_FILE = os.path.join(os.path.dirname(__file__), "memory", "scheduler_state.json") # ============================================================ # IN-MEMORY STORES # ============================================================ conversation_history: list[dict] = [] reminders: list[dict] = [] # ============================================================ # SCHEDULER-STATE (persistent) # ============================================================ def load_scheduler_state() -> dict: """Lädt den Scheduler-Zustand aus der JSON-Datei.""" try: with open(SCHEDULER_STATE_FILE, "r", encoding="utf-8") as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return { "last_morning": None, "last_checkin": None, "sent_topic_ids": [] } def save_scheduler_state(state: dict): """Speichert den Scheduler-Zustand.""" os.makedirs(os.path.dirname(SCHEDULER_STATE_FILE), exist_ok=True) with open(SCHEDULER_STATE_FILE, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) # ============================================================ # LLM – Hugging Face Inference API # ============================================================ def call_hf_inference(system_prompt: str, user_message: str, history: list[dict]) -> str: """Ruft die Hugging Face Inference API auf.""" if not HF_API_TOKEN: logger.warning("Kein HF_API_TOKEN gesetzt – nutze Fallback.") return random.choice(FALLBACK_MESSAGES) url = f"https://api-inference.huggingface.co/models/{HF_MODEL}" prompt_parts = [f"[INST] <>\n{system_prompt}\n<>\n\n"] for h in history[-MAX_HISTORY:]: if h["role"] == "user": prompt_parts.append(f"{h['content']} [/INST] ") elif h["role"] == "assistant": prompt_parts.append(f"{h['content']} [INST] ") prompt_parts.append(f"{user_message} [/INST]") full_prompt = "".join(prompt_parts) headers = {"Authorization": f"Bearer {HF_API_TOKEN}", "Content-Type": "application/json"} payload = { "inputs": full_prompt, "parameters": { "max_new_tokens": 350, "temperature": 0.7, "top_p": 0.9, "repetition_penalty": 1.1, "return_full_text": False } } try: with httpx.Client(timeout=60) as client: response = client.post(url, json=payload, headers=headers) if response.status_code == 503: logger.info("Modell wird geladen, warte 20s...") _time.sleep(20) response = client.post(url, json=payload, headers=headers) response.raise_for_status() data = response.json() if isinstance(data, list) and len(data) > 0: generated = data[0].get("generated_text", "") return generated.strip() if generated.strip() else random.choice(FALLBACK_MESSAGES) return random.choice(FALLBACK_MESSAGES) except httpx.TimeoutException: logger.error("Timeout bei HF Inference API") return "Entschuldige, das hat etwas gedauert. Kannst du das bitte wiederholen? 😊" except Exception as e: logger.error(f"Fehler bei HF Inference API: {e}") return random.choice(FALLBACK_MESSAGES) # ============================================================ # WHATSAPP SENDEN (Green API) # ============================================================ def send_whatsapp(phone: str, message: str) -> bool: """Sendet eine WhatsApp-Nachricht über Green API.""" if not phone: return False try: url = f"{GREEN_API_URL}/waInstance{GREEN_API_ID_INSTANCE}/sendMessage/{GREEN_API_TOKEN}" payload = { "chatId": f"{phone}@s.whatsapp.net", "message": message } with httpx.Client(timeout=30) as client: resp = client.post(url, json=payload) data = resp.json() return data.get("idMessage", "") != "" except Exception as e: logger.error(f"Fehler beim WhatsApp-Senden (Green API): {e}") return False def receive_whatsapp_webhook(data: dict) -> tuple: """Parst eine Green API Webhook-Nachricht.""" try: # Green API Webhook Format message_body = data.get("message", {}).get("text", "") sender = data.get("sender", {}).get("id", "").replace("@s.whatsapp.net", "") return message_body, sender except Exception as e: logger.error(f"Fehler beim Parsen der Green API Nachricht: {e}") return "", "" # ============================================================ # INTENT-ERKENNUNG # ============================================================ def detect_intent(message: str) -> str: """Erkennt die Absicht: emergency, authority, health, reminder, chat.""" msg = message.lower().strip() for kw in EMERGENCY_KEYWORDS: if kw in msg: return "emergency" for kw in HEALTH_KEYWORDS: if kw in msg: return "health" for kw in AUTHORITY_KEYWORDS: if kw in msg: return "authority" for kw in REMINDER_KEYWORDS: if kw in msg: return "reminder" return "chat" # ============================================================ # DOMAIN-HANDLER # ============================================================ def handle_emergency() -> str: numbers = "\n".join(f"📞 {n}: {nr}" for n, nr in EMERGENCY_NUMBERS.items()) return ( "🚨 Ich bin hier, um dir zu helfen!\n\n" "Atme erst einmal tief durch. Alles wird gut.\n\n" f"Wichtige Telefonnummern:\n{numbers}\n\n" "Wenn es ein medizinischer Notfall ist, wähle bitte direkt die 112.\n" "Sag mir genau, was passiert ist, dann helfe ich dir weiter. 💝" ) def handle_authority(user_message: str) -> str: msg = user_message.lower() disclaimer = "⚠️ Das sind allgemeine Informationen, kein Rechtsrat. Bei wichtigen Entscheidungen bitte Beratung suchen." if "widerspruch" in msg: return ( "📋 Einen Widerspruch kannst du innerhalb von 1 Monat nach Zustellung des Bescheids einlegen.\n\n" "Schreibe einen Brief, in dem du erklärst, warum du nicht einverstanden bist, " "und schicke ihn an die Behörde, die den Bescheid geschickt hat.\n\n" "💡 Kostenlose Beratung: VdK 0800 888 0555\n\n" f"{disclaimer}" ) if "rente" in msg: return ( "💰 Den Rentenantrag solltest du 3 Monate vor deinem Rentenbeginn stellen.\n\n" "Deutsche Rentenversicherung: 0800 1000 100\n" "VdK-Beratung: 0800 888 0555\n\n" f"{disclaimer}" ) if "pflegegrad" in msg: return ( "🏥 Den Pflegegrad beantragst du bei deiner Krankenkasse.\n" "Du brauchst einen Arztbericht. Der MDK kommt zu dir und schaut, wie viel Hilfe du brauchst.\n\n" "💡 Beratung: VdK 0800 888 0555\n\n" f"{disclaimer}" ) return ( "🏛️ Kostenlose Beratung:\n" "• VdK: 0800 888 0555\n" "• SoVD: 0800 554 5555\n" "• Deutsche Rentenversicherung: 0800 1000 100\n\n" "Wichtige Fristen:\n" "• Widerspruch: 1 Monat nach Bescheid\n" "• Rente beantragen: 3 Monate vor Rentenbeginn\n\n" f"{disclaimer}" ) def handle_health(user_message: str) -> str: msg = user_message.lower() if "pflegegeld" in msg or "sachleistung" in msg: return ( "💊 Es gibt zwei Arten der Pflegeunterstützung:\n\n" "1. Pflegegeld: Geld für die häusliche Pflege durch Angehörige.\n" "2. Pflegesachleistung: Ein Pflegedienst kommt zu dir nach Hause.\n\n" "Du kannst auch beides mischen (Kombinationsleistung).\n\n" "💡 Nach § 45b SGB XI hast du auch Anspruch auf Entlastungsleistung " "(bis zu 120,80 € pro Monat).\n\n" "Ruf deine Krankenkasse an oder geh zum Pflegestützpunkt vor Ort. 💝" ) if "mdk" in msg or "gutachtung" in msg: return ( "🏥 Der MDK (Medizinischer Dienst) schaut bei deinem Pflegegrad-Antrag.\n\n" "So läuft's ab:\n" "1. Antrag bei der Krankenkasse\n" "2. Arztbericht erstellen\n" "3. MDK-Besuch bei dir zu Hause\n" "4. Pflegegrad-Bescheid (1-5)\n\n" "Das dauert meist 4-6 Wochen. Hab etwas Geduld! 😊" ) return ( "💚 Gesundheit ist das Wichtigste!\n\n" "Anlaufstellen:\n" "• Deine Krankenkasse (Nummer auf der Versichertenkarte)\n" "• Pflegestützpunkt vor Ort\n" "• VdK-Beratung: 0800 888 0555\n" "• Ärztlicher Notdienst: 116 117\n\n" "Wenn du Schmerzen hast, ruf bitte den Arzt an. " "Ich bin nur ein Computerprogramm und kann keine medizinische Beratung geben. 💝" ) def handle_reminder(user_message: str) -> str: reminder_id = str(datetime.now().timestamp()).replace(".", "") reminders.append({ "id": reminder_id, "message": user_message, "created_at": datetime.now().isoformat(), "status": "pending" }) return f"✅ Alles klar! Ich habe mir notiert: \"{user_message}\"\n\nIch werde dich daran erinnern! 📝" def handle_chat(user_message: str, history: list) -> str: """Normaler Chat – ruft HF Inference API auf.""" profile = load_user_profile() system_prompt = get_system_prompt( user_name=profile.get("name", USER_NAME), preferences=profile.get("preferences", []), concerns=profile.get("concerns", []) ) return call_hf_inference(system_prompt, user_message, history) # ============================================================ # WEBHOOK-HANDLER # ============================================================ @app.post("/webhook") async def webhook(request: Request): """Empfängt WhatsApp-Nachrichten von WAHA und sendet Antworten.""" try: data = await request.json() logger.info(f"Webhook empfangen: {json.dumps(data, ensure_ascii=False)[:500]}") # Green API Webhook Format message_body, sender = receive_whatsapp_webhook(data) # Fallback: WAHA Format if not message_body: message_body = data.get("body", data.get("text", "")) if not sender: sender = data.get("from", data.get("sender", "")) if not message_body: return JSONResponse({"status": "ok", "message": "Keine Nachricht"}) intent = detect_intent(message_body) logger.info(f"Intent: {intent}") if intent == "emergency": response_text = handle_emergency() elif intent == "authority": response_text = handle_authority(message_body) elif intent == "health": response_text = handle_health(message_body) elif intent == "reminder": response_text = handle_reminder(message_body) else: conversation_history.append({"role": "user", "content": message_body}) response_text = handle_chat(message_body, conversation_history) conversation_history.append({"role": "assistant", "content": response_text}) if len(conversation_history) > MAX_HISTORY * 2: conversation_history[:] = conversation_history[-MAX_HISTORY:] profile = load_user_profile() profile["last_contact"] = datetime.now().isoformat() save_user_profile(profile) if sender: send_whatsapp(sender, response_text) return JSONResponse({"status": "ok", "reply": response_text, "intent": intent}) except Exception as e: logger.error(f"Webhook-Fehler: {e}") return JSONResponse({"status": "error", "message": str(e)}) # ============================================================ # HEALTH & INFO ENDPOINTS # ============================================================ @app.get("/") async def root(): return { "status": "running", "service": "Mama Bot", "version": "3.0.0", "model": HF_MODEL, "timestamp": datetime.now().isoformat() } @app.get("/health") async def health(): state = load_scheduler_state() return { "status": "healthy", "model": HF_MODEL, "hf_token_set": bool(HF_API_TOKEN), "whatsapp": "green-api", "green_api_instance": GREEN_API_ID_INSTANCE, "conversation_length": len(conversation_history), "active_reminders": len([r for r in reminders if r["status"] == "pending"]), "last_morning": state.get("last_morning"), "last_checkin": state.get("last_checkin"), "sent_topics_count": len(state.get("sent_topic_ids", [])), "timestamp": datetime.now().isoformat() } # ============================================================ # KEEP-ALIVE ENDPOINTS # ============================================================ _last_self_ping = datetime.now() @app.get("/keepalive") async def keepalive(): """ Keep-Alive Endpoint für externe Ping-Services. Rufen Sie diesen Endpoint alle 30-60 Minuten auf, um den Space aktiv zu halten. Empfohlene externe Services (kostenlos): - cron-job.org (kostenlos, bis 10 Jobs) - UptimeRobot (kostenlos, 5-Minuten-Intervall) - Freshping (kostenlos, 1-Minuten-Intervall) """ global _last_self_ping _last_self_ping = datetime.now() return { "status": "alive", "service": "Mama Bot", "message": "Space ist wach! 💚", "timestamp": datetime.now().isoformat() } @app.get("/ping") async def ping(): """Einfacher Ping-Endpoint (kompatibel mit UptimeRobot etc.).""" return "pong" # ============================================================ # SCHEDULER (integriert – läuft im Hauptprozess) # ============================================================ def load_topics(topic_key: str) -> list: """Lädt Themen aus der JSON-Datei.""" cfg = TOPICS.get(topic_key) if not cfg: return [] path = os.path.join(os.path.dirname(__file__), cfg["file"]) try: with open(path, "r", encoding="utf-8") as f: return json.load(f).get("topics", []) except (FileNotFoundError, json.JSONDecodeError) as e: logger.error(f"Themen laden fehlgeschlagen ({topic_key}): {e}") return [] def get_today_topic_key() -> str: """Bestimmt das Thema basierend auf dem Wochentag.""" keys = list(TOPICS.keys()) return keys[datetime.now().weekday() % len(keys)] def generate_morning_message() -> str: """Generiert die tägliche Morgennachricht.""" profile = load_user_profile() user_name = profile.get("name", USER_NAME) state = load_scheduler_state() topic_key = get_today_topic_key() topic_cfg = TOPICS[topic_key] emoji = topic_cfg["emoji"] topic_name = topic_cfg["name"] all_topics = load_topics(topic_key) available = [t for t in all_topics if t.get("id") not in state.get("sent_topic_ids", [])] if not available: state["sent_topic_ids"] = [] save_scheduler_state(state) available = all_topics if available: chosen = random.choice(available) topic_id = chosen.get("id", "") state.setdefault("sent_topic_ids", []).append(topic_id) save_scheduler_state(state) base_content = chosen.get("content", "") else: base_content = f"Heute geht es um {topic_name}! {emoji}" # Mit LLM aufbereiten system = ( "Du bist ein liebevoller, geduldiger Assistent für eine ältere Person. " "Schreibe eine kurze, warme Morgennachricht auf Deutsch (max. 3 Sätze). " "Du verwendest einfache Wörter, ein freundliches 'Du' und passende Emojis. " "Die Person heißt Sabine – sprich sie direkt mit Namen an." ) prompt = ( f"Schreibe eine Morgennachricht für {user_name}. " f"Thema heute: {emoji} {topic_name}. " f"Info: {base_content}" ) generated = call_hf_inference(system, prompt, []) if generated and len(generated) > 20: return generated # Fallback: Template-basiert template = random.choice(MORNING_TEMPLATES) return template.format(name=user_name, content=base_content) def generate_weekly_message() -> str: """Generiert die Montags-Frage für das Wochenprogramm.""" profile = load_user_profile() user_name = profile.get("name", USER_NAME) return random.choice(WEEKLY_PROGRAM_MESSAGES).format(name=user_name) def generate_checkin_message() -> str: """Generiert eine sanfte Check-in Nachricht.""" profile = load_user_profile() user_name = profile.get("name", USER_NAME) return random.choice(CHECKIN_MESSAGES).format(name=user_name) def check_morning(): """Prüft und sendet die Morgennachricht.""" now = datetime.now() state = load_scheduler_state() # Heute schon gesendet? last = state.get("last_morning") if last and datetime.fromisoformat(last).date() == now.date(): return # Zur richtigen Uhrzeit? if now.hour != MORNING_HOUR or now.minute < MORNING_MINUTE: return if now.weekday() == 0: msg = generate_weekly_message() logger.info("Sende Wochenprogramm (Montag)...") else: msg = generate_morning_message() logger.info("Sende Morgennachricht...") if USER_PHONE: ok = send_whatsapp(USER_PHONE, msg) if ok: state["last_morning"] = now.isoformat() save_scheduler_state(state) logger.info("Morgennachricht gesendet!") else: logger.error("Morgennachricht senden fehlgeschlagen.") else: logger.info(f"[KEIN PHONE] Nachricht: {msg}") def check_checkin(): """Prüft ob Check-in nötig ist und sendet.""" profile = load_user_profile() last_contact = profile.get("last_contact") if not last_contact: return days = (datetime.now() - datetime.fromisoformat(last_contact)).days if days >= CHECKIN_DAYS: msg = generate_checkin_message() if USER_PHONE: ok = send_whatsapp(USER_PHONE, msg) if ok: logger.info(f"Check-in nach {days} Tagen gesendet.") else: logger.error("Check-in senden fehlgeschlagen.") else: logger.info(f"Letzter Kontakt {days} Tage her. Kein Check-in.") # ============================================================ # BACKGROUND THREADS (Keep-Alive + Scheduler) # ============================================================ def _keep_alive_loop(): """ Keep-Alive Thread: Pingt sich selbst alle 30 Minuten und führt die Scheduler-Aufgaben aus. """ # Warte 2 Minuten nach Start _time.sleep(120) last_scheduler_check = datetime.now() while True: try: # Self-Ping (hält Space wach) port = int(os.getenv("PORT", 7860)) url = f"http://127.0.0.1:{port}/keepalive" with httpx.Client(timeout=10) as client: resp = client.get(url) if resp.status_code == 200: logger.info("✅ Keep-Alive Ping erfolgreich – Space bleibt wach.") else: logger.warning(f"Keep-Alive Ping fehlgeschlagen: {resp.status_code}") except Exception as e: logger.warning(f"Keep-Alive Ping Fehler: {e}") # Scheduler-Aufgaben prüfen (alle 5 Minuten) now = datetime.now() if (now - last_scheduler_check).total_seconds() >= 300: last_scheduler_check = now try: check_morning() check_checkin() except Exception as e: logger.error(f"Scheduler-Fehler: {e}") # Alle 30 Minuten pingen _time.sleep(1800) def start_background_threads(): """Startet alle Hintergrund-Threads.""" # Keep-Alive + Scheduler Thread t = threading.Thread(target=_keep_alive_loop, daemon=True, name="keep-alive-scheduler") t.start() logger.info("🔄 Keep-Alive + Scheduler Thread gestartet.") # ============================================================ # USER PROFILE & REMINDER ENDPOINTS # ============================================================ @app.get("/profile") async def get_profile(): return load_user_profile() @app.put("/profile") async def update_profile(request: Request): data = await request.json() save_user_profile(data) return {"status": "ok", "profile": data} @app.get("/reminders") async def list_reminders(): active = [r for r in reminders if r["status"] == "pending"] return {"reminders": active, "count": len(active)} # ============================================================ # ENTRYPOINT # ============================================================ if __name__ == "__main__": import uvicorn port = int(os.getenv("PORT", 7860)) # Hintergrund-Threads starten (Keep-Alive + Scheduler) start_background_threads() uvicorn.run(app, host="0.0.0.0", port=port)