Spaces:
Running
Running
Initial Body Debt Gradio app for Build Small hackathon
Browse files- README.md +68 -6
- app.py +334 -0
- face_scan.py +133 -0
- generate_model.py +98 -0
- health_coach.py +122 -0
- models/stress_model.onnx +3 -0
- requirements.txt +6 -0
- scoring.py +365 -0
- stress_model.py +57 -0
README.md
CHANGED
|
@@ -1,13 +1,75 @@
|
|
| 1 |
---
|
| 2 |
title: Body Debt
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.18.0
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
-
pinned:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Body Debt
|
| 3 |
+
emoji: 🫀
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.18.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
pinned: true
|
| 10 |
+
license: mit
|
| 11 |
+
tags:
|
| 12 |
+
- build-small
|
| 13 |
+
- backyard-ai
|
| 14 |
+
- tiny-titan
|
| 15 |
+
- best-agent
|
| 16 |
+
- off-brand
|
| 17 |
+
models:
|
| 18 |
+
- hugging-quants/Llama-3.2-1B-Instruct-Q4_K_M-GGUF
|
| 19 |
---
|
| 20 |
|
| 21 |
+
# 🫀 Body Debt
|
| 22 |
+
|
| 23 |
+
**Quantify your physiological debt. Get AI-backed recovery prescriptions.**
|
| 24 |
+
|
| 25 |
+
Body Debt calculates the precise recovery cost of last night's choices — alcohol, training, poor sleep, stress, illness — across five biological systems, then generates personalized recovery advice using a **1-billion parameter local LLM**.
|
| 26 |
+
|
| 27 |
+
## What it does
|
| 28 |
+
|
| 29 |
+
1. **Log stressors** — tap what happened (drank, trained, slept badly, stressed, ill, or took care)
|
| 30 |
+
2. **Face scan** (optional) — webcam capture analyzed by MediaPipe FaceMesh to detect fatigue markers (eye aspect ratio, brow tension, eye symmetry)
|
| 31 |
+
3. **Deterministic scoring** — five biological systems (Cardiovascular, Brain, Liver, Muscular/CNS, Gut) scored with physiological weights and circadian penalties
|
| 32 |
+
4. **Local AI recovery coach** — Llama-3.2-1B generates a personalized prescription (Right Now / This Morning / Today / Avoid)
|
| 33 |
+
|
| 34 |
+
## The model
|
| 35 |
+
|
| 36 |
+
**Llama-3.2-1B-Instruct** (Q4_K_M quantization, ~700MB) — runs entirely on CPU via `llama-cpp-python`. No API calls, no cloud inference. Your health data never leaves your machine.
|
| 37 |
+
|
| 38 |
+
The face scan stress classifier is a custom 7→16→8→1 MLP (~2KB ONNX) that converts facial geometry features into a fatigue score.
|
| 39 |
+
|
| 40 |
+
## Tech
|
| 41 |
+
|
| 42 |
+
- **LLM**: Llama-3.2-1B-Instruct (1B params, Q4_K_M GGUF) via llama-cpp-python
|
| 43 |
+
- **Face analysis**: MediaPipe FaceMesh → 7 stress features → ONNX MLP
|
| 44 |
+
- **Scoring**: Deterministic 5-system engine with physiological weights, drink-type modifiers, training CNS load, circadian alignment penalties
|
| 45 |
+
- **UI**: Gradio 5 with custom dark theme
|
| 46 |
+
|
| 47 |
+
## Privacy
|
| 48 |
+
|
| 49 |
+
- Face scan runs via MediaPipe on-device — no images are transmitted
|
| 50 |
+
- LLM inference is local — no API calls to external services
|
| 51 |
+
- No data persistence — nothing is stored between sessions
|
| 52 |
+
|
| 53 |
+
## Demo
|
| 54 |
+
|
| 55 |
+
[Demo video link]
|
| 56 |
+
|
| 57 |
+
## Social
|
| 58 |
+
|
| 59 |
+
[Social media post link]
|
| 60 |
+
|
| 61 |
+
## Try it locally
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
pip install -r requirements.txt
|
| 65 |
+
python generate_model.py # creates the ONNX stress model
|
| 66 |
+
python app.py
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
## Full product
|
| 70 |
+
|
| 71 |
+
The complete Body Debt application (Next.js, ZK proofs on SKALE, real-time animated dashboard) is at: [github.com/body-debt](https://github.com)
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
*Built for the [Build Small Hackathon](https://huggingface.co/spaces/huggingface/build-small-hackathon). Everything under 32B parameters, running on hardware you own.*
|
app.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Body Debt — Gradio App
|
| 3 |
+
Quantifies physiological debt from lifestyle stressors and provides
|
| 4 |
+
AI-backed recovery prescriptions using a local 1B parameter model.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import time
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
import gradio as gr
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
from scoring import (
|
| 16 |
+
Stressor,
|
| 17 |
+
compute_live_score,
|
| 18 |
+
compute_system_scores,
|
| 19 |
+
STRESSOR_DEFS,
|
| 20 |
+
)
|
| 21 |
+
from face_scan import run_face_scan, features_to_array
|
| 22 |
+
from stress_model import predict_stress_score
|
| 23 |
+
from health_coach import generate_advice
|
| 24 |
+
|
| 25 |
+
# ─── Theme ────────────────────────────────────────────────────────────────────
|
| 26 |
+
|
| 27 |
+
theme = gr.themes.Base(
|
| 28 |
+
primary_hue=gr.themes.colors.orange,
|
| 29 |
+
secondary_hue=gr.themes.colors.stone,
|
| 30 |
+
neutral_hue=gr.themes.colors.stone,
|
| 31 |
+
font=gr.themes.GoogleFont("Inter"),
|
| 32 |
+
).set(
|
| 33 |
+
body_background_fill="#0a0a0a",
|
| 34 |
+
body_background_fill_dark="#0a0a0a",
|
| 35 |
+
block_background_fill="#141414",
|
| 36 |
+
block_background_fill_dark="#141414",
|
| 37 |
+
block_border_color="#262626",
|
| 38 |
+
block_border_color_dark="#262626",
|
| 39 |
+
button_primary_background_fill="#ea580c",
|
| 40 |
+
button_primary_background_fill_dark="#ea580c",
|
| 41 |
+
button_primary_text_color="white",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# ─── Scoring logic wrappers ───────────────────────────────────────────────────
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def build_stressors(
|
| 48 |
+
alcohol: bool,
|
| 49 |
+
alcohol_type: str,
|
| 50 |
+
alcohol_count: str,
|
| 51 |
+
training: bool,
|
| 52 |
+
training_area: str,
|
| 53 |
+
training_intensity: str,
|
| 54 |
+
sleep: bool,
|
| 55 |
+
sleep_hours: str,
|
| 56 |
+
stress: bool,
|
| 57 |
+
stress_carried: str,
|
| 58 |
+
ill: bool,
|
| 59 |
+
ill_severity: str,
|
| 60 |
+
care: bool,
|
| 61 |
+
) -> list[Stressor]:
|
| 62 |
+
stressors = []
|
| 63 |
+
if alcohol:
|
| 64 |
+
stressors.append(
|
| 65 |
+
Stressor(type="alcohol", alcohol_type=alcohol_type, alcohol_count=alcohol_count)
|
| 66 |
+
)
|
| 67 |
+
if training:
|
| 68 |
+
stressors.append(
|
| 69 |
+
Stressor(type="training", training_area=training_area, training_intensity=training_intensity)
|
| 70 |
+
)
|
| 71 |
+
if sleep:
|
| 72 |
+
stressors.append(Stressor(type="sleep", sleep_hours=sleep_hours))
|
| 73 |
+
if stress:
|
| 74 |
+
stressors.append(Stressor(type="stress", stress_carried=stress_carried))
|
| 75 |
+
if ill:
|
| 76 |
+
stressors.append(Stressor(type="ill", ill_severity=ill_severity))
|
| 77 |
+
if care:
|
| 78 |
+
stressors.append(Stressor(type="care"))
|
| 79 |
+
return stressors
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def run_analysis(
|
| 83 |
+
alcohol,
|
| 84 |
+
alcohol_type,
|
| 85 |
+
alcohol_count,
|
| 86 |
+
training,
|
| 87 |
+
training_area,
|
| 88 |
+
training_intensity,
|
| 89 |
+
sleep,
|
| 90 |
+
sleep_hours,
|
| 91 |
+
stress,
|
| 92 |
+
stress_carried,
|
| 93 |
+
ill,
|
| 94 |
+
ill_severity,
|
| 95 |
+
care,
|
| 96 |
+
bed_time,
|
| 97 |
+
wake_time,
|
| 98 |
+
face_image,
|
| 99 |
+
progress=gr.Progress(),
|
| 100 |
+
):
|
| 101 |
+
stressors = build_stressors(
|
| 102 |
+
alcohol, alcohol_type, alcohol_count,
|
| 103 |
+
training, training_area, training_intensity,
|
| 104 |
+
sleep, sleep_hours,
|
| 105 |
+
stress, stress_carried,
|
| 106 |
+
ill, ill_severity,
|
| 107 |
+
care,
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
if not stressors:
|
| 111 |
+
return (
|
| 112 |
+
"## No stressors logged\nLog at least one stressor to calculate your debt.",
|
| 113 |
+
"",
|
| 114 |
+
"",
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
progress(0.1, desc="Calculating debt score...")
|
| 118 |
+
live_score = compute_live_score(stressors)
|
| 119 |
+
system_scores = compute_system_scores(
|
| 120 |
+
stressors,
|
| 121 |
+
now=datetime.now(),
|
| 122 |
+
bed_time=bed_time or None,
|
| 123 |
+
wake_time=wake_time or None,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# Face scan
|
| 127 |
+
face_stress = None
|
| 128 |
+
face_text = ""
|
| 129 |
+
if face_image is not None:
|
| 130 |
+
progress(0.3, desc="Analyzing face...")
|
| 131 |
+
features = run_face_scan(face_image)
|
| 132 |
+
if features:
|
| 133 |
+
arr = features_to_array(features)
|
| 134 |
+
face_stress, is_healthy = predict_stress_score(arr)
|
| 135 |
+
status = "✅ Healthy" if is_healthy else "⚠️ Stressed"
|
| 136 |
+
face_text = f"### 🔬 Face Scan\n**Facial stress:** {face_stress:.0f}/100 ({status})\n\n"
|
| 137 |
+
face_text += "Features detected: "
|
| 138 |
+
face_text += f"Eye aspect={features.left_eye_aspect:.3f}/{features.right_eye_aspect:.3f}, "
|
| 139 |
+
face_text += f"Brow tension={features.brow_tension:.4f}, "
|
| 140 |
+
face_text += f"Eye symmetry={features.eye_symmetry:.3f}\n\n"
|
| 141 |
+
face_text += "*Processed entirely on-device. No biometric data leaves your machine.*"
|
| 142 |
+
|
| 143 |
+
# Build score display
|
| 144 |
+
progress(0.5, desc="Building system breakdown...")
|
| 145 |
+
score_emoji = "🟢" if live_score < 30 else ("🟡" if live_score < 60 else "🔴")
|
| 146 |
+
verdict = (
|
| 147 |
+
"You're clear. Minimal debt."
|
| 148 |
+
if live_score < 20
|
| 149 |
+
else (
|
| 150 |
+
"Low debt. Minor adjustments needed."
|
| 151 |
+
if live_score < 40
|
| 152 |
+
else (
|
| 153 |
+
"Moderate debt. Recovery actions recommended."
|
| 154 |
+
if live_score < 60
|
| 155 |
+
else (
|
| 156 |
+
"High debt. Prioritize recovery."
|
| 157 |
+
if live_score < 80
|
| 158 |
+
else "Critical debt. Full rest mode."
|
| 159 |
+
)
|
| 160 |
+
)
|
| 161 |
+
)
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
score_md = f"# {score_emoji} Body Debt: {live_score}/100\n\n"
|
| 165 |
+
score_md += f"**{verdict}**\n\n---\n\n"
|
| 166 |
+
score_md += "### Five-System Breakdown\n\n"
|
| 167 |
+
score_md += "| System | Load | Clears | Action |\n|---|---|---|---|\n"
|
| 168 |
+
for s in system_scores:
|
| 169 |
+
bar = "█" * (s.score // 10) + "░" * (10 - s.score // 10)
|
| 170 |
+
score_md += f"| {s.icon} {s.label} | {bar} {s.score} | {s.cleared_at} | {s.action_text} |\n"
|
| 171 |
+
|
| 172 |
+
score_md += "\n---\n\n### Cause Analysis\n\n"
|
| 173 |
+
for s in system_scores:
|
| 174 |
+
if s.score > 0:
|
| 175 |
+
score_md += f"**{s.icon} {s.label}:** {s.cause_text}\n\n"
|
| 176 |
+
|
| 177 |
+
if any(s.science_fact for s in system_scores if s.score > 20):
|
| 178 |
+
score_md += "---\n\n### 🔬 Science\n\n"
|
| 179 |
+
for s in system_scores:
|
| 180 |
+
if s.score > 20 and s.science_fact:
|
| 181 |
+
score_md += f"> {s.science_fact}\n> — *{s.science_cite}*\n\n"
|
| 182 |
+
|
| 183 |
+
# LLM advice
|
| 184 |
+
progress(0.6, desc="Generating recovery prescription (local LLM)...")
|
| 185 |
+
stressor_summary = ", ".join(
|
| 186 |
+
f"{STRESSOR_DEFS[s.type]['icon']} {STRESSOR_DEFS[s.type]['label']}" for s in stressors
|
| 187 |
+
)
|
| 188 |
+
system_dicts = [
|
| 189 |
+
{"label": s.label, "score": s.score, "cleared_at": s.cleared_at} for s in system_scores
|
| 190 |
+
]
|
| 191 |
+
advice = generate_advice(
|
| 192 |
+
debt_score=live_score,
|
| 193 |
+
system_scores=system_dicts,
|
| 194 |
+
stressor_summary=stressor_summary,
|
| 195 |
+
face_stress=face_stress,
|
| 196 |
+
progress_callback=lambda p, msg: progress(0.6 + p * 0.35, desc=msg),
|
| 197 |
+
)
|
| 198 |
+
progress(1.0, desc="Done!")
|
| 199 |
+
|
| 200 |
+
advice_md = "### 🤖 Recovery Prescription\n\n"
|
| 201 |
+
advice_md += f"*Generated by Llama-3.2-1B running locally*\n\n{advice}"
|
| 202 |
+
|
| 203 |
+
return score_md, face_text, advice_md
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# ─── UI ───────────────────────────────────────────────────────────────────────
|
| 207 |
+
|
| 208 |
+
css = """
|
| 209 |
+
.dark { --body-background-fill: #0a0a0a; }
|
| 210 |
+
.stressor-section { border: 1px solid #262626; border-radius: 8px; padding: 12px; margin: 4px 0; }
|
| 211 |
+
footer { display: none !important; }
|
| 212 |
+
"""
|
| 213 |
+
|
| 214 |
+
with gr.Blocks(title="Body Debt") as demo:
|
| 215 |
+
gr.Markdown(
|
| 216 |
+
"""
|
| 217 |
+
# 🫀 Body Debt
|
| 218 |
+
**Quantify your physiological debt. Get AI-backed recovery prescriptions.**
|
| 219 |
+
|
| 220 |
+
Log what happened last night → get a precise, system-level recovery plan powered by
|
| 221 |
+
a local 1B-parameter model. Everything runs on-device.
|
| 222 |
+
""",
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
with gr.Row():
|
| 226 |
+
with gr.Column(scale=1):
|
| 227 |
+
gr.Markdown("### Log Stressors")
|
| 228 |
+
|
| 229 |
+
alcohol = gr.Checkbox(label="🍺 Drank", value=False)
|
| 230 |
+
with gr.Group(visible=False) as alcohol_details:
|
| 231 |
+
alcohol_type = gr.Dropdown(
|
| 232 |
+
choices=["beer", "red_wine", "white_wine", "spirits", "cocktails", "champagne"],
|
| 233 |
+
value="beer",
|
| 234 |
+
label="What?",
|
| 235 |
+
)
|
| 236 |
+
alcohol_count = gr.Dropdown(
|
| 237 |
+
choices=["1-2", "3-4", "5+", "lost_count"],
|
| 238 |
+
value="3-4",
|
| 239 |
+
label="How many?",
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
training = gr.Checkbox(label="💪 Trained", value=False)
|
| 243 |
+
with gr.Group(visible=False) as training_details:
|
| 244 |
+
training_area = gr.Dropdown(
|
| 245 |
+
choices=["legs", "upper", "cardio", "hiit", "full_body", "mobility"],
|
| 246 |
+
value="full_body",
|
| 247 |
+
label="What?",
|
| 248 |
+
)
|
| 249 |
+
training_intensity = gr.Dropdown(
|
| 250 |
+
choices=["easy", "hard", "destroyed"],
|
| 251 |
+
value="hard",
|
| 252 |
+
label="Intensity?",
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
sleep = gr.Checkbox(label="😴 Slept badly", value=False)
|
| 256 |
+
with gr.Group(visible=False) as sleep_details:
|
| 257 |
+
sleep_hours = gr.Dropdown(
|
| 258 |
+
choices=["under_4", "4-6", "6-7"],
|
| 259 |
+
value="4-6",
|
| 260 |
+
label="How many hours?",
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
stress = gr.Checkbox(label="😤 High stress", value=False)
|
| 264 |
+
with gr.Group(visible=False) as stress_details:
|
| 265 |
+
stress_carried = gr.Dropdown(
|
| 266 |
+
choices=["yes", "mostly_gone"],
|
| 267 |
+
value="yes",
|
| 268 |
+
label="Still carrying it?",
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
ill = gr.Checkbox(label="🤒 Feeling ill", value=False)
|
| 272 |
+
with gr.Group(visible=False) as ill_details:
|
| 273 |
+
ill_severity = gr.Dropdown(
|
| 274 |
+
choices=["mild", "moderate", "floored"],
|
| 275 |
+
value="moderate",
|
| 276 |
+
label="How bad?",
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
care = gr.Checkbox(label="✦ Took care of myself", value=False)
|
| 280 |
+
|
| 281 |
+
gr.Markdown("### Timing")
|
| 282 |
+
bed_time = gr.Textbox(label="Bedtime (e.g. 2:00 AM)", placeholder="2:00 AM")
|
| 283 |
+
wake_time = gr.Textbox(label="Wake time (e.g. 8:30 AM)", placeholder="8:30 AM")
|
| 284 |
+
|
| 285 |
+
gr.Markdown("### 📷 Face Scan (Optional)")
|
| 286 |
+
face_image = gr.Image(
|
| 287 |
+
label="Capture or upload a photo",
|
| 288 |
+
sources=["webcam", "upload"],
|
| 289 |
+
type="numpy",
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
analyze_btn = gr.Button("⚡ Calculate Body Debt", variant="primary", size="lg")
|
| 293 |
+
|
| 294 |
+
with gr.Column(scale=2):
|
| 295 |
+
score_output = gr.Markdown(
|
| 296 |
+
value="### Results will appear here\nLog your stressors and click Calculate.",
|
| 297 |
+
)
|
| 298 |
+
face_output = gr.Markdown(value="")
|
| 299 |
+
advice_output = gr.Markdown(value="")
|
| 300 |
+
|
| 301 |
+
# Toggle detail sections
|
| 302 |
+
alcohol.change(lambda v: gr.Group(visible=v), alcohol, alcohol_details)
|
| 303 |
+
training.change(lambda v: gr.Group(visible=v), training, training_details)
|
| 304 |
+
sleep.change(lambda v: gr.Group(visible=v), sleep, sleep_details)
|
| 305 |
+
stress.change(lambda v: gr.Group(visible=v), stress, stress_details)
|
| 306 |
+
ill.change(lambda v: gr.Group(visible=v), ill, ill_details)
|
| 307 |
+
|
| 308 |
+
analyze_btn.click(
|
| 309 |
+
fn=run_analysis,
|
| 310 |
+
inputs=[
|
| 311 |
+
alcohol, alcohol_type, alcohol_count,
|
| 312 |
+
training, training_area, training_intensity,
|
| 313 |
+
sleep, sleep_hours,
|
| 314 |
+
stress, stress_carried,
|
| 315 |
+
ill, ill_severity,
|
| 316 |
+
care,
|
| 317 |
+
bed_time, wake_time,
|
| 318 |
+
face_image,
|
| 319 |
+
],
|
| 320 |
+
outputs=[score_output, face_output, advice_output],
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
gr.Markdown(
|
| 324 |
+
"""
|
| 325 |
+
---
|
| 326 |
+
*Body Debt uses Llama-3.2-1B (1 billion parameters) running locally via llama-cpp-python.
|
| 327 |
+
Face analysis uses MediaPipe FaceMesh — no biometric data leaves your device.
|
| 328 |
+
Built for the [Build Small Hackathon](https://huggingface.co/spaces/huggingface/build-small-hackathon).*
|
| 329 |
+
"""
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
if __name__ == "__main__":
|
| 334 |
+
demo.launch(theme=theme, css=css)
|
face_scan.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Face scan stress feature extraction using MediaPipe FaceMesh.
|
| 3 |
+
Ported from src/lib/ai/face-mesh.ts
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import math
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
LANDMARKS = {
|
| 15 |
+
"LEFT_EYE_OUTER": 33,
|
| 16 |
+
"LEFT_EYE_INNER": 133,
|
| 17 |
+
"LEFT_EYE_TOP": 159,
|
| 18 |
+
"LEFT_EYE_BOTTOM": 145,
|
| 19 |
+
"RIGHT_EYE_OUTER": 263,
|
| 20 |
+
"RIGHT_EYE_INNER": 362,
|
| 21 |
+
"RIGHT_EYE_TOP": 386,
|
| 22 |
+
"RIGHT_EYE_BOTTOM": 374,
|
| 23 |
+
"LEFT_EYEBROW_INNER": 107,
|
| 24 |
+
"LEFT_EYEBROW_OUTER": 70,
|
| 25 |
+
"RIGHT_EYEBROW_INNER": 336,
|
| 26 |
+
"RIGHT_EYEBROW_OUTER": 300,
|
| 27 |
+
"MOUTH_TOP": 13,
|
| 28 |
+
"MOUTH_BOTTOM": 14,
|
| 29 |
+
"MOUTH_LEFT": 61,
|
| 30 |
+
"MOUTH_RIGHT": 291,
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class StressFeatures:
|
| 36 |
+
left_eye_aspect: float
|
| 37 |
+
right_eye_aspect: float
|
| 38 |
+
brow_tension: float
|
| 39 |
+
mouth_tension: float
|
| 40 |
+
eye_symmetry: float
|
| 41 |
+
mouth_opening: float
|
| 42 |
+
timestamp: float
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _distance(p1, p2) -> float:
|
| 46 |
+
return math.sqrt(
|
| 47 |
+
(p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2 + (p2[2] - p1[2]) ** 2
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _ear(outer, inner, top, bottom) -> float:
|
| 52 |
+
v = _distance(top, bottom)
|
| 53 |
+
h = _distance(outer, inner)
|
| 54 |
+
return v / h if h > 0 else 0
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def extract_stress_features(landmarks: list) -> Optional[StressFeatures]:
|
| 58 |
+
"""Extract 7 stress features from 478 MediaPipe face landmarks."""
|
| 59 |
+
if not landmarks or len(landmarks) < 468:
|
| 60 |
+
return None
|
| 61 |
+
|
| 62 |
+
def p(idx):
|
| 63 |
+
lm = landmarks[idx]
|
| 64 |
+
return (lm.x, lm.y, lm.z)
|
| 65 |
+
|
| 66 |
+
left_ear = _ear(
|
| 67 |
+
p(LANDMARKS["LEFT_EYE_OUTER"]),
|
| 68 |
+
p(LANDMARKS["LEFT_EYE_INNER"]),
|
| 69 |
+
p(LANDMARKS["LEFT_EYE_TOP"]),
|
| 70 |
+
p(LANDMARKS["LEFT_EYE_BOTTOM"]),
|
| 71 |
+
)
|
| 72 |
+
right_ear = _ear(
|
| 73 |
+
p(LANDMARKS["RIGHT_EYE_OUTER"]),
|
| 74 |
+
p(LANDMARKS["RIGHT_EYE_INNER"]),
|
| 75 |
+
p(LANDMARKS["RIGHT_EYE_TOP"]),
|
| 76 |
+
p(LANDMARKS["RIGHT_EYE_BOTTOM"]),
|
| 77 |
+
)
|
| 78 |
+
brow_tension = (
|
| 79 |
+
_distance(p(LANDMARKS["LEFT_EYEBROW_INNER"]), p(LANDMARKS["LEFT_EYE_TOP"]))
|
| 80 |
+
+ _distance(p(LANDMARKS["RIGHT_EYEBROW_INNER"]), p(LANDMARKS["RIGHT_EYE_TOP"]))
|
| 81 |
+
) / 2
|
| 82 |
+
mouth_width = _distance(p(LANDMARKS["MOUTH_LEFT"]), p(LANDMARKS["MOUTH_RIGHT"]))
|
| 83 |
+
mouth_height = _distance(p(LANDMARKS["MOUTH_TOP"]), p(LANDMARKS["MOUTH_BOTTOM"]))
|
| 84 |
+
mouth_tension = mouth_width / mouth_height if mouth_height > 0 else 1.0
|
| 85 |
+
eye_symmetry = abs(left_ear - right_ear) / ((left_ear + right_ear) / 2 + 0.001)
|
| 86 |
+
mouth_opening = mouth_height / mouth_width if mouth_width > 0 else 0.1
|
| 87 |
+
|
| 88 |
+
import time
|
| 89 |
+
|
| 90 |
+
return StressFeatures(
|
| 91 |
+
left_eye_aspect=left_ear,
|
| 92 |
+
right_eye_aspect=right_ear,
|
| 93 |
+
brow_tension=brow_tension,
|
| 94 |
+
mouth_tension=mouth_tension,
|
| 95 |
+
eye_symmetry=eye_symmetry,
|
| 96 |
+
mouth_opening=mouth_opening,
|
| 97 |
+
timestamp=time.time(),
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def features_to_array(features: StressFeatures) -> np.ndarray:
|
| 102 |
+
"""Convert StressFeatures to a 7-element numpy array for the ONNX model."""
|
| 103 |
+
return np.array(
|
| 104 |
+
[
|
| 105 |
+
features.left_eye_aspect,
|
| 106 |
+
features.right_eye_aspect,
|
| 107 |
+
features.brow_tension,
|
| 108 |
+
features.mouth_tension,
|
| 109 |
+
features.eye_symmetry,
|
| 110 |
+
features.mouth_opening,
|
| 111 |
+
features.timestamp % 86400 / 86400, # normalized time-of-day
|
| 112 |
+
],
|
| 113 |
+
dtype=np.float32,
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def run_face_scan(image: np.ndarray) -> Optional[StressFeatures]:
|
| 118 |
+
"""Run MediaPipe FaceMesh on a BGR image and extract stress features."""
|
| 119 |
+
import mediapipe as mp
|
| 120 |
+
|
| 121 |
+
mp_face_mesh = mp.solutions.face_mesh
|
| 122 |
+
|
| 123 |
+
with mp_face_mesh.FaceMesh(
|
| 124 |
+
static_image_mode=True,
|
| 125 |
+
max_num_faces=1,
|
| 126 |
+
refine_landmarks=True,
|
| 127 |
+
min_detection_confidence=0.5,
|
| 128 |
+
) as face_mesh:
|
| 129 |
+
results = face_mesh.process(image)
|
| 130 |
+
if not results.multi_face_landmarks:
|
| 131 |
+
return None
|
| 132 |
+
landmarks = results.multi_face_landmarks[0].landmark
|
| 133 |
+
return extract_stress_features(landmarks)
|
generate_model.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Generate the stress classifier ONNX model (7→16→8→1 MLP with ReLU).
|
| 3 |
+
Same architecture as the original Body Debt ZK circuit.
|
| 4 |
+
Run: python generate_model.py
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
try:
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn as nn
|
| 12 |
+
|
| 13 |
+
class StressMLP(nn.Module):
|
| 14 |
+
def __init__(self):
|
| 15 |
+
super().__init__()
|
| 16 |
+
self.net = nn.Sequential(
|
| 17 |
+
nn.Linear(7, 16),
|
| 18 |
+
nn.ReLU(),
|
| 19 |
+
nn.Linear(16, 8),
|
| 20 |
+
nn.ReLU(),
|
| 21 |
+
nn.Linear(8, 1),
|
| 22 |
+
nn.Sigmoid(),
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
def forward(self, x):
|
| 26 |
+
return self.net(x)
|
| 27 |
+
|
| 28 |
+
model = StressMLP()
|
| 29 |
+
model.eval()
|
| 30 |
+
|
| 31 |
+
# Export to ONNX
|
| 32 |
+
dummy_input = torch.randn(1, 7)
|
| 33 |
+
import os
|
| 34 |
+
os.makedirs("models", exist_ok=True)
|
| 35 |
+
torch.onnx.export(
|
| 36 |
+
model,
|
| 37 |
+
dummy_input,
|
| 38 |
+
"models/stress_model.onnx",
|
| 39 |
+
input_names=["input"],
|
| 40 |
+
output_names=["output"],
|
| 41 |
+
dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}},
|
| 42 |
+
opset_version=10,
|
| 43 |
+
)
|
| 44 |
+
print("✓ Exported models/stress_model.onnx")
|
| 45 |
+
|
| 46 |
+
except ImportError:
|
| 47 |
+
print("PyTorch not available — generating ONNX with numpy + onnx library")
|
| 48 |
+
import onnx
|
| 49 |
+
from onnx import helper, TensorProto, numpy_helper
|
| 50 |
+
|
| 51 |
+
# Build the same 7→16→8→1 MLP manually
|
| 52 |
+
rng = np.random.default_rng(42)
|
| 53 |
+
|
| 54 |
+
def make_linear(name, in_f, out_f):
|
| 55 |
+
W = rng.normal(0, 0.3, (out_f, in_f)).astype(np.float32)
|
| 56 |
+
b = np.zeros(out_f, dtype=np.float32)
|
| 57 |
+
W_init = numpy_helper.from_array(W, name=f"{name}_W")
|
| 58 |
+
b_init = numpy_helper.from_array(b, name=f"{name}_b")
|
| 59 |
+
matmul = helper.make_node("Gemm", [f"{name}_in", f"{name}_W", f"{name}_b"], [f"{name}_out"], transB=1)
|
| 60 |
+
return matmul, [W_init, b_init]
|
| 61 |
+
|
| 62 |
+
nodes = []
|
| 63 |
+
initializers = []
|
| 64 |
+
|
| 65 |
+
# Layer 1: 7→16
|
| 66 |
+
n, inits = make_linear("l1", 7, 16)
|
| 67 |
+
nodes.append(helper.make_node("Identity", ["input"], ["l1_in"]))
|
| 68 |
+
nodes.append(n)
|
| 69 |
+
initializers.extend(inits)
|
| 70 |
+
nodes.append(helper.make_node("Relu", ["l1_out"], ["r1_out"]))
|
| 71 |
+
|
| 72 |
+
# Layer 2: 16→8
|
| 73 |
+
n, inits = make_linear("l2", 16, 8)
|
| 74 |
+
nodes.append(helper.make_node("Identity", ["r1_out"], ["l2_in"]))
|
| 75 |
+
nodes.append(n)
|
| 76 |
+
initializers.extend(inits)
|
| 77 |
+
nodes.append(helper.make_node("Relu", ["l2_out"], ["r2_out"]))
|
| 78 |
+
|
| 79 |
+
# Layer 3: 8→1
|
| 80 |
+
n, inits = make_linear("l3", 8, 1)
|
| 81 |
+
nodes.append(helper.make_node("Identity", ["r2_out"], ["l3_in"]))
|
| 82 |
+
nodes.append(n)
|
| 83 |
+
initializers.extend(inits)
|
| 84 |
+
nodes.append(helper.make_node("Sigmoid", ["l3_out"], ["output"]))
|
| 85 |
+
|
| 86 |
+
graph = helper.make_graph(
|
| 87 |
+
nodes,
|
| 88 |
+
"stress_mlp",
|
| 89 |
+
[helper.make_tensor_value_info("input", TensorProto.FLOAT, [None, 7])],
|
| 90 |
+
[helper.make_tensor_value_info("output", TensorProto.FLOAT, [None, 1])],
|
| 91 |
+
initializer=initializers,
|
| 92 |
+
)
|
| 93 |
+
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 10)])
|
| 94 |
+
model.ir_version = 7
|
| 95 |
+
import os
|
| 96 |
+
os.makedirs("models", exist_ok=True)
|
| 97 |
+
onnx.save(model, "models/stress_model.onnx")
|
| 98 |
+
print("✓ Exported models/stress_model.onnx (numpy fallback)")
|
health_coach.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Local LLM health coach using llama-cpp-python.
|
| 3 |
+
Generates personalized recovery advice from stressor + face scan data.
|
| 4 |
+
Falls back to a template-based response if model unavailable.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
from huggingface_hub import hf_hub_download
|
| 14 |
+
|
| 15 |
+
MODEL_REPO = "hugging-quants/Llama-3.2-1B-Instruct-Q4_K_M-GGUF"
|
| 16 |
+
MODEL_FILE = "llama-3.2-1b-instruct-q4_k_m.gguf"
|
| 17 |
+
CACHE_DIR = Path.home() / ".cache" / "body-debt-models"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_model_path() -> Path:
|
| 21 |
+
local = CACHE_DIR / MODEL_FILE
|
| 22 |
+
if local.exists():
|
| 23 |
+
return local
|
| 24 |
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
| 25 |
+
path = hf_hub_download(
|
| 26 |
+
repo_id=MODEL_REPO,
|
| 27 |
+
filename=MODEL_FILE,
|
| 28 |
+
local_dir=str(CACHE_DIR),
|
| 29 |
+
)
|
| 30 |
+
return Path(path)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def generate_advice(
|
| 34 |
+
debt_score: int,
|
| 35 |
+
system_scores: list[dict],
|
| 36 |
+
stressor_summary: str,
|
| 37 |
+
face_stress: Optional[float] = None,
|
| 38 |
+
progress_callback=None,
|
| 39 |
+
) -> str:
|
| 40 |
+
"""Generate personalized recovery advice using local Llama-3.2-1B."""
|
| 41 |
+
try:
|
| 42 |
+
if progress_callback:
|
| 43 |
+
progress_callback(0.1, "Loading model...")
|
| 44 |
+
model_path = get_model_path()
|
| 45 |
+
if progress_callback:
|
| 46 |
+
progress_callback(0.5, "Model loaded, generating advice...")
|
| 47 |
+
return _llm_generate(model_path, debt_score, system_scores, stressor_summary, face_stress)
|
| 48 |
+
except Exception as e:
|
| 49 |
+
return _fallback_advice(debt_score, system_scores, stressor_summary)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _build_prompt(
|
| 53 |
+
debt_score: int,
|
| 54 |
+
system_scores: list[dict],
|
| 55 |
+
stressor_summary: str,
|
| 56 |
+
face_stress: Optional[float],
|
| 57 |
+
) -> str:
|
| 58 |
+
systems_text = "\n".join(
|
| 59 |
+
f"- {s['label']}: {s['score']}/100 (clears {s['cleared_at']})"
|
| 60 |
+
for s in system_scores
|
| 61 |
+
)
|
| 62 |
+
face_text = f"\nFacial stress indicator: {face_stress:.0f}/100" if face_stress else ""
|
| 63 |
+
|
| 64 |
+
return f"""<|begin_of_text|><|start_header_id|>system<|end_header_id|>
|
| 65 |
+
You are a concise recovery coach. Given physiological debt data, provide specific, actionable recovery advice in 4 categories: Right Now, This Morning, Today, Avoid. Be direct, no fluff. Use the system scores to prioritize which body systems need attention most urgently.<|eot_id|><|start_header_id|>user<|end_header_id|>
|
| 66 |
+
My body debt score: {debt_score}/100
|
| 67 |
+
Stressors: {stressor_summary}{face_text}
|
| 68 |
+
|
| 69 |
+
System breakdown:
|
| 70 |
+
{systems_text}
|
| 71 |
+
|
| 72 |
+
Give me my recovery prescription.<|eot_id|><|start_header_id|>assistant<|end_header_id|>
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _llm_generate(
|
| 77 |
+
model_path: Path,
|
| 78 |
+
debt_score: int,
|
| 79 |
+
system_scores: list[dict],
|
| 80 |
+
stressor_summary: str,
|
| 81 |
+
face_stress: Optional[float],
|
| 82 |
+
) -> str:
|
| 83 |
+
from llama_cpp import Llama
|
| 84 |
+
|
| 85 |
+
llm = Llama(
|
| 86 |
+
model_path=str(model_path),
|
| 87 |
+
n_ctx=2048,
|
| 88 |
+
n_threads=4,
|
| 89 |
+
verbose=False,
|
| 90 |
+
)
|
| 91 |
+
prompt = _build_prompt(debt_score, system_scores, stressor_summary, face_stress)
|
| 92 |
+
output = llm(
|
| 93 |
+
prompt,
|
| 94 |
+
max_tokens=512,
|
| 95 |
+
temperature=0.7,
|
| 96 |
+
top_p=0.9,
|
| 97 |
+
stop=["<|eot_id|>"],
|
| 98 |
+
)
|
| 99 |
+
return output["choices"][0]["text"].strip()
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _fallback_advice(debt_score: int, system_scores: list[dict], stressor_summary: str) -> str:
|
| 103 |
+
worst = max(system_scores, key=lambda s: s["score"]) if system_scores else None
|
| 104 |
+
severity = "high" if debt_score > 60 else ("moderate" if debt_score > 30 else "low")
|
| 105 |
+
|
| 106 |
+
advice = f"**Debt Level: {severity.upper()}** (Score: {debt_score}/100)\n\n"
|
| 107 |
+
if worst:
|
| 108 |
+
advice += f"Priority system: {worst['label']} ({worst['score']}/100)\n\n"
|
| 109 |
+
advice += "**Right Now:** 500ml water with electrolytes. No screens for 10 minutes.\n\n"
|
| 110 |
+
if debt_score > 60:
|
| 111 |
+
advice += "**This Morning:** Delay caffeine 90 minutes. Light walk only.\n\n"
|
| 112 |
+
advice += "**Today:** No training. Prioritize sleep tonight. Bland foods.\n\n"
|
| 113 |
+
advice += "**Avoid:** Alcohol, heavy decisions, intense exercise.\n"
|
| 114 |
+
elif debt_score > 30:
|
| 115 |
+
advice += "**This Morning:** Protein-rich breakfast. Gentle movement.\n\n"
|
| 116 |
+
advice += "**Today:** Light activity OK. Avoid evening alcohol.\n\n"
|
| 117 |
+
advice += "**Avoid:** High-intensity training, late caffeine.\n"
|
| 118 |
+
else:
|
| 119 |
+
advice += "**This Morning:** Normal routine — you're in good shape.\n\n"
|
| 120 |
+
advice += "**Today:** Train if you want. Stay hydrated.\n\n"
|
| 121 |
+
advice += "**Avoid:** Nothing specific — maintain the streak.\n"
|
| 122 |
+
return advice
|
models/stress_model.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a11d8b01aa928ac12ac3f67aea255f83045e682f334d990026dc461f708d9cde
|
| 3 |
+
size 1561
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.0.0,<7.0.0
|
| 2 |
+
mediapipe>=0.10.14
|
| 3 |
+
numpy>=1.26.0
|
| 4 |
+
onnxruntime>=1.18.0
|
| 5 |
+
llama-cpp-python>=0.3.0
|
| 6 |
+
huggingface_hub>=0.25.0
|
scoring.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Five-system deterministic scoring engine.
|
| 3 |
+
Ported from src/lib/systemScoring.ts and src/lib/stressor-scoring.ts
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
# ─── Types ────────────────────────────────────────────────────────────────────
|
| 13 |
+
|
| 14 |
+
STRESSOR_TYPES = ["alcohol", "sleep", "training", "stress", "ill", "care"]
|
| 15 |
+
|
| 16 |
+
RECOVERY_SYSTEMS = ["cardiovascular", "brain", "liver", "muscular", "gut"]
|
| 17 |
+
|
| 18 |
+
SYSTEM_META = {
|
| 19 |
+
"cardiovascular": {"label": "Cardiovascular", "icon": "🫀", "base_window_hrs": 18},
|
| 20 |
+
"brain": {"label": "Brain / Cognition", "icon": "🧠", "base_window_hrs": 24},
|
| 21 |
+
"liver": {"label": "Liver", "icon": "🫁", "base_window_hrs": 30},
|
| 22 |
+
"muscular": {"label": "Muscular / CNS", "icon": "💪", "base_window_hrs": 48},
|
| 23 |
+
"gut": {"label": "Gut", "icon": "🦠", "base_window_hrs": 36},
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
# ─── Stressor definitions ─────────────────────────────────────────────────────
|
| 27 |
+
|
| 28 |
+
STRESSOR_DEFS = {
|
| 29 |
+
"alcohol": {"label": "Drank", "icon": "🍺", "base_points": 32},
|
| 30 |
+
"training": {"label": "Trained", "icon": "💪", "base_points": 18},
|
| 31 |
+
"sleep": {"label": "Slept badly", "icon": "😴", "base_points": 24},
|
| 32 |
+
"stress": {"label": "High stress", "icon": "😤", "base_points": 14},
|
| 33 |
+
"ill": {"label": "Feeling ill", "icon": "🤒", "base_points": 35},
|
| 34 |
+
"care": {"label": "Took care of myself", "icon": "✦", "base_points": -10},
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
# ─── Modifiers ────────────────────────────────────────────────────────────────
|
| 38 |
+
|
| 39 |
+
DRINK_TYPE_MOD = {
|
| 40 |
+
"beer": {"liver": 0.8, "brain": 0.4, "gut": 1.3, "cardio": 0.7},
|
| 41 |
+
"red_wine": {"liver": 1.0, "brain": 0.8, "gut": 0.9, "cardio": 0.8},
|
| 42 |
+
"white_wine": {"liver": 1.0, "brain": 0.7, "gut": 0.8, "cardio": 0.7},
|
| 43 |
+
"spirits": {"liver": 1.4, "brain": 1.3, "gut": 1.0, "cardio": 1.1},
|
| 44 |
+
"cocktails": {"liver": 1.3, "brain": 1.4, "gut": 1.2, "cardio": 1.0},
|
| 45 |
+
"champagne": {"liver": 0.9, "brain": 0.6, "gut": 1.0, "cardio": 0.7},
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
DRINK_COUNT_MOD = {"1-2": 0.5, "3-4": 0.8, "5+": 1.0, "lost_count": 1.2}
|
| 49 |
+
|
| 50 |
+
TRAINING_CNS = {
|
| 51 |
+
"legs": 1.0,
|
| 52 |
+
"full_body": 1.0,
|
| 53 |
+
"hiit": 0.8,
|
| 54 |
+
"cardio": 0.6,
|
| 55 |
+
"upper": 0.5,
|
| 56 |
+
"mobility": -0.5,
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
TRAINING_CARDIO = {
|
| 60 |
+
"hiit": 1.0,
|
| 61 |
+
"cardio": 0.9,
|
| 62 |
+
"legs": 0.6,
|
| 63 |
+
"full_body": 0.7,
|
| 64 |
+
"upper": 0.3,
|
| 65 |
+
"mobility": -0.3,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
INTENSITY_MOD = {"easy": 0.4, "hard": 0.85, "destroyed": 1.2}
|
| 69 |
+
|
| 70 |
+
SLEEP_BRAIN = {"under_4": 1.0, "4-6": 0.75, "6-7": 0.40}
|
| 71 |
+
|
| 72 |
+
# ─── Science citations ────────────────────────────────────────────────────────
|
| 73 |
+
|
| 74 |
+
SCIENCE = {
|
| 75 |
+
"liver": {
|
| 76 |
+
"fact": "The liver metabolises approximately one standard drink per hour. Processing speed cannot be accelerated by sleep, coffee, or exercise.",
|
| 77 |
+
"cite": "Lieber, Physiological Reviews, 1997",
|
| 78 |
+
},
|
| 79 |
+
"muscular": {
|
| 80 |
+
"fact": "Alcohol consumed within 24 hours of resistance training reduces muscle protein synthesis by up to 37%, even when protein intake is maintained.",
|
| 81 |
+
"cite": "Parr et al., PLOS ONE, 2014",
|
| 82 |
+
},
|
| 83 |
+
"gut": {
|
| 84 |
+
"fact": "A single episode of heavy drinking alters gut microbiome composition within 24 hours, increasing intestinal permeability and systemic inflammation.",
|
| 85 |
+
"cite": "Bishehsari et al., Alcohol Research, 2017",
|
| 86 |
+
},
|
| 87 |
+
"brain": {
|
| 88 |
+
"fact": "Sleep deprivation of even one night impairs prefrontal cortex function equivalently to 0.08% blood alcohol concentration.",
|
| 89 |
+
"cite": "Harrison & Horne, Journal of Sleep Research, 2000",
|
| 90 |
+
},
|
| 91 |
+
"cardiovascular": {
|
| 92 |
+
"fact": "Resting heart rate remains elevated for 12–24 hours after alcohol consumption as the autonomic nervous system works to restore balance.",
|
| 93 |
+
"cite": "Spaak et al., Journal of the American College of Cardiology, 2008",
|
| 94 |
+
},
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ─── Data classes ─────────────────────────────────────────────────────────────
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@dataclass
|
| 102 |
+
class Stressor:
|
| 103 |
+
type: str
|
| 104 |
+
alcohol_type: Optional[str] = None
|
| 105 |
+
alcohol_count: Optional[str] = None
|
| 106 |
+
training_area: Optional[str] = None
|
| 107 |
+
training_intensity: Optional[str] = None
|
| 108 |
+
sleep_hours: Optional[str] = None
|
| 109 |
+
stress_carried: Optional[str] = None
|
| 110 |
+
ill_severity: Optional[str] = None
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@dataclass
|
| 114 |
+
class SystemScore:
|
| 115 |
+
system: str
|
| 116 |
+
label: str
|
| 117 |
+
icon: str
|
| 118 |
+
score: int
|
| 119 |
+
cleared_at: str
|
| 120 |
+
recovery_hrs: float
|
| 121 |
+
cause_text: str
|
| 122 |
+
action_text: str
|
| 123 |
+
science_fact: Optional[str] = None
|
| 124 |
+
science_cite: Optional[str] = None
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ─── Live score (quick meter) ─────────────────────────────────────────────────
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def compute_live_score(stressors: list[Stressor]) -> int:
|
| 131 |
+
score = 0
|
| 132 |
+
for s in stressors:
|
| 133 |
+
defn = STRESSOR_DEFS.get(s.type)
|
| 134 |
+
if not defn:
|
| 135 |
+
continue
|
| 136 |
+
score += defn["base_points"]
|
| 137 |
+
if s.type == "training" and s.training_area == "mobility":
|
| 138 |
+
score -= int(defn["base_points"] * 1.5)
|
| 139 |
+
if s.type == "training" and s.training_intensity == "destroyed":
|
| 140 |
+
score += 8
|
| 141 |
+
if s.type == "alcohol" and s.alcohol_type == "spirits":
|
| 142 |
+
score += 6
|
| 143 |
+
if s.type == "alcohol" and s.alcohol_count == "5+":
|
| 144 |
+
score += 8
|
| 145 |
+
if s.type == "alcohol" and s.alcohol_count == "lost_count":
|
| 146 |
+
score += 12
|
| 147 |
+
return max(0, min(100, score))
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ─── Five-system scoring ──────────────────────────────────────────────────────
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def compute_system_scores(
|
| 154 |
+
stressors: list[Stressor],
|
| 155 |
+
now: Optional[datetime] = None,
|
| 156 |
+
bed_time: Optional[str] = None,
|
| 157 |
+
wake_time: Optional[str] = None,
|
| 158 |
+
) -> list[SystemScore]:
|
| 159 |
+
if now is None:
|
| 160 |
+
now = datetime.now()
|
| 161 |
+
|
| 162 |
+
raw = {s: 0.0 for s in RECOVERY_SYSTEMS}
|
| 163 |
+
|
| 164 |
+
for s in stressors:
|
| 165 |
+
if s.type == "alcohol":
|
| 166 |
+
drink_mod = DRINK_TYPE_MOD.get(s.alcohol_type or "beer", DRINK_TYPE_MOD["beer"])
|
| 167 |
+
count_mod = DRINK_COUNT_MOD.get(s.alcohol_count or "3-4", 0.8)
|
| 168 |
+
base = 30
|
| 169 |
+
raw["liver"] += base * drink_mod["liver"] * count_mod
|
| 170 |
+
raw["brain"] += base * drink_mod["brain"] * count_mod
|
| 171 |
+
raw["gut"] += base * drink_mod["gut"] * count_mod
|
| 172 |
+
raw["cardiovascular"] += base * drink_mod["cardio"] * count_mod * 0.5
|
| 173 |
+
|
| 174 |
+
if s.type == "training":
|
| 175 |
+
area = s.training_area or "full_body"
|
| 176 |
+
intensity = s.training_intensity or "hard"
|
| 177 |
+
cns = TRAINING_CNS.get(area, 0.5) * INTENSITY_MOD.get(intensity, 0.85)
|
| 178 |
+
cardio = TRAINING_CARDIO.get(area, 0.5) * INTENSITY_MOD.get(intensity, 0.85)
|
| 179 |
+
raw["muscular"] += 40 * cns
|
| 180 |
+
raw["cardiovascular"] += 35 * cardio
|
| 181 |
+
|
| 182 |
+
if s.type == "sleep":
|
| 183 |
+
brain_hit = SLEEP_BRAIN.get(s.sleep_hours or "4-6", 0.75)
|
| 184 |
+
raw["brain"] += 35 * brain_hit
|
| 185 |
+
raw["gut"] += 15 * brain_hit
|
| 186 |
+
|
| 187 |
+
if s.type == "stress":
|
| 188 |
+
carried = s.stress_carried != "mostly_gone"
|
| 189 |
+
raw["brain"] += 28 if carried else 14
|
| 190 |
+
raw["cardiovascular"] += 15 if carried else 7
|
| 191 |
+
|
| 192 |
+
if s.type == "ill":
|
| 193 |
+
sev_mod = 1.2 if s.ill_severity == "floored" else (0.6 if s.ill_severity == "mild" else 0.9)
|
| 194 |
+
raw["gut"] += 30 * sev_mod
|
| 195 |
+
raw["brain"] += 20 * sev_mod
|
| 196 |
+
raw["muscular"] += 15 * sev_mod
|
| 197 |
+
raw["cardiovascular"] += 12 * sev_mod
|
| 198 |
+
|
| 199 |
+
if s.type == "care":
|
| 200 |
+
raw["brain"] -= 8
|
| 201 |
+
raw["cardiovascular"] -= 8
|
| 202 |
+
raw["liver"] -= 5
|
| 203 |
+
raw["muscular"] -= 5
|
| 204 |
+
raw["gut"] -= 5
|
| 205 |
+
|
| 206 |
+
if bed_time and wake_time:
|
| 207 |
+
penalty = circadian_penalty(bed_time, wake_time)
|
| 208 |
+
raw["brain"] += penalty["brain_pts"]
|
| 209 |
+
raw["cardiovascular"] += penalty["cardio_pts"]
|
| 210 |
+
|
| 211 |
+
results = []
|
| 212 |
+
for system in RECOVERY_SYSTEMS:
|
| 213 |
+
meta = SYSTEM_META[system]
|
| 214 |
+
score = max(0, min(100, round(raw[system])))
|
| 215 |
+
recovery_hrs = (score / 100) * meta["base_window_hrs"]
|
| 216 |
+
cleared_at = now + timedelta(hours=recovery_hrs)
|
| 217 |
+
science = SCIENCE.get(system)
|
| 218 |
+
|
| 219 |
+
results.append(
|
| 220 |
+
SystemScore(
|
| 221 |
+
system=system,
|
| 222 |
+
label=meta["label"],
|
| 223 |
+
icon=meta["icon"],
|
| 224 |
+
score=score,
|
| 225 |
+
cleared_at=cleared_at.strftime("%I:%M%p %A").lstrip("0"),
|
| 226 |
+
recovery_hrs=round(recovery_hrs, 1),
|
| 227 |
+
cause_text=_build_cause_text(system, stressors),
|
| 228 |
+
action_text=_build_action_text(system, stressors),
|
| 229 |
+
science_fact=science["fact"] if science else None,
|
| 230 |
+
science_cite=science["cite"] if science else None,
|
| 231 |
+
)
|
| 232 |
+
)
|
| 233 |
+
return results
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ─── Circadian penalty ────────────────────────────────────────────────────────
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _parse_hour(time_str: str) -> Optional[float]:
|
| 240 |
+
import re
|
| 241 |
+
|
| 242 |
+
clean = time_str.strip().upper()
|
| 243 |
+
m = re.match(r"^(\d{1,2}):(\d{2})\s*(AM|PM)?$", clean)
|
| 244 |
+
if not m:
|
| 245 |
+
return None
|
| 246 |
+
h = int(m.group(1))
|
| 247 |
+
mins = int(m.group(2))
|
| 248 |
+
period = m.group(3)
|
| 249 |
+
if period == "PM" and h != 12:
|
| 250 |
+
h += 12
|
| 251 |
+
if period == "AM" and h == 12:
|
| 252 |
+
h = 0
|
| 253 |
+
return h + mins / 60
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def circadian_penalty(bed_time: str, wake_time: str) -> dict:
|
| 257 |
+
bed = _parse_hour(bed_time)
|
| 258 |
+
wake = _parse_hour(wake_time)
|
| 259 |
+
if bed is None or wake is None:
|
| 260 |
+
return {"brain_pts": 0, "cardio_pts": 0, "label": "unknown"}
|
| 261 |
+
|
| 262 |
+
sleep_hrs = (24 - bed) + wake if bed > wake else wake - bed
|
| 263 |
+
|
| 264 |
+
brain_pts = 0
|
| 265 |
+
cardio_pts = 0
|
| 266 |
+
label = "aligned"
|
| 267 |
+
|
| 268 |
+
if 0 <= bed < 2:
|
| 269 |
+
brain_pts, cardio_pts, label = 10, 5, "mild misalignment"
|
| 270 |
+
elif 2 <= bed < 4:
|
| 271 |
+
brain_pts, cardio_pts, label = 22, 10, "significant misalignment"
|
| 272 |
+
elif 4 <= bed < 6:
|
| 273 |
+
brain_pts, cardio_pts, label = 32, 16, "severe misalignment"
|
| 274 |
+
|
| 275 |
+
if 0 < sleep_hrs < 6:
|
| 276 |
+
brain_pts += round((6 - sleep_hrs) * 4)
|
| 277 |
+
|
| 278 |
+
return {"brain_pts": brain_pts, "cardio_pts": cardio_pts, "label": label}
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ─── Helper text builders ─────────────────────────────────────────────────────
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _build_cause_text(system: str, stressors: list[Stressor]) -> str:
|
| 285 |
+
alcohol = next((s for s in stressors if s.type == "alcohol"), None)
|
| 286 |
+
training = next((s for s in stressors if s.type == "training"), None)
|
| 287 |
+
sleep = next((s for s in stressors if s.type == "sleep"), None)
|
| 288 |
+
stress = next((s for s in stressors if s.type == "stress"), None)
|
| 289 |
+
ill = next((s for s in stressors if s.type == "ill"), None)
|
| 290 |
+
|
| 291 |
+
if system == "liver":
|
| 292 |
+
if alcohol:
|
| 293 |
+
t = (alcohol.alcohol_type or "alcohol").replace("_", " ")
|
| 294 |
+
c = alcohol.alcohol_count or "several drinks"
|
| 295 |
+
return f"{t.capitalize()} — {c} units to process"
|
| 296 |
+
return "No significant liver load"
|
| 297 |
+
|
| 298 |
+
if system == "brain":
|
| 299 |
+
if alcohol and alcohol.alcohol_type in ("spirits", "cocktails"):
|
| 300 |
+
return "Spirits/cocktails hit cognition hardest. Decision quality reduced."
|
| 301 |
+
if sleep:
|
| 302 |
+
return f"{(sleep.sleep_hours or 'Poor sleep').replace('_', ' ')} — cognitive recovery in progress"
|
| 303 |
+
if stress and stress.stress_carried != "mostly_gone":
|
| 304 |
+
return "Stress hormones still elevated. Focus window reduced."
|
| 305 |
+
return "Mild cognitive load"
|
| 306 |
+
|
| 307 |
+
if system == "cardiovascular":
|
| 308 |
+
if training and training.training_area in ("hiit", "cardio"):
|
| 309 |
+
return f"{training.training_area.upper()} session — heart rate recovery active"
|
| 310 |
+
if alcohol:
|
| 311 |
+
return "Alcohol elevates resting HR for 12–18hrs"
|
| 312 |
+
return "Mild cardiovascular load"
|
| 313 |
+
|
| 314 |
+
if system == "muscular":
|
| 315 |
+
if training:
|
| 316 |
+
area = (training.training_area or "training").replace("_", " ").capitalize()
|
| 317 |
+
intensity = training.training_intensity or "hard"
|
| 318 |
+
return f"{area} session at {intensity} intensity — CNS repair ongoing"
|
| 319 |
+
return "No significant muscular load"
|
| 320 |
+
|
| 321 |
+
if system == "gut":
|
| 322 |
+
if alcohol and alcohol.alcohol_type == "beer":
|
| 323 |
+
return "Beer — carbonation and fermentation byproducts affecting gut"
|
| 324 |
+
if alcohol and alcohol.alcohol_type == "cocktails":
|
| 325 |
+
return "Cocktail mixers adding fructose and gut load"
|
| 326 |
+
if sleep:
|
| 327 |
+
return "Poor sleep disrupts gut microbiome rhythm"
|
| 328 |
+
if ill:
|
| 329 |
+
return "Illness affecting gut barrier function"
|
| 330 |
+
return "Minimal gut load"
|
| 331 |
+
|
| 332 |
+
return ""
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def _build_action_text(system: str, stressors: list[Stressor]) -> str:
|
| 336 |
+
alcohol = next((s for s in stressors if s.type == "alcohol"), None)
|
| 337 |
+
training = next((s for s in stressors if s.type == "training"), None)
|
| 338 |
+
|
| 339 |
+
if system == "liver":
|
| 340 |
+
return (
|
| 341 |
+
"Avoid further alcohol. 500ml water + electrolytes now."
|
| 342 |
+
if alcohol
|
| 343 |
+
else "Liver clear — no action needed."
|
| 344 |
+
)
|
| 345 |
+
if system == "brain":
|
| 346 |
+
return "No decisions requiring deep focus until your window opens."
|
| 347 |
+
if system == "cardiovascular":
|
| 348 |
+
return (
|
| 349 |
+
"No cardio today. Walk only."
|
| 350 |
+
if training and training.training_intensity == "destroyed"
|
| 351 |
+
else "Keep activity light until cleared."
|
| 352 |
+
)
|
| 353 |
+
if system == "muscular":
|
| 354 |
+
return (
|
| 355 |
+
"Protein within 2 hrs. No re-training the same group today."
|
| 356 |
+
if training
|
| 357 |
+
else "No significant muscular debt."
|
| 358 |
+
)
|
| 359 |
+
if system == "gut":
|
| 360 |
+
return (
|
| 361 |
+
"Bland foods, no coffee on an empty stomach, no more alcohol."
|
| 362 |
+
if alcohol
|
| 363 |
+
else "Probiotic-rich foods will help speed gut clearance."
|
| 364 |
+
)
|
| 365 |
+
return ""
|
stress_model.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stress score inference via ONNX model (7→16→8→1 MLP).
|
| 3 |
+
Falls back to a heuristic if model file not available.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
MODEL_PATH = Path(__file__).parent / "models" / "stress_model.onnx"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def predict_stress_score(features: np.ndarray) -> tuple[float, bool]:
|
| 17 |
+
"""
|
| 18 |
+
Run the stress MLP on a 7-feature vector.
|
| 19 |
+
Returns (stress_score 0-100, is_healthy bool).
|
| 20 |
+
"""
|
| 21 |
+
if MODEL_PATH.exists():
|
| 22 |
+
return _onnx_predict(features)
|
| 23 |
+
return _heuristic_predict(features)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _onnx_predict(features: np.ndarray) -> tuple[float, bool]:
|
| 27 |
+
import onnxruntime as ort
|
| 28 |
+
|
| 29 |
+
session = ort.InferenceSession(str(MODEL_PATH))
|
| 30 |
+
input_name = session.get_inputs()[0].name
|
| 31 |
+
inp = features.reshape(1, -1).astype(np.float32)
|
| 32 |
+
output = session.run(None, {input_name: inp})
|
| 33 |
+
raw = float(output[0][0][0])
|
| 34 |
+
score = max(0.0, min(100.0, raw * 100))
|
| 35 |
+
return score, score < 50
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _heuristic_predict(features: np.ndarray) -> tuple[float, bool]:
|
| 39 |
+
"""Simple heuristic from feature ranges when ONNX model unavailable."""
|
| 40 |
+
left_ear, right_ear, brow, mouth_t, eye_sym, mouth_o, _ = features
|
| 41 |
+
|
| 42 |
+
fatigue = 0.0
|
| 43 |
+
avg_ear = (left_ear + right_ear) / 2
|
| 44 |
+
if avg_ear < 0.25:
|
| 45 |
+
fatigue += 30
|
| 46 |
+
elif avg_ear < 0.35:
|
| 47 |
+
fatigue += 15
|
| 48 |
+
|
| 49 |
+
if brow < 0.03:
|
| 50 |
+
fatigue += 20
|
| 51 |
+
if eye_sym > 0.15:
|
| 52 |
+
fatigue += 15
|
| 53 |
+
if mouth_t > 8:
|
| 54 |
+
fatigue += 10
|
| 55 |
+
|
| 56 |
+
score = max(0, min(100, fatigue))
|
| 57 |
+
return score, score < 50
|