Admin: KI-Anfragen nach Quelle aufschlüsseln (cloud/local/luna)

- ki_daily_calls: PK auf (user_id, date, source) erweitert + Index; Migration
  baut Tabelle mit neuer Struktur neu auf, behält Altdaten als 'cloud'
- ki.py: return_source=True-Parameter gibt (text, 'cloud'|'local') zurück
- training.py: ki_source aus ki.complete() auslesen, in DB speichern
- social.py: _ki_complete_tracked() zählt Luna-Anfragen mit source='luna';
  alle Content-Endpoints (generate, evaluate, training-tip, breed-of-day,
  pflege-tipp) nutzen tracking-Variante
- admin.py: Stats aufgeteilt in ki_cloud/ki_local/ki_luna je heute+Monat
- admin.js: KI-Karte zeigt 9 Zeilen mit ☁️ Claude / 🖥️ LM Studio / 🌙 Luna
- SW by-v359, APP_VER 344
This commit is contained in:
rene 2026-04-25 08:20:29 +02:00
parent 74b6c03bb3
commit 8d5c7a19b1
6 changed files with 136 additions and 33 deletions

View file

@ -530,6 +530,8 @@ 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:
@ -1108,3 +1110,22 @@ 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-opus-4-6") CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-sonnet-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,6 +70,7 @@ 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.
@ -81,9 +82,10 @@ 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 KI-Antwort als String, oder (str, str) wenn return_source=True
Raises: Raises:
KIPremiumRequired: Cloud-Feature ohne Premium KIPremiumRequired: Cloud-Feature ohne Premium
@ -101,20 +103,26 @@ 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)
return (text, CLOUD_MODEL) if return_model else text if return_model:
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)
return (text, LOCAL_MODEL) if return_model else text if return_model:
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)
return (text, CLOUD_MODEL) if return_model else text if return_model:
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,8 +160,30 @@ 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:
@ -217,6 +239,12 @@ 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

@ -466,9 +466,19 @@ _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 trimmen"], "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"],
"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,"tipp":"Lockiges Fell = hypoallergen, aber Pflegeaufwand unterschätzt!"}, "fell_typ":"lockig","saison":None,"fell_pflege_art":"schneiden","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"],
@ -949,6 +959,28 @@ 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:
@ -1064,7 +1096,7 @@ async def generate_content(req: GenerateRequest, user=Depends(require_social_med
) )
try: try:
raw = await _ki_complete(prompt) raw = await _ki_complete_tracked(prompt, user["id"])
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)
@ -1113,7 +1145,7 @@ async def evaluate_content(req: EvaluateRequest, user=Depends(require_social_med
) )
try: try:
raw = await _ki_complete(prompt) raw = await _ki_complete_tracked(prompt, user["id"])
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}")
@ -1197,13 +1229,21 @@ 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,
VALUES (?,?,?,?,?,?,?,?,?,?,?)""", fell_pflege_art)
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()
@ -1251,7 +1291,7 @@ async def training_tip(user=Depends(require_social_media)):
) )
try: try:
raw = await _ki_complete(prompt) raw = await _ki_complete_tracked(prompt, user["id"])
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}")
@ -1407,7 +1447,7 @@ async def breed_of_day(user=Depends(require_social_media)):
) )
try: try:
raw = await _ki_complete(prompt) raw = await _ki_complete_tracked(prompt, user["id"])
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}")
@ -1546,7 +1586,7 @@ async def pflege_tipp(breed_id: Optional[int] = None, user=Depends(require_socia
) )
try: try:
raw = await _ki_complete(prompt) raw = await _ki_complete_tracked(prompt, user["id"])
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,11 +777,10 @@ 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:
daily_used = conn.execute( used = conn.execute(
"SELECT COALESCE(count,0) FROM ki_daily_calls WHERE user_id=? AND date=?", "SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE user_id=? AND date=?",
(uid, today) (uid, today)
).fetchone() ).fetchone()[0]
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}
@ -791,14 +790,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,
PRIMARY KEY (user_id, date) source TEXT NOT NULL DEFAULT 'cloud',
PRIMARY KEY (user_id, date, source)
) )
""") """)
row = conn.execute( daily_used = conn.execute(
"SELECT count FROM ki_daily_calls WHERE user_id=? AND date=?", "SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE user_id=? AND date=?",
(uid, today) (uid, today)
).fetchone() ).fetchone()[0]
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.")
@ -867,12 +866,13 @@ async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)):
) )
try: try:
feedback_text = await ki.complete( feedback_text, ki_source = 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) VALUES (?, ?, 1) INSERT INTO ki_daily_calls (user_id, date, count, source) VALUES (?, ?, 1, ?)
ON CONFLICT(user_id, date) DO UPDATE SET count = count + 1 ON CONFLICT(user_id, date, source) DO UPDATE SET count = count + 1
""", (uid, today)) """, (uid, today, ki_source))
new_count = conn.execute( new_count = conn.execute(
"SELECT count FROM ki_daily_calls WHERE user_id=? AND date=?", "SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE user_id=? AND date=?",
(uid, today) (uid, today)
).fetchone()[0] ).fetchone()[0]

View file

@ -207,12 +207,18 @@ 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-Trainer Nutzung (Claude API)</p> <p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">KI-Nutzung</p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)"> <div style="display:flex;flex-direction:column;gap:var(--space-2)">
${[ ${[
['Anfragen heute', s.ki_today, 'var(--c-primary)'], ['☁️ Claude heute', s.ki_cloud_today, 'var(--c-primary)'],
['Anfragen diesen Monat', s.ki_month, 'var(--c-text-secondary)'], ['🖥️ LM Studio heute', s.ki_local_today, 'var(--c-success)'],
['Aktive User heute', s.ki_users_today, 'var(--c-text-secondary)'], ['🌙 Luna heute', s.ki_luna_today, 'var(--c-warning)'],
['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>