Compare commits

..

No commits in common. "5aba366b218a06584bd0da0dd7779fa9cc90ad46" and "74b6c03bb32b0bf9b0db4df0472a075ba8d11a68" have entirely different histories.

12 changed files with 62 additions and 441 deletions

View file

@ -530,8 +530,6 @@ def _migrate(conn_factory):
("social_content", "exercise_id", "TEXT"), ("social_content", "exercise_id", "TEXT"),
("social_content", "post_url", "TEXT"), ("social_content", "post_url", "TEXT"),
("dogs", "rasse_id", "INTEGER"), ("dogs", "rasse_id", "INTEGER"),
# Pflege: Schere vs. Trimmen unterscheiden
("pflege_tipps", "fell_pflege_art", "TEXT"),
] ]
with conn_factory() as conn: with conn_factory() as conn:
for table, column, col_type in migrations: for table, column, col_type in migrations:
@ -1110,22 +1108,3 @@ def _migrate(conn_factory):
except Exception: except Exception:
pass pass
logger.info("Migration: routes.is_valid bereit.") logger.info("Migration: routes.is_valid bereit.")
# ki_daily_calls: source-Spalte + PK auf (user_id, date, source) erweitern
ki_cols = {r[1] for r in conn.execute("PRAGMA table_info(ki_daily_calls)").fetchall()}
if "source" not in ki_cols:
conn.executescript("""
ALTER TABLE ki_daily_calls RENAME TO ki_daily_calls_old;
CREATE TABLE ki_daily_calls (
user_id INTEGER NOT NULL,
date TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'cloud',
PRIMARY KEY (user_id, date, source)
);
INSERT INTO ki_daily_calls (user_id, date, count, source)
SELECT user_id, date, count, 'cloud' FROM ki_daily_calls_old;
DROP TABLE ki_daily_calls_old;
CREATE INDEX IF NOT EXISTS idx_ki_daily_source ON ki_daily_calls(date, source);
""")
logger.info("Migration: ki_daily_calls.source bereit.")

View file

@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
KI_MODE = os.getenv("KI_MODE", "local") # off | local | cloud KI_MODE = os.getenv("KI_MODE", "local") # off | local | cloud
LOCAL_BASE_URL = os.getenv("KI_LOCAL_URL", "http://10.47.11.70:11435/v1") LOCAL_BASE_URL = os.getenv("KI_LOCAL_URL", "http://10.47.11.70:11435/v1")
LOCAL_MODEL = os.getenv("KI_LOCAL_MODEL", "gemma-4-31b-it") LOCAL_MODEL = os.getenv("KI_LOCAL_MODEL", "gemma-4-31b-it")
CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-sonnet-4-6") CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-opus-4-6")
ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "")
# Lazy Imports — nur laden wenn wirklich benötigt # Lazy Imports — nur laden wenn wirklich benötigt
@ -70,7 +70,6 @@ async def complete(
user_is_premium: bool = False, user_is_premium: bool = False,
json_mode: bool = False, json_mode: bool = False,
return_model: bool = False, return_model: bool = False,
return_source: bool = False,
) -> str: ) -> str:
""" """
KI-Completion. Wählt automatisch den richtigen Backend. KI-Completion. Wählt automatisch den richtigen Backend.
@ -82,10 +81,9 @@ async def complete(
requires_premium: True = nur für Premium-User (nutzt Cloud) requires_premium: True = nur für Premium-User (nutzt Cloud)
user_is_premium: Ob der anfragende User Premium hat user_is_premium: Ob der anfragende User Premium hat
json_mode: Antwort als JSON anfordern json_mode: Antwort als JSON anfordern
return_source: Falls True: gibt (text, source) zurück, source = 'cloud'|'local'
Returns: Returns:
KI-Antwort als String, oder (str, str) wenn return_source=True KI-Antwort als String
Raises: Raises:
KIPremiumRequired: Cloud-Feature ohne Premium KIPremiumRequired: Cloud-Feature ohne Premium
@ -103,26 +101,20 @@ async def complete(
# Cloud-Aufruf: nur wenn Premium UND cloud-Modus # Cloud-Aufruf: nur wenn Premium UND cloud-Modus
if requires_premium and user_is_premium and KI_MODE == "cloud": if requires_premium and user_is_premium and KI_MODE == "cloud":
text = await _cloud_complete(prompt, system, max_tokens, json_mode) text = await _cloud_complete(prompt, system, max_tokens, json_mode)
if return_model: return (text, CLOUD_MODEL) if return_model else text
return (text, CLOUD_MODEL)
return (text, "cloud") if return_source else text
# Lokaler Aufruf: Entwicklung + Free-User # Lokaler Aufruf: Entwicklung + Free-User
if KI_MODE in ("local", "cloud"): if KI_MODE in ("local", "cloud"):
try: try:
text = await _local_complete(prompt, system, max_tokens, json_mode) text = await _local_complete(prompt, system, max_tokens, json_mode)
if return_model: return (text, LOCAL_MODEL) if return_model else text
return (text, LOCAL_MODEL)
return (text, "local") if return_source else text
except Exception as e: except Exception as e:
logger.warning(f"Lokales KI-Modell nicht erreichbar: {e}") logger.warning(f"Lokales KI-Modell nicht erreichbar: {e}")
# Cloud-Fallback: im cloud-Modus immer, sonst nur für Premium-User # Cloud-Fallback: im cloud-Modus immer, sonst nur für Premium-User
if ANTHROPIC_KEY and (KI_MODE == "cloud" or (requires_premium and user_is_premium)): if ANTHROPIC_KEY and (KI_MODE == "cloud" or (requires_premium and user_is_premium)):
logger.info("Fallback auf Cloud-KI.") logger.info("Fallback auf Cloud-KI.")
text = await _cloud_complete(prompt, system, max_tokens, json_mode) text = await _cloud_complete(prompt, system, max_tokens, json_mode)
if return_model: return (text, CLOUD_MODEL) if return_model else text
return (text, CLOUD_MODEL)
return (text, "cloud") if return_source else text
raise KIUnavailableError( raise KIUnavailableError(
"KI-Modell momentan nicht erreichbar. Bitte später erneut versuchen." "KI-Modell momentan nicht erreichbar. Bitte später erneut versuchen."
) from e ) from e

View file

@ -160,30 +160,8 @@ async def stats(user=Depends(require_mod)):
ki_users_today = conn.execute( ki_users_today = conn.execute(
"SELECT COUNT(DISTINCT user_id) FROM ki_daily_calls WHERE date=DATE('now')" "SELECT COUNT(DISTINCT user_id) FROM ki_daily_calls WHERE date=DATE('now')"
).fetchone()[0] ).fetchone()[0]
# Aufschlüsselung nach Quelle (heute)
_src_today = {
r[0]: r[1] for r in conn.execute(
"SELECT source, COALESCE(SUM(count),0) FROM ki_daily_calls "
"WHERE date=DATE('now') GROUP BY source"
).fetchall()
}
ki_cloud_today = _src_today.get("cloud", 0)
ki_local_today = _src_today.get("local", 0)
ki_luna_today = _src_today.get("luna", 0)
# Aufschlüsselung nach Quelle (Monat)
_src_month = {
r[0]: r[1] for r in conn.execute(
"SELECT source, COALESCE(SUM(count),0) FROM ki_daily_calls "
"WHERE date>=DATE('now','start of month') GROUP BY source"
).fetchall()
}
ki_cloud_month = _src_month.get("cloud", 0)
ki_local_month = _src_month.get("local", 0)
ki_luna_month = _src_month.get("luna", 0)
except Exception: except Exception:
ki_today = ki_month = ki_users_today = 0 ki_today = ki_month = ki_users_today = 0
ki_cloud_today = ki_local_today = ki_luna_today = 0
ki_cloud_month = ki_local_month = ki_luna_month = 0
# Social Media Tracking # Social Media Tracking
try: try:
@ -239,12 +217,6 @@ async def stats(user=Depends(require_mod)):
"ki_today": ki_today, "ki_today": ki_today,
"ki_month": ki_month, "ki_month": ki_month,
"ki_users_today": ki_users_today, "ki_users_today": ki_users_today,
"ki_cloud_today": ki_cloud_today,
"ki_local_today": ki_local_today,
"ki_luna_today": ki_luna_today,
"ki_cloud_month": ki_cloud_month,
"ki_local_month": ki_local_month,
"ki_luna_month": ki_luna_month,
"social_total": social_total, "social_total": social_total,
"social_published": social_published, "social_published": social_published,
"social_scheduled": social_scheduled, "social_scheduled": social_scheduled,

View file

@ -361,9 +361,8 @@ async def get_pflege_tipps(dog_id: int, user=Depends(get_current_user)):
(f"%{dog['rasse']}%",) (f"%{dog['rasse']}%",)
).fetchone() ).fetchone()
# Fell-Typ und Pflegeart ableiten # Fell-Typ ableiten
fell_filter = None fell_filter = None
fell_pflege_art_filter = None
if rasse_info: if rasse_info:
beschr = (rasse_info["beschreibung"] or "").lower() beschr = (rasse_info["beschreibung"] or "").lower()
if any(w in beschr for w in ["lockig", "wellig", "kraus", "pudel", "doodle"]): if any(w in beschr for w in ["lockig", "wellig", "kraus", "pudel", "doodle"]):
@ -375,12 +374,6 @@ async def get_pflege_tipps(dog_id: int, user=Depends(get_current_user)):
elif rasse_info["groesse"] in ("gross", "sehr_gross"): elif rasse_info["groesse"] in ("gross", "sehr_gross"):
fell_filter = "doppel" fell_filter = "doppel"
# Pflegeart: Trimmen vs. Schneiden
if any(w in beschr for w in ["trimm", "hand-stripping", "stripping", "rauhhaar", "drahthaar", "rauhaar"]):
fell_pflege_art_filter = "trimmen"
elif any(w in beschr for w in ["schneid", "geschoren", "schere", "clipper"]):
fell_pflege_art_filter = "schneiden"
with db() as conn: with db() as conn:
alle_tipps = conn.execute( alle_tipps = conn.execute(
"SELECT * FROM pflege_tipps ORDER BY kategorie, titel" "SELECT * FROM pflege_tipps ORDER BY kategorie, titel"
@ -395,15 +388,10 @@ async def get_pflege_tipps(dog_id: int, user=Depends(get_current_user)):
result = [] result = []
for t in alle_tipps: for t in alle_tipps:
t = dict(t) t = dict(t)
# Fell-Typ-Filter # Fell-Filter
if fell_filter and t["fell_typ"] and t["fell_typ"] != "alle": if fell_filter and t["fell_typ"] and t["fell_typ"] != "alle":
if fell_filter not in t["fell_typ"].split(","): if fell_filter not in t["fell_typ"].split(","):
continue continue
# Pflegeart-Filter: Trimm-Tipps nicht bei Schneidehunden und umgekehrt
tipp_art = t.get("fell_pflege_art")
if tipp_art and tipp_art != "alle" and fell_pflege_art_filter:
if tipp_art != fell_pflege_art_filter:
continue
t["schritte"] = _json.loads(t["schritte"] or "[]") t["schritte"] = _json.loads(t["schritte"] or "[]")
t["saisonal_aktuell"] = bool(t["saison"] and heute_saison in t["saison"]) t["saisonal_aktuell"] = bool(t["saison"] and heute_saison in t["saison"])
result.append(t) result.append(t)
@ -415,10 +403,9 @@ async def get_pflege_tipps(dog_id: int, user=Depends(get_current_user)):
tipp_des_tages = (saisonal or result)[day_hash % len(saisonal or result)] if result else None tipp_des_tages = (saisonal or result)[day_hash % len(saisonal or result)] if result else None
return { return {
"dog_name": dog["name"], "dog_name": dog["name"],
"rasse_name": rasse_info["name"] if rasse_info else dog["rasse"], "rasse_name": rasse_info["name"] if rasse_info else dog["rasse"],
"tipp_des_tages": tipp_des_tages, "tipp_des_tages": tipp_des_tages,
"tipps": result, "tipps": result,
"kategorien": list(dict.fromkeys(t["kategorie"] for t in result)), "kategorien": list(dict.fromkeys(t["kategorie"] for t in result)),
"fell_pflege_art": fell_pflege_art_filter, # 'schneiden' | 'trimmen' | None
} }

View file

@ -466,19 +466,9 @@ _PFLEGE_TIPPS = [
"fell_typ":"lang","saison":None,"tipp":"Von Beinen und Schwanz beginnen — dort filzt es zuerst."}, "fell_typ":"lang","saison":None,"tipp":"Von Beinen und Schwanz beginnen — dort filzt es zuerst."},
{"id":"fell_buersten_lockig","titel":"Pflege bei Lockenfell (Pudel, Labradoodle)","kat":"Fell", {"id":"fell_buersten_lockig","titel":"Pflege bei Lockenfell (Pudel, Labradoodle)","kat":"Fell",
"beschreibung":"Lockiges Fell verliert kaum Haare, verfilzt aber stark — spezielle Technik nötig.", "beschreibung":"Lockiges Fell verliert kaum Haare, verfilzt aber stark — spezielle Technik nötig.",
"schritte":["Täglich durchkämmen mit Metallkamm","Verfilzungen mit Finger lösen, dann Kamm","Alle 68 Wochen Schertermin","Zwischen Augen und Pfoten regelmäßig kürzen"], "schritte":["Täglich durchkämmen mit Metallkamm","Verfilzungen mit Finger lösen, dann Kamm","Alle 68 Wochen Schertermin","Zwischen Augen und Pfoten regelmäßig trimmen"],
"materialien":"Metallkamm, Pin-Bürste, Schere","haeufigkeit":"Täglich + 68 Wochen Schertermin", "materialien":"Metallkamm, Pin-Bürste, Schere","haeufigkeit":"Täglich + 68 Wochen Schertermin",
"fell_typ":"lockig","saison":None,"fell_pflege_art":"schneiden","tipp":"Lockiges Fell = hypoallergen, aber Pflegeaufwand unterschätzt!"}, "fell_typ":"lockig","saison":None,"tipp":"Lockiges Fell = hypoallergen, aber Pflegeaufwand unterschätzt!"},
{"id":"fell_scheren_technik","titel":"Fell schneiden: Technik & Scheren-Tipps","kat":"Fell",
"beschreibung":"Für Rassen mit kontinuierlichem Fellwuchs (Pudel, Bichon, Spoodle) — Scheren statt Trimmen!",
"schritte":["Fell nach dem Bad komplett trocknen und bürsten","Schere oder Clipper parallel zur Haarwuchsrichtung führen","Empfindliche Stellen (Gesicht, Pfoten) mit Effilierschere","Alle 68 Wochen zum Groomer oder selbst lernen","Nach dem Scheren: Fell bürsten und kontrollieren"],
"materialien":"Effilierschere, Clipper, Metallkamm","haeufigkeit":"Alle 68 Wochen",
"fell_typ":"lockig","saison":None,"fell_pflege_art":"schneiden","tipp":"Nie nasses Fell scheren — immer erst trocknen, sonst ungleichmäßiges Ergebnis."},
{"id":"fell_trimmen_technik","titel":"Fell trimmen: Stripping beim Rauhaar-Terrier","kat":"Fell",
"beschreibung":"Rauhaardrahtiges Fell (Westie, Schnauzer, Jack Russell) hat natürliche Wachstumsbegrenzung — Trimmen statt Scheren!",
"schritte":["Daumen und Zeigefinger: abgestorbene Haare zupfen (Stripping)","Immer in Haarwuchsrichtung arbeiten","Unterwolle mit feinem Rake ausbürsten","Niemals scheren — zerstört Textur dauerhaft","Alle 34 Monate professionelles Hand-Stripping"],
"materialien":"Stripper-Messer (Trimmmesser), Rake, Kreide für Grip","haeufigkeit":"Alle 34 Monate",
"fell_typ":"alle","saison":None,"fell_pflege_art":"trimmen","tipp":"Geschorenes Drahthaarfell verliert seine typische Textur dauerhaft — immer trimmen!"},
{"id":"fell_unterwolle", "titel":"Unterwolle ausbürsten (Fellwechsel)","kat":"Fell", {"id":"fell_unterwolle", "titel":"Unterwolle ausbürsten (Fellwechsel)","kat":"Fell",
"beschreibung":"Zweimal jährlich toter Unterwolle-Berg — richtig ausgebürstet statt überall verteilt.", "beschreibung":"Zweimal jährlich toter Unterwolle-Berg — richtig ausgebürstet statt überall verteilt.",
"schritte":["Undercoat-Rake gegen Haarwuchsrichtung","Abschnittweise: Rücken, Flanken, Bauch","Furminator maximal 2x/Woche","Nach dem Bürsten: Hund ausschütteln lassen"], "schritte":["Undercoat-Rake gegen Haarwuchsrichtung","Abschnittweise: Rücken, Flanken, Bauch","Furminator maximal 2x/Woche","Nach dem Bürsten: Hund ausschütteln lassen"],
@ -959,28 +949,6 @@ async def _ki_complete(prompt: str) -> str:
) )
async def _ki_complete_tracked(prompt: str, user_id: int) -> str:
"""Wie _ki_complete, zählt die Anfrage in ki_daily_calls (source='luna')."""
import datetime as _dt
import ki as ki_module
text = await ki_module.complete(
prompt,
system=_SYSTEM,
max_tokens=1200,
requires_premium=False,
)
today = str(_dt.date.today())
try:
with db() as conn:
conn.execute("""
INSERT INTO ki_daily_calls (user_id, date, count, source) VALUES (?, ?, 1, 'luna')
ON CONFLICT(user_id, date, source) DO UPDATE SET count = count + 1
""", (user_id, today))
except Exception:
pass
return text
def _parse_json(raw: str) -> dict: def _parse_json(raw: str) -> dict:
import re import re
try: try:
@ -1096,7 +1064,7 @@ async def generate_content(req: GenerateRequest, user=Depends(require_social_med
) )
try: try:
raw = await _ki_complete_tracked(prompt, user["id"]) raw = await _ki_complete(prompt)
data = _parse_json(raw) data = _parse_json(raw)
except Exception as e: except Exception as e:
logger.error("Social-Media-Generierung fehlgeschlagen: %s", e) logger.error("Social-Media-Generierung fehlgeschlagen: %s", e)
@ -1145,7 +1113,7 @@ async def evaluate_content(req: EvaluateRequest, user=Depends(require_social_med
) )
try: try:
raw = await _ki_complete_tracked(prompt, user["id"]) raw = await _ki_complete(prompt)
data = _parse_json(raw) data = _parse_json(raw)
except Exception as e: except Exception as e:
raise HTTPException(500, f"KI-Fehler: {e}") raise HTTPException(500, f"KI-Fehler: {e}")
@ -1229,21 +1197,13 @@ def _seed_pflege():
conn.execute( conn.execute(
"""INSERT OR IGNORE INTO pflege_tipps """INSERT OR IGNORE INTO pflege_tipps
(tipp_id, titel, kategorie, beschreibung, schritte, (tipp_id, titel, kategorie, beschreibung, schritte,
materialien, haeufigkeit, fell_typ, saison, rassengruppe, tipp, materialien, haeufigkeit, fell_typ, saison, rassengruppe, tipp)
fell_pflege_art) VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(p["id"], p["titel"], p["kat"], (p["id"], p["titel"], p["kat"],
p.get("beschreibung"), json.dumps(p.get("schritte", []), ensure_ascii=False), p.get("beschreibung"), json.dumps(p.get("schritte", []), ensure_ascii=False),
p.get("materialien"), p.get("haeufigkeit"), p.get("materialien"), p.get("haeufigkeit"),
p.get("fell_typ"), p.get("saison"), p.get("rassengruppe"), p.get("tipp"), p.get("fell_typ"), p.get("saison"), p.get("rassengruppe"), p.get("tipp")),
p.get("fell_pflege_art")),
) )
# UPDATE für bereits bestehende Tipps (idempotent)
if p.get("fell_pflege_art"):
conn.execute(
"UPDATE pflege_tipps SET fell_pflege_art=? WHERE tipp_id=? AND (fell_pflege_art IS NULL OR fell_pflege_art != ?)",
(p["fell_pflege_art"], p["id"], p["fell_pflege_art"]),
)
try: try:
_seed_exercises() _seed_exercises()
@ -1291,7 +1251,7 @@ async def training_tip(user=Depends(require_social_media)):
) )
try: try:
raw = await _ki_complete_tracked(prompt, user["id"]) raw = await _ki_complete(prompt)
data = _parse_json(raw) data = _parse_json(raw)
except Exception as e: except Exception as e:
raise HTTPException(500, f"KI-Fehler: {e}") raise HTTPException(500, f"KI-Fehler: {e}")
@ -1447,7 +1407,7 @@ async def breed_of_day(user=Depends(require_social_media)):
) )
try: try:
raw = await _ki_complete_tracked(prompt, user["id"]) raw = await _ki_complete(prompt)
data = _parse_json(raw) data = _parse_json(raw)
except Exception as e: except Exception as e:
raise HTTPException(500, f"KI-Fehler: {e}") raise HTTPException(500, f"KI-Fehler: {e}")
@ -1586,7 +1546,7 @@ async def pflege_tipp(breed_id: Optional[int] = None, user=Depends(require_socia
) )
try: try:
raw = await _ki_complete_tracked(prompt, user["id"]) raw = await _ki_complete(prompt)
data = _parse_json(raw) data = _parse_json(raw)
except Exception as e: except Exception as e:
raise HTTPException(500, f"KI-Fehler: {e}") raise HTTPException(500, f"KI-Fehler: {e}")

View file

@ -777,10 +777,11 @@ async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)):
).fetchone()[0] ).fetchone()[0]
if age_hours < 6 and new_since == 0: if age_hours < 6 and new_since == 0:
used = conn.execute( daily_used = conn.execute(
"SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE user_id=? AND date=?", "SELECT COALESCE(count,0) FROM ki_daily_calls WHERE user_id=? AND date=?",
(uid, today) (uid, today)
).fetchone()[0] ).fetchone()
used = daily_used[0] if daily_used else 0
return {"feedback": cache_row["feedback"], "cached": True, return {"feedback": cache_row["feedback"], "cached": True,
"daily_used": used, "daily_limit": KI_DAILY_LIMIT} "daily_used": used, "daily_limit": KI_DAILY_LIMIT}
@ -790,14 +791,14 @@ async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)):
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
date TEXT NOT NULL, date TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0, count INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'cloud', PRIMARY KEY (user_id, date)
PRIMARY KEY (user_id, date, source)
) )
""") """)
daily_used = conn.execute( row = conn.execute(
"SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE user_id=? AND date=?", "SELECT count FROM ki_daily_calls WHERE user_id=? AND date=?",
(uid, today) (uid, today)
).fetchone()[0] ).fetchone()
daily_used = row[0] if row else 0
if daily_used >= KI_DAILY_LIMIT: if daily_used >= KI_DAILY_LIMIT:
raise HTTPException(429, f"Tages-Limit erreicht ({KI_DAILY_LIMIT} Anfragen/Tag). Morgen wieder verfügbar.") raise HTTPException(429, f"Tages-Limit erreicht ({KI_DAILY_LIMIT} Anfragen/Tag). Morgen wieder verfügbar.")
@ -866,13 +867,12 @@ async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)):
) )
try: try:
feedback_text, ki_source = await ki.complete( feedback_text = await ki.complete(
prompt, prompt,
system=system, system=system,
max_tokens=400, max_tokens=400,
requires_premium=False, requires_premium=False,
user_is_premium=user.get("is_premium", False), user_is_premium=user.get("is_premium", False),
return_source=True,
) )
except (ki.KIUnavailableError, ki.KIPremiumRequired) as e: except (ki.KIUnavailableError, ki.KIPremiumRequired) as e:
raise HTTPException(503, str(e)) raise HTTPException(503, str(e))
@ -889,11 +889,11 @@ async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)):
(body.dog_id, feedback_text) (body.dog_id, feedback_text)
) )
conn.execute(""" conn.execute("""
INSERT INTO ki_daily_calls (user_id, date, count, source) VALUES (?, ?, 1, ?) INSERT INTO ki_daily_calls (user_id, date, count) VALUES (?, ?, 1)
ON CONFLICT(user_id, date, source) DO UPDATE SET count = count + 1 ON CONFLICT(user_id, date) DO UPDATE SET count = count + 1
""", (uid, today, ki_source)) """, (uid, today))
new_count = conn.execute( new_count = conn.execute(
"SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE user_id=? AND date=?", "SELECT count FROM ki_daily_calls WHERE user_id=? AND date=?",
(uid, today) (uid, today)
).fetchone()[0] ).fetchone()[0]

View file

@ -1,254 +0,0 @@
"""
BAN YARO Fehlende Rassen-Fotos von Wikipedia/Wikimedia holen
Strategie:
1. Alle Rassen ohne foto_url aus wiki_rassen holen
2. Pro Rasse: Wikipedia pageimages API (de en Fallback)
3. Letzter Fallback: Wikimedia Commons pageimages API
4. Sinnlose Bilder filtern (SVG, Flaggen-Icons, Karten, Logos)
5. URL direkt in wiki_rassen.foto_url speichern
CLI-Optionen:
--limit N Nur N Rassen bearbeiten (Default: 100)
--dry-run Nur anzeigen, nicht speichern
--model NAME Claude-Modell für ggf. zukünftige Text-Tasks
(Default: claude-sonnet-4-6)
"""
import argparse
import asyncio
import logging
import os
import sys
import httpx
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database import db
logger = logging.getLogger(__name__)
_WP_HEADERS = {
"User-Agent": "Banyaro/1.0 (https://banyaro.de; mail@banyaro.de) httpx/Python"
}
_THUMB_SIZE = 600
# Dateinamen-Fragmente, die auf unbrauchbare Bilder hindeuten
_SKIP_PATTERNS = (
".svg",
"flag_of_",
"coat_of_arms",
"emblem_of_",
"location_map",
"orthographic_projection",
"locator_map",
"blank_map",
"wikimedia-logo",
"commons-logo",
"question_mark",
"noimage",
)
def _is_usable(url: str) -> bool:
"""Gibt True zurück wenn die Bild-URL brauchbar erscheint."""
low = url.lower()
if low.endswith(".svg"):
return False
for pattern in _SKIP_PATTERNS:
if pattern in low:
return False
return True
async def _fetch_wp_image(name: str, lang: str, client: httpx.AsyncClient) -> str | None:
"""
Fragt Wikipedia pageimages API für `name` in `lang` ab.
Gibt Thumbnail-URL zurück oder None.
"""
try:
resp = await client.get(
f"https://{lang}.wikipedia.org/w/api.php",
params={
"action": "query",
"titles": name,
"prop": "pageimages",
"format": "json",
"pithumbsize": _THUMB_SIZE,
"redirects": 1,
},
)
resp.raise_for_status()
pages = resp.json().get("query", {}).get("pages", {})
for page in pages.values():
if page.get("pageid", -1) == -1:
continue
thumb = page.get("thumbnail", {}).get("source", "")
if thumb and _is_usable(thumb):
return thumb
except Exception as exc:
logger.debug("WP pageimages (%s/%s) Fehler: %s", lang, name, exc)
return None
async def _fetch_commons_image(name: str, client: httpx.AsyncClient) -> str | None:
"""
Fragt Wikimedia Commons pageimages API für `name` ab.
Wird als letzter Fallback genutzt.
"""
try:
resp = await client.get(
"https://commons.wikimedia.org/w/api.php",
params={
"action": "query",
"titles": name,
"prop": "pageimages",
"format": "json",
"pithumbsize": _THUMB_SIZE,
},
)
resp.raise_for_status()
pages = resp.json().get("query", {}).get("pages", {})
for page in pages.values():
if page.get("pageid", -1) == -1:
continue
thumb = page.get("thumbnail", {}).get("source", "")
if thumb and _is_usable(thumb):
return thumb
except Exception as exc:
logger.debug("Commons pageimages (%s) Fehler: %s", name, exc)
return None
async def fetch_wiki_images(limit: int = 100, dry_run: bool = False) -> dict:
"""
Holt Wikipedia-Fotos für alle Rassen ohne foto_url.
Returns: {'found': int, 'saved': int, 'missing': int}
"""
with db() as conn:
rows = conn.execute(
"""SELECT id, name, name_de, slug
FROM wiki_rassen
WHERE (foto_url IS NULL OR foto_url = '')
ORDER BY name ASC
LIMIT ?""",
(limit,),
).fetchall()
total = len(rows)
if total == 0:
logger.info("Alle Rassen haben bereits ein Foto — nichts zu tun.")
return {"found": 0, "saved": 0, "missing": 0}
logger.info("%d Rassen ohne Foto werden verarbeitet (limit=%d).", total, limit)
found = 0
saved = 0
async with httpx.AsyncClient(
timeout=12,
follow_redirects=True,
headers=_WP_HEADERS,
) as client:
for idx, row in enumerate(rows, start=1):
name = row["name"]
name_de = row["name_de"] or ""
slug = row["slug"] or name
# Suchreihenfolge: DE-Name → EN-Name → Commons mit EN-Name
candidates: list[tuple[str, str]] = []
if name_de:
candidates.append((name_de, "de"))
candidates.append((name, "en"))
if name_de:
candidates.append((name_de, "en"))
foto_url: str | None = None
for search_name, lang in candidates:
foto_url = await _fetch_wp_image(search_name, lang, client)
if foto_url:
logger.info(
"[%d/%d] ✓ %s → WP %s (%s)",
idx, total, name, lang.upper(), search_name,
)
break
# Letzter Fallback: Wikimedia Commons
if not foto_url:
foto_url = await _fetch_commons_image(name, client)
if foto_url:
logger.info(
"[%d/%d] ✓ %s → Commons", idx, total, name
)
if foto_url:
found += 1
if dry_run:
logger.info(" [dry-run] würde setzen: %s", foto_url)
else:
try:
with db() as conn:
conn.execute(
"UPDATE wiki_rassen SET foto_url=? WHERE id=?",
(foto_url, row["id"]),
)
saved += 1
except Exception as exc:
logger.error("DB-Update fehlgeschlagen für %s: %s", name, exc)
else:
logger.info("[%d/%d] ✗ %s — kein Foto gefunden", idx, total, name)
# Rate-Limit: 1 Sekunde zwischen Anfragen
await asyncio.sleep(1.0)
missing = total - found
logger.info(
"Fertig: %d/%d Fotos gefunden, %d gespeichert, %d ohne Treffer.",
found, total, saved, missing,
)
return {"found": found, "saved": saved, "missing": missing}
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
parser = argparse.ArgumentParser(
description="Fehlende Rassen-Fotos von Wikipedia/Wikimedia holen"
)
parser.add_argument(
"--limit",
type=int,
default=100,
metavar="N",
help="Maximale Anzahl Rassen (Default: 100)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Nur anzeigen, nicht in DB speichern",
)
parser.add_argument(
"--model",
default="claude-sonnet-4-6",
metavar="MODEL",
help="Claude-Modell für Text-Tasks (Default: claude-sonnet-4-6)",
)
args = parser.parse_args()
if args.dry_run:
logger.info("DRY-RUN Modus — keine DB-Änderungen.")
result = asyncio.run(fetch_wiki_images(limit=args.limit, dry_run=args.dry_run))
print(
f"\nErgebnis: {result['found']} gefunden, "
f"{result['saved']} gespeichert, "
f"{result['missing']} ohne Treffer."
)

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '345'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '344'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => { const App = (() => {

View file

@ -207,18 +207,12 @@ window.Page_admin = (() => {
</div> </div>
<div class="card" style="padding:var(--space-4)"> <div class="card" style="padding:var(--space-4)">
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">KI-Nutzung</p> <p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">KI-Trainer Nutzung (Claude API)</p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)"> <div style="display:flex;flex-direction:column;gap:var(--space-2)">
${[ ${[
['☁️ Claude heute', s.ki_cloud_today, 'var(--c-primary)'], ['Anfragen heute', s.ki_today, 'var(--c-primary)'],
['🖥️ LM Studio heute', s.ki_local_today, 'var(--c-success)'], ['Anfragen diesen Monat', s.ki_month, 'var(--c-text-secondary)'],
['🌙 Luna heute', s.ki_luna_today, 'var(--c-warning)'], ['Aktive User heute', s.ki_users_today, 'var(--c-text-secondary)'],
['Gesamt heute', s.ki_today, 'var(--c-text-secondary)'],
['☁️ Claude Monat', s.ki_cloud_month, 'var(--c-primary)'],
['🖥️ LM Studio Monat', s.ki_local_month, 'var(--c-success)'],
['🌙 Luna Monat', s.ki_luna_month, 'var(--c-warning)'],
['Gesamt Monat', s.ki_month, 'var(--c-text-secondary)'],
['Aktive User heute', s.ki_users_today, 'var(--c-text-secondary)'],
].map(([label, val, color]) => ` ].map(([label, val, color]) => `
<div style="display:flex;justify-content:space-between;font-size:var(--text-sm)"> <div style="display:flex;justify-content:space-between;font-size:var(--text-sm)">
<span style="color:var(--c-text-secondary)">${label}</span> <span style="color:var(--c-text-secondary)">${label}</span>

View file

@ -700,22 +700,6 @@ window.Page_diary = (() => {
<textarea class="form-control" name="text" rows="5" <textarea class="form-control" name="text" rows="5"
placeholder="Was ist passiert? Besonderheiten, Gedanken…">${UI.escape(entry?.text || '')}</textarea> placeholder="Was ist passiert? Besonderheiten, Gedanken…">${UI.escape(entry?.text || '')}</textarea>
</div> </div>
<div class="form-group">
<!-- Bestehende Medien (Edit-Modus) -->
<div id="diary-existing-media"></div>
<!-- Neue Medien: Vorschau-Grid -->
<div id="diary-new-media-grid" class="diary-media-grid" style="display:none"></div>
<!-- versteckter Input multiple für Mehrfachauswahl -->
<input type="file" id="diary-media-input" accept="image/*,video/*,application/pdf" multiple style="display:none">
<!-- Einzelner Button iOS zeigt nativen Picker (Mediathek / Kamera / Datei) -->
<label for="diary-media-input" class="btn btn-secondary" style="cursor:pointer;display:flex;align-items:center;gap:var(--space-2);justify-content:center">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg>
Fotos / Videos hinzufügen
</label>
</div>
<div class="form-group" id="diary-location-group"> <div class="form-group" id="diary-location-group">
<label class="form-label">Ort <span style="color:var(--c-text-secondary)">(optional)</span></label> <label class="form-label">Ort <span style="color:var(--c-text-secondary)">(optional)</span></label>
@ -760,6 +744,24 @@ window.Page_diary = (() => {
<span>${entry?.is_milestone ? 'Meilenstein ✓' : 'Als Meilenstein markieren'}</span> <span>${entry?.is_milestone ? 'Meilenstein ✓' : 'Als Meilenstein markieren'}</span>
</button> </button>
</div> </div>
<div class="form-group">
<label class="form-label">Fotos / Videos <span style="color:var(--c-text-secondary)">(optional)</span></label>
<!-- Bestehende Medien (Edit-Modus) -->
<div id="diary-existing-media"></div>
<!-- Neue Medien: Vorschau-Grid -->
<div id="diary-new-media-grid" class="diary-media-grid" style="display:none"></div>
<!-- versteckter Input multiple für Mehrfachauswahl -->
<input type="file" id="diary-media-input" accept="image/*,video/*,application/pdf" multiple style="display:none">
<!-- Einzelner Button iOS zeigt nativen Picker (Mediathek / Kamera / Datei) -->
<label for="diary-media-input" class="btn btn-secondary" style="margin-top:var(--space-2);cursor:pointer;display:flex;align-items:center;gap:var(--space-2);justify-content:center">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg>
Fotos / Videos hinzufügen
</label>
</div>
</form> </form>
`; `;

View file

@ -349,16 +349,6 @@ window.Page_dog_profile = (() => {
'Saisonal':'🌸','Gesundheitsvorsorge':'❤️','Welpen-Pflege':'🐶', 'Saisonal':'🌸','Gesundheitsvorsorge':'❤️','Welpen-Pflege':'🐶',
}; };
const pflegeArtBadge = data.fell_pflege_art === 'schneiden'
? `<span title="Dieses Fell wächst kontinuierlich und wird mit der Schere geschnitten"
style="font-size:10px;font-weight:700;padding:2px 7px;border-radius:20px;
background:#dbeafe;color:#1d4ed8;margin-left:6px"> Schneiden</span>`
: data.fell_pflege_art === 'trimmen'
? `<span title="Dieses Fell hat natürliche Wachstumsbegrenzung und wird durch Hand-Stripping gepflegt"
style="font-size:10px;font-weight:700;padding:2px 7px;border-radius:20px;
background:#fef9c3;color:#92400e;margin-left:6px"> Trimmen</span>`
: '';
el.innerHTML = ` el.innerHTML = `
<div class="card" style="padding:var(--space-4)"> <div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)"> <div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
@ -406,12 +396,11 @@ window.Page_dog_profile = (() => {
<div id="dp-pflege-liste" style="display:none;margin-top:var(--space-3)"> <div id="dp-pflege-liste" style="display:none;margin-top:var(--space-3)">
${data.kategorien.map(kat => { ${data.kategorien.map(kat => {
const katTipps = data.tipps.filter(t=>t.kategorie===kat); const katTipps = data.tipps.filter(t=>t.kategorie===kat);
const katBadge = kat === 'Fell' ? pflegeArtBadge : '';
return ` return `
<div style="margin-bottom:var(--space-3)"> <div style="margin-bottom:var(--space-3)">
<div style="font-size:11px;font-weight:700;color:var(--c-text-muted); <div style="font-size:11px;font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;margin-bottom:8px;display:flex;align-items:center"> text-transform:uppercase;margin-bottom:8px">
${kat_icons[kat]||'🐾'} ${_esc(kat)}${katBadge}</div> ${kat_icons[kat]||'🐾'} ${_esc(kat)}</div>
${katTipps.map(tip => ` ${katTipps.map(tip => `
<details style="background:var(--c-surface-2);border-radius:8px; <details style="background:var(--c-surface-2);border-radius:8px;
padding:10px;margin-bottom:6px"> padding:10px;margin-bottom:6px">

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v360'; const CACHE_VERSION = 'by-v359';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten