""" Body Debt — Gradio App (dark "off-brand" edition) Quantifies physiological debt from lifestyle stressors and provides AI-backed recovery prescriptions using a local small model. """ from __future__ import annotations import html import time from datetime import datetime import gradio as gr import numpy as np from scoring import ( Stressor, compute_live_score, compute_system_scores, compute_counterfactual, STRESSOR_DEFS, SYSTEM_META, ) from face_scan import run_face_scan, features_to_array from stress_model import predict_stress_score from health_coach import stream_advice, stream_plan, _fallback_advice, _fallback_plan # ─── Design tokens (mirrors src/lib/design-tokens.ts) ──────────────────────── BG_BASE = "#0A0A0B" BG_SURFACE = "#141416" BG_ELEVATED = "#1C1C1F" BORDER = "rgba(168, 162, 158, 0.10)" BORDER_SOFT = "rgba(168, 162, 158, 0.06)" TEXT_PRIMARY = "#F5F5F4" TEXT_SECONDARY = "#A8A29E" TEXT_MUTED = "#524F4C" TEXT_FAINT = "#3a3835" BRAND_PRIMARY = "#EA580C" BRAND_SECONDARY = "#F59E0B" RECOVERY_GREEN = "#4ADE80" # Light mode tokens LT_BG_BASE = "#FAFAF9" LT_BG_SURFACE = "#F5F5F4" LT_BG_ELEVATED = "#E7E5E4" LT_BORDER = "rgba(0, 0, 0, 0.08)" LT_BORDER_SOFT = "rgba(0, 0, 0, 0.04)" LT_TEXT_PRIMARY = "#1C1917" LT_TEXT_SECONDARY = "#57534E" LT_TEXT_MUTED = "#7A7672" LT_TEXT_FAINT = "#B8B4B0" SYSTEM_ACCENTS = { "cardiovascular": ("#F43F5E", "rgba(244, 63, 94, 0.18)", "rgba(244, 63, 94, 0.40)"), "brain": ("#22D3EE", "rgba(34, 211, 238, 0.18)", "rgba(34, 211, 238, 0.40)"), "liver": ("#EAB308", "rgba(234, 179, 8, 0.18)", "rgba(234, 179, 8, 0.40)"), "muscular": ("#A78BFA", "rgba(167, 139, 250, 0.18)","rgba(167, 139, 250, 0.40)"), "gut": ("#2DD4BF", "rgba(45, 212, 191, 0.18)", "rgba(45, 212, 191, 0.40)"), } SYSTEM_GLYPHS = { "cardiovascular": "C", "brain": "N", "liver": "L", "muscular": "M", "gut": "G", } DEBT_TIERS = [ (0, 20, "#4ADE80", "You're clear. Minimal debt.", "low"), (20, 40, "#F59E0B", "Low debt. Minor adjustments needed.", "low"), (40, 60, "#EA580C", "Moderate debt. Recovery recommended.","moderate"), (60, 80, "#DC2626", "High debt. Prioritize recovery.", "high"), (80, 101,"#991B1B", "Critical debt. Full rest mode.", "critical"), ] TIME_OPTIONS = [] for h in range(12): for m in ["00", "30"]: if h == 0: TIME_OPTIONS.append(f"12:{m} AM") else: TIME_OPTIONS.append(f"{h}:{m} AM") for h in range(12): for m in ["00", "30"]: if h == 0: TIME_OPTIONS.append(f"12:{m} PM") else: TIME_OPTIONS.append(f"{h}:{m} PM") WINDOW_COLORS = { "RIGHT NOW": "#DC2626", "THIS MORNING": "#EA580C", "TODAY": "#F59E0B", "AVOID": "#A78BFA", } def debt_tier(score: int) -> tuple[str, str, str]: for lo, hi, color, verdict, _ in DEBT_TIERS: if lo <= score < hi: return color, verdict, DEBT_TIERS[DEBT_TIERS.index((lo, hi, color, verdict, _))][4] return "#4ADE80", "You're clear. Minimal debt.", "low" # ─── Custom CSS (off-brand dark theme) ─────────────────────────────────────── CUSTOM_CSS = f""" @import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600;700&display=swap'); :root {{ --bg-base: {BG_BASE}; --bg-surface: {BG_SURFACE}; --bg-elevated: {BG_ELEVATED}; --border: {BORDER}; --border-soft: {BORDER_SOFT}; --text-primary: {TEXT_PRIMARY}; --text-secondary: {TEXT_SECONDARY}; --text-muted: {TEXT_MUTED}; --text-faint: {TEXT_FAINT}; --brand: {BRAND_PRIMARY}; --brand-secondary: {BRAND_SECONDARY}; --recovery-green: {RECOVERY_GREEN}; }} body.light-mode {{ --bg-base: {LT_BG_BASE}; --bg-surface: {LT_BG_SURFACE}; --bg-elevated: {LT_BG_ELEVATED}; --border: {LT_BORDER}; --border-soft: {LT_BORDER_SOFT}; --text-primary: {LT_TEXT_PRIMARY}; --text-secondary: {LT_TEXT_SECONDARY}; --text-muted: {LT_TEXT_MUTED}; --text-faint: {LT_TEXT_FAINT}; --brand: {BRAND_PRIMARY}; --brand-secondary: {BRAND_SECONDARY}; --recovery-green: {RECOVERY_GREEN}; }} html, body, .gradio-container {{ background: var(--bg-base) !important; color: var(--text-primary) !important; font-family: 'Inter', system-ui, sans-serif !important; }} .gradio-container {{ max-width: 1180px !important; padding: 0 24px 60px !important; }} footer {{ display: none !important; }} /* Hide default Gradio chrome we don't need */ .block.padded, .panel, .gap, .form {{ background: transparent !important; border: none !important; }} /* The giant debt score number */ .debt-hero {{ font-family: 'DM Serif Display', Georgia, serif; font-size: clamp(7rem, 22vw, 11rem); font-weight: 400; line-height: 0.9; letter-spacing: -0.04em; margin: 0; text-shadow: 0 0 60px currentColor; transition: color 0.6s ease; animation: heroDrop 0.7s cubic-bezier(0.22, 1, 0.36, 1) backwards; }} .debt-hero-label {{ font-family: 'Inter', sans-serif; font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: var(--text-secondary); margin-bottom: 8px; animation: fadeUp 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.05s backwards; }} .debt-verdict {{ font-family: 'Inter', sans-serif; font-size: 14px; font-weight: 500; color: var(--text-secondary); margin-top: 6px; animation: fadeUp 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.25s backwards; }} /* The breathing orb behind the score */ .orb-wrap {{ position: relative; display: inline-block; padding: 40px 60px 30px; }} .orb-wrap::before {{ content: ''; position: absolute; inset: -30px; background: radial-gradient(circle, currentColor 0%, transparent 65%); opacity: 0.18; border-radius: 50%; animation: orbBreath 4s ease-in-out infinite; z-index: -2; }} .orb-wrap::after {{ content: ''; position: absolute; inset: -50px; background: radial-gradient(circle, currentColor 0%, transparent 70%); opacity: 0.06; border-radius: 50%; animation: orbBreath 4s ease-in-out infinite 0.5s; z-index: -3; filter: blur(8px); }} @keyframes orbBreath {{ 0%, 100% {{ transform: scale(1); }} 50% {{ transform: scale(1.10); }} }} @keyframes heroDrop {{ 0% {{ opacity: 0; transform: scale(0.6) translateY(8px); filter: blur(8px); }} 100% {{ opacity: 1; transform: scale(1) translateY(0); filter: blur(0); }} }} @keyframes fadeUp {{ 0% {{ opacity: 0; transform: translateY(6px); }} 100% {{ opacity: 1; transform: translateY(0); }} }} /* Section labels */ .section-label {{ font-family: 'Inter', sans-serif; font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: var(--text-muted); margin: 0 0 12px; display: flex; align-items: center; gap: 10px; }} .section-label::after {{ content: ''; flex: 1; height: 1px; background: var(--border-soft); }} /* System meter */ .sys-meter {{ padding: 10px 14px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px; margin-bottom: 8px; display: flex; align-items: center; gap: 12px; transition: border-color 0.2s, background 0.2s; animation: fadeUp 0.5s cubic-bezier(0.22, 1, 0.36, 1) backwards; }} .sys-meter:nth-child(1) {{ animation-delay: 0.05s; }} .sys-meter:nth-child(2) {{ animation-delay: 0.10s; }} .sys-meter:nth-child(3) {{ animation-delay: 0.15s; }} .sys-meter:nth-child(4) {{ animation-delay: 0.20s; }} .sys-meter:nth-child(5) {{ animation-delay: 0.25s; }} .sys-meter.is-primary {{ background: linear-gradient(180deg, rgba(255,255,255,0.02), transparent); }} .sys-glyph {{ width: 28px; height: 28px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 700; flex-shrink: 0; }} .sys-body {{ flex: 1; min-width: 0; }} .sys-row {{ display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }} .sys-label {{ font-size: 13px; font-weight: 600; color: var(--text-primary); }} .sys-time {{ font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-muted); }} .sys-bar {{ margin-top: 8px; height: 3px; background: rgba(168, 162, 158, 0.10); border-radius: 2px; overflow: hidden; }} .sys-bar-fill {{ height: 100%; border-radius: 2px; transition: width 0.7s cubic-bezier(0.22, 1, 0.36, 1); }} .sys-cause {{ font-size: 11px; color: var(--text-muted); margin-top: 6px; line-height: 1.4; }} /* Protocol step */ .proto-step {{ display: flex; gap: 12px; padding: 8px 0; }} .proto-rail {{ display: flex; flex-direction: column; align-items: center; width: 30px; flex-shrink: 0; }} .proto-num {{ width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; border: 1px solid currentColor; background: rgba(255,255,255,0.02); }} .proto-conn {{ flex: 1; width: 1px; background: var(--border); min-height: 18px; margin-top: 4px; }} .proto-window {{ font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; margin-bottom: 4px; }} .proto-action {{ font-size: 13px; color: var(--text-primary); line-height: 1.55; font-weight: 500; }} /* Science cards */ .sci-card {{ padding: 12px 14px; background: var(--bg-elevated); border-left: 2px solid currentColor; border-radius: 0 8px 8px 0; margin-bottom: 8px; }} .sci-fact {{ font-size: 12px; color: var(--text-secondary); line-height: 1.55; margin: 0 0 4px; }} .sci-cite {{ font-size: 10px; color: var(--text-faint); font-style: italic; margin: 0; font-family: 'JetBrains Mono', monospace; }} /* Face scan pill */ .face-pill {{ padding: 14px 16px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px; display: flex; align-items: center; gap: 16px; margin-bottom: 16px; }} .face-pill-placeholder {{ padding: 20px; background: var(--bg-surface); border: 1.5px dashed var(--border); border-radius: 12px; text-align: center; margin-bottom: 16px; transition: border-color 0.2s; }} .face-pill-placeholder:hover {{ border-color: var(--brand); }} .face-pill-placeholder .icon {{ font-size: 28px; opacity: 0.4; margin-bottom: 6px; }} .face-pill-placeholder .label {{ font-size: 11px; color: var(--text-muted); font-weight: 500; }} .face-pill-placeholder .sub {{ font-size: 9px; color: var(--text-faint); margin-top: 2px; font-family: 'JetBrains Mono', monospace; }} .face-num {{ font-family: 'DM Serif Display', serif; font-size: 36px; line-height: 1; }} .face-label {{ font-size: 9px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-muted); }} .face-status {{ font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 4px; }} .face-meta {{ font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text-faint); margin-top: 4px; }} .face-bar {{ margin-top: 8px; height: 4px; background: rgba(168, 162, 158, 0.08); border-radius: 2px; overflow: hidden; }} .face-bar-fill {{ height: 100%; border-radius: 2px; transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); }} /* Agent trace */ .trace-step {{ display: flex; align-items: center; gap: 10px; padding: 6px 10px; border-radius: 6px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary); margin-bottom: 4px; }} .trace-step.is-active {{ color: var(--brand); background: rgba(234, 88, 12, 0.06); }} .trace-step.is-done {{ color: var(--recovery-green); }} .trace-dot {{ width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex-shrink: 0; }} .trace-step.is-active .trace-dot {{ animation: pulse 1.2s ease-in-out infinite; }} @keyframes pulse {{ 0%,100% {{ opacity: 1; }} 50% {{ opacity: 0.3; }} }} /* AI coach block */ .coach-block {{ padding: 20px 22px; background: linear-gradient(180deg, var(--bg-surface), var(--bg-base)); border: 1px solid var(--border); border-radius: 14px; margin-top: 8px; }} .coach-header {{ display: flex; align-items: center; gap: 10px; margin-bottom: 14px; font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: var(--brand); }} .coach-pill {{ background: var(--bg-elevated); color: var(--text-muted); padding: 3px 8px; border-radius: 4px; font-size: 9px; }} .coach-body {{ font-size: 14px; color: var(--text-primary); line-height: 1.7; white-space: pre-wrap; min-height: 60px; }} .coach-cursor {{ display: inline-block; width: 7px; height: 14px; background: var(--brand); margin-left: 2px; vertical-align: text-bottom; animation: blink 1s steps(2, start) infinite; }} @keyframes blink {{ to {{ visibility: hidden; }} }} /* Buttons */ button.primary, .gr-button-primary {{ background: var(--brand) !important; color: white !important; border: none !important; border-radius: 10px !important; font-weight: 700 !important; letter-spacing: 0.02em !important; transition: transform 0.15s, box-shadow 0.15s !important; }} button.primary:hover, .gr-button-primary:hover {{ transform: translateY(-1px) !important; box-shadow: 0 6px 20px rgba(234, 88, 12, 0.35) !important; }} /* Inputs */ input, textarea, .gr-input, .gr-text-input, .gr-dropdown {{ background: var(--bg-surface) !important; border: 1px solid var(--border) !important; color: var(--text-primary) !important; border-radius: 8px !important; }} input:focus, textarea:focus {{ border-color: var(--brand) !important; outline: none !important; }} /* Checkbox */ .gr-checkbox {{ background: transparent !important; }} .gr-checkbox input[type="checkbox"] {{ appearance: none; width: 18px; height: 18px; border: 1.5px solid var(--text-muted); border-radius: 4px; background: var(--bg-surface); cursor: pointer; position: relative; transition: all 0.15s; }} .gr-checkbox input[type="checkbox"]:checked {{ background: var(--brand); border-color: var(--brand); }} .gr-checkbox input[type="checkbox"]:checked::after {{ content: '✓'; color: white; position: absolute; top: -2px; left: 3px; font-size: 14px; font-weight: 800; }} .gr-checkbox label {{ color: var(--text-primary) !important; font-weight: 500 !important; }} /* Care checkbox — green when checked (positive stressor) */ #care-checkbox input[type="checkbox"]:checked {{ background: var(--recovery-green) !important; border-color: var(--recovery-green) !important; }} #care-checkbox label {{ color: var(--recovery-green) !important; }} /* Image upload area */ .gr-image, .gr-image-upload {{ background: var(--bg-surface) !important; border: 1px dashed var(--border) !important; border-radius: 10px !important; }} /* Empty state */ .empty-state {{ text-align: center; padding: 80px 20px; color: var(--text-faint); }} .empty-state .icon {{ font-size: 48px; opacity: 0.3; margin-bottom: 12px; }} .empty-state .label {{ font-size: 14px; font-weight: 500; color: var(--text-muted); }} .empty-state .sub {{ font-size: 12px; color: var(--text-faint); margin-top: 4px; }} /* Hide group labels for cleaner look */ .gr-group {{ background: transparent !important; border: none !important; }} .gr-form {{ background: transparent !important; }} /* App header */ .app-header {{ text-align: center; padding: 36px 0 28px; border-bottom: 1px solid var(--border-soft); margin-bottom: 28px; }} .app-title {{ font-family: 'Inter', sans-serif; font-weight: 800; letter-spacing: 0.22em; text-transform: uppercase; font-size: 12px; color: var(--text-primary); margin: 0 0 6px; }} .app-subtitle {{ font-family: 'Inter', sans-serif; font-size: 13px; color: var(--text-muted); margin: 0; max-width: 480px; margin: 0 auto; }} /* Footer */ .app-footer {{ text-align: center; padding: 28px 16px 16px; margin-top: 36px; border-top: 1px solid var(--border-soft); font-size: 10px; color: var(--text-faint); font-family: 'JetBrains Mono', monospace; }} .app-footer a {{ color: var(--brand); text-decoration: none; }} /* Header attribution pills */ .attr-row {{ display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 14px; flex-wrap: wrap; }} .attr-pill {{ display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border: 1px solid var(--border); border-radius: 999px; font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 600; color: var(--text-secondary); background: var(--bg-surface); text-decoration: none; transition: border-color 0.15s, color 0.15s; }} .attr-pill:hover {{ border-color: var(--brand); color: var(--text-primary); }} .attr-pill .dot {{ width: 6px; height: 6px; border-radius: 50%; background: var(--recovery-green); box-shadow: 0 0 8px var(--recovery-green); }} /* Ready pulse for empty coach/trace */ .ready-pulse {{ display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--brand); margin-right: 8px; animation: pulse 1.6s ease-in-out infinite; }} /* Triage plan */ .plan-block {{ margin-bottom: 20px; animation: fadeUp 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.15s backwards; }} .plan-source {{ font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text-faint); font-weight: 600; margin-left: auto; text-transform: none; letter-spacing: 0.1em; }} .plan-line {{ display: flex; align-items: center; gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--border-soft); animation: fadeUp 0.4s cubic-bezier(0.22, 1, 0.36, 1) backwards; }} .plan-line:last-child {{ border-bottom: none; }} .plan-tag {{ font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 800; letter-spacing: 0.14em; padding: 3px 8px; border: 1px solid; border-radius: 4px; flex-shrink: 0; min-width: 78px; text-align: center; }} .plan-text {{ font-family: 'Inter', sans-serif; font-size: 13px; font-weight: 500; color: var(--text-primary); line-height: 1.4; }} /* Counterfactual hint */ .cf-block {{ display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px; background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid; border-radius: 0 10px 10px 0; margin: 16px 0; animation: fadeUp 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.2s backwards; }} .cf-label {{ font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 800; letter-spacing: 0.16em; flex-shrink: 0; padding-top: 2px; min-width: 140px; }} .cf-body {{ font-size: 13px; color: var(--text-secondary); line-height: 1.55; }} /* Running debt pill */ .debt-pill {{ display: inline-flex; align-items: center; gap: 8px; padding: 5px 14px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 999px; font-family: 'JetBrains Mono', monospace; font-size: 11px; transition: all 0.15s; margin-bottom: 14px; }} /* Preset scenario chips */ .preset-row {{ display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 18px; }} .preset-chip {{ font-family: 'JetBrains Mono', monospace; font-size: 10px !important; font-weight: 700 !important; padding: 5px 14px !important; border-radius: 999px !important; border: 1px solid var(--border) !important; background: var(--bg-surface) !important; color: var(--text-secondary) !important; letter-spacing: 0.04em !important; transition: all 0.15s !important; box-shadow: none !important; }} .preset-chip:hover {{ border-color: var(--brand) !important; color: var(--text-primary) !important; background: rgba(234, 88, 12, 0.06) !important; }} /* Clear all button */ button.clear-all {{ font-family: 'JetBrains Mono', monospace !important; font-size: 10px !important; font-weight: 600 !important; padding: 4px 12px !important; border: 1px solid var(--border) !important; border-radius: 8px !important; background: transparent !important; color: var(--text-muted) !important; transition: all 0.15s !important; margin-bottom: 8px !important; }} button.clear-all:hover {{ border-color: var(--text-faint) !important; color: var(--text-secondary) !important; }} /* Sample badge */ .sample-badge {{ font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }} .sample-badge .dot {{ width: 5px; height: 5px; border-radius: 50%; background: var(--brand); animation: pulse 1.6s ease-in-out infinite; }} /* Theme toggle button */ .theme-toggle {{ position: fixed; top: 16px; right: 20px; z-index: 9999; width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--border); background: var(--bg-surface); color: var(--text-secondary); font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s; backdrop-filter: blur(8px); }} .theme-toggle:hover {{ border-color: var(--brand); color: var(--brand); transform: scale(1.08); }} /* Smooth theme transitions — 0.2s matches existing hover rhythms */ .gradio-container, .sys-meter, .coach-block, .coach-pill, .tl-wrap, .face-pill, .face-pill-placeholder, .cf-block, .debt-pill, .attr-pill, input, textarea, .gr-input, .gr-text-input, .gr-dropdown, .gr-checkbox input[type="checkbox"]:not(:checked), .gr-checkbox label, .theme-toggle, .sci-card, .trace-step, .plan-line, .plan-tag, .sample-badge, .app-header, .app-footer, .section-label, .section-label::after, .debt-hero-label, .debt-verdict, .sys-label, .sys-time, .sys-cause, .sys-bar-fill, .proto-conn, .proto-action, .face-label, .face-meta, .plan-source, .plan-text, .cf-label, .cf-body, .empty-state .label, .empty-state .sub, .orb-wrap::before, .orb-wrap::after, #care-checkbox input[type="checkbox"]:not(:checked) {{ transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; }} /* These have !important on their existing transitions, so !important needed here too */ .preset-chip, button.clear-all, button.primary, .gr-button-primary {{ transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease !important; }} /* Comparison card carousel */ .cmp-section {{ margin-top: 28px; }} .cmp-row {{ display: flex; gap: 10px; overflow-x: auto; padding: 4px 0 12px; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; }} .cmp-card {{ min-width: 170px; max-width: 200px; flex-shrink: 0; scroll-snap-align: start; padding: 12px 14px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px; position: relative; }} .cmp-hero {{ font-family: 'DM Serif Display', Georgia, serif; font-size: 30px; line-height: 1; margin-bottom: 2px; }} .cmp-label {{ font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-secondary); }} .cmp-time {{ font-family: 'JetBrains Mono', monospace; font-size: 8px; color: var(--text-faint); margin-top: 2px; margin-bottom: 8px; }} .cmp-sys {{ display: flex; align-items: center; gap: 5px; margin-top: 5px; font-size: 10px; }} .cmp-sys-glyph {{ width: 16px; font-family: 'JetBrains Mono', monospace; font-weight: 700; flex-shrink: 0; }} .cmp-sys-bar {{ flex: 1; height: 3px; background: rgba(168, 162, 158, 0.10); border-radius: 2px; overflow: hidden; }} .cmp-sys-fill {{ height: 100%; border-radius: 2px; transition: width 0.4s ease; }} .cmp-del {{ position: absolute; top: 4px; right: 6px; width: 20px; height: 20px; border-radius: 50%; border: none; background: transparent; color: var(--text-faint); font-size: 13px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; font-family: system-ui, sans-serif; }} .cmp-del:hover {{ background: rgba(244, 63, 94, 0.15); color: #F43F5E; }} .cmp-empty {{ text-align: center; padding: 24px; color: var(--text-faint); font-size: 12px; }} /* Timeline chart */ .tl-wrap {{ margin: 16px 0; padding: 14px 16px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px; }} .tl-label {{ font-family: 'JetBrains Mono', monospace; font-size: 7px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-faint); min-width: 32px; flex-shrink: 0; }} .tl-row {{ display: flex; align-items: center; gap: 6px; padding: 3px 0; }} .tl-bar-wrap {{ flex: 1; height: 10px; background: rgba(168, 162, 158, 0.06); border-radius: 5px; overflow: hidden; position: relative; }} .tl-bar {{ height: 100%; border-radius: 5px; transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); }} .tl-bar-stack {{ height: 100%; display: flex; border-radius: 5px; overflow: hidden; }} .tl-seg {{ height: 100%; transition: flex 0.6s cubic-bezier(0.22, 1, 0.36, 1); }} .tl-seg:first-child {{ border-radius: 5px 0 0 5px; }} .tl-seg:last-child {{ border-radius: 0 5px 5px 0; }} .tl-seg:only-child {{ border-radius: 5px; }} .tl-score {{ font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; min-width: 22px; text-align: right; flex-shrink: 0; }} .tl-now {{ display: inline-block; font-size: 7px; font-weight: 800; letter-spacing: 0.1em; color: var(--brand); margin-left: 4px; }} .tl-dom {{ font-family: 'JetBrains Mono', monospace; font-size: 8px; font-weight: 800; min-width: 16px; text-align: center; flex-shrink: 0; }} /* Timeline legend */ .tl-legend {{ display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border-soft); }} .tl-leg {{ font-family: 'JetBrains Mono', monospace; font-size: 8px; font-weight: 600; display: inline-flex; align-items: center; gap: 4px; }} .tl-leg-swatch {{ width: 8px; height: 8px; border-radius: 2px; display: inline-block; flex-shrink: 0; }} /* Save compare button */ .save-compare {{ font-family: 'JetBrains Mono', monospace !important; font-size: 10px !important; font-weight: 700 !important; padding: 5px 12px !important; border-radius: 8px !important; border: 1px solid var(--border) !important; background: transparent !important; color: var(--text-secondary) !important; transition: all 0.15s !important; }} .save-compare:hover {{ border-color: var(--brand) !important; color: var(--brand) !important; }} /* Mobile / narrow viewport */ @media (max-width: 900px) {{ .gradio-container {{ padding: 0 16px 40px !important; }} .app-header {{ padding: 24px 0 20px; margin-bottom: 18px; }} .debt-hero {{ font-size: clamp(5.5rem, 28vw, 8rem); }} .orb-wrap {{ padding: 28px 20px 22px; }} .theme-toggle {{ top: 12px; right: 12px; width: 32px; height: 32px; font-size: 14px; }} .gradio-row > div {{ flex-wrap: wrap !important; }} }} @media (max-width: 600px) {{ .gradio-container {{ padding: 0 10px 28px !important; }} .app-header {{ padding: 18px 0 16px; margin-bottom: 14px; }} .app-title {{ font-size: 10px; }} .app-subtitle {{ font-size: 11px; }} .attr-pill {{ font-size: 8px; padding: 2px 8px; }} .debt-hero {{ font-size: clamp(3.8rem, 24vw, 5.5rem) !important; }} .debt-hero-label {{ font-size: 8px; }} .debt-verdict {{ font-size: 12px; }} .orb-wrap {{ padding: 20px 12px 16px; }} .orb-wrap::before {{ inset: -20px; }} .orb-wrap::after {{ inset: -35px; }} .section-label {{ font-size: 8px; margin: 0 0 8px; }} .sys-meter {{ padding: 8px 10px; gap: 8px; }} .sys-glyph {{ width: 22px; height: 22px; font-size: 10px; }} .sys-label {{ font-size: 11px; }} .sys-time {{ font-size: 8px; }} .sys-cause {{ font-size: 10px; }} .coach-block {{ padding: 14px 16px; }} .coach-body {{ font-size: 12px; min-height: 40px; }} .proto-action {{ font-size: 11px; }} .proto-step {{ gap: 8px; }} .preset-chip {{ font-size: 9px !important; padding: 4px 10px !important; }} .debt-pill {{ font-size: 9px; padding: 4px 10px; }} .face-pill {{ padding: 10px 12px; gap: 10px; }} .face-num {{ font-size: 28px; }} .cf-block {{ padding: 10px 12px; flex-direction: column; gap: 6px; }} .cf-label {{ min-width: auto; font-size: 8px; }} .cf-body {{ font-size: 11px; }} .plan-line {{ gap: 8px; padding: 7px 0; }} .plan-tag {{ font-size: 8px; min-width: 64px; padding: 2px 6px; }} .plan-text {{ font-size: 11px; }} .trace-step {{ font-size: 9px; padding: 4px 8px; }} .tl-legend {{ gap: 6px; }} .tl-leg {{ font-size: 7px; }} .tl-leg-swatch {{ width: 6px; height: 6px; }} .tl-dom {{ font-size: 7px; min-width: 12px; }} .cmp-card {{ min-width: 140px; max-width: 160px; padding: 10px 10px; }} .cmp-hero {{ font-size: 24px; }} .cmp-label {{ font-size: 8px; }} .empty-state {{ padding: 40px 16px; }} .empty-state .icon {{ font-size: 36px; }} .empty-state .label {{ font-size: 12px; }} .empty-state .sub {{ font-size: 10px; }} .sci-card {{ padding: 10px 12px; }} .sci-fact {{ font-size: 11px; }} .sci-cite {{ font-size: 9px; }} .theme-toggle {{ top: 10px; right: 10px; width: 28px; height: 28px; font-size: 12px; }} .face-meta {{ font-size: 8px; }} .face-status {{ font-size: 9px; }} input, textarea {{ font-size: 14px !important; }} .gr-checkbox input[type="checkbox"] {{ width: 22px; height: 22px; }} .gr-checkbox input[type="checkbox"]:checked::after {{ font-size: 16px; top: -1px; left: 5px; }} .gr-checkbox label {{ font-size: 14px !important; }} .gr-dropdown {{ font-size: 13px !important; }} }} @media (max-width: 420px) {{ .gradio-container {{ padding: 0 8px 24px !important; }} .app-header {{ padding: 14px 0 12px; margin-bottom: 12px; }} .app-title {{ font-size: 9px; letter-spacing: 0.16em; }} .debt-hero {{ font-size: clamp(2.8rem, 22vw, 3.8rem) !important; }} .orb-wrap {{ padding: 14px 8px 12px; }} .orb-wrap::before {{ inset: -14px; }} .orb-wrap::after {{ inset: -24px; }} .preset-row {{ gap: 4px; }} .preset-chip {{ font-size: 8px !important; padding: 3px 8px !important; }} .attr-pill {{ font-size: 7px; padding: 2px 6px; gap: 4px; }} .cmp-card {{ min-width: 120px; max-width: 140px; padding: 8px; }} .cmp-hero {{ font-size: 20px; }} .plan-tag {{ font-size: 7px; min-width: 54px; }} .coach-block {{ padding: 12px 12px; }} .coach-header {{ font-size: 8px; }} .coach-pill {{ font-size: 8px; padding: 2px 6px; }} button.clear-all {{ font-size: 9px !important; padding: 3px 8px !important; }} .save-compare {{ font-size: 9px !important; padding: 3px 10px !important; }} }} """ # ─── Theme toggle JS ───────────────────────────────────────────────────────── THEME_TOGGLE_HTML = """ """ # ─── HTML renderers ────────────────────────────────────────────────────────── def render_hero(score: int, verdict: str, is_sample: bool = False) -> str: color, _, _ = debt_tier(score) badge = "" if is_sample: badge = ( '
' '' "SAMPLE · adjust the form and click Calculate for your own" "
" ) return f""" {badge}
Body Debt Score
{score}
{html.escape(verdict)}
""" def render_system_meters(system_scores) -> str: max_score = max((s.score for s in system_scores), default=0) primary_system = None if max_score > 0: primary_system = max(system_scores, key=lambda s: s.score).system rows = [] for s in system_scores: accent_active, accent_soft, accent_muted = SYSTEM_ACCENTS.get( s.system, ("var(--text-secondary)", "var(--border-soft)", "var(--text-muted)") ) is_primary = s.system == primary_system glyph = SYSTEM_GLYPHS.get(s.system, "•") bar_color = accent_active if is_primary else accent_muted label_color = "var(--text-primary)" if is_primary else "var(--text-secondary)" pct = max(0, min(100, s.score)) glyph_bg = accent_soft if is_primary else "rgba(168, 162, 158, 0.06)" rows.append(f"""
{glyph}
{s.icon} {html.escape(s.label)} clears {s.cleared_at}
{html.escape(s.cause_text)}
""") return f"""
Five-system breakdown
{''.join(rows)}
""" def render_prescription(system_scores, debt_score: int) -> str: if debt_score < 20: return f"""
Recovery Protocol · Cleared
All five systems below threshold. Maintain the streak.
""" # Map system actions into the four temporal windows based on severity windows = ["RIGHT NOW", "THIS MORNING", "TODAY", "AVOID"] active = [s for s in system_scores if s.score > 15] if not active: active = system_scores[:3] # Sort: highest-debt system goes to RIGHT NOW active.sort(key=lambda s: -s.score) steps = [] for i, s in enumerate(active[:4]): window = windows[min(i, 3)] color = WINDOW_COLORS[window] steps.append((i + 1, window, s.action_text, color)) is_last = lambda i: i == len(steps) - 1 steps_html = "" for i, (num, window, action, color) in enumerate(steps): connector = "" if is_last(i) else '
' steps_html += f"""
{str(num).zfill(2)}
{connector}
{window} · {html.escape(active[i].label)}

{html.escape(action)}

""" return f"""
Recovery Protocol
{steps_html}
""" def render_science(system_scores) -> str: items = [] for s in system_scores: if s.score > 20 and s.science_fact and s.science_cite: accent = SYSTEM_ACCENTS.get(s.system, ("var(--text-secondary)",))[0] items.append((accent, s.science_fact, s.science_cite)) if not items: return "" cards = "".join( f"""

{html.escape(fact)}

— {html.escape(cite)}

""" for accent, fact, cite in items ) return f"""
The science
{cards}
""" def render_face_scan(face_stress, is_healthy, _features) -> str: status_color = "var(--recovery-green)" if is_healthy else "var(--brand)" status_text = "Healthy" if is_healthy else "Stressed" status_bg = "rgba(74, 222, 128, 0.10)" if is_healthy else "rgba(234, 88, 12, 0.10)" pct = max(0, min(100, face_stress)) return f"""
Face Scan
{face_stress:.0f}/100
{status_text}
Processed on-device. No biometric data transmitted.
""" def render_face_scan_placeholder() -> str: """Call-to-action placeholder for face scan before the user runs an analysis.""" return f"""
📷
Capture a photo or use your webcam
MediaPipe FaceMesh → 7 features → stress MLP · all on-device
""" # ─── Debt timeline chart ───────────────────────────────────────────────── def compute_debt_timeline(system_scores, now=None): """Compute how total body debt changes over the recovery window. Each system decays linearly from its current score to 0 over its recovery window. Returns list of dicts, each with total score, per-system breakdown, and the dominant system at that point. """ if now is None: now = datetime.now() max_window = max((s for s in system_scores), default=None, key=lambda s: s.recovery_hrs) max_hrs = max_window.recovery_hrs if max_window else 10 max_hrs = max(max_hrs, 8) max_hrs = min(max_hrs, 48) system_order = ["cardiovascular", "brain", "liver", "muscular", "gut"] points = [] for h in range(0, int(max_hrs) + 2, 2): total = 0 systems_at_h = [] for sys_name in system_order: s = next((x for x in system_scores if x.system == sys_name), None) if s is None: continue if s.recovery_hrs > 0 and h <= s.recovery_hrs: remaining_frac = (s.recovery_hrs - h) / s.recovery_hrs sys_score = round(s.score * remaining_frac, 1) else: sys_score = 0.0 total += sys_score accent = SYSTEM_ACCENTS.get(sys_name, ("var(--text-muted)",))[0] systems_at_h.append({ "name": sys_name, "glyph": SYSTEM_GLYPHS.get(sys_name, "•"), "score": round(sys_score), "color": accent, }) total = round(total) color, _, _ = debt_tier(total) label = "Now" if h == 0 else f"+{h}h" # Find dominant system (highest contributing) dominant = max(systems_at_h, key=lambda x: x["score"]) if systems_at_h else None points.append({ "hour": h, "score": total, "color": color, "label": label, "systems": systems_at_h, "dominant": dominant["name"] if dominant and dominant["score"] > 0 else None, "dominant_glyph": dominant["glyph"] if dominant and dominant["score"] > 0 else "", }) return points def render_timeline(points: list[dict]) -> str: """Render the debt timeline as a horizontal bar chart with system breakdown. Each bar is a stacked segment showing each system's contribution in its accent color. The dominant system is labeled to the right of the bar. """ if not points: return "" max_score = max(p["score"] for p in points) or 1 rows = [] for p in points: now_tag = '· now' if p["hour"] == 0 else "" # Build stacked bar segments — each system gets a proportional slice bar_pct = max(2.0, (p["score"] / max_score) * 100) segments = [] if p["systems"] and p["score"] > 0: total_sys = sum(x["score"] for x in p["systems"]) for sys in p["systems"]: if sys["score"] <= 0: continue share = (sys["score"] / total_sys) * 100 sys_label = {"cardiovascular":"Cardiovascular","brain":"Brain","liver":"Liver","muscular":"Muscular","gut":"Gut"}.get(sys["name"], sys["name"]) segments.append(f'') else: segments = [] seg_html = "".join(segments) if segments else f'' # Dominant system indicator dom_html = "" if p["dominant"] and p["dominant_glyph"]: dom_color = SYSTEM_ACCENTS.get(p["dominant"], ("var(--text-secondary)",))[0] dom_html = f'{p["dominant_glyph"]}' rows.append(f"""
{p['label']}{now_tag}
{seg_html}
{p['score']} {dom_html}
""") # Build a legend showing system glyphs legend_items = [] for sys_name in ["cardiovascular", "brain", "liver", "muscular", "gut"]: accent = SYSTEM_ACCENTS.get(sys_name, ("var(--text-muted)",))[0] glyph = SYSTEM_GLYPHS.get(sys_name, "•") label = { "cardiovascular": "Cardio", "brain": "Brain", "liver": "Liver", "muscular": "Muscle", "gut": "Gut", }.get(sys_name, sys_name) legend_items.append(f'{glyph} {label}') legend_html = f'
{" ".join(legend_items)}
' if len(legend_items) > 0 else "" return f"""
Recovery forecast
{legend_html} {"".join(rows)}
""" def render_plan(plan: dict | None, lines_so_far: list[str] | None = None) -> str: """Triage plan: 3 lines from SmolLM2's structured plan step. `lines_so_far` lets the UI show the plan being formed (one line at a time) as the LLM streams. Falls back to `plan` dict for the final render. """ if lines_so_far is None: lines_so_far = [] if plan: if plan.get("priority"): lines_so_far.append(f"PRIORITY: {plan['priority']}") if plan.get("secondary"): lines_so_far.append(f"SECONDARY: {plan['secondary']}") if plan.get("avoid"): lines_so_far.append(f"AVOID: {plan['avoid']}") if not lines_so_far and not plan: return "" rendered_lines = [] for line in lines_so_far: up = line.upper().strip() if up.startswith("PRIORITY:"): color = "#DC2626" label = "PRIORITY" elif up.startswith("SECONDARY:"): color = "#EA580C" label = "SECONDARY" elif up.startswith("AVOID:"): color = "#A78BFA" label = "AVOID" else: color = "var(--text-muted)" label = "" rest = line.split(":", 1)[1].strip() if ":" in line else line rendered_lines.append(f"""
{label} {html.escape(rest)}
""") return f"""
Triage plan SmolLM2-360M
{''.join(rendered_lines)}
""" def render_counterfactual(cf: dict | None) -> str: if not cf: return "" accent = SYSTEM_ACCENTS.get(cf["system"], ("var(--text-secondary)",))[0] return f"""
WHAT WOULD CHANGE THIS If you had {html.escape(cf['lever_label'])}, {html.escape(cf['system_label'])} debt would drop from {cf['from_score']} to {cf['to_score']}.
""" def render_agent_trace(steps: list[tuple[str, str, str]]) -> str: """steps: list of (label, status, message) where status is pending|active|done|error.""" if steps: items = [] for label, status, message in steps: items.append(f"""
{html.escape(label)} {html.escape(message)}
""") body = "".join(items) else: body = f"""
parse_stressors
compute_live_score
face_scan
llm_coach
Awaiting analysis
""" return f"""
Agent trace
{body}
""" def render_empty_state() -> str: return f"""
🫀
Your debt will appear here
Log your stressors on the left. Tap calculate.
""" # ─── Running estimate (live debt pill) ─────────────────────────────────────── def compute_running_estimate( alcohol, alcohol_type, alcohol_count, training, training_area, training_intensity, sleep, sleep_hours, stress, stress_carried, ill, ill_severity, care, bed_time, wake_time, ) -> str: """Return a small HTML pill showing the current running debt score.""" stressors = build_stressors( alcohol, alcohol_type, alcohol_count, training, training_area, training_intensity, sleep, sleep_hours, stress, stress_carried, ill, ill_severity, care, ) score = compute_live_score(stressors) color, _, _ = debt_tier(score) return f'Running debt · {score}/100' # ─── Preset scenario fillers ───────────────────────────────────────────────── def _fill_preset( a, a_t, a_c, a_v, t, t_a, t_i, t_v, s, s_h, s_v, st, st_c, st_v, i, i_s, i_v, c, bt, wt, ): """Return (20-element) tuple matching the preset outputs list below.""" return (a, a_t, a_c, gr.Group(visible=a_v), t, t_a, t_i, gr.Group(visible=t_v), s, s_h, gr.Group(visible=s_v), st, st_c, gr.Group(visible=st_v), i, i_s, gr.Group(visible=i_v), c, bt, wt) def fill_bad_night(): """Drank red wine 3-4, trained legs hard, slept 4-6.""" return _fill_preset( True, "red_wine", "3-4", True, True, "legs", "hard", True, True, "4-6", True, False, "yes", False, False, "moderate", False, False, "2:00 AM", "8:00 AM", ) def fill_recovery_day(): """Beer 1-2, slept 6-7, took care of myself.""" return _fill_preset( True, "beer", "1-2", True, False, "full_body", "hard", False, True, "6-7", True, False, "yes", False, False, "moderate", False, True, "10:00 PM", "6:30 AM", ) def fill_hit_it_hard(): """No alcohol, trained destroyed, slept okay, took care.""" return _fill_preset( False, "red_wine", "3-4", False, True, "legs", "destroyed", True, True, "6-7", True, False, "yes", False, False, "moderate", False, True, "10:30 PM", "6:00 AM", ) def fill_sick(): """Slept terribly, high stress, floored by illness.""" return _fill_preset( False, "red_wine", "3-4", False, False, "full_body", "hard", False, True, "under_4", True, True, "yes", True, True, "floored", True, False, "11:00 PM", "7:00 AM", ) def clear_all_form(): """Reset all form inputs to defaults.""" return _fill_preset( False, "red_wine", "3-4", False, False, "full_body", "hard", False, False, "4-6", False, False, "yes", False, False, "moderate", False, False, "", "", ) # ─── Sample preview builder ────────────────────────────────────────────────── def render_sample_preview(): """Pre-render a sample analysis so the right column has content on first load.""" sample_stressors = build_stressors( True, "red_wine", "3-4", True, "legs", "hard", True, "4-6", False, "yes", False, "moderate", False, ) sample_now = datetime.now() sample_score = compute_live_score(sample_stressors) sample_system_scores = compute_system_scores( sample_stressors, now=sample_now, bed_time="2:00 AM", wake_time="8:00 AM", ) _, sample_verdict, _ = debt_tier(sample_score) hero_html = render_hero(sample_score, sample_verdict, is_sample=True) meters_html = render_system_meters(sample_system_scores) rx_html = render_prescription(sample_system_scores, sample_score) science_html = render_science(sample_system_scores) system_dicts = [ {"label": s.label, "score": s.score, "cleared_at": s.cleared_at} for s in sample_system_scores ] fallback_plan = _fallback_plan(system_dicts) plan_html = render_plan(fallback_plan) cf = compute_counterfactual(sample_stressors, sample_system_scores, "2:00 AM", "8:00 AM") cf_html = render_counterfactual(cf) sample_trace = [ ("parse_stressors", "done", "3 stressors"), ("compute_live_score", "done", f"score={sample_score}/100"), ("triage_plan", "done", "PRIORITY · SECONDARY · AVOID"), ("llm_coach", "done", "sample"), ] trace_html = render_agent_trace(sample_trace) face_html = render_face_scan_placeholder() timeline_html = render_timeline(compute_debt_timeline(sample_system_scores, now=sample_now)) return hero_html, plan_html, meters_html, face_html, timeline_html, rx_html + science_html, trace_html, cf_html, _sample_coach() def _sample_coach() -> str: return f"""
AI Recovery Coach SmolLM2-360M · local
Sample advice shown. Tap Calculate Body Debt for your own.
""" # ─── Main analysis pipeline (yields streaming trace updates) ───────────────── def build_stressors( alcohol, alcohol_type, alcohol_count, training, training_area, training_intensity, sleep, sleep_hours, stress, stress_carried, ill, ill_severity, care, ): stressors = [] if alcohol: stressors.append(Stressor(type="alcohol", alcohol_type=alcohol_type, alcohol_count=alcohol_count)) if training: stressors.append(Stressor(type="training", training_area=training_area, training_intensity=training_intensity)) if sleep: stressors.append(Stressor(type="sleep", sleep_hours=sleep_hours)) if stress: stressors.append(Stressor(type="stress", stress_carried=stress_carried)) if ill: stressors.append(Stressor(type="ill", ill_severity=ill_severity)) if care: stressors.append(Stressor(type="care")) return stressors def run_analysis_stream( alcohol, alcohol_type, alcohol_count, training, training_area, training_intensity, sleep, sleep_hours, stress, stress_carried, ill, ill_severity, care, bed_time, wake_time, face_image, progress=gr.Progress(), ): """Streaming generator. Yield tuple: (hero, meters, rx, face, timeline, plan, trace, counterfactual, coach) """ stressors = build_stressors( alcohol, alcohol_type, alcohol_count, training, training_area, training_intensity, sleep, sleep_hours, stress, stress_carried, ill, ill_severity, care, ) EMPTY = render_empty_state() NUL = "" E_C = _empty_coach() E_P = "" E_T = render_agent_trace([]) E_CF = "" # Step 1: parse stressors trace = [("parse_stressors", "active", f"{len(stressors)} selected")] yield (EMPTY, EMPTY, NUL, NUL, "", E_P, E_T, E_CF, E_C) time.sleep(0.05) if not stressors: trace[-1] = ("parse_stressors", "error", "none selected") msg = ( f'
🫀
' f'
Log at least one stressor
' f'
Tap a checkbox on the left to begin.
' ) yield (msg, NUL, NUL, NUL, "", E_P, render_agent_trace(trace), E_CF, E_C) return trace[-1] = ("parse_stressors", "done", f"{len(stressors)} stressors") progress(0.1, desc="Computing debt score...") # Step 2: compute scores trace.append(("compute_live_score", "active", "deterministic engine")) yield (NUL, NUL, NUL, NUL, "", E_P, render_agent_trace(trace), E_CF, E_C) time.sleep(0.05) live_score = compute_live_score(stressors) system_scores = compute_system_scores( stressors, now=datetime.now(), bed_time=bed_time or None, wake_time=wake_time or None, ) trace[-1] = ("compute_live_score", "done", f"score={live_score}/100") progress(0.3, desc="Mapping 5 systems...") # Compute timeline once system scores are available timeline_html = render_timeline(compute_debt_timeline(system_scores)) # Step 3: face scan face_html = "" face_stress = None if face_image is not None: trace.append(("face_scan", "active", "MediaPipe FaceMesh")) yield (NUL, NUL, NUL, NUL, timeline_html, E_P, render_agent_trace(trace), E_CF, E_C) time.sleep(0.05) features = run_face_scan(face_image) if features: arr = features_to_array(features) face_stress, is_healthy = predict_stress_score(arr) face_html = render_face_scan(face_stress, is_healthy, features) trace[-1] = ("face_scan", "done", f"stress={face_stress:.0f}/100") else: trace[-1] = ("face_scan", "error", "no face detected") else: trace.append(("face_scan", "done", "skipped")) # Step 3.5: triage plan (the real "agent" step) system_dicts = [ {"label": s.label, "score": s.score, "cleared_at": s.cleared_at} for s in system_scores ] trace.append(("triage_plan", "active", "SmolLM2-360M")) plan_html = render_plan(None, []) yield (NUL, NUL, NUL, NUL, timeline_html, plan_html, render_agent_trace(trace), E_CF, E_C) plan_dict: dict = {"priority": None, "secondary": None, "avoid": None} plan_lines: list[str] = [] try: for plan_dict, line in stream_plan(system_dicts): if line: plan_lines.append(line) plan_html = render_plan(None, list(plan_lines)) yield (NUL, NUL, NUL, NUL, timeline_html, plan_html, render_agent_trace(trace), E_CF, E_C) except Exception as e: print(f"Plan stream failed: {e}") plan_html = render_plan(plan_dict, plan_lines) trace[-1] = ("triage_plan", "done", "PRIORITY · SECONDARY · AVOID") progress(0.5, desc="Building system breakdown...") # Step 4: render hero + systems + prescription _, verdict, _ = debt_tier(live_score) hero_html = render_hero(live_score, verdict) meters_html = render_system_meters(system_scores) rx_html = render_prescription(system_scores, live_score) science_html = render_science(system_scores) cf = compute_counterfactual(stressors, system_scores, bed_time, wake_time) cf_html = render_counterfactual(cf) # Step 5: streaming LLM advice trace.append(("llm_coach", "active", "SmolLM2-360M local")) accumulated = "" stressor_summary = ", ".join( f"{STRESSOR_DEFS[s.type]['icon']} {STRESSOR_DEFS[s.type]['label']}" for s in stressors ) yield ( hero_html, meters_html, rx_html + science_html, face_html, timeline_html, plan_html, render_agent_trace(trace), cf_html, _coach_with_cursor(""), ) try: for piece in stream_advice(live_score, system_dicts, stressor_summary, face_stress): accumulated += piece yield ( hero_html, meters_html, rx_html + science_html, face_html, timeline_html, plan_html, render_agent_trace(trace), cf_html, _coach_with_cursor(accumulated), ) except Exception as e: print(f"Stream fallback: {e}") accumulated = _fallback_advice(live_score, system_dicts, stressor_summary) yield ( hero_html, meters_html, rx_html + science_html, face_html, timeline_html, plan_html, render_agent_trace(trace), cf_html, _coach_with_cursor(accumulated), ) trace[-1] = ("llm_coach", "done", f"{len(accumulated)} chars") progress(1.0, desc="Done") yield ( hero_html, meters_html, rx_html + science_html, face_html, timeline_html, plan_html, render_agent_trace(trace), cf_html, _coach_with_cursor(accumulated), ) def _empty_coach() -> str: return f"""
AI Recovery Coach SmolLM2-360M · local
Ready. Tap Calculate Body Debt to stream advice.
""" def _coach_with_cursor(text: str) -> str: safe = html.escape(text) return f"""
AI Recovery CoachSmolLM2-360M · local
{safe}
""" # ─── Compare scenarios ─────────────────────────────────────────────────── def _build_compare_label(stressors: list) -> str: """Build a short label from stressor icons.""" if not stressors: return "Clear day" icons = [STRESSOR_DEFS[s.type]["icon"] for s in stressors] return " ".join(icons) def render_comparison_html(comparisons: list) -> str: """Render the horizontal comparison carousel as HTML.""" if not comparisons: return '' cards = [] for i, c in enumerate(comparisons): color, verdict, _ = debt_tier(c["score"]) systems_html = "" for sys in c["system_scores"]: accent = SYSTEM_ACCENTS.get(sys["system"], ("var(--text-secondary)",))[0] pct = max(0, min(100, sys["score"])) systems_html += f"""
{sys['glyph']}
""" cards.append(f"""
{c['score']}
{html.escape(c['label'])}
{html.escape(c['timestamp'])}
{systems_html}
""") return f'
{"".join(cards)}
' def save_compare( comparisons, # list from gr.State alcohol, alcohol_type, alcohol_count, training, training_area, training_intensity, sleep, sleep_hours, stress, stress_carried, ill, ill_severity, care, bed_time, wake_time, ): """Recompute from current form inputs, append to comparisons, return updated state + HTML.""" stressors = build_stressors( alcohol, alcohol_type, alcohol_count, training, training_area, training_intensity, sleep, sleep_hours, stress, stress_carried, ill, ill_severity, care, ) score = compute_live_score(stressors) system_scores = compute_system_scores( stressors, now=datetime.now(), bed_time=bed_time or None, wake_time=wake_time or None, ) label = _build_compare_label(stressors) now_str = datetime.now().strftime("%I:%M %p").lstrip("0") entry = { "score": score, "label": label, "timestamp": now_str, "system_scores": [ { "system": s.system, "glyph": SYSTEM_GLYPHS.get(s.system, "•"), "score": s.score, } for s in system_scores ], } new_list = list(comparisons or []) new_list.append(entry) return new_list, render_comparison_html(new_list) def remove_compare(comparisons, idx: int): """Remove a comparison by index. Returns -1 sentinel to reset the trigger.""" new_list = list(comparisons or []) if 0 <= idx < len(new_list): del new_list[idx] return new_list, render_comparison_html(new_list), -1 def clear_comparisons(): """Clear all comparisons.""" return [], "" # ─── Pre-compute sample preview ───────────────────────────────────────────── SAMPLE_HERO, SAMPLE_PLAN, SAMPLE_METERS, SAMPLE_FACE, SAMPLE_TIMELINE, SAMPLE_RX, SAMPLE_TRACE, SAMPLE_CF, SAMPLE_COACH = render_sample_preview() # ─── Layout ────────────────────────────────────────────────────────────────── with gr.Blocks(title="Body Debt") as demo: gr.HTML(THEME_TOGGLE_HTML) gr.HTML(f"""

🫀 Body Debt

Quantify your physiological debt. Get a precise, system-level recovery plan. On-device AI. Zero cloud calls.

SmolLM2-360M · local 360M params · 250MB RAM Built with OpenAI Codex
""") with gr.Row(): with gr.Column(scale=1): # Preset scenario chips gr.HTML('
Try a scenario
') with gr.Row(): preset_bad_night = gr.Button("🌙 Bad night", elem_classes="preset-chip", size="sm") preset_recovery = gr.Button("♻️ Recovery day", elem_classes="preset-chip", size="sm") with gr.Row(): preset_hit_hard = gr.Button("🔥 Hit it hard", elem_classes="preset-chip", size="sm") preset_sick = gr.Button("🤒 Sick", elem_classes="preset-chip", size="sm") gr.HTML('
What happened
') # Running debt pill debt_pill = gr.HTML(value='Running debt · 0/100') alcohol = gr.Checkbox(label="🍺 Drank", value=False) with gr.Group(visible=False) as alcohol_details: alcohol_type = gr.Dropdown( choices=["beer", "red_wine", "white_wine", "spirits", "cocktails", "champagne"], value="red_wine", label="What?", ) alcohol_count = gr.Dropdown( choices=["1-2", "3-4", "5+", "lost_count"], value="3-4", label="How many?", ) training = gr.Checkbox(label="💪 Trained", value=False) with gr.Group(visible=False) as training_details: training_area = gr.Dropdown( choices=["legs", "upper", "cardio", "hiit", "full_body", "mobility"], value="full_body", label="What?", ) training_intensity = gr.Dropdown( choices=["easy", "hard", "destroyed"], value="hard", label="Intensity?", ) sleep = gr.Checkbox(label="😴 Slept badly", value=False) with gr.Group(visible=False) as sleep_details: sleep_hours = gr.Dropdown( choices=["under_4", "4-6", "6-7"], value="4-6", label="How many hours?", ) stress = gr.Checkbox(label="😤 High stress", value=False) with gr.Group(visible=False) as stress_details: stress_carried = gr.Dropdown( choices=["yes", "mostly_gone"], value="yes", label="Still carrying it?", ) ill = gr.Checkbox(label="🤒 Feeling ill", value=False) with gr.Group(visible=False) as ill_details: ill_severity = gr.Dropdown( choices=["mild", "moderate", "floored"], value="moderate", label="How bad?", ) care = gr.Checkbox(label="✦ Took care of myself", value=False, elem_id="care-checkbox") gr.HTML('
Timing
') bed_time = gr.Dropdown( choices=TIME_OPTIONS, value="", label="Bedtime", allow_custom_value=True, info="Select or type (e.g. 2:00 AM)", ) wake_time = gr.Dropdown( choices=TIME_OPTIONS, value="", label="Wake time", allow_custom_value=True, info="Select or type (e.g. 8:30 AM)", ) gr.HTML('
Face scan (optional)
') face_image = gr.Image( label="Capture or upload", sources=["webcam", "upload"], type="numpy", ) with gr.Row(): clear_btn = gr.Button("Clear all", elem_classes="clear-all", size="sm") analyze_btn = gr.Button("Calculate Body Debt", variant="primary", size="lg") with gr.Row(): save_compare_btn = gr.Button("📋 Save to compare", elem_classes="save-compare", size="sm") clear_compare_btn = gr.Button("✕ Clear saved", elem_classes="save-compare", size="sm") with gr.Column(scale=2): hero_output = gr.HTML(value=SAMPLE_HERO) plan_output = gr.HTML(value=SAMPLE_PLAN) meters_output = gr.HTML(value=SAMPLE_METERS) face_output = gr.HTML(value=SAMPLE_FACE) timeline_output = gr.HTML(value=SAMPLE_TIMELINE) with gr.Row(): with gr.Column(scale=3): rx_output = gr.HTML(value=SAMPLE_RX) with gr.Column(scale=2): trace_output = gr.HTML(value=SAMPLE_TRACE) counterfactual_output = gr.HTML(value=SAMPLE_CF) coach_output = gr.HTML(value=SAMPLE_COACH) gr.HTML('
Saved comparisons
') compare_output = gr.HTML(value="", visible=True) remove_idx = gr.Number(value=-1, visible=False, elem_id="cmp-remove-idx") comparisons_state = gr.State([]) ANALYSIS_INPUTS = [ alcohol, alcohol_type, alcohol_count, training, training_area, training_intensity, sleep, sleep_hours, stress, stress_carried, ill, ill_severity, care, bed_time, wake_time, face_image, ] ANALYSIS_OUTPUTS = [ hero_output, meters_output, rx_output, face_output, timeline_output, plan_output, trace_output, counterfactual_output, coach_output, ] # ─── Event wiring ──────────────────────────────────────────────────────── # Toggle detail sections alcohol.change(lambda v: gr.Group(visible=v), alcohol, alcohol_details) training.change(lambda v: gr.Group(visible=v), training, training_details) sleep.change(lambda v: gr.Group(visible=v), sleep, sleep_details) stress.change(lambda v: gr.Group(visible=v), stress, stress_details) ill.change(lambda v: gr.Group(visible=v), ill, ill_details) # Live running debt pill — update on any form input change debt_inputs = [ alcohol, alcohol_type, alcohol_count, training, training_area, training_intensity, sleep, sleep_hours, stress, stress_carried, ill, ill_severity, care, bed_time, wake_time, ] for inp in debt_inputs: inp.change( fn=compute_running_estimate, inputs=debt_inputs, outputs=debt_pill, ) # Preset scenario buttons: fill form then auto-run analysis _PRESET_FILL_OUTPUTS = [ alcohol, alcohol_type, alcohol_count, alcohol_details, training, training_area, training_intensity, training_details, sleep, sleep_hours, sleep_details, stress, stress_carried, stress_details, ill, ill_severity, ill_details, care, bed_time, wake_time, ] for preset_btn, fill_fn in [ (preset_bad_night, fill_bad_night), (preset_recovery, fill_recovery_day), (preset_hit_hard, fill_hit_it_hard), (preset_sick, fill_sick), ]: preset_btn.click( fn=fill_fn, outputs=_PRESET_FILL_OUTPUTS, ).then( fn=run_analysis_stream, inputs=ANALYSIS_INPUTS, outputs=ANALYSIS_OUTPUTS, ) # Clear all button clear_btn.click( fn=clear_all_form, outputs=_PRESET_FILL_OUTPUTS, ) # Main Calculate button analyze_btn.click( fn=run_analysis_stream, inputs=ANALYSIS_INPUTS, outputs=ANALYSIS_OUTPUTS, ) # Save to compare COMPARE_INPUTS = [ alcohol, alcohol_type, alcohol_count, training, training_area, training_intensity, sleep, sleep_hours, stress, stress_carried, ill, ill_severity, care, bed_time, wake_time, ] save_compare_btn.click( fn=save_compare, inputs=[comparisons_state] + COMPARE_INPUTS, outputs=[comparisons_state, compare_output], ) # Remove comparison by index remove_idx.change( fn=remove_compare, inputs=[comparisons_state, remove_idx], outputs=[comparisons_state, compare_output, remove_idx], ) # Clear all comparisons clear_compare_btn.click( fn=clear_comparisons, outputs=[comparisons_state, compare_output], ) gr.HTML(f""" """) if __name__ == "__main__": demo.launch(css=CUSTOM_CSS)