Papajams commited on
Commit
b11e593
·
verified ·
1 Parent(s): 3635b1c

Initial Body Debt Gradio app for Build Small hackathon

Browse files
Files changed (9) hide show
  1. README.md +68 -6
  2. app.py +334 -0
  3. face_scan.py +133 -0
  4. generate_model.py +98 -0
  5. health_coach.py +122 -0
  6. models/stress_model.onnx +3 -0
  7. requirements.txt +6 -0
  8. scoring.py +365 -0
  9. stress_model.py +57 -0
README.md CHANGED
@@ -1,13 +1,75 @@
1
  ---
2
  title: Body Debt
3
- emoji: 🌖
4
- colorFrom: gray
5
- colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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