362 lines
14 KiB
Python
362 lines
14 KiB
Python
"""BAN YARO — KI Routes"""
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
|
|
from pydantic import BaseModel
|
|
from typing import Optional
|
|
import ki as ki_module
|
|
from auth import get_current_user
|
|
from ratelimit import check as rl_check
|
|
from database import db
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class TrainingRequest(BaseModel):
|
|
problem: str
|
|
rasse: Optional[str] = None
|
|
alter: Optional[str] = None
|
|
|
|
|
|
@router.post("/training")
|
|
async def ki_training(req: TrainingRequest, request: Request,
|
|
user=Depends(get_current_user)):
|
|
"""KI-Trainingsberatung für individuelle Verhaltens- und Trainingsprobleme."""
|
|
rl_check(request, max_requests=10, window_seconds=3600, key="ki_training")
|
|
if not req.problem or len(req.problem.strip()) < 10:
|
|
raise HTTPException(400, "Bitte beschreibe das Problem genauer.")
|
|
if len(req.problem) > 1000:
|
|
raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).")
|
|
|
|
rasse = req.rasse or "unbekannt"
|
|
alter = req.alter or "unbekannt"
|
|
|
|
system = (
|
|
"Du bist ein erfahrener, zertifizierter Hundetrainer mit Schwerpunkt "
|
|
"auf positiver Verstärkung und gewaltfreier Erziehung. "
|
|
"Antworte immer auf Deutsch, konkret, verständlich und motivierend. "
|
|
"Gib keine Ratschläge die Schmerz oder Zwang beinhalten. "
|
|
"Wenn das Problem schwerwiegend ist (Aggression, starke Angst), "
|
|
"empfehle professionellen Hundetrainer vor Ort zusätzlich."
|
|
)
|
|
|
|
prompt = f"""Hund: {rasse}, {alter} alt.
|
|
|
|
Problem: {req.problem.strip()}
|
|
|
|
Bitte gib:
|
|
1. Eine kurze Einschätzung des Problems (1-2 Sätze)
|
|
2. 3-5 konkrete Trainingsschritte die ich heute starten kann
|
|
3. Was ich vermeiden sollte
|
|
4. Wann ich einen Profi hinzuziehen sollte (falls relevant)
|
|
|
|
Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
|
|
|
|
try:
|
|
result = await ki_module.complete(
|
|
prompt=prompt,
|
|
system=system,
|
|
max_tokens=600,
|
|
requires_premium=False,
|
|
user_id=user["id"],
|
|
)
|
|
return {"antwort": result}
|
|
except ki_module.KIUnavailableError as e:
|
|
raise HTTPException(503, str(e))
|
|
except Exception as e:
|
|
raise HTTPException(500, "KI momentan nicht verfügbar.")
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /ki/tierarzt — KI-Tierarztfragen
|
|
# ------------------------------------------------------------------
|
|
class TierarztRequest(BaseModel):
|
|
symptom: str
|
|
dog_id: Optional[int] = None
|
|
dog_name: Optional[str] = None
|
|
rasse: Optional[str] = None
|
|
|
|
|
|
@router.post("/tierarzt")
|
|
async def ki_tierarzt(req: TierarztRequest, request: Request,
|
|
user=Depends(get_current_user)):
|
|
"""KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung."""
|
|
if not req.symptom or len(req.symptom.strip()) < 5:
|
|
raise HTTPException(400, "Bitte beschreibe das Symptom genauer.")
|
|
if len(req.symptom) > 1000:
|
|
raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).")
|
|
|
|
# Rate-Limit: max 5 Anfragen pro User pro Tag
|
|
with db() as conn:
|
|
count = conn.execute(
|
|
"SELECT COUNT(*) FROM ki_tierarzt_log "
|
|
"WHERE user_id=? AND created_at >= datetime('now','-1 day')",
|
|
(user["id"],)
|
|
).fetchone()[0]
|
|
if count >= 5:
|
|
raise HTTPException(429, "Tageslimit erreicht. Du kannst maximal 5 Tierarztfragen pro Tag stellen.")
|
|
|
|
dog_name = req.dog_name or "unbekannt"
|
|
rasse = req.rasse or "unbekannt"
|
|
|
|
system = (
|
|
"Du bist ein erfahrener Tierarzt-Assistent für Hunde. "
|
|
"Deine Aufgabe ist es, Hundebesitzern eine erste Orientierung zu geben — "
|
|
"kein Ersatz für eine echte tierärztliche Untersuchung. "
|
|
"Antworte immer auf Deutsch, klar und verständlich. "
|
|
"Stelle keine medizinischen Diagnosen. "
|
|
"Empfehle im Zweifel immer den Gang zum Tierarzt."
|
|
)
|
|
|
|
prompt = f"""Hund: {dog_name}, Rasse: {rasse}
|
|
Symptom: {req.symptom.strip()}
|
|
|
|
Gib eine strukturierte, verständliche Einschätzung:
|
|
1. Mögliche Ursachen (2-3 wahrscheinlichste)
|
|
2. Was der Besitzer jetzt tun kann (Erstmaßnahmen)
|
|
3. Wann unbedingt zum Tierarzt (Dringlichkeit: beobachten / bald / sofort)
|
|
|
|
Antworte auf Deutsch, klar und verständlich. Maximal 300 Wörter.
|
|
Schreibe KEINE medizinischen Diagnosen und empfehle im Zweifel immer den Tierarzt."""
|
|
|
|
try:
|
|
antwort = await ki_module.complete(
|
|
prompt=prompt,
|
|
system=system,
|
|
max_tokens=600,
|
|
requires_premium=False,
|
|
user_id=user["id"],
|
|
)
|
|
# Erfolg: Rate-Limit-Eintrag speichern
|
|
with db() as conn:
|
|
conn.execute(
|
|
"INSERT INTO ki_tierarzt_log (user_id, dog_id) VALUES (?, ?)",
|
|
(user["id"], req.dog_id)
|
|
)
|
|
return {"antwort": antwort, "anfragen_heute": count + 1, "limit": 5}
|
|
except ki_module.KIUnavailableError as e:
|
|
raise HTTPException(503, str(e))
|
|
except HTTPException:
|
|
raise
|
|
except Exception:
|
|
raise HTTPException(500, "KI momentan nicht verfügbar.")
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Rate-Limit-Helfer für Rassen-Erkennung
|
|
# ------------------------------------------------------------------
|
|
_RASSE_DAILY_LIMIT = 10
|
|
|
|
|
|
def _check_rasse_limit(user_id: int) -> int:
|
|
"""Gibt verbleibende Erkennungen zurück. Wirft HTTPException wenn Limit erreicht."""
|
|
with db() as conn:
|
|
used = conn.execute(
|
|
"""SELECT COUNT(*) FROM ki_rasse_log
|
|
WHERE user_id = ? AND created_at >= datetime('now', 'start of day')""",
|
|
(user_id,)
|
|
).fetchone()[0]
|
|
remaining = _RASSE_DAILY_LIMIT - used
|
|
if remaining <= 0:
|
|
raise HTTPException(429, f"Tageslimit erreicht ({_RASSE_DAILY_LIMIT} Erkennungen/Tag). Morgen wieder verfügbar.")
|
|
return remaining
|
|
|
|
|
|
def _log_rasse_request(user_id: int):
|
|
with db() as conn:
|
|
conn.execute(
|
|
"INSERT INTO ki_rasse_log (user_id) VALUES (?)", (user_id,)
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /ki/geburtstag — Geburtstags-Überraschungsideen (kostenlos für alle)
|
|
# ------------------------------------------------------------------
|
|
|
|
class BirthdayRequest(BaseModel):
|
|
dog_id: int
|
|
name: str
|
|
rasse: Optional[str] = None
|
|
alter: Optional[int] = None
|
|
mode: str = "tomorrow" # "tomorrow" | "today"
|
|
|
|
@router.post("/geburtstag")
|
|
async def ki_geburtstag(req: BirthdayRequest, request: Request,
|
|
user=Depends(get_current_user)):
|
|
"""Kostenlose KI-Geburtstagsideen — kein Premium nötig, 1x/Tag, DB-gecacht."""
|
|
from datetime import date
|
|
year = date.today().year
|
|
mode = req.mode if req.mode in ("tomorrow", "today") else "tomorrow"
|
|
|
|
# Aus DB-Cache zurückgeben wenn bereits generiert
|
|
with db() as conn:
|
|
cached = conn.execute(
|
|
"SELECT content FROM bday_ki_cache WHERE dog_id=? AND year=? AND mode=?",
|
|
(req.dog_id, year, mode)
|
|
).fetchone()
|
|
if cached:
|
|
return {"answer": cached["content"], "cached": True}
|
|
|
|
name = req.name.strip()[:40] or "deinen Hund"
|
|
rasse = req.rasse or None
|
|
alter = req.alter
|
|
rasse_str = f"({rasse})" if rasse else ""
|
|
|
|
if mode == "today":
|
|
# Aus Sicht des Hundes — was er sich für seinen Geburtstag vorstellt
|
|
alter_str = f"{alter}. Geburtstag" if alter else "Geburtstag"
|
|
system = (
|
|
"Du bist ein Hund und erzählst aus deiner eigenen Perspektive. "
|
|
"Schreibe auf Deutsch, verspielt, liebevoll und mit Hundelogik. "
|
|
"Verwende typische Hundegedanken: Fressen, Gassi, Schmusen, Spielen, Gerüche."
|
|
)
|
|
prompt = (
|
|
f"Ich bin {name} {rasse_str} und heute ist mein {alter_str}! "
|
|
f"Erzähl in meiner Stimme (als Hund), wie ich mir den perfekten Geburtstagstag vorgestellt habe — "
|
|
f"von Morgen bis Abend. Was möchte ich erleben, fressen, riechen, spielen? "
|
|
f"Ca. 150 Wörter, herzlich und humorvoll."
|
|
)
|
|
else:
|
|
# Überraschungsideen für morgen
|
|
alter_str = f"{alter}. Geburtstag" if alter else "Geburtstag"
|
|
system = (
|
|
"Du bist ein begeisterter Hundefreund mit vielen kreativen Ideen. "
|
|
"Antworte auf Deutsch, herzlich, konkret und mit einer Prise Humor. "
|
|
"Fokus auf praktische, umsetzbare Überraschungen."
|
|
)
|
|
prompt = (
|
|
f"Morgen ist der {alter_str} von {name} {rasse_str}! "
|
|
f"Was können wir {name} besonders gönnen? "
|
|
f"Gib 5 konkrete, liebevolle Überraschungsideen — von einfach bis aufwendig, "
|
|
f"jeweils mit einem Satz warum Hunde das lieben."
|
|
)
|
|
|
|
try:
|
|
answer = await ki_module.complete(
|
|
system=system, prompt=prompt, max_tokens=600, requires_premium=False,
|
|
)
|
|
with db() as conn:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO bday_ki_cache (dog_id, year, mode, content) VALUES (?,?,?,?)",
|
|
(req.dog_id, year, mode, answer)
|
|
)
|
|
return {"answer": answer, "cached": False}
|
|
except ki_module.KIUnavailableError:
|
|
raise HTTPException(503, "KI momentan nicht verfügbar.")
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /ki/rasse-erkennung — Vision-basierte Rassenerkennung
|
|
# ------------------------------------------------------------------
|
|
@router.post("/rasse-erkennung")
|
|
async def ki_rasse_erkennung(
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
user=Depends(get_current_user),
|
|
):
|
|
"""Hunderassen per Foto erkennen (Claude Vision, max 5 MB, 10x/Tag)."""
|
|
import base64
|
|
import json
|
|
import re
|
|
import anthropic
|
|
|
|
# Dateigröße prüfen
|
|
content = await file.read()
|
|
if len(content) > 5 * 1024 * 1024:
|
|
raise HTTPException(400, "Bild zu groß. Maximal 5 MB erlaubt.")
|
|
|
|
# MIME-Typ prüfen
|
|
ct = (file.content_type or "").lower()
|
|
if not ct.startswith("image/"):
|
|
raise HTTPException(400, "Nur Bilddateien erlaubt (JPG, PNG, WebP).")
|
|
|
|
# MIME-Typ auf erlaubte Werte beschränken
|
|
allowed_mimes = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
|
mime_type = ct if ct in allowed_mimes else "image/jpeg"
|
|
|
|
# Rate-Limit prüfen
|
|
remaining_before = _check_rasse_limit(user["id"])
|
|
|
|
# Anthropic-Client holen (nutzt cached Instanz aus ki.py)
|
|
if not ki_module.ANTHROPIC_KEY:
|
|
raise HTTPException(503, "KI-Bildanalyse ist momentan nicht verfügbar.")
|
|
|
|
api_key = ki_module.ANTHROPIC_KEY
|
|
base64_data = base64.standard_b64encode(content).decode("utf-8")
|
|
|
|
prompt_text = """Analysiere dieses Bild und erkenne die Hunderasse(n).
|
|
|
|
Antworte NUR im folgenden JSON-Format (kein anderer Text):
|
|
{
|
|
"rassen": [
|
|
{"name": "Labrador Retriever", "sicherheit": 85, "beschreibung": "Kurze Begründung"},
|
|
{"name": "Golden Retriever", "sicherheit": 12, "beschreibung": "Falls Mischling"}
|
|
],
|
|
"ist_hund": true,
|
|
"hinweis": "Optionaler Hinweis z.B. bei Welpen oder schlechter Bildqualität"
|
|
}
|
|
|
|
Gib 1-3 Rassen nach Wahrscheinlichkeit sortiert an. Sicherheit in Prozent (0-100).
|
|
Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
|
|
|
|
try:
|
|
def _sync_call():
|
|
client = anthropic.Anthropic(api_key=api_key)
|
|
return client.messages.create(
|
|
model="claude-opus-4-7",
|
|
max_tokens=500,
|
|
messages=[{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": mime_type,
|
|
"data": base64_data,
|
|
}
|
|
},
|
|
{
|
|
"type": "text",
|
|
"text": prompt_text,
|
|
}
|
|
]
|
|
}]
|
|
)
|
|
|
|
import asyncio
|
|
response = await asyncio.get_event_loop().run_in_executor(None, _sync_call)
|
|
raw = response.content[0].text.strip()
|
|
|
|
except anthropic.APIError as e:
|
|
raise HTTPException(503, f"KI-Bildanalyse nicht verfügbar: {e}")
|
|
except Exception as e:
|
|
raise HTTPException(500, "Fehler bei der Bildanalyse.")
|
|
|
|
# JSON parsen — Claude kann manchmal ```json ... ``` wrappen
|
|
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.DOTALL).strip()
|
|
try:
|
|
parsed = json.loads(cleaned)
|
|
except json.JSONDecodeError:
|
|
raise HTTPException(500, "KI-Antwort konnte nicht verarbeitet werden.")
|
|
|
|
# Usage loggen (erst nach erfolgreicher KI-Antwort)
|
|
_log_rasse_request(user["id"])
|
|
remaining_after = remaining_before - 1
|
|
|
|
# Wiki-Slugs für erkannte Rassen nachschlagen
|
|
rassen = parsed.get("rassen", [])
|
|
if rassen:
|
|
with db() as conn:
|
|
for r in rassen:
|
|
name = r.get("name", "")
|
|
# Exakter Name-Match (case-insensitive)
|
|
row = conn.execute(
|
|
"SELECT slug FROM wiki_rassen WHERE LOWER(name) = LOWER(?)", (name,)
|
|
).fetchone()
|
|
r["wiki_slug"] = row["slug"] if row else None
|
|
|
|
return {
|
|
"rassen": rassen,
|
|
"ist_hund": parsed.get("ist_hund", False),
|
|
"hinweis": parsed.get("hinweis") or None,
|
|
"verbleibende_anfragen": remaining_after,
|
|
}
|