Feature: Sprint31 — 9 Features merged (Streak, Ausgaben, KI-Tierarzt, Rückrufe, Adoption, Vet+Befunde, Hundepass, Playdate, Rassenerkennung)
- Trainings-Streak: streak.py, DB training_streaks, Scheduler 19:00, Widget in welcome.js, Ping in uebungen.js
- Ausgaben-Tracker: expenses.py, expenses.js, DB expenses-Tabelle
- KI-Tierarztfragen: ki.py /tierarzt, health.js Button+Modal, DB ki_tierarzt_log
- Rückruf-Alarm: recalls.py, recalls.js, DB feed_recalls, Scheduler 08:00 RASFF
- Adoption: adoption.py, adoption.js, DB adoption_cache
- Tierarzt-Favorit + Befunde: tieraerzte.py /my-favorite+/favorite, health_docs.py, health.js, api.js, DB favorite_vets+health_documents
- Digitaler Hundepass: passport.py, dog-profile.js, main.py /pass/{token}, DB vaccinations+medications+dog_passport_meta+passport_shares, requirements.txt fpdf2
- Playdate-Matching: playdate.py, playdate.js, DB playdate_listings+playdate_requests
- Rassen-Erkennung: ki.py /rasse-erkennung (Claude Vision), dog-profile.js+wiki.js, CSS .rasse-result-card, DB ki_rasse_log
This commit is contained in:
parent
031c6028ac
commit
742ad189e8
26 changed files with 5734 additions and 27 deletions
|
|
@ -1,10 +1,11 @@
|
|||
"""BAN YARO — KI Routes"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
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()
|
||||
|
||||
|
|
@ -62,3 +63,224 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
|
|||
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/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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue