Spaces:
Sleeping
Sleeping
Sync trained MLP, agent trace dataset, updated README, and submission prep scripts
Browse files- README.md +59 -19
- app.py +2055 -287
- generate_trace_dataset.py +360 -0
- health_coach.py +170 -12
- models/README.md +143 -0
- models/stress_metrics.json +39 -0
- models/stress_model.onnx +2 -2
- models/stress_model_weights.npz +3 -0
- models/stress_training_data.npz +3 -0
- publish_mlp.py +83 -0
- publish_traces.py +66 -0
- scoring.py +109 -0
- train_stress_model.py +331 -0
README.md
CHANGED
|
@@ -15,35 +15,67 @@ tags:
|
|
| 15 |
- best-agent
|
| 16 |
- off-brand
|
| 17 |
- openai-codex
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
models:
|
| 19 |
- HuggingFaceTB/SmolLM2-360M-Instruct
|
|
|
|
| 20 |
---
|
| 21 |
|
| 22 |
# π« Body Debt
|
| 23 |
|
| 24 |
**Quantify your physiological debt. Get AI-backed recovery prescriptions.**
|
| 25 |
|
| 26 |
-
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 **
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
## What it does
|
| 29 |
|
| 30 |
1. **Log stressors** β tap what happened (drank, trained, slept badly, stressed, ill, or took care)
|
| 31 |
2. **Face scan** (optional) β webcam capture analyzed by MediaPipe FaceMesh to detect fatigue markers (eye aspect ratio, brow tension, eye symmetry)
|
| 32 |
3. **Deterministic scoring** β five biological systems (Cardiovascular, Brain, Liver, Muscular/CNS, Gut) scored with physiological weights and circadian penalties
|
| 33 |
-
4. **
|
|
|
|
| 34 |
|
| 35 |
-
## The
|
| 36 |
|
| 37 |
**SmolLM2-360M-Instruct** (360M parameters) β runs entirely on CPU via HuggingFace Transformers. No external API calls, no cloud inference. Your health data stays on-device.
|
| 38 |
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
## Tech
|
| 42 |
|
| 43 |
-
- **LLM**: SmolLM2-360M-Instruct (360M params) via HuggingFace Transformers
|
| 44 |
-
- **Face analysis**: MediaPipe FaceMesh β 7 stress features β ONNX MLP
|
| 45 |
- **Scoring**: Deterministic 5-system engine with physiological weights, drink-type modifiers, training CNS load, circadian alignment penalties
|
| 46 |
-
- **UI**: Gradio
|
| 47 |
|
| 48 |
## Privacy
|
| 49 |
|
|
@@ -51,32 +83,40 @@ The face scan stress classifier is a custom 7β16β8β1 MLP (~2KB ONNX) that
|
|
| 51 |
- LLM inference is local β no API calls to external services
|
| 52 |
- No data persistence β nothing is stored between sessions
|
| 53 |
|
| 54 |
-
## Demo
|
| 55 |
-
|
| 56 |
-
[Demo video link]
|
| 57 |
-
|
| 58 |
-
## Social
|
| 59 |
-
|
| 60 |
-
[Social media post link]
|
| 61 |
-
|
| 62 |
## Try it locally
|
| 63 |
|
| 64 |
```bash
|
| 65 |
pip install -r requirements.txt
|
| 66 |
-
python
|
| 67 |
python app.py
|
| 68 |
```
|
| 69 |
|
|
|
|
|
|
|
| 70 |
## OpenAI Codex Track
|
| 71 |
|
| 72 |
-
This Space was built with OpenAI Codex as the coding agent. The
|
| 73 |
|
| 74 |
**Repository:** [github.com/udirobert/bodydebt](https://github.com/udirobert/bodydebt)
|
| 75 |
|
|
|
|
|
|
|
| 76 |
## Full product
|
| 77 |
|
| 78 |
-
The complete Body Debt application
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
---
|
| 81 |
|
| 82 |
-
*Built for the [Build Small Hackathon](https://huggingface.co/
|
|
|
|
|
|
| 15 |
- best-agent
|
| 16 |
- off-brand
|
| 17 |
- openai-codex
|
| 18 |
+
- well-tuned
|
| 19 |
+
- field-notes
|
| 20 |
+
- off-the-grid
|
| 21 |
+
datasets:
|
| 22 |
+
- Papajams/body-debt-traces
|
| 23 |
models:
|
| 24 |
- HuggingFaceTB/SmolLM2-360M-Instruct
|
| 25 |
+
- Papajams/body-debt-stress-mlp
|
| 26 |
---
|
| 27 |
|
| 28 |
# π« Body Debt
|
| 29 |
|
| 30 |
**Quantify your physiological debt. Get AI-backed recovery prescriptions.**
|
| 31 |
|
| 32 |
+
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 **360-million parameter local LLM** that streams on-device.
|
| 33 |
+
|
| 34 |
+
## Demo
|
| 35 |
+
|
| 36 |
+
π¬ **[Watch the 75-second demo](https://github.com/udirobert/bodydebt/blob/main/hf-space/demo-video-script.md)** β the Space itself, captured shot-by-shot, including the streaming LLM token reveal.
|
| 37 |
+
|
| 38 |
+
π **[Read the field notes](https://huggingface.co/blog/build-small-hackathon/body-debt-field-notes)** β the four lessons I learned shipping a 360M health coach for myself.
|
| 39 |
+
|
| 40 |
+
π **[Inspect the agent traces](https://huggingface.co/datasets/Papajams/body-debt-traces)** β twelve real analyses, JSONL, showing the full reasoning chain.
|
| 41 |
+
|
| 42 |
+
## Why I built this
|
| 43 |
+
|
| 44 |
+
I built Body Debt for myself. I kept training on bad sleep, drinking on Wednesdays, and wondering on Saturday why I felt like I was running through mud. Wearables told me *what* my body was doing; nothing told me *why today* felt like a high-debt day and what the cheapest recovery move was.
|
| 45 |
+
|
| 46 |
+
So this is the app I wanted: log last night, get a single number, see which of the five systems is the actual problem, and read a four-line prescription that tells me what to do in the next 60 seconds. The face scan is a bonus β it catches the days when the *number* says I'm fine but my face says I look like I slept on a plane.
|
| 47 |
+
|
| 48 |
+
I've been running the local version for two weeks. The agent trace (top-right of the results panel) is the part I trust most: it's a transparent record of *why* the score is what it is. I can disagree with the prescription, but I can't disagree with the chain of reasoning that produced it.
|
| 49 |
+
|
| 50 |
+
The whole thing runs on a $300 Chromebook with no internet. That was the constraint that made it worth building β privacy on health data isn't a feature here, it's the only design space that exists.
|
| 51 |
|
| 52 |
## What it does
|
| 53 |
|
| 54 |
1. **Log stressors** β tap what happened (drank, trained, slept badly, stressed, ill, or took care)
|
| 55 |
2. **Face scan** (optional) β webcam capture analyzed by MediaPipe FaceMesh to detect fatigue markers (eye aspect ratio, brow tension, eye symmetry)
|
| 56 |
3. **Deterministic scoring** β five biological systems (Cardiovascular, Brain, Liver, Muscular/CNS, Gut) scored with physiological weights and circadian penalties
|
| 57 |
+
4. **Visible agent trace** β every step of the reasoning chain streams into the UI: parse stressors β compute score β face scan β triage plan β counterfactual β LLM coach
|
| 58 |
+
5. **Local AI recovery coach** β SmolLM2-360M-Instruct streams a personalized prescription token-by-token, right now
|
| 59 |
|
| 60 |
+
## The models
|
| 61 |
|
| 62 |
**SmolLM2-360M-Instruct** (360M parameters) β runs entirely on CPU via HuggingFace Transformers. No external API calls, no cloud inference. Your health data stays on-device.
|
| 63 |
|
| 64 |
+
A 360M parameter model is the *right* size for this product, not a compromise:
|
| 65 |
+
|
| 66 |
+
- **Privacy.** Health data never leaves the device. A 70B model doesn't help when the user is undressed, hungover, or at 2am with a chest flutter β they need on-device.
|
| 67 |
+
- **Latency.** 360M streams the first token in under a second on a modern laptop. A 7B cloud call is 2-8 seconds of network + queue.
|
| 68 |
+
- **Footprint.** 360M fits in 250MB of RAM. The whole app, model and all, runs on a $300 Chromebook.
|
| 69 |
+
- **Output shape.** The advice is short, structured, and rule-bound (Right Now / This Morning / Today / Avoid). Bigger models wouldn't make it more correct.
|
| 70 |
+
|
| 71 |
+
The face scan stress classifier is a custom 7β16β8β1 MLP (**553 parameters, ~1.5KB ONNX**) that converts facial geometry features into a fatigue score. The model is **fine-tuned on 2,000 physiologically-motivated synthetic samples** and published as [`Papajams/body-debt-stress-mlp`](https://huggingface.co/Papajams/body-debt-stress-mlp) with a full model card. Validation MAE: 0.060 (probability units). A linear regression on the same 7 inputs gets 0.061, so the network is earning its parameters.
|
| 72 |
|
| 73 |
## Tech
|
| 74 |
|
| 75 |
+
- **LLM**: SmolLM2-360M-Instruct (360M params) via HuggingFace Transformers, streamed via `TextIteratorStreamer`
|
| 76 |
+
- **Face analysis**: MediaPipe FaceMesh β 7 stress features β ONNX MLP (553 params, fine-tuned)
|
| 77 |
- **Scoring**: Deterministic 5-system engine with physiological weights, drink-type modifiers, training CNS load, circadian alignment penalties
|
| 78 |
+
- **UI**: Custom dark Gradio theme β `DM Serif Display` for the debt number, system-specific accent tokens, breathing-orb animation, monogram glyphs, agent trace panel
|
| 79 |
|
| 80 |
## Privacy
|
| 81 |
|
|
|
|
| 83 |
- LLM inference is local β no API calls to external services
|
| 84 |
- No data persistence β nothing is stored between sessions
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
## Try it locally
|
| 87 |
|
| 88 |
```bash
|
| 89 |
pip install -r requirements.txt
|
| 90 |
+
python train_stress_model.py # trains the face MLP on 2,000 synthetic samples and exports ONNX (~2s on CPU)
|
| 91 |
python app.py
|
| 92 |
```
|
| 93 |
|
| 94 |
+
The training script has no PyTorch or scikit-learn dependency. It trains the 553-parameter MLP in pure NumPy using Adam, then re-exports the ONNX. Two seconds on a modern laptop.
|
| 95 |
+
|
| 96 |
## OpenAI Codex Track
|
| 97 |
|
| 98 |
+
This Space was built end-to-end with **OpenAI Codex** as the coding agent. The full source repository, including Codex-attributed commits, is here:
|
| 99 |
|
| 100 |
**Repository:** [github.com/udirobert/bodydebt](https://github.com/udirobert/bodydebt)
|
| 101 |
|
| 102 |
+
Codex handled the bulk of the architecture: porting the Next.js TypeScript scoring engine to Python, porting the dark design system from CSS variables into a custom Gradio theme, and wiring the streaming agent trace. The repo's `git log` shows consecutive Codex-attributed commits for each subsystem.
|
| 103 |
+
|
| 104 |
## Full product
|
| 105 |
|
| 106 |
+
The complete Body Debt application β Next.js, animated debt orb, ZK proofs on SKALE, full state machine β is at: [github.com/udirobert/bodydebt](https://github.com/udirobert/bodydebt)
|
| 107 |
+
|
| 108 |
+
## Bonus quest coverage
|
| 109 |
+
|
| 110 |
+
- **Off the Grid** β on-device only, no API calls
|
| 111 |
+
- **Tiny Titan** β SmolLM2-360M is well under the 4B threshold
|
| 112 |
+
- **Off-Brand** β custom dark Gradio theme, agent trace, system accents, breathing-orb
|
| 113 |
+
- **Best Agent** β visible multi-step trace: parse β score β face β triage plan β counterfactual β coach
|
| 114 |
+
- **Well-Tuned** β fine-tuned 553-param ONNX MLP at `Papajams/body-debt-stress-mlp`
|
| 115 |
+
- **Field Notes** β this blog post and the field-notes writeup
|
| 116 |
+
- **Sharing is Caring** β agent trace dataset at `Papajams/body-debt-traces`
|
| 117 |
+
- **OpenAI Codex** β the Space was Codex-built, commit trail in the repo
|
| 118 |
|
| 119 |
---
|
| 120 |
|
| 121 |
+
*Built for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon). 360M parameters, on a laptop, no cloud.*
|
| 122 |
+
|
app.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
| 1 |
"""
|
| 2 |
-
Body Debt β Gradio App
|
| 3 |
Quantifies physiological debt from lifestyle stressors and provides
|
| 4 |
AI-backed recovery prescriptions using a local small model.
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
|
|
|
| 9 |
from datetime import datetime
|
| 10 |
|
| 11 |
import gradio as gr
|
|
@@ -15,251 +17,1533 @@ from scoring import (
|
|
| 15 |
Stressor,
|
| 16 |
compute_live_score,
|
| 17 |
compute_system_scores,
|
|
|
|
| 18 |
STRESSOR_DEFS,
|
| 19 |
SYSTEM_META,
|
| 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
|
| 24 |
-
|
| 25 |
-
# βββ
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
checkbox_background_color_dark="#FFFFFF",
|
| 61 |
-
checkbox_border_color="#D6D3D1",
|
| 62 |
-
checkbox_border_color_dark="#D6D3D1",
|
| 63 |
-
checkbox_label_text_color="#1C1917",
|
| 64 |
-
checkbox_label_text_color_dark="#1C1917",
|
| 65 |
-
)
|
| 66 |
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
-
|
| 70 |
-
"
|
| 71 |
-
"
|
| 72 |
-
"
|
| 73 |
-
"
|
| 74 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
# βββ HTML renderers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
|
| 86 |
|
| 87 |
-
def
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
color = SCORE_COLORS["high"]
|
| 98 |
-
bg = "#FEF2F2"
|
| 99 |
-
ring = "rgba(220, 38, 38, 0.2)"
|
| 100 |
-
else:
|
| 101 |
-
color = SCORE_COLORS["critical"]
|
| 102 |
-
bg = "#FEF2F2"
|
| 103 |
-
ring = "rgba(153, 27, 27, 0.25)"
|
| 104 |
-
|
| 105 |
return f"""
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
</div>
|
| 110 |
-
<div
|
| 111 |
-
{score}
|
| 112 |
-
</div>
|
| 113 |
-
<div style="font-family: 'Inter', system-ui; font-size: 14px; font-weight: 500; color: #44403C;">
|
| 114 |
-
{verdict}
|
| 115 |
-
</div>
|
| 116 |
</div>
|
| 117 |
"""
|
| 118 |
|
| 119 |
|
| 120 |
def render_system_meters(system_scores) -> str:
|
| 121 |
-
rows = ""
|
| 122 |
max_score = max((s.score for s in system_scores), default=0)
|
|
|
|
|
|
|
|
|
|
| 123 |
|
|
|
|
| 124 |
for s in system_scores:
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
| 130 |
pct = max(0, min(100, s.score))
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
<div
|
| 137 |
-
<div
|
| 138 |
-
{
|
| 139 |
-
<span
|
| 140 |
</div>
|
| 141 |
-
<
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
<div
|
| 145 |
-
</div>
|
| 146 |
-
<div style="display:flex; justify-content:space-between; margin-top:6px;">
|
| 147 |
-
<span style="font-size:11px; color:#78716C;">{s.cause_text}</span>
|
| 148 |
-
<span style="font-family:monospace; font-size:10px; color:#A8A29E;">clears {s.cleared_at}</span>
|
| 149 |
</div>
|
| 150 |
</div>
|
| 151 |
-
"""
|
| 152 |
|
| 153 |
return f"""
|
| 154 |
-
<div
|
| 155 |
-
<div
|
| 156 |
-
|
| 157 |
-
</div>
|
| 158 |
-
{rows}
|
| 159 |
</div>
|
| 160 |
-
<style>
|
| 161 |
-
@keyframes pulse {{ 0%,100% {{ opacity:1; }} 50% {{ opacity:0.3; }} }}
|
| 162 |
-
</style>
|
| 163 |
"""
|
| 164 |
|
| 165 |
|
| 166 |
-
def render_prescription(system_scores) -> str:
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
steps_data.append({"action": s.action_text, "system": s.label, "color": SYSTEM_COLORS.get(s.system, "#78716C")})
|
| 173 |
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
| 176 |
|
|
|
|
| 177 |
steps_html = ""
|
| 178 |
-
for i,
|
| 179 |
-
|
| 180 |
-
connector = "" if is_last else f'<div style="flex:1; width:1px; background:#E7E5E4; min-height:16px; margin-top:4px;"></div>'
|
| 181 |
-
|
| 182 |
steps_html += f"""
|
| 183 |
-
<div
|
| 184 |
-
<div
|
| 185 |
-
<div
|
| 186 |
-
{str(i+1).zfill(2)}
|
| 187 |
-
</div>
|
| 188 |
{connector}
|
| 189 |
</div>
|
| 190 |
-
<div style="flex:1; padding-bottom:{'
|
| 191 |
-
<div
|
| 192 |
-
|
| 193 |
-
</div>
|
| 194 |
-
<p style="font-size:13px; color:#1C1917; line-height:1.5; margin:0;">
|
| 195 |
-
{step['action']}
|
| 196 |
-
</p>
|
| 197 |
</div>
|
| 198 |
</div>
|
| 199 |
"""
|
| 200 |
|
| 201 |
return f"""
|
| 202 |
-
<div
|
| 203 |
-
<div
|
| 204 |
-
RECOVERY PROTOCOL
|
| 205 |
-
</div>
|
| 206 |
{steps_html}
|
| 207 |
</div>
|
| 208 |
"""
|
| 209 |
|
| 210 |
|
| 211 |
def render_science(system_scores) -> str:
|
| 212 |
-
|
| 213 |
for s in system_scores:
|
| 214 |
-
if s.score > 20 and s.science_fact:
|
| 215 |
-
|
|
|
|
| 216 |
|
| 217 |
-
if not
|
| 218 |
return ""
|
| 219 |
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
<p
|
| 225 |
-
<p style="font-size:10px; color:#A8A29E; margin:0; font-style:italic;">β {c['cite']}</p>
|
| 226 |
</div>
|
| 227 |
-
"""
|
|
|
|
| 228 |
|
| 229 |
return f"""
|
| 230 |
-
<div style="margin-
|
| 231 |
-
<div
|
| 232 |
-
|
| 233 |
-
</div>
|
| 234 |
-
{items}
|
| 235 |
</div>
|
| 236 |
"""
|
| 237 |
|
| 238 |
|
| 239 |
-
def render_face_scan(face_stress, is_healthy,
|
| 240 |
-
status_color = "
|
| 241 |
status_text = "Healthy" if is_healthy else "Stressed"
|
| 242 |
-
status_bg = "
|
|
|
|
| 243 |
|
| 244 |
return f"""
|
| 245 |
-
<div
|
| 246 |
-
<div
|
| 247 |
-
<
|
| 248 |
-
<
|
| 249 |
</div>
|
| 250 |
-
<div style="
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
| 253 |
</div>
|
| 254 |
-
<div style="font-size:10px; color:#A8A29E; margin-top:6px; font-style:italic;">Processed entirely on-device. No biometric data transmitted.</div>
|
| 255 |
</div>
|
| 256 |
"""
|
| 257 |
|
| 258 |
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
|
| 262 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
alcohol, alcohol_type, alcohol_count,
|
| 264 |
training, training_area, training_intensity,
|
| 265 |
sleep, sleep_hours,
|
|
@@ -267,8 +1551,181 @@ def run_analysis(
|
|
| 267 |
ill, ill_severity,
|
| 268 |
care,
|
| 269 |
bed_time, wake_time,
|
| 270 |
-
|
| 271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
):
|
| 273 |
stressors = []
|
| 274 |
if alcohol:
|
|
@@ -283,17 +1740,61 @@ def run_analysis(
|
|
| 283 |
stressors.append(Stressor(type="ill", ill_severity=ill_severity))
|
| 284 |
if care:
|
| 285 |
stressors.append(Stressor(type="care"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
|
| 287 |
if not stressors:
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
<div
|
| 291 |
-
<div
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
|
| 296 |
-
progress(0.1, desc="Calculating debt score...")
|
| 297 |
live_score = compute_live_score(stressors)
|
| 298 |
system_scores = compute_system_scores(
|
| 299 |
stressors,
|
|
@@ -301,111 +1802,267 @@ def run_analysis(
|
|
| 301 |
bed_time=bed_time or None,
|
| 302 |
wake_time=wake_time or None,
|
| 303 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
|
| 305 |
-
#
|
| 306 |
face_html = ""
|
| 307 |
face_stress = None
|
| 308 |
if face_image is not None:
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
| 310 |
features = run_face_scan(face_image)
|
| 311 |
if features:
|
| 312 |
arr = features_to_array(features)
|
| 313 |
face_stress, is_healthy = predict_stress_score(arr)
|
| 314 |
face_html = render_face_scan(face_stress, is_healthy, features)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
|
| 316 |
-
#
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
)
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
|
|
|
|
|
|
| 335 |
|
| 336 |
-
|
| 337 |
-
score_html += render_system_meters(system_scores)
|
| 338 |
-
score_html += render_prescription(system_scores)
|
| 339 |
-
score_html += render_science(system_scores)
|
| 340 |
|
| 341 |
-
#
|
| 342 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
stressor_summary = ", ".join(
|
| 344 |
f"{STRESSOR_DEFS[s.type]['icon']} {STRESSOR_DEFS[s.type]['label']}" for s in stressors
|
| 345 |
)
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
debt_score=live_score,
|
| 351 |
-
system_scores=system_dicts,
|
| 352 |
-
stressor_summary=stressor_summary,
|
| 353 |
-
face_stress=face_stress,
|
| 354 |
-
progress_callback=lambda p, msg: progress(0.6 + p * 0.35, desc=msg),
|
| 355 |
)
|
| 356 |
-
progress(1.0, desc="Done!")
|
| 357 |
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
</div>
|
| 364 |
-
<div style="font-size:13px; color:#1C1917; line-height:1.7; white-space:pre-wrap;">{advice}</div>
|
| 365 |
</div>
|
| 366 |
"""
|
| 367 |
|
| 368 |
-
return score_html, face_html, advice_html
|
| 369 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
|
| 371 |
-
# βββ CSS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 372 |
|
| 373 |
-
|
| 374 |
-
/* Force light mode */
|
| 375 |
-
.dark { --body-background-fill: #FAFAF9 !important; }
|
| 376 |
-
.gradio-container { max-width: 1100px !important; }
|
| 377 |
-
footer { display: none !important; }
|
| 378 |
|
| 379 |
-
/* Checkbox styling */
|
| 380 |
-
.gr-checkbox label { font-size: 15px !important; font-weight: 500 !important; }
|
| 381 |
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
|
| 386 |
-
# βββ
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
|
| 388 |
with gr.Blocks(title="Body Debt") as demo:
|
| 389 |
-
gr.HTML(
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
</
|
| 394 |
-
<
|
| 395 |
-
|
| 396 |
-
|
|
|
|
|
|
|
| 397 |
</div>
|
| 398 |
""")
|
| 399 |
|
| 400 |
with gr.Row():
|
| 401 |
-
with gr.Column(scale=1
|
| 402 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
|
| 404 |
alcohol = gr.Checkbox(label="πΊ Drank", value=False)
|
| 405 |
with gr.Group(visible=False) as alcohol_details:
|
| 406 |
alcohol_type = gr.Dropdown(
|
| 407 |
choices=["beer", "red_wine", "white_wine", "spirits", "cocktails", "champagne"],
|
| 408 |
-
value="
|
| 409 |
)
|
| 410 |
alcohol_count = gr.Dropdown(
|
| 411 |
choices=["1-2", "3-4", "5+", "lost_count"],
|
|
@@ -444,33 +2101,74 @@ with gr.Blocks(title="Body Debt") as demo:
|
|
| 444 |
value="moderate", label="How bad?",
|
| 445 |
)
|
| 446 |
|
| 447 |
-
care = gr.Checkbox(label="β¦ Took care of myself", value=False)
|
| 448 |
|
| 449 |
-
gr.HTML('<div
|
| 450 |
-
bed_time = gr.
|
| 451 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
|
| 453 |
-
gr.HTML('<div
|
| 454 |
face_image = gr.Image(
|
| 455 |
label="Capture or upload",
|
| 456 |
sources=["webcam", "upload"],
|
| 457 |
type="numpy",
|
| 458 |
)
|
| 459 |
|
| 460 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
|
| 462 |
with gr.Column(scale=2):
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
|
| 475 |
# Toggle detail sections
|
| 476 |
alcohol.change(lambda v: gr.Group(visible=v), alcohol, alcohol_details)
|
|
@@ -479,31 +2177,101 @@ with gr.Blocks(title="Body Debt") as demo:
|
|
| 479 |
stress.change(lambda v: gr.Group(visible=v), stress, stress_details)
|
| 480 |
ill.change(lambda v: gr.Group(visible=v), ill, ill_details)
|
| 481 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
analyze_btn.click(
|
| 483 |
-
fn=
|
| 484 |
-
inputs=
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
)
|
| 496 |
|
| 497 |
-
gr.HTML("""
|
| 498 |
-
<div
|
| 499 |
-
<
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
</p>
|
| 504 |
</div>
|
| 505 |
""")
|
| 506 |
|
| 507 |
|
| 508 |
if __name__ == "__main__":
|
| 509 |
-
demo.launch(
|
|
|
|
| 1 |
"""
|
| 2 |
+
Body Debt β Gradio App (dark "off-brand" edition)
|
| 3 |
Quantifies physiological debt from lifestyle stressors and provides
|
| 4 |
AI-backed recovery prescriptions using a local small model.
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
import html
|
| 10 |
+
import time
|
| 11 |
from datetime import datetime
|
| 12 |
|
| 13 |
import gradio as gr
|
|
|
|
| 17 |
Stressor,
|
| 18 |
compute_live_score,
|
| 19 |
compute_system_scores,
|
| 20 |
+
compute_counterfactual,
|
| 21 |
STRESSOR_DEFS,
|
| 22 |
SYSTEM_META,
|
| 23 |
)
|
| 24 |
from face_scan import run_face_scan, features_to_array
|
| 25 |
from stress_model import predict_stress_score
|
| 26 |
+
from health_coach import stream_advice, stream_plan, _fallback_advice, _fallback_plan
|
| 27 |
+
|
| 28 |
+
# βββ Design tokens (mirrors src/lib/design-tokens.ts) ββββββββββββββββββββββββ
|
| 29 |
+
|
| 30 |
+
BG_BASE = "#0A0A0B"
|
| 31 |
+
BG_SURFACE = "#141416"
|
| 32 |
+
BG_ELEVATED = "#1C1C1F"
|
| 33 |
+
BORDER = "rgba(168, 162, 158, 0.10)"
|
| 34 |
+
BORDER_SOFT = "rgba(168, 162, 158, 0.06)"
|
| 35 |
+
|
| 36 |
+
TEXT_PRIMARY = "#F5F5F4"
|
| 37 |
+
TEXT_SECONDARY = "#A8A29E"
|
| 38 |
+
TEXT_MUTED = "#524F4C"
|
| 39 |
+
TEXT_FAINT = "#3a3835"
|
| 40 |
+
|
| 41 |
+
BRAND_PRIMARY = "#EA580C"
|
| 42 |
+
BRAND_SECONDARY = "#F59E0B"
|
| 43 |
+
RECOVERY_GREEN = "#4ADE80"
|
| 44 |
+
|
| 45 |
+
# Light mode tokens
|
| 46 |
+
LT_BG_BASE = "#FAFAF9"
|
| 47 |
+
LT_BG_SURFACE = "#F5F5F4"
|
| 48 |
+
LT_BG_ELEVATED = "#E7E5E4"
|
| 49 |
+
LT_BORDER = "rgba(0, 0, 0, 0.08)"
|
| 50 |
+
LT_BORDER_SOFT = "rgba(0, 0, 0, 0.04)"
|
| 51 |
+
LT_TEXT_PRIMARY = "#1C1917"
|
| 52 |
+
LT_TEXT_SECONDARY = "#57534E"
|
| 53 |
+
LT_TEXT_MUTED = "#7A7672"
|
| 54 |
+
LT_TEXT_FAINT = "#B8B4B0"
|
| 55 |
+
|
| 56 |
+
SYSTEM_ACCENTS = {
|
| 57 |
+
"cardiovascular": ("#F43F5E", "rgba(244, 63, 94, 0.18)", "rgba(244, 63, 94, 0.40)"),
|
| 58 |
+
"brain": ("#22D3EE", "rgba(34, 211, 238, 0.18)", "rgba(34, 211, 238, 0.40)"),
|
| 59 |
+
"liver": ("#EAB308", "rgba(234, 179, 8, 0.18)", "rgba(234, 179, 8, 0.40)"),
|
| 60 |
+
"muscular": ("#A78BFA", "rgba(167, 139, 250, 0.18)","rgba(167, 139, 250, 0.40)"),
|
| 61 |
+
"gut": ("#2DD4BF", "rgba(45, 212, 191, 0.18)", "rgba(45, 212, 191, 0.40)"),
|
| 62 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
+
SYSTEM_GLYPHS = {
|
| 65 |
+
"cardiovascular": "C",
|
| 66 |
+
"brain": "N",
|
| 67 |
+
"liver": "L",
|
| 68 |
+
"muscular": "M",
|
| 69 |
+
"gut": "G",
|
| 70 |
+
}
|
| 71 |
|
| 72 |
+
DEBT_TIERS = [
|
| 73 |
+
(0, 20, "#4ADE80", "You're clear. Minimal debt.", "low"),
|
| 74 |
+
(20, 40, "#F59E0B", "Low debt. Minor adjustments needed.", "low"),
|
| 75 |
+
(40, 60, "#EA580C", "Moderate debt. Recovery recommended.","moderate"),
|
| 76 |
+
(60, 80, "#DC2626", "High debt. Prioritize recovery.", "high"),
|
| 77 |
+
(80, 101,"#991B1B", "Critical debt. Full rest mode.", "critical"),
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
TIME_OPTIONS = []
|
| 81 |
+
for h in range(12):
|
| 82 |
+
for m in ["00", "30"]:
|
| 83 |
+
if h == 0:
|
| 84 |
+
TIME_OPTIONS.append(f"12:{m} AM")
|
| 85 |
+
else:
|
| 86 |
+
TIME_OPTIONS.append(f"{h}:{m} AM")
|
| 87 |
+
for h in range(12):
|
| 88 |
+
for m in ["00", "30"]:
|
| 89 |
+
if h == 0:
|
| 90 |
+
TIME_OPTIONS.append(f"12:{m} PM")
|
| 91 |
+
else:
|
| 92 |
+
TIME_OPTIONS.append(f"{h}:{m} PM")
|
| 93 |
+
|
| 94 |
+
WINDOW_COLORS = {
|
| 95 |
+
"RIGHT NOW": "#DC2626",
|
| 96 |
+
"THIS MORNING": "#EA580C",
|
| 97 |
+
"TODAY": "#F59E0B",
|
| 98 |
+
"AVOID": "#A78BFA",
|
| 99 |
}
|
| 100 |
|
| 101 |
+
|
| 102 |
+
def debt_tier(score: int) -> tuple[str, str, str]:
|
| 103 |
+
for lo, hi, color, verdict, _ in DEBT_TIERS:
|
| 104 |
+
if lo <= score < hi:
|
| 105 |
+
return color, verdict, DEBT_TIERS[DEBT_TIERS.index((lo, hi, color, verdict, _))][4]
|
| 106 |
+
return "#4ADE80", "You're clear. Minimal debt.", "low"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# βββ Custom CSS (off-brand dark theme) βββββββββββββββββββββββββββββββββββββββ
|
| 110 |
+
|
| 111 |
+
CUSTOM_CSS = f"""
|
| 112 |
+
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600;700&display=swap');
|
| 113 |
+
|
| 114 |
+
:root {{
|
| 115 |
+
--bg-base: {BG_BASE};
|
| 116 |
+
--bg-surface: {BG_SURFACE};
|
| 117 |
+
--bg-elevated: {BG_ELEVATED};
|
| 118 |
+
--border: {BORDER};
|
| 119 |
+
--border-soft: {BORDER_SOFT};
|
| 120 |
+
--text-primary: {TEXT_PRIMARY};
|
| 121 |
+
--text-secondary: {TEXT_SECONDARY};
|
| 122 |
+
--text-muted: {TEXT_MUTED};
|
| 123 |
+
--text-faint: {TEXT_FAINT};
|
| 124 |
+
--brand: {BRAND_PRIMARY};
|
| 125 |
+
--brand-secondary: {BRAND_SECONDARY};
|
| 126 |
+
--recovery-green: {RECOVERY_GREEN};
|
| 127 |
+
}}
|
| 128 |
+
|
| 129 |
+
body.light-mode {{
|
| 130 |
+
--bg-base: {LT_BG_BASE};
|
| 131 |
+
--bg-surface: {LT_BG_SURFACE};
|
| 132 |
+
--bg-elevated: {LT_BG_ELEVATED};
|
| 133 |
+
--border: {LT_BORDER};
|
| 134 |
+
--border-soft: {LT_BORDER_SOFT};
|
| 135 |
+
--text-primary: {LT_TEXT_PRIMARY};
|
| 136 |
+
--text-secondary: {LT_TEXT_SECONDARY};
|
| 137 |
+
--text-muted: {LT_TEXT_MUTED};
|
| 138 |
+
--text-faint: {LT_TEXT_FAINT};
|
| 139 |
+
--brand: {BRAND_PRIMARY};
|
| 140 |
+
--brand-secondary: {BRAND_SECONDARY};
|
| 141 |
+
--recovery-green: {RECOVERY_GREEN};
|
| 142 |
+
}}
|
| 143 |
+
|
| 144 |
+
html, body, .gradio-container {{
|
| 145 |
+
background: var(--bg-base) !important;
|
| 146 |
+
color: var(--text-primary) !important;
|
| 147 |
+
font-family: 'Inter', system-ui, sans-serif !important;
|
| 148 |
+
}}
|
| 149 |
+
|
| 150 |
+
.gradio-container {{ max-width: 1180px !important; padding: 0 24px 60px !important; }}
|
| 151 |
+
footer {{ display: none !important; }}
|
| 152 |
+
|
| 153 |
+
/* Hide default Gradio chrome we don't need */
|
| 154 |
+
.block.padded, .panel, .gap, .form {{ background: transparent !important; border: none !important; }}
|
| 155 |
+
|
| 156 |
+
/* The giant debt score number */
|
| 157 |
+
.debt-hero {{
|
| 158 |
+
font-family: 'DM Serif Display', Georgia, serif;
|
| 159 |
+
font-size: clamp(7rem, 22vw, 11rem);
|
| 160 |
+
font-weight: 400;
|
| 161 |
+
line-height: 0.9;
|
| 162 |
+
letter-spacing: -0.04em;
|
| 163 |
+
margin: 0;
|
| 164 |
+
text-shadow: 0 0 60px currentColor;
|
| 165 |
+
transition: color 0.6s ease;
|
| 166 |
+
animation: heroDrop 0.7s cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
| 167 |
+
}}
|
| 168 |
+
|
| 169 |
+
.debt-hero-label {{
|
| 170 |
+
font-family: 'Inter', sans-serif;
|
| 171 |
+
font-size: 10px;
|
| 172 |
+
font-weight: 700;
|
| 173 |
+
letter-spacing: 0.18em;
|
| 174 |
+
text-transform: uppercase;
|
| 175 |
+
color: var(--text-secondary);
|
| 176 |
+
margin-bottom: 8px;
|
| 177 |
+
animation: fadeUp 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.05s backwards;
|
| 178 |
+
}}
|
| 179 |
+
|
| 180 |
+
.debt-verdict {{
|
| 181 |
+
font-family: 'Inter', sans-serif;
|
| 182 |
+
font-size: 14px;
|
| 183 |
+
font-weight: 500;
|
| 184 |
+
color: var(--text-secondary);
|
| 185 |
+
margin-top: 6px;
|
| 186 |
+
animation: fadeUp 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.25s backwards;
|
| 187 |
+
}}
|
| 188 |
+
|
| 189 |
+
/* The breathing orb behind the score */
|
| 190 |
+
.orb-wrap {{
|
| 191 |
+
position: relative;
|
| 192 |
+
display: inline-block;
|
| 193 |
+
padding: 40px 60px 30px;
|
| 194 |
+
}}
|
| 195 |
+
|
| 196 |
+
.orb-wrap::before {{
|
| 197 |
+
content: '';
|
| 198 |
+
position: absolute;
|
| 199 |
+
inset: -30px;
|
| 200 |
+
background: radial-gradient(circle, currentColor 0%, transparent 65%);
|
| 201 |
+
opacity: 0.18;
|
| 202 |
+
border-radius: 50%;
|
| 203 |
+
animation: orbBreath 4s ease-in-out infinite;
|
| 204 |
+
z-index: -2;
|
| 205 |
+
}}
|
| 206 |
+
|
| 207 |
+
.orb-wrap::after {{
|
| 208 |
+
content: '';
|
| 209 |
+
position: absolute;
|
| 210 |
+
inset: -50px;
|
| 211 |
+
background: radial-gradient(circle, currentColor 0%, transparent 70%);
|
| 212 |
+
opacity: 0.06;
|
| 213 |
+
border-radius: 50%;
|
| 214 |
+
animation: orbBreath 4s ease-in-out infinite 0.5s;
|
| 215 |
+
z-index: -3;
|
| 216 |
+
filter: blur(8px);
|
| 217 |
+
}}
|
| 218 |
+
|
| 219 |
+
@keyframes orbBreath {{
|
| 220 |
+
0%, 100% {{ transform: scale(1); }}
|
| 221 |
+
50% {{ transform: scale(1.10); }}
|
| 222 |
+
}}
|
| 223 |
+
|
| 224 |
+
@keyframes heroDrop {{
|
| 225 |
+
0% {{ opacity: 0; transform: scale(0.6) translateY(8px); filter: blur(8px); }}
|
| 226 |
+
100% {{ opacity: 1; transform: scale(1) translateY(0); filter: blur(0); }}
|
| 227 |
+
}}
|
| 228 |
+
|
| 229 |
+
@keyframes fadeUp {{
|
| 230 |
+
0% {{ opacity: 0; transform: translateY(6px); }}
|
| 231 |
+
100% {{ opacity: 1; transform: translateY(0); }}
|
| 232 |
+
}}
|
| 233 |
+
|
| 234 |
+
/* Section labels */
|
| 235 |
+
.section-label {{
|
| 236 |
+
font-family: 'Inter', sans-serif;
|
| 237 |
+
font-size: 10px;
|
| 238 |
+
font-weight: 700;
|
| 239 |
+
letter-spacing: 0.18em;
|
| 240 |
+
text-transform: uppercase;
|
| 241 |
+
color: var(--text-muted);
|
| 242 |
+
margin: 0 0 12px;
|
| 243 |
+
display: flex;
|
| 244 |
+
align-items: center;
|
| 245 |
+
gap: 10px;
|
| 246 |
+
}}
|
| 247 |
+
|
| 248 |
+
.section-label::after {{
|
| 249 |
+
content: '';
|
| 250 |
+
flex: 1;
|
| 251 |
+
height: 1px;
|
| 252 |
+
background: var(--border-soft);
|
| 253 |
+
}}
|
| 254 |
+
|
| 255 |
+
/* System meter */
|
| 256 |
+
.sys-meter {{
|
| 257 |
+
padding: 10px 14px;
|
| 258 |
+
background: var(--bg-surface);
|
| 259 |
+
border: 1px solid var(--border);
|
| 260 |
+
border-radius: 12px;
|
| 261 |
+
margin-bottom: 8px;
|
| 262 |
+
display: flex;
|
| 263 |
+
align-items: center;
|
| 264 |
+
gap: 12px;
|
| 265 |
+
transition: border-color 0.2s, background 0.2s;
|
| 266 |
+
animation: fadeUp 0.5s cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
| 267 |
+
}}
|
| 268 |
+
.sys-meter:nth-child(1) {{ animation-delay: 0.05s; }}
|
| 269 |
+
.sys-meter:nth-child(2) {{ animation-delay: 0.10s; }}
|
| 270 |
+
.sys-meter:nth-child(3) {{ animation-delay: 0.15s; }}
|
| 271 |
+
.sys-meter:nth-child(4) {{ animation-delay: 0.20s; }}
|
| 272 |
+
.sys-meter:nth-child(5) {{ animation-delay: 0.25s; }}
|
| 273 |
+
.sys-meter.is-primary {{
|
| 274 |
+
background: linear-gradient(180deg, rgba(255,255,255,0.02), transparent);
|
| 275 |
+
}}
|
| 276 |
+
.sys-glyph {{
|
| 277 |
+
width: 28px;
|
| 278 |
+
height: 28px;
|
| 279 |
+
border-radius: 8px;
|
| 280 |
+
display: flex;
|
| 281 |
+
align-items: center;
|
| 282 |
+
justify-content: center;
|
| 283 |
+
font-family: 'JetBrains Mono', monospace;
|
| 284 |
+
font-size: 12px;
|
| 285 |
+
font-weight: 700;
|
| 286 |
+
flex-shrink: 0;
|
| 287 |
+
}}
|
| 288 |
+
.sys-body {{ flex: 1; min-width: 0; }}
|
| 289 |
+
.sys-row {{ display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }}
|
| 290 |
+
.sys-label {{ font-size: 13px; font-weight: 600; color: var(--text-primary); }}
|
| 291 |
+
.sys-time {{ font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-muted); }}
|
| 292 |
+
.sys-bar {{ margin-top: 8px; height: 3px; background: rgba(168, 162, 158, 0.10); border-radius: 2px; overflow: hidden; }}
|
| 293 |
+
.sys-bar-fill {{ height: 100%; border-radius: 2px; transition: width 0.7s cubic-bezier(0.22, 1, 0.36, 1); }}
|
| 294 |
+
.sys-cause {{ font-size: 11px; color: var(--text-muted); margin-top: 6px; line-height: 1.4; }}
|
| 295 |
+
|
| 296 |
+
/* Protocol step */
|
| 297 |
+
.proto-step {{ display: flex; gap: 12px; padding: 8px 0; }}
|
| 298 |
+
.proto-rail {{ display: flex; flex-direction: column; align-items: center; width: 30px; flex-shrink: 0; }}
|
| 299 |
+
.proto-num {{
|
| 300 |
+
width: 28px; height: 28px; border-radius: 50%;
|
| 301 |
+
display: flex; align-items: center; justify-content: center;
|
| 302 |
+
font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700;
|
| 303 |
+
border: 1px solid currentColor;
|
| 304 |
+
background: rgba(255,255,255,0.02);
|
| 305 |
+
}}
|
| 306 |
+
.proto-conn {{ flex: 1; width: 1px; background: var(--border); min-height: 18px; margin-top: 4px; }}
|
| 307 |
+
.proto-window {{
|
| 308 |
+
font-family: 'JetBrains Mono', monospace;
|
| 309 |
+
font-size: 9px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase;
|
| 310 |
+
margin-bottom: 4px;
|
| 311 |
+
}}
|
| 312 |
+
.proto-action {{
|
| 313 |
+
font-size: 13px; color: var(--text-primary); line-height: 1.55;
|
| 314 |
+
font-weight: 500;
|
| 315 |
+
}}
|
| 316 |
+
|
| 317 |
+
/* Science cards */
|
| 318 |
+
.sci-card {{
|
| 319 |
+
padding: 12px 14px;
|
| 320 |
+
background: var(--bg-elevated);
|
| 321 |
+
border-left: 2px solid currentColor;
|
| 322 |
+
border-radius: 0 8px 8px 0;
|
| 323 |
+
margin-bottom: 8px;
|
| 324 |
+
}}
|
| 325 |
+
.sci-fact {{ font-size: 12px; color: var(--text-secondary); line-height: 1.55; margin: 0 0 4px; }}
|
| 326 |
+
.sci-cite {{ font-size: 10px; color: var(--text-faint); font-style: italic; margin: 0; font-family: 'JetBrains Mono', monospace; }}
|
| 327 |
+
|
| 328 |
+
/* Face scan pill */
|
| 329 |
+
.face-pill {{
|
| 330 |
+
padding: 14px 16px;
|
| 331 |
+
background: var(--bg-surface);
|
| 332 |
+
border: 1px solid var(--border);
|
| 333 |
+
border-radius: 12px;
|
| 334 |
+
display: flex;
|
| 335 |
+
align-items: center;
|
| 336 |
+
gap: 16px;
|
| 337 |
+
margin-bottom: 16px;
|
| 338 |
+
}}
|
| 339 |
+
.face-pill-placeholder {{
|
| 340 |
+
padding: 20px;
|
| 341 |
+
background: var(--bg-surface);
|
| 342 |
+
border: 1.5px dashed var(--border);
|
| 343 |
+
border-radius: 12px;
|
| 344 |
+
text-align: center;
|
| 345 |
+
margin-bottom: 16px;
|
| 346 |
+
transition: border-color 0.2s;
|
| 347 |
+
}}
|
| 348 |
+
.face-pill-placeholder:hover {{
|
| 349 |
+
border-color: var(--brand);
|
| 350 |
+
}}
|
| 351 |
+
.face-pill-placeholder .icon {{ font-size: 28px; opacity: 0.4; margin-bottom: 6px; }}
|
| 352 |
+
.face-pill-placeholder .label {{ font-size: 11px; color: var(--text-muted); font-weight: 500; }}
|
| 353 |
+
.face-pill-placeholder .sub {{ font-size: 9px; color: var(--text-faint); margin-top: 2px; font-family: 'JetBrains Mono', monospace; }}
|
| 354 |
+
.face-num {{ font-family: 'DM Serif Display', serif; font-size: 36px; line-height: 1; }}
|
| 355 |
+
.face-label {{ font-size: 9px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-muted); }}
|
| 356 |
+
.face-status {{ font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 4px; }}
|
| 357 |
+
.face-meta {{ font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text-faint); margin-top: 4px; }}
|
| 358 |
+
.face-bar {{ margin-top: 8px; height: 4px; background: rgba(168, 162, 158, 0.08); border-radius: 2px; overflow: hidden; }}
|
| 359 |
+
.face-bar-fill {{ height: 100%; border-radius: 2px; transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); }}
|
| 360 |
+
|
| 361 |
+
/* Agent trace */
|
| 362 |
+
.trace-step {{
|
| 363 |
+
display: flex; align-items: center; gap: 10px;
|
| 364 |
+
padding: 6px 10px;
|
| 365 |
+
border-radius: 6px;
|
| 366 |
+
font-family: 'JetBrains Mono', monospace;
|
| 367 |
+
font-size: 11px;
|
| 368 |
+
color: var(--text-secondary);
|
| 369 |
+
margin-bottom: 4px;
|
| 370 |
+
}}
|
| 371 |
+
.trace-step.is-active {{ color: var(--brand); background: rgba(234, 88, 12, 0.06); }}
|
| 372 |
+
.trace-step.is-done {{ color: var(--recovery-green); }}
|
| 373 |
+
.trace-dot {{ width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex-shrink: 0; }}
|
| 374 |
+
.trace-step.is-active .trace-dot {{ animation: pulse 1.2s ease-in-out infinite; }}
|
| 375 |
+
@keyframes pulse {{ 0%,100% {{ opacity: 1; }} 50% {{ opacity: 0.3; }} }}
|
| 376 |
+
|
| 377 |
+
/* AI coach block */
|
| 378 |
+
.coach-block {{
|
| 379 |
+
padding: 20px 22px;
|
| 380 |
+
background: linear-gradient(180deg, var(--bg-surface), var(--bg-base));
|
| 381 |
+
border: 1px solid var(--border);
|
| 382 |
+
border-radius: 14px;
|
| 383 |
+
margin-top: 8px;
|
| 384 |
+
}}
|
| 385 |
+
.coach-header {{
|
| 386 |
+
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
| 387 |
+
font-family: 'JetBrains Mono', monospace;
|
| 388 |
+
font-size: 9px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase;
|
| 389 |
+
color: var(--brand);
|
| 390 |
+
}}
|
| 391 |
+
.coach-pill {{
|
| 392 |
+
background: var(--bg-elevated);
|
| 393 |
+
color: var(--text-muted);
|
| 394 |
+
padding: 3px 8px;
|
| 395 |
+
border-radius: 4px;
|
| 396 |
+
font-size: 9px;
|
| 397 |
+
}}
|
| 398 |
+
.coach-body {{
|
| 399 |
+
font-size: 14px;
|
| 400 |
+
color: var(--text-primary);
|
| 401 |
+
line-height: 1.7;
|
| 402 |
+
white-space: pre-wrap;
|
| 403 |
+
min-height: 60px;
|
| 404 |
+
}}
|
| 405 |
+
.coach-cursor {{
|
| 406 |
+
display: inline-block;
|
| 407 |
+
width: 7px; height: 14px;
|
| 408 |
+
background: var(--brand);
|
| 409 |
+
margin-left: 2px;
|
| 410 |
+
vertical-align: text-bottom;
|
| 411 |
+
animation: blink 1s steps(2, start) infinite;
|
| 412 |
+
}}
|
| 413 |
+
@keyframes blink {{ to {{ visibility: hidden; }} }}
|
| 414 |
+
|
| 415 |
+
/* Buttons */
|
| 416 |
+
button.primary, .gr-button-primary {{
|
| 417 |
+
background: var(--brand) !important;
|
| 418 |
+
color: white !important;
|
| 419 |
+
border: none !important;
|
| 420 |
+
border-radius: 10px !important;
|
| 421 |
+
font-weight: 700 !important;
|
| 422 |
+
letter-spacing: 0.02em !important;
|
| 423 |
+
transition: transform 0.15s, box-shadow 0.15s !important;
|
| 424 |
+
}}
|
| 425 |
+
button.primary:hover, .gr-button-primary:hover {{
|
| 426 |
+
transform: translateY(-1px) !important;
|
| 427 |
+
box-shadow: 0 6px 20px rgba(234, 88, 12, 0.35) !important;
|
| 428 |
+
}}
|
| 429 |
+
|
| 430 |
+
/* Inputs */
|
| 431 |
+
input, textarea, .gr-input, .gr-text-input, .gr-dropdown {{
|
| 432 |
+
background: var(--bg-surface) !important;
|
| 433 |
+
border: 1px solid var(--border) !important;
|
| 434 |
+
color: var(--text-primary) !important;
|
| 435 |
+
border-radius: 8px !important;
|
| 436 |
+
}}
|
| 437 |
+
input:focus, textarea:focus {{ border-color: var(--brand) !important; outline: none !important; }}
|
| 438 |
+
|
| 439 |
+
/* Checkbox */
|
| 440 |
+
.gr-checkbox {{ background: transparent !important; }}
|
| 441 |
+
.gr-checkbox input[type="checkbox"] {{
|
| 442 |
+
appearance: none;
|
| 443 |
+
width: 18px; height: 18px;
|
| 444 |
+
border: 1.5px solid var(--text-muted);
|
| 445 |
+
border-radius: 4px;
|
| 446 |
+
background: var(--bg-surface);
|
| 447 |
+
cursor: pointer;
|
| 448 |
+
position: relative;
|
| 449 |
+
transition: all 0.15s;
|
| 450 |
+
}}
|
| 451 |
+
.gr-checkbox input[type="checkbox"]:checked {{
|
| 452 |
+
background: var(--brand);
|
| 453 |
+
border-color: var(--brand);
|
| 454 |
+
}}
|
| 455 |
+
.gr-checkbox input[type="checkbox"]:checked::after {{
|
| 456 |
+
content: 'β';
|
| 457 |
+
color: white;
|
| 458 |
+
position: absolute;
|
| 459 |
+
top: -2px; left: 3px;
|
| 460 |
+
font-size: 14px; font-weight: 800;
|
| 461 |
+
}}
|
| 462 |
+
.gr-checkbox label {{ color: var(--text-primary) !important; font-weight: 500 !important; }}
|
| 463 |
+
|
| 464 |
+
/* Care checkbox β green when checked (positive stressor) */
|
| 465 |
+
#care-checkbox input[type="checkbox"]:checked {{
|
| 466 |
+
background: var(--recovery-green) !important;
|
| 467 |
+
border-color: var(--recovery-green) !important;
|
| 468 |
+
}}
|
| 469 |
+
#care-checkbox label {{ color: var(--recovery-green) !important; }}
|
| 470 |
+
|
| 471 |
+
/* Image upload area */
|
| 472 |
+
.gr-image, .gr-image-upload {{
|
| 473 |
+
background: var(--bg-surface) !important;
|
| 474 |
+
border: 1px dashed var(--border) !important;
|
| 475 |
+
border-radius: 10px !important;
|
| 476 |
+
}}
|
| 477 |
+
|
| 478 |
+
/* Empty state */
|
| 479 |
+
.empty-state {{
|
| 480 |
+
text-align: center;
|
| 481 |
+
padding: 80px 20px;
|
| 482 |
+
color: var(--text-faint);
|
| 483 |
+
}}
|
| 484 |
+
.empty-state .icon {{ font-size: 48px; opacity: 0.3; margin-bottom: 12px; }}
|
| 485 |
+
.empty-state .label {{ font-size: 14px; font-weight: 500; color: var(--text-muted); }}
|
| 486 |
+
.empty-state .sub {{ font-size: 12px; color: var(--text-faint); margin-top: 4px; }}
|
| 487 |
+
|
| 488 |
+
/* Hide group labels for cleaner look */
|
| 489 |
+
.gr-group {{ background: transparent !important; border: none !important; }}
|
| 490 |
+
.gr-form {{ background: transparent !important; }}
|
| 491 |
+
|
| 492 |
+
/* App header */
|
| 493 |
+
.app-header {{ text-align: center; padding: 36px 0 28px; border-bottom: 1px solid var(--border-soft); margin-bottom: 28px; }}
|
| 494 |
+
.app-title {{
|
| 495 |
+
font-family: 'Inter', sans-serif;
|
| 496 |
+
font-weight: 800;
|
| 497 |
+
letter-spacing: 0.22em;
|
| 498 |
+
text-transform: uppercase;
|
| 499 |
+
font-size: 12px;
|
| 500 |
+
color: var(--text-primary);
|
| 501 |
+
margin: 0 0 6px;
|
| 502 |
+
}}
|
| 503 |
+
.app-subtitle {{
|
| 504 |
+
font-family: 'Inter', sans-serif;
|
| 505 |
+
font-size: 13px;
|
| 506 |
+
color: var(--text-muted);
|
| 507 |
+
margin: 0;
|
| 508 |
+
max-width: 480px;
|
| 509 |
+
margin: 0 auto;
|
| 510 |
+
}}
|
| 511 |
+
|
| 512 |
+
/* Footer */
|
| 513 |
+
.app-footer {{
|
| 514 |
+
text-align: center;
|
| 515 |
+
padding: 28px 16px 16px;
|
| 516 |
+
margin-top: 36px;
|
| 517 |
+
border-top: 1px solid var(--border-soft);
|
| 518 |
+
font-size: 10px;
|
| 519 |
+
color: var(--text-faint);
|
| 520 |
+
font-family: 'JetBrains Mono', monospace;
|
| 521 |
+
}}
|
| 522 |
+
.app-footer a {{ color: var(--brand); text-decoration: none; }}
|
| 523 |
+
|
| 524 |
+
/* Header attribution pills */
|
| 525 |
+
.attr-row {{
|
| 526 |
+
display: flex;
|
| 527 |
+
align-items: center;
|
| 528 |
+
justify-content: center;
|
| 529 |
+
gap: 8px;
|
| 530 |
+
margin-top: 14px;
|
| 531 |
+
flex-wrap: wrap;
|
| 532 |
+
}}
|
| 533 |
+
.attr-pill {{
|
| 534 |
+
display: inline-flex;
|
| 535 |
+
align-items: center;
|
| 536 |
+
gap: 6px;
|
| 537 |
+
padding: 4px 10px;
|
| 538 |
+
border: 1px solid var(--border);
|
| 539 |
+
border-radius: 999px;
|
| 540 |
+
font-family: 'JetBrains Mono', monospace;
|
| 541 |
+
font-size: 10px;
|
| 542 |
+
font-weight: 600;
|
| 543 |
+
color: var(--text-secondary);
|
| 544 |
+
background: var(--bg-surface);
|
| 545 |
+
text-decoration: none;
|
| 546 |
+
transition: border-color 0.15s, color 0.15s;
|
| 547 |
+
}}
|
| 548 |
+
.attr-pill:hover {{
|
| 549 |
+
border-color: var(--brand);
|
| 550 |
+
color: var(--text-primary);
|
| 551 |
+
}}
|
| 552 |
+
.attr-pill .dot {{
|
| 553 |
+
width: 6px; height: 6px;
|
| 554 |
+
border-radius: 50%;
|
| 555 |
+
background: var(--recovery-green);
|
| 556 |
+
box-shadow: 0 0 8px var(--recovery-green);
|
| 557 |
+
}}
|
| 558 |
+
|
| 559 |
+
/* Ready pulse for empty coach/trace */
|
| 560 |
+
.ready-pulse {{
|
| 561 |
+
display: inline-block;
|
| 562 |
+
width: 6px; height: 6px;
|
| 563 |
+
border-radius: 50%;
|
| 564 |
+
background: var(--brand);
|
| 565 |
+
margin-right: 8px;
|
| 566 |
+
animation: pulse 1.6s ease-in-out infinite;
|
| 567 |
+
}}
|
| 568 |
+
|
| 569 |
+
/* Triage plan */
|
| 570 |
+
.plan-block {{
|
| 571 |
+
margin-bottom: 20px;
|
| 572 |
+
animation: fadeUp 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.15s backwards;
|
| 573 |
+
}}
|
| 574 |
+
.plan-source {{
|
| 575 |
+
font-family: 'JetBrains Mono', monospace;
|
| 576 |
+
font-size: 9px;
|
| 577 |
+
color: var(--text-faint);
|
| 578 |
+
font-weight: 600;
|
| 579 |
+
margin-left: auto;
|
| 580 |
+
text-transform: none;
|
| 581 |
+
letter-spacing: 0.1em;
|
| 582 |
+
}}
|
| 583 |
+
.plan-line {{
|
| 584 |
+
display: flex;
|
| 585 |
+
align-items: center;
|
| 586 |
+
gap: 12px;
|
| 587 |
+
padding: 9px 0;
|
| 588 |
+
border-bottom: 1px solid var(--border-soft);
|
| 589 |
+
animation: fadeUp 0.4s cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
| 590 |
+
}}
|
| 591 |
+
.plan-line:last-child {{ border-bottom: none; }}
|
| 592 |
+
.plan-tag {{
|
| 593 |
+
font-family: 'JetBrains Mono', monospace;
|
| 594 |
+
font-size: 9px;
|
| 595 |
+
font-weight: 800;
|
| 596 |
+
letter-spacing: 0.14em;
|
| 597 |
+
padding: 3px 8px;
|
| 598 |
+
border: 1px solid;
|
| 599 |
+
border-radius: 4px;
|
| 600 |
+
flex-shrink: 0;
|
| 601 |
+
min-width: 78px;
|
| 602 |
+
text-align: center;
|
| 603 |
+
}}
|
| 604 |
+
.plan-text {{
|
| 605 |
+
font-family: 'Inter', sans-serif;
|
| 606 |
+
font-size: 13px;
|
| 607 |
+
font-weight: 500;
|
| 608 |
+
color: var(--text-primary);
|
| 609 |
+
line-height: 1.4;
|
| 610 |
+
}}
|
| 611 |
+
|
| 612 |
+
/* Counterfactual hint */
|
| 613 |
+
.cf-block {{
|
| 614 |
+
display: flex;
|
| 615 |
+
align-items: flex-start;
|
| 616 |
+
gap: 12px;
|
| 617 |
+
padding: 12px 16px;
|
| 618 |
+
background: var(--bg-surface);
|
| 619 |
+
border: 1px solid var(--border);
|
| 620 |
+
border-left: 2px solid;
|
| 621 |
+
border-radius: 0 10px 10px 0;
|
| 622 |
+
margin: 16px 0;
|
| 623 |
+
animation: fadeUp 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.2s backwards;
|
| 624 |
+
}}
|
| 625 |
+
.cf-label {{
|
| 626 |
+
font-family: 'JetBrains Mono', monospace;
|
| 627 |
+
font-size: 9px;
|
| 628 |
+
font-weight: 800;
|
| 629 |
+
letter-spacing: 0.16em;
|
| 630 |
+
flex-shrink: 0;
|
| 631 |
+
padding-top: 2px;
|
| 632 |
+
min-width: 140px;
|
| 633 |
+
}}
|
| 634 |
+
.cf-body {{
|
| 635 |
+
font-size: 13px;
|
| 636 |
+
color: var(--text-secondary);
|
| 637 |
+
line-height: 1.55;
|
| 638 |
+
}}
|
| 639 |
+
|
| 640 |
+
/* Running debt pill */
|
| 641 |
+
.debt-pill {{
|
| 642 |
+
display: inline-flex;
|
| 643 |
+
align-items: center;
|
| 644 |
+
gap: 8px;
|
| 645 |
+
padding: 5px 14px;
|
| 646 |
+
background: var(--bg-elevated);
|
| 647 |
+
border: 1px solid var(--border);
|
| 648 |
+
border-radius: 999px;
|
| 649 |
+
font-family: 'JetBrains Mono', monospace;
|
| 650 |
+
font-size: 11px;
|
| 651 |
+
transition: all 0.15s;
|
| 652 |
+
margin-bottom: 14px;
|
| 653 |
+
}}
|
| 654 |
+
|
| 655 |
+
/* Preset scenario chips */
|
| 656 |
+
.preset-row {{
|
| 657 |
+
display: flex;
|
| 658 |
+
gap: 6px;
|
| 659 |
+
flex-wrap: wrap;
|
| 660 |
+
margin-bottom: 18px;
|
| 661 |
+
}}
|
| 662 |
+
.preset-chip {{
|
| 663 |
+
font-family: 'JetBrains Mono', monospace;
|
| 664 |
+
font-size: 10px !important;
|
| 665 |
+
font-weight: 700 !important;
|
| 666 |
+
padding: 5px 14px !important;
|
| 667 |
+
border-radius: 999px !important;
|
| 668 |
+
border: 1px solid var(--border) !important;
|
| 669 |
+
background: var(--bg-surface) !important;
|
| 670 |
+
color: var(--text-secondary) !important;
|
| 671 |
+
letter-spacing: 0.04em !important;
|
| 672 |
+
transition: all 0.15s !important;
|
| 673 |
+
box-shadow: none !important;
|
| 674 |
+
}}
|
| 675 |
+
.preset-chip:hover {{
|
| 676 |
+
border-color: var(--brand) !important;
|
| 677 |
+
color: var(--text-primary) !important;
|
| 678 |
+
background: rgba(234, 88, 12, 0.06) !important;
|
| 679 |
+
}}
|
| 680 |
+
|
| 681 |
+
/* Clear all button */
|
| 682 |
+
button.clear-all {{
|
| 683 |
+
font-family: 'JetBrains Mono', monospace !important;
|
| 684 |
+
font-size: 10px !important;
|
| 685 |
+
font-weight: 600 !important;
|
| 686 |
+
padding: 4px 12px !important;
|
| 687 |
+
border: 1px solid var(--border) !important;
|
| 688 |
+
border-radius: 8px !important;
|
| 689 |
+
background: transparent !important;
|
| 690 |
+
color: var(--text-muted) !important;
|
| 691 |
+
transition: all 0.15s !important;
|
| 692 |
+
margin-bottom: 8px !important;
|
| 693 |
+
}}
|
| 694 |
+
button.clear-all:hover {{
|
| 695 |
+
border-color: var(--text-faint) !important;
|
| 696 |
+
color: var(--text-secondary) !important;
|
| 697 |
+
}}
|
| 698 |
+
|
| 699 |
+
/* Sample badge */
|
| 700 |
+
.sample-badge {{
|
| 701 |
+
font-family: 'JetBrains Mono', monospace;
|
| 702 |
+
font-size: 9px;
|
| 703 |
+
font-weight: 700;
|
| 704 |
+
letter-spacing: 0.14em;
|
| 705 |
+
text-transform: uppercase;
|
| 706 |
+
color: var(--text-muted);
|
| 707 |
+
margin-bottom: 12px;
|
| 708 |
+
display: flex;
|
| 709 |
+
align-items: center;
|
| 710 |
+
gap: 8px;
|
| 711 |
+
}}
|
| 712 |
+
.sample-badge .dot {{
|
| 713 |
+
width: 5px; height: 5px;
|
| 714 |
+
border-radius: 50%;
|
| 715 |
+
background: var(--brand);
|
| 716 |
+
animation: pulse 1.6s ease-in-out infinite;
|
| 717 |
+
}}
|
| 718 |
+
|
| 719 |
+
/* Theme toggle button */
|
| 720 |
+
.theme-toggle {{
|
| 721 |
+
position: fixed;
|
| 722 |
+
top: 16px;
|
| 723 |
+
right: 20px;
|
| 724 |
+
z-index: 9999;
|
| 725 |
+
width: 36px;
|
| 726 |
+
height: 36px;
|
| 727 |
+
border-radius: 50%;
|
| 728 |
+
border: 1px solid var(--border);
|
| 729 |
+
background: var(--bg-surface);
|
| 730 |
+
color: var(--text-secondary);
|
| 731 |
+
font-size: 16px;
|
| 732 |
+
cursor: pointer;
|
| 733 |
+
display: flex;
|
| 734 |
+
align-items: center;
|
| 735 |
+
justify-content: center;
|
| 736 |
+
transition: all 0.2s;
|
| 737 |
+
backdrop-filter: blur(8px);
|
| 738 |
+
}}
|
| 739 |
+
.theme-toggle:hover {{
|
| 740 |
+
border-color: var(--brand);
|
| 741 |
+
color: var(--brand);
|
| 742 |
+
transform: scale(1.08);
|
| 743 |
+
}}
|
| 744 |
+
|
| 745 |
+
/* Smooth theme transitions β 0.2s matches existing hover rhythms */
|
| 746 |
+
.gradio-container,
|
| 747 |
+
.sys-meter, .coach-block, .coach-pill, .tl-wrap, .face-pill, .face-pill-placeholder, .cf-block,
|
| 748 |
+
.debt-pill, .attr-pill,
|
| 749 |
+
input, textarea, .gr-input, .gr-text-input, .gr-dropdown,
|
| 750 |
+
.gr-checkbox input[type="checkbox"]:not(:checked),
|
| 751 |
+
.gr-checkbox label,
|
| 752 |
+
.theme-toggle,
|
| 753 |
+
.sci-card, .trace-step,
|
| 754 |
+
.plan-line, .plan-tag,
|
| 755 |
+
.sample-badge, .app-header, .app-footer,
|
| 756 |
+
.section-label, .section-label::after,
|
| 757 |
+
.debt-hero-label, .debt-verdict,
|
| 758 |
+
.sys-label, .sys-time, .sys-cause, .sys-bar-fill,
|
| 759 |
+
.proto-conn, .proto-action,
|
| 760 |
+
.face-label, .face-meta,
|
| 761 |
+
.plan-source, .plan-text,
|
| 762 |
+
.cf-label, .cf-body,
|
| 763 |
+
.empty-state .label, .empty-state .sub,
|
| 764 |
+
.orb-wrap::before, .orb-wrap::after,
|
| 765 |
+
#care-checkbox input[type="checkbox"]:not(:checked) {{
|
| 766 |
+
transition: background 0.2s ease,
|
| 767 |
+
color 0.2s ease,
|
| 768 |
+
border-color 0.2s ease;
|
| 769 |
+
}}
|
| 770 |
+
|
| 771 |
+
/* These have !important on their existing transitions, so !important needed here too */
|
| 772 |
+
.preset-chip,
|
| 773 |
+
button.clear-all,
|
| 774 |
+
button.primary, .gr-button-primary {{
|
| 775 |
+
transition: background 0.2s ease,
|
| 776 |
+
color 0.2s ease,
|
| 777 |
+
border-color 0.2s ease !important;
|
| 778 |
+
}}
|
| 779 |
+
|
| 780 |
+
/* Comparison card carousel */
|
| 781 |
+
.cmp-section {{ margin-top: 28px; }}
|
| 782 |
+
.cmp-row {{
|
| 783 |
+
display: flex;
|
| 784 |
+
gap: 10px;
|
| 785 |
+
overflow-x: auto;
|
| 786 |
+
padding: 4px 0 12px;
|
| 787 |
+
scroll-snap-type: x mandatory;
|
| 788 |
+
-webkit-overflow-scrolling: touch;
|
| 789 |
+
}}
|
| 790 |
+
.cmp-card {{
|
| 791 |
+
min-width: 170px;
|
| 792 |
+
max-width: 200px;
|
| 793 |
+
flex-shrink: 0;
|
| 794 |
+
scroll-snap-align: start;
|
| 795 |
+
padding: 12px 14px;
|
| 796 |
+
background: var(--bg-surface);
|
| 797 |
+
border: 1px solid var(--border);
|
| 798 |
+
border-radius: 12px;
|
| 799 |
+
position: relative;
|
| 800 |
+
}}
|
| 801 |
+
.cmp-hero {{
|
| 802 |
+
font-family: 'DM Serif Display', Georgia, serif;
|
| 803 |
+
font-size: 30px;
|
| 804 |
+
line-height: 1;
|
| 805 |
+
margin-bottom: 2px;
|
| 806 |
+
}}
|
| 807 |
+
.cmp-label {{
|
| 808 |
+
font-family: 'JetBrains Mono', monospace;
|
| 809 |
+
font-size: 9px;
|
| 810 |
+
font-weight: 700;
|
| 811 |
+
letter-spacing: 0.1em;
|
| 812 |
+
text-transform: uppercase;
|
| 813 |
+
color: var(--text-secondary);
|
| 814 |
+
}}
|
| 815 |
+
.cmp-time {{
|
| 816 |
+
font-family: 'JetBrains Mono', monospace;
|
| 817 |
+
font-size: 8px;
|
| 818 |
+
color: var(--text-faint);
|
| 819 |
+
margin-top: 2px;
|
| 820 |
+
margin-bottom: 8px;
|
| 821 |
+
}}
|
| 822 |
+
.cmp-sys {{
|
| 823 |
+
display: flex;
|
| 824 |
+
align-items: center;
|
| 825 |
+
gap: 5px;
|
| 826 |
+
margin-top: 5px;
|
| 827 |
+
font-size: 10px;
|
| 828 |
+
}}
|
| 829 |
+
.cmp-sys-glyph {{
|
| 830 |
+
width: 16px;
|
| 831 |
+
font-family: 'JetBrains Mono', monospace;
|
| 832 |
+
font-weight: 700;
|
| 833 |
+
flex-shrink: 0;
|
| 834 |
+
}}
|
| 835 |
+
.cmp-sys-bar {{
|
| 836 |
+
flex: 1;
|
| 837 |
+
height: 3px;
|
| 838 |
+
background: rgba(168, 162, 158, 0.10);
|
| 839 |
+
border-radius: 2px;
|
| 840 |
+
overflow: hidden;
|
| 841 |
+
}}
|
| 842 |
+
.cmp-sys-fill {{
|
| 843 |
+
height: 100%;
|
| 844 |
+
border-radius: 2px;
|
| 845 |
+
transition: width 0.4s ease;
|
| 846 |
+
}}
|
| 847 |
+
.cmp-del {{
|
| 848 |
+
position: absolute;
|
| 849 |
+
top: 4px;
|
| 850 |
+
right: 6px;
|
| 851 |
+
width: 20px;
|
| 852 |
+
height: 20px;
|
| 853 |
+
border-radius: 50%;
|
| 854 |
+
border: none;
|
| 855 |
+
background: transparent;
|
| 856 |
+
color: var(--text-faint);
|
| 857 |
+
font-size: 13px;
|
| 858 |
+
cursor: pointer;
|
| 859 |
+
display: flex;
|
| 860 |
+
align-items: center;
|
| 861 |
+
justify-content: center;
|
| 862 |
+
transition: all 0.15s;
|
| 863 |
+
font-family: system-ui, sans-serif;
|
| 864 |
+
}}
|
| 865 |
+
.cmp-del:hover {{
|
| 866 |
+
background: rgba(244, 63, 94, 0.15);
|
| 867 |
+
color: #F43F5E;
|
| 868 |
+
}}
|
| 869 |
+
.cmp-empty {{
|
| 870 |
+
text-align: center;
|
| 871 |
+
padding: 24px;
|
| 872 |
+
color: var(--text-faint);
|
| 873 |
+
font-size: 12px;
|
| 874 |
+
}}
|
| 875 |
+
|
| 876 |
+
/* Timeline chart */
|
| 877 |
+
.tl-wrap {{
|
| 878 |
+
margin: 16px 0;
|
| 879 |
+
padding: 14px 16px;
|
| 880 |
+
background: var(--bg-surface);
|
| 881 |
+
border: 1px solid var(--border);
|
| 882 |
+
border-radius: 12px;
|
| 883 |
+
}}
|
| 884 |
+
.tl-label {{
|
| 885 |
+
font-family: 'JetBrains Mono', monospace;
|
| 886 |
+
font-size: 7px;
|
| 887 |
+
font-weight: 700;
|
| 888 |
+
letter-spacing: 0.12em;
|
| 889 |
+
text-transform: uppercase;
|
| 890 |
+
color: var(--text-faint);
|
| 891 |
+
min-width: 32px;
|
| 892 |
+
flex-shrink: 0;
|
| 893 |
+
}}
|
| 894 |
+
.tl-row {{
|
| 895 |
+
display: flex;
|
| 896 |
+
align-items: center;
|
| 897 |
+
gap: 6px;
|
| 898 |
+
padding: 3px 0;
|
| 899 |
+
}}
|
| 900 |
+
.tl-bar-wrap {{
|
| 901 |
+
flex: 1;
|
| 902 |
+
height: 10px;
|
| 903 |
+
background: rgba(168, 162, 158, 0.06);
|
| 904 |
+
border-radius: 5px;
|
| 905 |
+
overflow: hidden;
|
| 906 |
+
position: relative;
|
| 907 |
+
}}
|
| 908 |
+
.tl-bar {{
|
| 909 |
+
height: 100%;
|
| 910 |
+
border-radius: 5px;
|
| 911 |
+
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
| 912 |
+
}}
|
| 913 |
+
.tl-bar-stack {{
|
| 914 |
+
height: 100%;
|
| 915 |
+
display: flex;
|
| 916 |
+
border-radius: 5px;
|
| 917 |
+
overflow: hidden;
|
| 918 |
+
}}
|
| 919 |
+
.tl-seg {{
|
| 920 |
+
height: 100%;
|
| 921 |
+
transition: flex 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
| 922 |
+
}}
|
| 923 |
+
.tl-seg:first-child {{ border-radius: 5px 0 0 5px; }}
|
| 924 |
+
.tl-seg:last-child {{ border-radius: 0 5px 5px 0; }}
|
| 925 |
+
.tl-seg:only-child {{ border-radius: 5px; }}
|
| 926 |
+
.tl-score {{
|
| 927 |
+
font-family: 'JetBrains Mono', monospace;
|
| 928 |
+
font-size: 9px;
|
| 929 |
+
font-weight: 700;
|
| 930 |
+
min-width: 22px;
|
| 931 |
+
text-align: right;
|
| 932 |
+
flex-shrink: 0;
|
| 933 |
+
}}
|
| 934 |
+
.tl-now {{
|
| 935 |
+
display: inline-block;
|
| 936 |
+
font-size: 7px;
|
| 937 |
+
font-weight: 800;
|
| 938 |
+
letter-spacing: 0.1em;
|
| 939 |
+
color: var(--brand);
|
| 940 |
+
margin-left: 4px;
|
| 941 |
+
}}
|
| 942 |
+
.tl-dom {{
|
| 943 |
+
font-family: 'JetBrains Mono', monospace;
|
| 944 |
+
font-size: 8px;
|
| 945 |
+
font-weight: 800;
|
| 946 |
+
min-width: 16px;
|
| 947 |
+
text-align: center;
|
| 948 |
+
flex-shrink: 0;
|
| 949 |
+
}}
|
| 950 |
+
/* Timeline legend */
|
| 951 |
+
.tl-legend {{
|
| 952 |
+
display: flex;
|
| 953 |
+
gap: 10px;
|
| 954 |
+
flex-wrap: wrap;
|
| 955 |
+
margin-bottom: 10px;
|
| 956 |
+
padding-bottom: 10px;
|
| 957 |
+
border-bottom: 1px solid var(--border-soft);
|
| 958 |
+
}}
|
| 959 |
+
.tl-leg {{
|
| 960 |
+
font-family: 'JetBrains Mono', monospace;
|
| 961 |
+
font-size: 8px;
|
| 962 |
+
font-weight: 600;
|
| 963 |
+
display: inline-flex;
|
| 964 |
+
align-items: center;
|
| 965 |
+
gap: 4px;
|
| 966 |
+
}}
|
| 967 |
+
.tl-leg-swatch {{
|
| 968 |
+
width: 8px;
|
| 969 |
+
height: 8px;
|
| 970 |
+
border-radius: 2px;
|
| 971 |
+
display: inline-block;
|
| 972 |
+
flex-shrink: 0;
|
| 973 |
+
}}
|
| 974 |
+
|
| 975 |
+
/* Save compare button */
|
| 976 |
+
.save-compare {{
|
| 977 |
+
font-family: 'JetBrains Mono', monospace !important;
|
| 978 |
+
font-size: 10px !important;
|
| 979 |
+
font-weight: 700 !important;
|
| 980 |
+
padding: 5px 12px !important;
|
| 981 |
+
border-radius: 8px !important;
|
| 982 |
+
border: 1px solid var(--border) !important;
|
| 983 |
+
background: transparent !important;
|
| 984 |
+
color: var(--text-secondary) !important;
|
| 985 |
+
transition: all 0.15s !important;
|
| 986 |
+
}}
|
| 987 |
+
.save-compare:hover {{
|
| 988 |
+
border-color: var(--brand) !important;
|
| 989 |
+
color: var(--brand) !important;
|
| 990 |
+
}}
|
| 991 |
+
|
| 992 |
+
/* Mobile / narrow viewport */
|
| 993 |
+
@media (max-width: 900px) {{
|
| 994 |
+
.gradio-container {{ padding: 0 16px 40px !important; }}
|
| 995 |
+
.app-header {{ padding: 24px 0 20px; margin-bottom: 18px; }}
|
| 996 |
+
.debt-hero {{ font-size: clamp(5.5rem, 28vw, 8rem); }}
|
| 997 |
+
.orb-wrap {{ padding: 28px 20px 22px; }}
|
| 998 |
+
.theme-toggle {{ top: 12px; right: 12px; width: 32px; height: 32px; font-size: 14px; }}
|
| 999 |
+
.gradio-row > div {{ flex-wrap: wrap !important; }}
|
| 1000 |
+
}}
|
| 1001 |
+
@media (max-width: 600px) {{
|
| 1002 |
+
.gradio-container {{ padding: 0 10px 28px !important; }}
|
| 1003 |
+
.app-header {{ padding: 18px 0 16px; margin-bottom: 14px; }}
|
| 1004 |
+
.app-title {{ font-size: 10px; }}
|
| 1005 |
+
.app-subtitle {{ font-size: 11px; }}
|
| 1006 |
+
.attr-pill {{ font-size: 8px; padding: 2px 8px; }}
|
| 1007 |
+
.debt-hero {{ font-size: clamp(3.8rem, 24vw, 5.5rem) !important; }}
|
| 1008 |
+
.debt-hero-label {{ font-size: 8px; }}
|
| 1009 |
+
.debt-verdict {{ font-size: 12px; }}
|
| 1010 |
+
.orb-wrap {{ padding: 20px 12px 16px; }}
|
| 1011 |
+
.orb-wrap::before {{ inset: -20px; }}
|
| 1012 |
+
.orb-wrap::after {{ inset: -35px; }}
|
| 1013 |
+
.section-label {{ font-size: 8px; margin: 0 0 8px; }}
|
| 1014 |
+
.sys-meter {{ padding: 8px 10px; gap: 8px; }}
|
| 1015 |
+
.sys-glyph {{ width: 22px; height: 22px; font-size: 10px; }}
|
| 1016 |
+
.sys-label {{ font-size: 11px; }}
|
| 1017 |
+
.sys-time {{ font-size: 8px; }}
|
| 1018 |
+
.sys-cause {{ font-size: 10px; }}
|
| 1019 |
+
.coach-block {{ padding: 14px 16px; }}
|
| 1020 |
+
.coach-body {{ font-size: 12px; min-height: 40px; }}
|
| 1021 |
+
.proto-action {{ font-size: 11px; }}
|
| 1022 |
+
.proto-step {{ gap: 8px; }}
|
| 1023 |
+
.preset-chip {{ font-size: 9px !important; padding: 4px 10px !important; }}
|
| 1024 |
+
.debt-pill {{ font-size: 9px; padding: 4px 10px; }}
|
| 1025 |
+
.face-pill {{ padding: 10px 12px; gap: 10px; }}
|
| 1026 |
+
.face-num {{ font-size: 28px; }}
|
| 1027 |
+
.cf-block {{ padding: 10px 12px; flex-direction: column; gap: 6px; }}
|
| 1028 |
+
.cf-label {{ min-width: auto; font-size: 8px; }}
|
| 1029 |
+
.cf-body {{ font-size: 11px; }}
|
| 1030 |
+
.plan-line {{ gap: 8px; padding: 7px 0; }}
|
| 1031 |
+
.plan-tag {{ font-size: 8px; min-width: 64px; padding: 2px 6px; }}
|
| 1032 |
+
.plan-text {{ font-size: 11px; }}
|
| 1033 |
+
.trace-step {{ font-size: 9px; padding: 4px 8px; }}
|
| 1034 |
+
.tl-legend {{ gap: 6px; }}
|
| 1035 |
+
.tl-leg {{ font-size: 7px; }}
|
| 1036 |
+
.tl-leg-swatch {{ width: 6px; height: 6px; }}
|
| 1037 |
+
.tl-dom {{ font-size: 7px; min-width: 12px; }}
|
| 1038 |
+
.cmp-card {{ min-width: 140px; max-width: 160px; padding: 10px 10px; }}
|
| 1039 |
+
.cmp-hero {{ font-size: 24px; }}
|
| 1040 |
+
.cmp-label {{ font-size: 8px; }}
|
| 1041 |
+
.empty-state {{ padding: 40px 16px; }}
|
| 1042 |
+
.empty-state .icon {{ font-size: 36px; }}
|
| 1043 |
+
.empty-state .label {{ font-size: 12px; }}
|
| 1044 |
+
.empty-state .sub {{ font-size: 10px; }}
|
| 1045 |
+
.sci-card {{ padding: 10px 12px; }}
|
| 1046 |
+
.sci-fact {{ font-size: 11px; }}
|
| 1047 |
+
.sci-cite {{ font-size: 9px; }}
|
| 1048 |
+
.theme-toggle {{ top: 10px; right: 10px; width: 28px; height: 28px; font-size: 12px; }}
|
| 1049 |
+
.face-meta {{ font-size: 8px; }}
|
| 1050 |
+
.face-status {{ font-size: 9px; }}
|
| 1051 |
+
input, textarea {{ font-size: 14px !important; }}
|
| 1052 |
+
.gr-checkbox input[type="checkbox"] {{ width: 22px; height: 22px; }}
|
| 1053 |
+
.gr-checkbox input[type="checkbox"]:checked::after {{ font-size: 16px; top: -1px; left: 5px; }}
|
| 1054 |
+
.gr-checkbox label {{ font-size: 14px !important; }}
|
| 1055 |
+
.gr-dropdown {{ font-size: 13px !important; }}
|
| 1056 |
+
}}
|
| 1057 |
+
@media (max-width: 420px) {{
|
| 1058 |
+
.gradio-container {{ padding: 0 8px 24px !important; }}
|
| 1059 |
+
.app-header {{ padding: 14px 0 12px; margin-bottom: 12px; }}
|
| 1060 |
+
.app-title {{ font-size: 9px; letter-spacing: 0.16em; }}
|
| 1061 |
+
.debt-hero {{ font-size: clamp(2.8rem, 22vw, 3.8rem) !important; }}
|
| 1062 |
+
.orb-wrap {{ padding: 14px 8px 12px; }}
|
| 1063 |
+
.orb-wrap::before {{ inset: -14px; }}
|
| 1064 |
+
.orb-wrap::after {{ inset: -24px; }}
|
| 1065 |
+
.preset-row {{ gap: 4px; }}
|
| 1066 |
+
.preset-chip {{ font-size: 8px !important; padding: 3px 8px !important; }}
|
| 1067 |
+
.attr-pill {{ font-size: 7px; padding: 2px 6px; gap: 4px; }}
|
| 1068 |
+
.cmp-card {{ min-width: 120px; max-width: 140px; padding: 8px; }}
|
| 1069 |
+
.cmp-hero {{ font-size: 20px; }}
|
| 1070 |
+
.plan-tag {{ font-size: 7px; min-width: 54px; }}
|
| 1071 |
+
.coach-block {{ padding: 12px 12px; }}
|
| 1072 |
+
.coach-header {{ font-size: 8px; }}
|
| 1073 |
+
.coach-pill {{ font-size: 8px; padding: 2px 6px; }}
|
| 1074 |
+
button.clear-all {{ font-size: 9px !important; padding: 3px 8px !important; }}
|
| 1075 |
+
.save-compare {{ font-size: 9px !important; padding: 3px 10px !important; }}
|
| 1076 |
+
}}
|
| 1077 |
+
"""
|
| 1078 |
+
|
| 1079 |
+
# βββ Theme toggle JS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1080 |
+
|
| 1081 |
+
THEME_TOGGLE_HTML = """
|
| 1082 |
+
<button class="theme-toggle" id="themeToggleBtn" onclick="toggleBodyDebtTheme()" aria-label="Toggle theme">π</button>
|
| 1083 |
+
<script>
|
| 1084 |
+
function toggleBodyDebtTheme() {
|
| 1085 |
+
var body = document.body;
|
| 1086 |
+
var btn = document.getElementById('themeToggleBtn');
|
| 1087 |
+
body.classList.toggle('light-mode');
|
| 1088 |
+
var isLight = body.classList.contains('light-mode');
|
| 1089 |
+
btn.textContent = isLight ? 'βοΈ' : 'π';
|
| 1090 |
+
try { localStorage.setItem('bodydebt-theme', isLight ? 'light' : 'dark'); } catch(e) {}
|
| 1091 |
}
|
| 1092 |
+
function removeCompare(idx) {
|
| 1093 |
+
var el = document.querySelector('#cmp-remove-idx input');
|
| 1094 |
+
if (el) { el.value = idx; el.dispatchEvent(new Event('input', { bubbles: true })); }
|
| 1095 |
+
}
|
| 1096 |
+
try {
|
| 1097 |
+
if (localStorage.getItem('bodydebt-theme') === 'light') {
|
| 1098 |
+
document.body.classList.add('light-mode');
|
| 1099 |
+
setTimeout(function() {
|
| 1100 |
+
var btn = document.getElementById('themeToggleBtn');
|
| 1101 |
+
if (btn) btn.textContent = 'βοΈ';
|
| 1102 |
+
}, 0);
|
| 1103 |
+
}
|
| 1104 |
+
} catch(e) {}
|
| 1105 |
+
</script>
|
| 1106 |
+
"""
|
| 1107 |
|
| 1108 |
# βββ HTML renderers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1109 |
|
| 1110 |
|
| 1111 |
+
def render_hero(score: int, verdict: str, is_sample: bool = False) -> str:
|
| 1112 |
+
color, _, _ = debt_tier(score)
|
| 1113 |
+
badge = ""
|
| 1114 |
+
if is_sample:
|
| 1115 |
+
badge = (
|
| 1116 |
+
'<div class="sample-badge">'
|
| 1117 |
+
'<span class="dot"></span>'
|
| 1118 |
+
"SAMPLE Β· adjust the form and click <strong>Calculate</strong> for your own"
|
| 1119 |
+
"</div>"
|
| 1120 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1121 |
return f"""
|
| 1122 |
+
{badge}
|
| 1123 |
+
<div class="orb-wrap" style="color: {color};">
|
| 1124 |
+
<div class="debt-hero-label">Body Debt Score</div>
|
| 1125 |
+
<div class="debt-hero" style="color: {color};">{score}</div>
|
| 1126 |
+
<div class="debt-verdict" style="color: var(--text-primary);">{html.escape(verdict)}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1127 |
</div>
|
| 1128 |
"""
|
| 1129 |
|
| 1130 |
|
| 1131 |
def render_system_meters(system_scores) -> str:
|
|
|
|
| 1132 |
max_score = max((s.score for s in system_scores), default=0)
|
| 1133 |
+
primary_system = None
|
| 1134 |
+
if max_score > 0:
|
| 1135 |
+
primary_system = max(system_scores, key=lambda s: s.score).system
|
| 1136 |
|
| 1137 |
+
rows = []
|
| 1138 |
for s in system_scores:
|
| 1139 |
+
accent_active, accent_soft, accent_muted = SYSTEM_ACCENTS.get(
|
| 1140 |
+
s.system, ("var(--text-secondary)", "var(--border-soft)", "var(--text-muted)")
|
| 1141 |
+
)
|
| 1142 |
+
is_primary = s.system == primary_system
|
| 1143 |
+
glyph = SYSTEM_GLYPHS.get(s.system, "β’")
|
| 1144 |
+
bar_color = accent_active if is_primary else accent_muted
|
| 1145 |
+
label_color = "var(--text-primary)" if is_primary else "var(--text-secondary)"
|
| 1146 |
pct = max(0, min(100, s.score))
|
| 1147 |
+
glyph_bg = accent_soft if is_primary else "rgba(168, 162, 158, 0.06)"
|
| 1148 |
+
|
| 1149 |
+
rows.append(f"""
|
| 1150 |
+
<div class="sys-meter {'is-primary' if is_primary else ''}" style="border-color: {accent_soft if is_primary else 'var(--border)'};">
|
| 1151 |
+
<div class="sys-glyph" style="background: {glyph_bg}; color: {bar_color};">{glyph}</div>
|
| 1152 |
+
<div class="sys-body">
|
| 1153 |
+
<div class="sys-row">
|
| 1154 |
+
<span class="sys-label">{s.icon} {html.escape(s.label)}</span>
|
| 1155 |
+
<span class="sys-time" style="color: {bar_color};">clears {s.cleared_at}</span>
|
| 1156 |
</div>
|
| 1157 |
+
<div class="sys-bar">
|
| 1158 |
+
<div class="sys-bar-fill" style="width: {pct}%; background: {bar_color};"></div>
|
| 1159 |
+
</div>
|
| 1160 |
+
<div class="sys-cause">{html.escape(s.cause_text)}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1161 |
</div>
|
| 1162 |
</div>
|
| 1163 |
+
""")
|
| 1164 |
|
| 1165 |
return f"""
|
| 1166 |
+
<div>
|
| 1167 |
+
<div class="section-label">Five-system breakdown</div>
|
| 1168 |
+
{''.join(rows)}
|
|
|
|
|
|
|
| 1169 |
</div>
|
|
|
|
|
|
|
|
|
|
| 1170 |
"""
|
| 1171 |
|
| 1172 |
|
| 1173 |
+
def render_prescription(system_scores, debt_score: int) -> str:
|
| 1174 |
+
if debt_score < 20:
|
| 1175 |
+
return f"""
|
| 1176 |
+
<div class="coach-block">
|
| 1177 |
+
<div class="coach-header">Recovery Protocol Β· Cleared</div>
|
| 1178 |
+
<div class="coach-body" style="color: var(--recovery-green);">All five systems below threshold. Maintain the streak.</div>
|
| 1179 |
+
</div>
|
| 1180 |
+
"""
|
| 1181 |
+
|
| 1182 |
+
# Map system actions into the four temporal windows based on severity
|
| 1183 |
+
windows = ["RIGHT NOW", "THIS MORNING", "TODAY", "AVOID"]
|
| 1184 |
+
active = [s for s in system_scores if s.score > 15]
|
| 1185 |
+
if not active:
|
| 1186 |
+
active = system_scores[:3]
|
| 1187 |
|
| 1188 |
+
# Sort: highest-debt system goes to RIGHT NOW
|
| 1189 |
+
active.sort(key=lambda s: -s.score)
|
|
|
|
| 1190 |
|
| 1191 |
+
steps = []
|
| 1192 |
+
for i, s in enumerate(active[:4]):
|
| 1193 |
+
window = windows[min(i, 3)]
|
| 1194 |
+
color = WINDOW_COLORS[window]
|
| 1195 |
+
steps.append((i + 1, window, s.action_text, color))
|
| 1196 |
|
| 1197 |
+
is_last = lambda i: i == len(steps) - 1
|
| 1198 |
steps_html = ""
|
| 1199 |
+
for i, (num, window, action, color) in enumerate(steps):
|
| 1200 |
+
connector = "" if is_last(i) else '<div class="proto-conn"></div>'
|
|
|
|
|
|
|
| 1201 |
steps_html += f"""
|
| 1202 |
+
<div class="proto-step">
|
| 1203 |
+
<div class="proto-rail">
|
| 1204 |
+
<div class="proto-num" style="color: {color};">{str(num).zfill(2)}</div>
|
|
|
|
|
|
|
| 1205 |
{connector}
|
| 1206 |
</div>
|
| 1207 |
+
<div style="flex: 1; padding-bottom: {'0' if is_last(i) else '16px'};">
|
| 1208 |
+
<div class="proto-window" style="color: {color};">{window} Β· {html.escape(active[i].label)}</div>
|
| 1209 |
+
<p class="proto-action">{html.escape(action)}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1210 |
</div>
|
| 1211 |
</div>
|
| 1212 |
"""
|
| 1213 |
|
| 1214 |
return f"""
|
| 1215 |
+
<div class="coach-block">
|
| 1216 |
+
<div class="coach-header">Recovery Protocol</div>
|
|
|
|
|
|
|
| 1217 |
{steps_html}
|
| 1218 |
</div>
|
| 1219 |
"""
|
| 1220 |
|
| 1221 |
|
| 1222 |
def render_science(system_scores) -> str:
|
| 1223 |
+
items = []
|
| 1224 |
for s in system_scores:
|
| 1225 |
+
if s.score > 20 and s.science_fact and s.science_cite:
|
| 1226 |
+
accent = SYSTEM_ACCENTS.get(s.system, ("var(--text-secondary)",))[0]
|
| 1227 |
+
items.append((accent, s.science_fact, s.science_cite))
|
| 1228 |
|
| 1229 |
+
if not items:
|
| 1230 |
return ""
|
| 1231 |
|
| 1232 |
+
cards = "".join(
|
| 1233 |
+
f"""
|
| 1234 |
+
<div class="sci-card" style="color: {accent};">
|
| 1235 |
+
<p class="sci-fact">{html.escape(fact)}</p>
|
| 1236 |
+
<p class="sci-cite">β {html.escape(cite)}</p>
|
|
|
|
| 1237 |
</div>
|
| 1238 |
+
""" for accent, fact, cite in items
|
| 1239 |
+
)
|
| 1240 |
|
| 1241 |
return f"""
|
| 1242 |
+
<div style="margin-top: 24px;">
|
| 1243 |
+
<div class="section-label">The science</div>
|
| 1244 |
+
{cards}
|
|
|
|
|
|
|
| 1245 |
</div>
|
| 1246 |
"""
|
| 1247 |
|
| 1248 |
|
| 1249 |
+
def render_face_scan(face_stress, is_healthy, _features) -> str:
|
| 1250 |
+
status_color = "var(--recovery-green)" if is_healthy else "var(--brand)"
|
| 1251 |
status_text = "Healthy" if is_healthy else "Stressed"
|
| 1252 |
+
status_bg = "rgba(74, 222, 128, 0.10)" if is_healthy else "rgba(234, 88, 12, 0.10)"
|
| 1253 |
+
pct = max(0, min(100, face_stress))
|
| 1254 |
|
| 1255 |
return f"""
|
| 1256 |
+
<div class="face-pill">
|
| 1257 |
+
<div>
|
| 1258 |
+
<div class="face-label">Face Scan</div>
|
| 1259 |
+
<div class="face-num" style="color: {status_color};">{face_stress:.0f}<span style="font-size: 14px; color: var(--text-faint);">/100</span></div>
|
| 1260 |
</div>
|
| 1261 |
+
<div style="flex: 1;">
|
| 1262 |
+
<span class="face-status" style="color: {status_color}; background: {status_bg};">{status_text}</span>
|
| 1263 |
+
<div class="face-bar">
|
| 1264 |
+
<div class="face-bar-fill" style="width: {pct}%; background: {status_color};"></div>
|
| 1265 |
+
</div>
|
| 1266 |
+
<div class="face-meta" style="margin-top: 6px; font-style: italic;">Processed on-device. No biometric data transmitted.</div>
|
| 1267 |
</div>
|
|
|
|
| 1268 |
</div>
|
| 1269 |
"""
|
| 1270 |
|
| 1271 |
|
| 1272 |
+
def render_face_scan_placeholder() -> str:
|
| 1273 |
+
"""Call-to-action placeholder for face scan before the user runs an analysis."""
|
| 1274 |
+
return f"""
|
| 1275 |
+
<div class="face-pill-placeholder">
|
| 1276 |
+
<div class="icon">π·</div>
|
| 1277 |
+
<div class="label">Capture a photo or use your webcam</div>
|
| 1278 |
+
<div class="sub">MediaPipe FaceMesh β 7 features β stress MLP Β· all on-device</div>
|
| 1279 |
+
</div>
|
| 1280 |
+
"""
|
| 1281 |
+
|
| 1282 |
+
|
| 1283 |
+
# βββ Debt timeline chart βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1284 |
|
| 1285 |
|
| 1286 |
+
def compute_debt_timeline(system_scores, now=None):
|
| 1287 |
+
"""Compute how total body debt changes over the recovery window.
|
| 1288 |
+
|
| 1289 |
+
Each system decays linearly from its current score to 0 over its
|
| 1290 |
+
recovery window. Returns list of dicts, each with total score, per-system
|
| 1291 |
+
breakdown, and the dominant system at that point.
|
| 1292 |
+
"""
|
| 1293 |
+
if now is None:
|
| 1294 |
+
now = datetime.now()
|
| 1295 |
+
|
| 1296 |
+
max_window = max((s for s in system_scores), default=None, key=lambda s: s.recovery_hrs)
|
| 1297 |
+
max_hrs = max_window.recovery_hrs if max_window else 10
|
| 1298 |
+
max_hrs = max(max_hrs, 8)
|
| 1299 |
+
max_hrs = min(max_hrs, 48)
|
| 1300 |
+
|
| 1301 |
+
system_order = ["cardiovascular", "brain", "liver", "muscular", "gut"]
|
| 1302 |
+
|
| 1303 |
+
points = []
|
| 1304 |
+
for h in range(0, int(max_hrs) + 2, 2):
|
| 1305 |
+
total = 0
|
| 1306 |
+
systems_at_h = []
|
| 1307 |
+
for sys_name in system_order:
|
| 1308 |
+
s = next((x for x in system_scores if x.system == sys_name), None)
|
| 1309 |
+
if s is None:
|
| 1310 |
+
continue
|
| 1311 |
+
if s.recovery_hrs > 0 and h <= s.recovery_hrs:
|
| 1312 |
+
remaining_frac = (s.recovery_hrs - h) / s.recovery_hrs
|
| 1313 |
+
sys_score = round(s.score * remaining_frac, 1)
|
| 1314 |
+
else:
|
| 1315 |
+
sys_score = 0.0
|
| 1316 |
+
total += sys_score
|
| 1317 |
+
accent = SYSTEM_ACCENTS.get(sys_name, ("var(--text-muted)",))[0]
|
| 1318 |
+
systems_at_h.append({
|
| 1319 |
+
"name": sys_name,
|
| 1320 |
+
"glyph": SYSTEM_GLYPHS.get(sys_name, "β’"),
|
| 1321 |
+
"score": round(sys_score),
|
| 1322 |
+
"color": accent,
|
| 1323 |
+
})
|
| 1324 |
+
|
| 1325 |
+
total = round(total)
|
| 1326 |
+
color, _, _ = debt_tier(total)
|
| 1327 |
+
label = "Now" if h == 0 else f"+{h}h"
|
| 1328 |
+
|
| 1329 |
+
# Find dominant system (highest contributing)
|
| 1330 |
+
dominant = max(systems_at_h, key=lambda x: x["score"]) if systems_at_h else None
|
| 1331 |
+
|
| 1332 |
+
points.append({
|
| 1333 |
+
"hour": h,
|
| 1334 |
+
"score": total,
|
| 1335 |
+
"color": color,
|
| 1336 |
+
"label": label,
|
| 1337 |
+
"systems": systems_at_h,
|
| 1338 |
+
"dominant": dominant["name"] if dominant and dominant["score"] > 0 else None,
|
| 1339 |
+
"dominant_glyph": dominant["glyph"] if dominant and dominant["score"] > 0 else "",
|
| 1340 |
+
})
|
| 1341 |
+
|
| 1342 |
+
return points
|
| 1343 |
+
|
| 1344 |
+
|
| 1345 |
+
def render_timeline(points: list[dict]) -> str:
|
| 1346 |
+
"""Render the debt timeline as a horizontal bar chart with system breakdown.
|
| 1347 |
+
|
| 1348 |
+
Each bar is a stacked segment showing each system's contribution in its
|
| 1349 |
+
accent color. The dominant system is labeled to the right of the bar.
|
| 1350 |
+
"""
|
| 1351 |
+
if not points:
|
| 1352 |
+
return ""
|
| 1353 |
+
|
| 1354 |
+
max_score = max(p["score"] for p in points) or 1
|
| 1355 |
+
|
| 1356 |
+
rows = []
|
| 1357 |
+
for p in points:
|
| 1358 |
+
now_tag = '<span class="tl-now">Β· now</span>' if p["hour"] == 0 else ""
|
| 1359 |
+
|
| 1360 |
+
# Build stacked bar segments β each system gets a proportional slice
|
| 1361 |
+
bar_pct = max(2.0, (p["score"] / max_score) * 100)
|
| 1362 |
+
segments = []
|
| 1363 |
+
if p["systems"] and p["score"] > 0:
|
| 1364 |
+
total_sys = sum(x["score"] for x in p["systems"])
|
| 1365 |
+
for sys in p["systems"]:
|
| 1366 |
+
if sys["score"] <= 0:
|
| 1367 |
+
continue
|
| 1368 |
+
share = (sys["score"] / total_sys) * 100
|
| 1369 |
+
sys_label = {"cardiovascular":"Cardiovascular","brain":"Brain","liver":"Liver","muscular":"Muscular","gut":"Gut"}.get(sys["name"], sys["name"])
|
| 1370 |
+
segments.append(f'<span class="tl-seg" style="flex:{share:.1f};background:{sys["color"]}" title="{sys_label}: {sys["score"]} pts"></span>')
|
| 1371 |
+
else:
|
| 1372 |
+
segments = []
|
| 1373 |
+
|
| 1374 |
+
seg_html = "".join(segments) if segments else f'<span class="tl-bar" style="width:100%;background:{p["color"]}"></span>'
|
| 1375 |
+
|
| 1376 |
+
# Dominant system indicator
|
| 1377 |
+
dom_html = ""
|
| 1378 |
+
if p["dominant"] and p["dominant_glyph"]:
|
| 1379 |
+
dom_color = SYSTEM_ACCENTS.get(p["dominant"], ("var(--text-secondary)",))[0]
|
| 1380 |
+
dom_html = f'<span class="tl-dom" style="color:{dom_color};">{p["dominant_glyph"]}</span>'
|
| 1381 |
+
|
| 1382 |
+
rows.append(f"""
|
| 1383 |
+
<div class="tl-row">
|
| 1384 |
+
<span class="tl-label">{p['label']}{now_tag}</span>
|
| 1385 |
+
<div class="tl-bar-wrap">
|
| 1386 |
+
<div class="tl-bar-stack" style="width:{bar_pct:.0f}%;">
|
| 1387 |
+
{seg_html}
|
| 1388 |
+
</div>
|
| 1389 |
+
</div>
|
| 1390 |
+
<span class="tl-score" style="color: {p['color']};">{p['score']}</span>
|
| 1391 |
+
{dom_html}
|
| 1392 |
+
</div>
|
| 1393 |
+
""")
|
| 1394 |
+
|
| 1395 |
+
# Build a legend showing system glyphs
|
| 1396 |
+
legend_items = []
|
| 1397 |
+
for sys_name in ["cardiovascular", "brain", "liver", "muscular", "gut"]:
|
| 1398 |
+
accent = SYSTEM_ACCENTS.get(sys_name, ("var(--text-muted)",))[0]
|
| 1399 |
+
glyph = SYSTEM_GLYPHS.get(sys_name, "β’")
|
| 1400 |
+
label = {
|
| 1401 |
+
"cardiovascular": "Cardio",
|
| 1402 |
+
"brain": "Brain",
|
| 1403 |
+
"liver": "Liver",
|
| 1404 |
+
"muscular": "Muscle",
|
| 1405 |
+
"gut": "Gut",
|
| 1406 |
+
}.get(sys_name, sys_name)
|
| 1407 |
+
legend_items.append(f'<span class="tl-leg" style="color:{accent};"><span class="tl-leg-swatch" style="background:{accent};"></span>{glyph} {label}</span>')
|
| 1408 |
+
|
| 1409 |
+
legend_html = f'<div class="tl-legend">{" ".join(legend_items)}</div>' if len(legend_items) > 0 else ""
|
| 1410 |
+
|
| 1411 |
+
return f"""
|
| 1412 |
+
<div class="tl-wrap">
|
| 1413 |
+
<div class="section-label" style="margin-bottom: 10px;">Recovery forecast</div>
|
| 1414 |
+
{legend_html}
|
| 1415 |
+
{"".join(rows)}
|
| 1416 |
+
</div>
|
| 1417 |
+
"""
|
| 1418 |
+
|
| 1419 |
+
|
| 1420 |
+
def render_plan(plan: dict | None, lines_so_far: list[str] | None = None) -> str:
|
| 1421 |
+
"""Triage plan: 3 lines from SmolLM2's structured plan step.
|
| 1422 |
+
|
| 1423 |
+
`lines_so_far` lets the UI show the plan being formed (one line at a
|
| 1424 |
+
time) as the LLM streams. Falls back to `plan` dict for the final
|
| 1425 |
+
render.
|
| 1426 |
+
"""
|
| 1427 |
+
if lines_so_far is None:
|
| 1428 |
+
lines_so_far = []
|
| 1429 |
+
if plan:
|
| 1430 |
+
if plan.get("priority"):
|
| 1431 |
+
lines_so_far.append(f"PRIORITY: {plan['priority']}")
|
| 1432 |
+
if plan.get("secondary"):
|
| 1433 |
+
lines_so_far.append(f"SECONDARY: {plan['secondary']}")
|
| 1434 |
+
if plan.get("avoid"):
|
| 1435 |
+
lines_so_far.append(f"AVOID: {plan['avoid']}")
|
| 1436 |
+
|
| 1437 |
+
if not lines_so_far and not plan:
|
| 1438 |
+
return ""
|
| 1439 |
+
|
| 1440 |
+
rendered_lines = []
|
| 1441 |
+
for line in lines_so_far:
|
| 1442 |
+
up = line.upper().strip()
|
| 1443 |
+
if up.startswith("PRIORITY:"):
|
| 1444 |
+
color = "#DC2626"
|
| 1445 |
+
label = "PRIORITY"
|
| 1446 |
+
elif up.startswith("SECONDARY:"):
|
| 1447 |
+
color = "#EA580C"
|
| 1448 |
+
label = "SECONDARY"
|
| 1449 |
+
elif up.startswith("AVOID:"):
|
| 1450 |
+
color = "#A78BFA"
|
| 1451 |
+
label = "AVOID"
|
| 1452 |
+
else:
|
| 1453 |
+
color = "var(--text-muted)"
|
| 1454 |
+
label = ""
|
| 1455 |
+
rest = line.split(":", 1)[1].strip() if ":" in line else line
|
| 1456 |
+
rendered_lines.append(f"""
|
| 1457 |
+
<div class="plan-line">
|
| 1458 |
+
<span class="plan-tag" style="color: {color}; border-color: {color}40; background: {color}10;">{label}</span>
|
| 1459 |
+
<span class="plan-text">{html.escape(rest)}</span>
|
| 1460 |
+
</div>
|
| 1461 |
+
""")
|
| 1462 |
+
|
| 1463 |
+
return f"""
|
| 1464 |
+
<div class="plan-block">
|
| 1465 |
+
<div class="section-label">Triage plan <span class="plan-source">SmolLM2-360M</span></div>
|
| 1466 |
+
{''.join(rendered_lines)}
|
| 1467 |
+
</div>
|
| 1468 |
+
"""
|
| 1469 |
+
|
| 1470 |
+
|
| 1471 |
+
def render_counterfactual(cf: dict | None) -> str:
|
| 1472 |
+
if not cf:
|
| 1473 |
+
return ""
|
| 1474 |
+
accent = SYSTEM_ACCENTS.get(cf["system"], ("var(--text-secondary)",))[0]
|
| 1475 |
+
return f"""
|
| 1476 |
+
<div class="cf-block" style="border-left-color: {accent};">
|
| 1477 |
+
<span class="cf-label" style="color: {accent};">WHAT WOULD CHANGE THIS</span>
|
| 1478 |
+
<span class="cf-body">
|
| 1479 |
+
If you had <strong style="color: var(--text-primary);">{html.escape(cf['lever_label'])}</strong>,
|
| 1480 |
+
<strong style="color: {accent};">{html.escape(cf['system_label'])}</strong> debt would drop
|
| 1481 |
+
from <strong style="color: var(--text-primary);">{cf['from_score']}</strong> to
|
| 1482 |
+
<strong style="color: var(--recovery-green);">{cf['to_score']}</strong>.
|
| 1483 |
+
</span>
|
| 1484 |
+
</div>
|
| 1485 |
+
"""
|
| 1486 |
+
|
| 1487 |
+
|
| 1488 |
+
def render_agent_trace(steps: list[tuple[str, str, str]]) -> str:
|
| 1489 |
+
"""steps: list of (label, status, message) where status is pending|active|done|error."""
|
| 1490 |
+
if steps:
|
| 1491 |
+
items = []
|
| 1492 |
+
for label, status, message in steps:
|
| 1493 |
+
items.append(f"""
|
| 1494 |
+
<div class="trace-step is-{status}">
|
| 1495 |
+
<span class="trace-dot"></span>
|
| 1496 |
+
<span style="flex: 1;">{html.escape(label)}</span>
|
| 1497 |
+
<span style="color: var(--text-faint); font-size: 10px;">{html.escape(message)}</span>
|
| 1498 |
+
</div>
|
| 1499 |
+
""")
|
| 1500 |
+
body = "".join(items)
|
| 1501 |
+
else:
|
| 1502 |
+
body = f"""
|
| 1503 |
+
<div class="trace-step">
|
| 1504 |
+
<span class="trace-dot" style="background: var(--text-faint);"></span>
|
| 1505 |
+
<span style="flex: 1; color: var(--text-faint);">parse_stressors</span>
|
| 1506 |
+
</div>
|
| 1507 |
+
<div class="trace-step">
|
| 1508 |
+
<span class="trace-dot" style="background: var(--text-faint);"></span>
|
| 1509 |
+
<span style="flex: 1; color: var(--text-faint);">compute_live_score</span>
|
| 1510 |
+
</div>
|
| 1511 |
+
<div class="trace-step">
|
| 1512 |
+
<span class="trace-dot" style="background: var(--text-faint);"></span>
|
| 1513 |
+
<span style="flex: 1; color: var(--text-faint);">face_scan</span>
|
| 1514 |
+
</div>
|
| 1515 |
+
<div class="trace-step">
|
| 1516 |
+
<span class="trace-dot" style="background: var(--text-faint);"></span>
|
| 1517 |
+
<span style="flex: 1; color: var(--text-faint);">llm_coach</span>
|
| 1518 |
+
</div>
|
| 1519 |
+
<div class="trace-step" style="margin-top: 8px;">
|
| 1520 |
+
<span class="ready-pulse"></span>
|
| 1521 |
+
<span style="color: var(--text-muted); font-size: 10px;">Awaiting analysis</span>
|
| 1522 |
+
</div>
|
| 1523 |
+
"""
|
| 1524 |
+
|
| 1525 |
+
return f"""
|
| 1526 |
+
<div>
|
| 1527 |
+
<div class="section-label">Agent trace</div>
|
| 1528 |
+
{body}
|
| 1529 |
+
</div>
|
| 1530 |
+
"""
|
| 1531 |
+
|
| 1532 |
+
|
| 1533 |
+
def render_empty_state() -> str:
|
| 1534 |
+
return f"""
|
| 1535 |
+
<div class="empty-state">
|
| 1536 |
+
<div class="icon">π«</div>
|
| 1537 |
+
<div class="label">Your debt will appear here</div>
|
| 1538 |
+
<div class="sub">Log your stressors on the left. Tap calculate.</div>
|
| 1539 |
+
</div>
|
| 1540 |
+
"""
|
| 1541 |
+
|
| 1542 |
+
|
| 1543 |
+
# βββ Running estimate (live debt pill) βββββββββββββββββββββββββββββββββββββββ
|
| 1544 |
+
|
| 1545 |
+
|
| 1546 |
+
def compute_running_estimate(
|
| 1547 |
alcohol, alcohol_type, alcohol_count,
|
| 1548 |
training, training_area, training_intensity,
|
| 1549 |
sleep, sleep_hours,
|
|
|
|
| 1551 |
ill, ill_severity,
|
| 1552 |
care,
|
| 1553 |
bed_time, wake_time,
|
| 1554 |
+
) -> str:
|
| 1555 |
+
"""Return a small HTML pill showing the current running debt score."""
|
| 1556 |
+
stressors = build_stressors(
|
| 1557 |
+
alcohol, alcohol_type, alcohol_count,
|
| 1558 |
+
training, training_area, training_intensity,
|
| 1559 |
+
sleep, sleep_hours, stress, stress_carried,
|
| 1560 |
+
ill, ill_severity, care,
|
| 1561 |
+
)
|
| 1562 |
+
score = compute_live_score(stressors)
|
| 1563 |
+
color, _, _ = debt_tier(score)
|
| 1564 |
+
return f'<span class="debt-pill" style="color: {color};">Running debt Β· <strong>{score}</strong>/100</span>'
|
| 1565 |
+
|
| 1566 |
+
|
| 1567 |
+
# βββ Preset scenario fillers βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1568 |
+
|
| 1569 |
+
|
| 1570 |
+
def _fill_preset(
|
| 1571 |
+
a, a_t, a_c, a_v,
|
| 1572 |
+
t, t_a, t_i, t_v,
|
| 1573 |
+
s, s_h, s_v,
|
| 1574 |
+
st, st_c, st_v,
|
| 1575 |
+
i, i_s, i_v,
|
| 1576 |
+
c,
|
| 1577 |
+
bt, wt,
|
| 1578 |
+
):
|
| 1579 |
+
"""Return (20-element) tuple matching the preset outputs list below."""
|
| 1580 |
+
return (a, a_t, a_c, gr.Group(visible=a_v),
|
| 1581 |
+
t, t_a, t_i, gr.Group(visible=t_v),
|
| 1582 |
+
s, s_h, gr.Group(visible=s_v),
|
| 1583 |
+
st, st_c, gr.Group(visible=st_v),
|
| 1584 |
+
i, i_s, gr.Group(visible=i_v),
|
| 1585 |
+
c, bt, wt)
|
| 1586 |
+
|
| 1587 |
+
|
| 1588 |
+
def fill_bad_night():
|
| 1589 |
+
"""Drank red wine 3-4, trained legs hard, slept 4-6."""
|
| 1590 |
+
return _fill_preset(
|
| 1591 |
+
True, "red_wine", "3-4", True,
|
| 1592 |
+
True, "legs", "hard", True,
|
| 1593 |
+
True, "4-6", True,
|
| 1594 |
+
False, "yes", False,
|
| 1595 |
+
False, "moderate", False,
|
| 1596 |
+
False,
|
| 1597 |
+
"2:00 AM", "8:00 AM",
|
| 1598 |
+
)
|
| 1599 |
+
|
| 1600 |
+
|
| 1601 |
+
def fill_recovery_day():
|
| 1602 |
+
"""Beer 1-2, slept 6-7, took care of myself."""
|
| 1603 |
+
return _fill_preset(
|
| 1604 |
+
True, "beer", "1-2", True,
|
| 1605 |
+
False, "full_body", "hard", False,
|
| 1606 |
+
True, "6-7", True,
|
| 1607 |
+
False, "yes", False,
|
| 1608 |
+
False, "moderate", False,
|
| 1609 |
+
True,
|
| 1610 |
+
"10:00 PM", "6:30 AM",
|
| 1611 |
+
)
|
| 1612 |
+
|
| 1613 |
+
|
| 1614 |
+
def fill_hit_it_hard():
|
| 1615 |
+
"""No alcohol, trained destroyed, slept okay, took care."""
|
| 1616 |
+
return _fill_preset(
|
| 1617 |
+
False, "red_wine", "3-4", False,
|
| 1618 |
+
True, "legs", "destroyed", True,
|
| 1619 |
+
True, "6-7", True,
|
| 1620 |
+
False, "yes", False,
|
| 1621 |
+
False, "moderate", False,
|
| 1622 |
+
True,
|
| 1623 |
+
"10:30 PM", "6:00 AM",
|
| 1624 |
+
)
|
| 1625 |
+
|
| 1626 |
+
|
| 1627 |
+
def fill_sick():
|
| 1628 |
+
"""Slept terribly, high stress, floored by illness."""
|
| 1629 |
+
return _fill_preset(
|
| 1630 |
+
False, "red_wine", "3-4", False,
|
| 1631 |
+
False, "full_body", "hard", False,
|
| 1632 |
+
True, "under_4", True,
|
| 1633 |
+
True, "yes", True,
|
| 1634 |
+
True, "floored", True,
|
| 1635 |
+
False,
|
| 1636 |
+
"11:00 PM", "7:00 AM",
|
| 1637 |
+
)
|
| 1638 |
+
|
| 1639 |
+
|
| 1640 |
+
def clear_all_form():
|
| 1641 |
+
"""Reset all form inputs to defaults."""
|
| 1642 |
+
return _fill_preset(
|
| 1643 |
+
False, "red_wine", "3-4", False,
|
| 1644 |
+
False, "full_body", "hard", False,
|
| 1645 |
+
False, "4-6", False,
|
| 1646 |
+
False, "yes", False,
|
| 1647 |
+
False, "moderate", False,
|
| 1648 |
+
False,
|
| 1649 |
+
"", "",
|
| 1650 |
+
)
|
| 1651 |
+
|
| 1652 |
+
|
| 1653 |
+
# βββ Sample preview builder ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1654 |
+
|
| 1655 |
+
|
| 1656 |
+
def render_sample_preview():
|
| 1657 |
+
"""Pre-render a sample analysis so the right column has content on first load."""
|
| 1658 |
+
sample_stressors = build_stressors(
|
| 1659 |
+
True, "red_wine", "3-4",
|
| 1660 |
+
True, "legs", "hard",
|
| 1661 |
+
True, "4-6",
|
| 1662 |
+
False, "yes",
|
| 1663 |
+
False, "moderate",
|
| 1664 |
+
False,
|
| 1665 |
+
)
|
| 1666 |
+
sample_now = datetime.now()
|
| 1667 |
+
sample_score = compute_live_score(sample_stressors)
|
| 1668 |
+
sample_system_scores = compute_system_scores(
|
| 1669 |
+
sample_stressors,
|
| 1670 |
+
now=sample_now,
|
| 1671 |
+
bed_time="2:00 AM",
|
| 1672 |
+
wake_time="8:00 AM",
|
| 1673 |
+
)
|
| 1674 |
+
|
| 1675 |
+
_, sample_verdict, _ = debt_tier(sample_score)
|
| 1676 |
+
hero_html = render_hero(sample_score, sample_verdict, is_sample=True)
|
| 1677 |
+
meters_html = render_system_meters(sample_system_scores)
|
| 1678 |
+
rx_html = render_prescription(sample_system_scores, sample_score)
|
| 1679 |
+
science_html = render_science(sample_system_scores)
|
| 1680 |
+
|
| 1681 |
+
system_dicts = [
|
| 1682 |
+
{"label": s.label, "score": s.score, "cleared_at": s.cleared_at}
|
| 1683 |
+
for s in sample_system_scores
|
| 1684 |
+
]
|
| 1685 |
+
fallback_plan = _fallback_plan(system_dicts)
|
| 1686 |
+
plan_html = render_plan(fallback_plan)
|
| 1687 |
+
|
| 1688 |
+
cf = compute_counterfactual(sample_stressors, sample_system_scores, "2:00 AM", "8:00 AM")
|
| 1689 |
+
cf_html = render_counterfactual(cf)
|
| 1690 |
+
|
| 1691 |
+
sample_trace = [
|
| 1692 |
+
("parse_stressors", "done", "3 stressors"),
|
| 1693 |
+
("compute_live_score", "done", f"score={sample_score}/100"),
|
| 1694 |
+
("triage_plan", "done", "PRIORITY Β· SECONDARY Β· AVOID"),
|
| 1695 |
+
("llm_coach", "done", "sample"),
|
| 1696 |
+
]
|
| 1697 |
+
trace_html = render_agent_trace(sample_trace)
|
| 1698 |
+
|
| 1699 |
+
face_html = render_face_scan_placeholder()
|
| 1700 |
+
timeline_html = render_timeline(compute_debt_timeline(sample_system_scores, now=sample_now))
|
| 1701 |
+
return hero_html, plan_html, meters_html, face_html, timeline_html, rx_html + science_html, trace_html, cf_html, _sample_coach()
|
| 1702 |
+
|
| 1703 |
+
|
| 1704 |
+
def _sample_coach() -> str:
|
| 1705 |
+
return f"""
|
| 1706 |
+
<div class="coach-block">
|
| 1707 |
+
<div class="coach-header">
|
| 1708 |
+
<span>AI Recovery Coach</span>
|
| 1709 |
+
<span class="coach-pill">SmolLM2-360M Β· local</span>
|
| 1710 |
+
</div>
|
| 1711 |
+
<div class="coach-body" style="color: var(--text-faint);">
|
| 1712 |
+
<span class="ready-pulse"></span>
|
| 1713 |
+
Sample advice shown. Tap <strong style="color: var(--text-secondary);">Calculate Body Debt</strong> for your own.
|
| 1714 |
+
</div>
|
| 1715 |
+
</div>
|
| 1716 |
+
"""
|
| 1717 |
+
|
| 1718 |
+
|
| 1719 |
+
# βββ Main analysis pipeline (yields streaming trace updates) βββββββββββββββββ
|
| 1720 |
+
|
| 1721 |
+
|
| 1722 |
+
def build_stressors(
|
| 1723 |
+
alcohol, alcohol_type, alcohol_count,
|
| 1724 |
+
training, training_area, training_intensity,
|
| 1725 |
+
sleep, sleep_hours,
|
| 1726 |
+
stress, stress_carried,
|
| 1727 |
+
ill, ill_severity,
|
| 1728 |
+
care,
|
| 1729 |
):
|
| 1730 |
stressors = []
|
| 1731 |
if alcohol:
|
|
|
|
| 1740 |
stressors.append(Stressor(type="ill", ill_severity=ill_severity))
|
| 1741 |
if care:
|
| 1742 |
stressors.append(Stressor(type="care"))
|
| 1743 |
+
return stressors
|
| 1744 |
+
|
| 1745 |
+
|
| 1746 |
+
def run_analysis_stream(
|
| 1747 |
+
alcohol, alcohol_type, alcohol_count,
|
| 1748 |
+
training, training_area, training_intensity,
|
| 1749 |
+
sleep, sleep_hours,
|
| 1750 |
+
stress, stress_carried,
|
| 1751 |
+
ill, ill_severity,
|
| 1752 |
+
care,
|
| 1753 |
+
bed_time, wake_time,
|
| 1754 |
+
face_image,
|
| 1755 |
+
progress=gr.Progress(),
|
| 1756 |
+
):
|
| 1757 |
+
"""Streaming generator. Yield tuple:
|
| 1758 |
+
|
| 1759 |
+
(hero, meters, rx, face, timeline, plan, trace, counterfactual, coach)
|
| 1760 |
+
"""
|
| 1761 |
+
stressors = build_stressors(
|
| 1762 |
+
alcohol, alcohol_type, alcohol_count,
|
| 1763 |
+
training, training_area, training_intensity,
|
| 1764 |
+
sleep, sleep_hours, stress, stress_carried,
|
| 1765 |
+
ill, ill_severity, care,
|
| 1766 |
+
)
|
| 1767 |
+
|
| 1768 |
+
EMPTY = render_empty_state()
|
| 1769 |
+
NUL = ""
|
| 1770 |
+
E_C = _empty_coach()
|
| 1771 |
+
E_P = ""
|
| 1772 |
+
E_T = render_agent_trace([])
|
| 1773 |
+
E_CF = ""
|
| 1774 |
+
|
| 1775 |
+
# Step 1: parse stressors
|
| 1776 |
+
trace = [("parse_stressors", "active", f"{len(stressors)} selected")]
|
| 1777 |
+
yield (EMPTY, EMPTY, NUL, NUL, "", E_P, E_T, E_CF, E_C)
|
| 1778 |
+
time.sleep(0.05)
|
| 1779 |
|
| 1780 |
if not stressors:
|
| 1781 |
+
trace[-1] = ("parse_stressors", "error", "none selected")
|
| 1782 |
+
msg = (
|
| 1783 |
+
f'<div class="empty-state"><div class="icon">π«</div>'
|
| 1784 |
+
f'<div class="label">Log at least one stressor</div>'
|
| 1785 |
+
f'<div class="sub">Tap a checkbox on the left to begin.</div></div>'
|
| 1786 |
+
)
|
| 1787 |
+
yield (msg, NUL, NUL, NUL, "", E_P, render_agent_trace(trace), E_CF, E_C)
|
| 1788 |
+
return
|
| 1789 |
+
|
| 1790 |
+
trace[-1] = ("parse_stressors", "done", f"{len(stressors)} stressors")
|
| 1791 |
+
progress(0.1, desc="Computing debt score...")
|
| 1792 |
+
|
| 1793 |
+
# Step 2: compute scores
|
| 1794 |
+
trace.append(("compute_live_score", "active", "deterministic engine"))
|
| 1795 |
+
yield (NUL, NUL, NUL, NUL, "", E_P, render_agent_trace(trace), E_CF, E_C)
|
| 1796 |
+
time.sleep(0.05)
|
| 1797 |
|
|
|
|
| 1798 |
live_score = compute_live_score(stressors)
|
| 1799 |
system_scores = compute_system_scores(
|
| 1800 |
stressors,
|
|
|
|
| 1802 |
bed_time=bed_time or None,
|
| 1803 |
wake_time=wake_time or None,
|
| 1804 |
)
|
| 1805 |
+
trace[-1] = ("compute_live_score", "done", f"score={live_score}/100")
|
| 1806 |
+
progress(0.3, desc="Mapping 5 systems...")
|
| 1807 |
+
|
| 1808 |
+
# Compute timeline once system scores are available
|
| 1809 |
+
timeline_html = render_timeline(compute_debt_timeline(system_scores))
|
| 1810 |
|
| 1811 |
+
# Step 3: face scan
|
| 1812 |
face_html = ""
|
| 1813 |
face_stress = None
|
| 1814 |
if face_image is not None:
|
| 1815 |
+
trace.append(("face_scan", "active", "MediaPipe FaceMesh"))
|
| 1816 |
+
yield (NUL, NUL, NUL, NUL, timeline_html, E_P, render_agent_trace(trace), E_CF, E_C)
|
| 1817 |
+
time.sleep(0.05)
|
| 1818 |
+
|
| 1819 |
features = run_face_scan(face_image)
|
| 1820 |
if features:
|
| 1821 |
arr = features_to_array(features)
|
| 1822 |
face_stress, is_healthy = predict_stress_score(arr)
|
| 1823 |
face_html = render_face_scan(face_stress, is_healthy, features)
|
| 1824 |
+
trace[-1] = ("face_scan", "done", f"stress={face_stress:.0f}/100")
|
| 1825 |
+
else:
|
| 1826 |
+
trace[-1] = ("face_scan", "error", "no face detected")
|
| 1827 |
+
else:
|
| 1828 |
+
trace.append(("face_scan", "done", "skipped"))
|
| 1829 |
|
| 1830 |
+
# Step 3.5: triage plan (the real "agent" step)
|
| 1831 |
+
system_dicts = [
|
| 1832 |
+
{"label": s.label, "score": s.score, "cleared_at": s.cleared_at}
|
| 1833 |
+
for s in system_scores
|
| 1834 |
+
]
|
| 1835 |
+
trace.append(("triage_plan", "active", "SmolLM2-360M"))
|
| 1836 |
+
plan_html = render_plan(None, [])
|
| 1837 |
+
yield (NUL, NUL, NUL, NUL, timeline_html, plan_html, render_agent_trace(trace), E_CF, E_C)
|
| 1838 |
+
|
| 1839 |
+
plan_dict: dict = {"priority": None, "secondary": None, "avoid": None}
|
| 1840 |
+
plan_lines: list[str] = []
|
| 1841 |
+
try:
|
| 1842 |
+
for plan_dict, line in stream_plan(system_dicts):
|
| 1843 |
+
if line:
|
| 1844 |
+
plan_lines.append(line)
|
| 1845 |
+
plan_html = render_plan(None, list(plan_lines))
|
| 1846 |
+
yield (NUL, NUL, NUL, NUL, timeline_html, plan_html, render_agent_trace(trace), E_CF, E_C)
|
| 1847 |
+
except Exception as e:
|
| 1848 |
+
print(f"Plan stream failed: {e}")
|
| 1849 |
+
plan_html = render_plan(plan_dict, plan_lines)
|
| 1850 |
+
trace[-1] = ("triage_plan", "done", "PRIORITY Β· SECONDARY Β· AVOID")
|
| 1851 |
|
| 1852 |
+
progress(0.5, desc="Building system breakdown...")
|
|
|
|
|
|
|
|
|
|
| 1853 |
|
| 1854 |
+
# Step 4: render hero + systems + prescription
|
| 1855 |
+
_, verdict, _ = debt_tier(live_score)
|
| 1856 |
+
hero_html = render_hero(live_score, verdict)
|
| 1857 |
+
meters_html = render_system_meters(system_scores)
|
| 1858 |
+
rx_html = render_prescription(system_scores, live_score)
|
| 1859 |
+
science_html = render_science(system_scores)
|
| 1860 |
+
cf = compute_counterfactual(stressors, system_scores, bed_time, wake_time)
|
| 1861 |
+
cf_html = render_counterfactual(cf)
|
| 1862 |
+
|
| 1863 |
+
# Step 5: streaming LLM advice
|
| 1864 |
+
trace.append(("llm_coach", "active", "SmolLM2-360M local"))
|
| 1865 |
+
accumulated = ""
|
| 1866 |
stressor_summary = ", ".join(
|
| 1867 |
f"{STRESSOR_DEFS[s.type]['icon']} {STRESSOR_DEFS[s.type]['label']}" for s in stressors
|
| 1868 |
)
|
| 1869 |
+
|
| 1870 |
+
yield (
|
| 1871 |
+
hero_html, meters_html, rx_html + science_html, face_html, timeline_html,
|
| 1872 |
+
plan_html, render_agent_trace(trace), cf_html, _coach_with_cursor(""),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1873 |
)
|
|
|
|
| 1874 |
|
| 1875 |
+
try:
|
| 1876 |
+
for piece in stream_advice(live_score, system_dicts, stressor_summary, face_stress):
|
| 1877 |
+
accumulated += piece
|
| 1878 |
+
yield (
|
| 1879 |
+
hero_html, meters_html, rx_html + science_html, face_html, timeline_html,
|
| 1880 |
+
plan_html, render_agent_trace(trace), cf_html,
|
| 1881 |
+
_coach_with_cursor(accumulated),
|
| 1882 |
+
)
|
| 1883 |
+
except Exception as e:
|
| 1884 |
+
print(f"Stream fallback: {e}")
|
| 1885 |
+
accumulated = _fallback_advice(live_score, system_dicts, stressor_summary)
|
| 1886 |
+
yield (
|
| 1887 |
+
hero_html, meters_html, rx_html + science_html, face_html, timeline_html,
|
| 1888 |
+
plan_html, render_agent_trace(trace), cf_html,
|
| 1889 |
+
_coach_with_cursor(accumulated),
|
| 1890 |
+
)
|
| 1891 |
+
|
| 1892 |
+
trace[-1] = ("llm_coach", "done", f"{len(accumulated)} chars")
|
| 1893 |
+
progress(1.0, desc="Done")
|
| 1894 |
+
yield (
|
| 1895 |
+
hero_html, meters_html, rx_html + science_html, face_html, timeline_html,
|
| 1896 |
+
plan_html, render_agent_trace(trace), cf_html,
|
| 1897 |
+
_coach_with_cursor(accumulated),
|
| 1898 |
+
)
|
| 1899 |
+
|
| 1900 |
+
|
| 1901 |
+
def _empty_coach() -> str:
|
| 1902 |
+
return f"""
|
| 1903 |
+
<div class="coach-block">
|
| 1904 |
+
<div class="coach-header">
|
| 1905 |
+
<span>AI Recovery Coach</span>
|
| 1906 |
+
<span class="coach-pill">SmolLM2-360M Β· local</span>
|
| 1907 |
+
</div>
|
| 1908 |
+
<div class="coach-body" style="color: var(--text-faint);">
|
| 1909 |
+
<span class="ready-pulse"></span>Ready. Tap <strong style="color: var(--text-secondary);">Calculate Body Debt</strong> to stream advice.
|
| 1910 |
</div>
|
|
|
|
| 1911 |
</div>
|
| 1912 |
"""
|
| 1913 |
|
|
|
|
| 1914 |
|
| 1915 |
+
def _coach_with_cursor(text: str) -> str:
|
| 1916 |
+
safe = html.escape(text)
|
| 1917 |
+
return f"""
|
| 1918 |
+
<div class="coach-block">
|
| 1919 |
+
<div class="coach-header"><span>AI Recovery Coach</span><span class="coach-pill">SmolLM2-360M Β· local</span></div>
|
| 1920 |
+
<div class="coach-body">{safe}<span class="coach-cursor"></span></div>
|
| 1921 |
+
</div>
|
| 1922 |
+
"""
|
| 1923 |
|
|
|
|
| 1924 |
|
| 1925 |
+
# βββ Compare scenarios βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1926 |
|
|
|
|
|
|
|
| 1927 |
|
| 1928 |
+
def _build_compare_label(stressors: list) -> str:
|
| 1929 |
+
"""Build a short label from stressor icons."""
|
| 1930 |
+
if not stressors:
|
| 1931 |
+
return "Clear day"
|
| 1932 |
+
icons = [STRESSOR_DEFS[s.type]["icon"] for s in stressors]
|
| 1933 |
+
return " ".join(icons)
|
| 1934 |
+
|
| 1935 |
+
|
| 1936 |
+
def render_comparison_html(comparisons: list) -> str:
|
| 1937 |
+
"""Render the horizontal comparison carousel as HTML."""
|
| 1938 |
+
if not comparisons:
|
| 1939 |
+
return ''
|
| 1940 |
+
|
| 1941 |
+
cards = []
|
| 1942 |
+
for i, c in enumerate(comparisons):
|
| 1943 |
+
color, verdict, _ = debt_tier(c["score"])
|
| 1944 |
+
systems_html = ""
|
| 1945 |
+
for sys in c["system_scores"]:
|
| 1946 |
+
accent = SYSTEM_ACCENTS.get(sys["system"], ("var(--text-secondary)",))[0]
|
| 1947 |
+
pct = max(0, min(100, sys["score"]))
|
| 1948 |
+
systems_html += f"""
|
| 1949 |
+
<div class="cmp-sys">
|
| 1950 |
+
<span class="cmp-sys-glyph" style="color: {accent};">{sys['glyph']}</span>
|
| 1951 |
+
<div class="cmp-sys-bar"><div class="cmp-sys-fill" style="width:{pct}%;background:{accent}"></div></div>
|
| 1952 |
+
</div>"""
|
| 1953 |
+
cards.append(f"""
|
| 1954 |
+
<div class="cmp-card">
|
| 1955 |
+
<button class="cmp-del" onclick="removeCompare({i})" aria-label="Remove">Γ</button>
|
| 1956 |
+
<div class="cmp-hero" style="color: {color};">{c['score']}</div>
|
| 1957 |
+
<div class="cmp-label">{html.escape(c['label'])}</div>
|
| 1958 |
+
<div class="cmp-time">{html.escape(c['timestamp'])}</div>
|
| 1959 |
+
{systems_html}
|
| 1960 |
+
</div>
|
| 1961 |
+
""")
|
| 1962 |
+
return f'<div class="cmp-row">{"".join(cards)}</div>'
|
| 1963 |
+
|
| 1964 |
+
|
| 1965 |
+
def save_compare(
|
| 1966 |
+
comparisons, # list from gr.State
|
| 1967 |
+
alcohol, alcohol_type, alcohol_count,
|
| 1968 |
+
training, training_area, training_intensity,
|
| 1969 |
+
sleep, sleep_hours,
|
| 1970 |
+
stress, stress_carried,
|
| 1971 |
+
ill, ill_severity,
|
| 1972 |
+
care,
|
| 1973 |
+
bed_time, wake_time,
|
| 1974 |
+
):
|
| 1975 |
+
"""Recompute from current form inputs, append to comparisons, return updated state + HTML."""
|
| 1976 |
+
stressors = build_stressors(
|
| 1977 |
+
alcohol, alcohol_type, alcohol_count,
|
| 1978 |
+
training, training_area, training_intensity,
|
| 1979 |
+
sleep, sleep_hours, stress, stress_carried,
|
| 1980 |
+
ill, ill_severity, care,
|
| 1981 |
+
)
|
| 1982 |
+
score = compute_live_score(stressors)
|
| 1983 |
+
system_scores = compute_system_scores(
|
| 1984 |
+
stressors,
|
| 1985 |
+
now=datetime.now(),
|
| 1986 |
+
bed_time=bed_time or None,
|
| 1987 |
+
wake_time=wake_time or None,
|
| 1988 |
+
)
|
| 1989 |
+
|
| 1990 |
+
label = _build_compare_label(stressors)
|
| 1991 |
+
now_str = datetime.now().strftime("%I:%M %p").lstrip("0")
|
| 1992 |
+
|
| 1993 |
+
entry = {
|
| 1994 |
+
"score": score,
|
| 1995 |
+
"label": label,
|
| 1996 |
+
"timestamp": now_str,
|
| 1997 |
+
"system_scores": [
|
| 1998 |
+
{
|
| 1999 |
+
"system": s.system,
|
| 2000 |
+
"glyph": SYSTEM_GLYPHS.get(s.system, "β’"),
|
| 2001 |
+
"score": s.score,
|
| 2002 |
+
}
|
| 2003 |
+
for s in system_scores
|
| 2004 |
+
],
|
| 2005 |
+
}
|
| 2006 |
+
|
| 2007 |
+
new_list = list(comparisons or [])
|
| 2008 |
+
new_list.append(entry)
|
| 2009 |
+
return new_list, render_comparison_html(new_list)
|
| 2010 |
+
|
| 2011 |
+
|
| 2012 |
+
def remove_compare(comparisons, idx: int):
|
| 2013 |
+
"""Remove a comparison by index. Returns -1 sentinel to reset the trigger."""
|
| 2014 |
+
new_list = list(comparisons or [])
|
| 2015 |
+
if 0 <= idx < len(new_list):
|
| 2016 |
+
del new_list[idx]
|
| 2017 |
+
return new_list, render_comparison_html(new_list), -1
|
| 2018 |
+
|
| 2019 |
+
|
| 2020 |
+
def clear_comparisons():
|
| 2021 |
+
"""Clear all comparisons."""
|
| 2022 |
+
return [], ""
|
| 2023 |
+
|
| 2024 |
|
| 2025 |
+
# βββ Pre-compute sample preview βββββββββββββββββββββββββββββββββββββββββββββ
|
| 2026 |
+
|
| 2027 |
+
SAMPLE_HERO, SAMPLE_PLAN, SAMPLE_METERS, SAMPLE_FACE, SAMPLE_TIMELINE, SAMPLE_RX, SAMPLE_TRACE, SAMPLE_CF, SAMPLE_COACH = render_sample_preview()
|
| 2028 |
+
|
| 2029 |
+
# βββ Layout ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2030 |
|
| 2031 |
with gr.Blocks(title="Body Debt") as demo:
|
| 2032 |
+
gr.HTML(THEME_TOGGLE_HTML)
|
| 2033 |
+
gr.HTML(f"""
|
| 2034 |
+
<div class="app-header">
|
| 2035 |
+
<h1 class="app-title">π« Body Debt</h1>
|
| 2036 |
+
<p class="app-subtitle">Quantify your physiological debt. Get a precise, system-level recovery plan. On-device AI. Zero cloud calls.</p>
|
| 2037 |
+
<div class="attr-row">
|
| 2038 |
+
<span class="attr-pill"><span class="dot"></span>SmolLM2-360M Β· local</span>
|
| 2039 |
+
<a class="attr-pill" href="https://huggingface.co/HuggingFaceTB/SmolLM2-360M-Instruct">360M params Β· 250MB RAM</a>
|
| 2040 |
+
<a class="attr-pill" href="https://github.com/udirobert/bodydebt">Built with OpenAI Codex</a>
|
| 2041 |
+
</div>
|
| 2042 |
</div>
|
| 2043 |
""")
|
| 2044 |
|
| 2045 |
with gr.Row():
|
| 2046 |
+
with gr.Column(scale=1):
|
| 2047 |
+
# Preset scenario chips
|
| 2048 |
+
gr.HTML('<div class="section-label">Try a scenario</div>')
|
| 2049 |
+
with gr.Row():
|
| 2050 |
+
preset_bad_night = gr.Button("π Bad night", elem_classes="preset-chip", size="sm")
|
| 2051 |
+
preset_recovery = gr.Button("β»οΈ Recovery day", elem_classes="preset-chip", size="sm")
|
| 2052 |
+
with gr.Row():
|
| 2053 |
+
preset_hit_hard = gr.Button("π₯ Hit it hard", elem_classes="preset-chip", size="sm")
|
| 2054 |
+
preset_sick = gr.Button("π€ Sick", elem_classes="preset-chip", size="sm")
|
| 2055 |
+
|
| 2056 |
+
gr.HTML('<div class="section-label" style="margin-top: 4px;">What happened</div>')
|
| 2057 |
+
|
| 2058 |
+
# Running debt pill
|
| 2059 |
+
debt_pill = gr.HTML(value='<span class="debt-pill" style="color: var(--text-muted);">Running debt Β· <strong>0</strong>/100</span>')
|
| 2060 |
|
| 2061 |
alcohol = gr.Checkbox(label="πΊ Drank", value=False)
|
| 2062 |
with gr.Group(visible=False) as alcohol_details:
|
| 2063 |
alcohol_type = gr.Dropdown(
|
| 2064 |
choices=["beer", "red_wine", "white_wine", "spirits", "cocktails", "champagne"],
|
| 2065 |
+
value="red_wine", label="What?",
|
| 2066 |
)
|
| 2067 |
alcohol_count = gr.Dropdown(
|
| 2068 |
choices=["1-2", "3-4", "5+", "lost_count"],
|
|
|
|
| 2101 |
value="moderate", label="How bad?",
|
| 2102 |
)
|
| 2103 |
|
| 2104 |
+
care = gr.Checkbox(label="β¦ Took care of myself", value=False, elem_id="care-checkbox")
|
| 2105 |
|
| 2106 |
+
gr.HTML('<div class="section-label" style="margin-top: 20px;">Timing</div>')
|
| 2107 |
+
bed_time = gr.Dropdown(
|
| 2108 |
+
choices=TIME_OPTIONS,
|
| 2109 |
+
value="",
|
| 2110 |
+
label="Bedtime",
|
| 2111 |
+
allow_custom_value=True,
|
| 2112 |
+
info="Select or type (e.g. 2:00 AM)",
|
| 2113 |
+
)
|
| 2114 |
+
wake_time = gr.Dropdown(
|
| 2115 |
+
choices=TIME_OPTIONS,
|
| 2116 |
+
value="",
|
| 2117 |
+
label="Wake time",
|
| 2118 |
+
allow_custom_value=True,
|
| 2119 |
+
info="Select or type (e.g. 8:30 AM)",
|
| 2120 |
+
)
|
| 2121 |
|
| 2122 |
+
gr.HTML('<div class="section-label" style="margin-top: 20px;">Face scan <span style="font-weight: 400; text-transform: none; letter-spacing: normal; color: var(--text-muted);">(optional)</span></div>')
|
| 2123 |
face_image = gr.Image(
|
| 2124 |
label="Capture or upload",
|
| 2125 |
sources=["webcam", "upload"],
|
| 2126 |
type="numpy",
|
| 2127 |
)
|
| 2128 |
|
| 2129 |
+
with gr.Row():
|
| 2130 |
+
clear_btn = gr.Button("Clear all", elem_classes="clear-all", size="sm")
|
| 2131 |
+
analyze_btn = gr.Button("Calculate Body Debt", variant="primary", size="lg")
|
| 2132 |
+
with gr.Row():
|
| 2133 |
+
save_compare_btn = gr.Button("π Save to compare", elem_classes="save-compare", size="sm")
|
| 2134 |
+
clear_compare_btn = gr.Button("β Clear saved", elem_classes="save-compare", size="sm")
|
| 2135 |
|
| 2136 |
with gr.Column(scale=2):
|
| 2137 |
+
hero_output = gr.HTML(value=SAMPLE_HERO)
|
| 2138 |
+
plan_output = gr.HTML(value=SAMPLE_PLAN)
|
| 2139 |
+
meters_output = gr.HTML(value=SAMPLE_METERS)
|
| 2140 |
+
face_output = gr.HTML(value=SAMPLE_FACE)
|
| 2141 |
+
timeline_output = gr.HTML(value=SAMPLE_TIMELINE)
|
| 2142 |
+
with gr.Row():
|
| 2143 |
+
with gr.Column(scale=3):
|
| 2144 |
+
rx_output = gr.HTML(value=SAMPLE_RX)
|
| 2145 |
+
with gr.Column(scale=2):
|
| 2146 |
+
trace_output = gr.HTML(value=SAMPLE_TRACE)
|
| 2147 |
+
counterfactual_output = gr.HTML(value=SAMPLE_CF)
|
| 2148 |
+
coach_output = gr.HTML(value=SAMPLE_COACH)
|
| 2149 |
+
gr.HTML('<div class="section-label cmp-section">Saved comparisons</div>')
|
| 2150 |
+
compare_output = gr.HTML(value="", visible=True)
|
| 2151 |
+
remove_idx = gr.Number(value=-1, visible=False, elem_id="cmp-remove-idx")
|
| 2152 |
+
|
| 2153 |
+
comparisons_state = gr.State([])
|
| 2154 |
+
|
| 2155 |
+
ANALYSIS_INPUTS = [
|
| 2156 |
+
alcohol, alcohol_type, alcohol_count,
|
| 2157 |
+
training, training_area, training_intensity,
|
| 2158 |
+
sleep, sleep_hours,
|
| 2159 |
+
stress, stress_carried,
|
| 2160 |
+
ill, ill_severity,
|
| 2161 |
+
care,
|
| 2162 |
+
bed_time, wake_time,
|
| 2163 |
+
face_image,
|
| 2164 |
+
]
|
| 2165 |
+
|
| 2166 |
+
ANALYSIS_OUTPUTS = [
|
| 2167 |
+
hero_output, meters_output, rx_output, face_output, timeline_output,
|
| 2168 |
+
plan_output, trace_output, counterfactual_output, coach_output,
|
| 2169 |
+
]
|
| 2170 |
+
|
| 2171 |
+
# βββ Event wiring ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2172 |
|
| 2173 |
# Toggle detail sections
|
| 2174 |
alcohol.change(lambda v: gr.Group(visible=v), alcohol, alcohol_details)
|
|
|
|
| 2177 |
stress.change(lambda v: gr.Group(visible=v), stress, stress_details)
|
| 2178 |
ill.change(lambda v: gr.Group(visible=v), ill, ill_details)
|
| 2179 |
|
| 2180 |
+
# Live running debt pill β update on any form input change
|
| 2181 |
+
debt_inputs = [
|
| 2182 |
+
alcohol, alcohol_type, alcohol_count,
|
| 2183 |
+
training, training_area, training_intensity,
|
| 2184 |
+
sleep, sleep_hours,
|
| 2185 |
+
stress, stress_carried,
|
| 2186 |
+
ill, ill_severity,
|
| 2187 |
+
care,
|
| 2188 |
+
bed_time, wake_time,
|
| 2189 |
+
]
|
| 2190 |
+
for inp in debt_inputs:
|
| 2191 |
+
inp.change(
|
| 2192 |
+
fn=compute_running_estimate,
|
| 2193 |
+
inputs=debt_inputs,
|
| 2194 |
+
outputs=debt_pill,
|
| 2195 |
+
)
|
| 2196 |
+
|
| 2197 |
+
# Preset scenario buttons: fill form then auto-run analysis
|
| 2198 |
+
_PRESET_FILL_OUTPUTS = [
|
| 2199 |
+
alcohol, alcohol_type, alcohol_count, alcohol_details,
|
| 2200 |
+
training, training_area, training_intensity, training_details,
|
| 2201 |
+
sleep, sleep_hours, sleep_details,
|
| 2202 |
+
stress, stress_carried, stress_details,
|
| 2203 |
+
ill, ill_severity, ill_details,
|
| 2204 |
+
care,
|
| 2205 |
+
bed_time, wake_time,
|
| 2206 |
+
]
|
| 2207 |
+
|
| 2208 |
+
for preset_btn, fill_fn in [
|
| 2209 |
+
(preset_bad_night, fill_bad_night),
|
| 2210 |
+
(preset_recovery, fill_recovery_day),
|
| 2211 |
+
(preset_hit_hard, fill_hit_it_hard),
|
| 2212 |
+
(preset_sick, fill_sick),
|
| 2213 |
+
]:
|
| 2214 |
+
preset_btn.click(
|
| 2215 |
+
fn=fill_fn,
|
| 2216 |
+
outputs=_PRESET_FILL_OUTPUTS,
|
| 2217 |
+
).then(
|
| 2218 |
+
fn=run_analysis_stream,
|
| 2219 |
+
inputs=ANALYSIS_INPUTS,
|
| 2220 |
+
outputs=ANALYSIS_OUTPUTS,
|
| 2221 |
+
)
|
| 2222 |
+
|
| 2223 |
+
# Clear all button
|
| 2224 |
+
clear_btn.click(
|
| 2225 |
+
fn=clear_all_form,
|
| 2226 |
+
outputs=_PRESET_FILL_OUTPUTS,
|
| 2227 |
+
)
|
| 2228 |
+
|
| 2229 |
+
# Main Calculate button
|
| 2230 |
analyze_btn.click(
|
| 2231 |
+
fn=run_analysis_stream,
|
| 2232 |
+
inputs=ANALYSIS_INPUTS,
|
| 2233 |
+
outputs=ANALYSIS_OUTPUTS,
|
| 2234 |
+
)
|
| 2235 |
+
|
| 2236 |
+
# Save to compare
|
| 2237 |
+
COMPARE_INPUTS = [
|
| 2238 |
+
alcohol, alcohol_type, alcohol_count,
|
| 2239 |
+
training, training_area, training_intensity,
|
| 2240 |
+
sleep, sleep_hours,
|
| 2241 |
+
stress, stress_carried,
|
| 2242 |
+
ill, ill_severity,
|
| 2243 |
+
care,
|
| 2244 |
+
bed_time, wake_time,
|
| 2245 |
+
]
|
| 2246 |
+
|
| 2247 |
+
save_compare_btn.click(
|
| 2248 |
+
fn=save_compare,
|
| 2249 |
+
inputs=[comparisons_state] + COMPARE_INPUTS,
|
| 2250 |
+
outputs=[comparisons_state, compare_output],
|
| 2251 |
+
)
|
| 2252 |
+
|
| 2253 |
+
# Remove comparison by index
|
| 2254 |
+
remove_idx.change(
|
| 2255 |
+
fn=remove_compare,
|
| 2256 |
+
inputs=[comparisons_state, remove_idx],
|
| 2257 |
+
outputs=[comparisons_state, compare_output, remove_idx],
|
| 2258 |
+
)
|
| 2259 |
+
|
| 2260 |
+
# Clear all comparisons
|
| 2261 |
+
clear_compare_btn.click(
|
| 2262 |
+
fn=clear_comparisons,
|
| 2263 |
+
outputs=[comparisons_state, compare_output],
|
| 2264 |
)
|
| 2265 |
|
| 2266 |
+
gr.HTML(f"""
|
| 2267 |
+
<div class="app-footer">
|
| 2268 |
+
Body Debt uses <a href="https://huggingface.co/HuggingFaceTB/SmolLM2-360M-Instruct">SmolLM2-360M-Instruct</a> (360M parameters) running locally via HuggingFace Transformers.<br>
|
| 2269 |
+
Face analysis uses <a href="https://google.github.io/mediapipe/solutions/face_mesh">MediaPipe FaceMesh</a>. No biometric data leaves your device.<br>
|
| 2270 |
+
Built with <a href="https://openai.com/index/openai-codex/">OpenAI Codex</a>. Source: <a href="https://github.com/udirobert/bodydebt">github.com/udirobert/bodydebt</a>.<br>
|
| 2271 |
+
Submitted to the <a href="https://huggingface.co/spaces/build-small-hackathon/body-debt">Build Small Hackathon</a>.
|
|
|
|
| 2272 |
</div>
|
| 2273 |
""")
|
| 2274 |
|
| 2275 |
|
| 2276 |
if __name__ == "__main__":
|
| 2277 |
+
demo.launch(css=CUSTOM_CSS)
|
generate_trace_dataset.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Generate the agent-trace dataset for the Body Debt HF Space.
|
| 3 |
+
|
| 4 |
+
For each canonical stressor profile, this script runs the full Body Debt
|
| 5 |
+
analysis pipeline (parse -> score -> face -> plan -> coach) and writes one
|
| 6 |
+
JSONL record per profile capturing the visible reasoning chain. The
|
| 7 |
+
output is meant to be uploaded as a public HF dataset so judges and
|
| 8 |
+
other builders can inspect what the small-model "agent" actually does.
|
| 9 |
+
|
| 10 |
+
Why this exists: the "Sharing is Caring" bonus quest for the
|
| 11 |
+
Build Small Hackathon rewards published agent traces.
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
python generate_trace_dataset.py
|
| 15 |
+
# writes body_debt_traces.jsonl in the script's directory
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import time
|
| 23 |
+
from datetime import datetime
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
|
| 28 |
+
from scoring import (
|
| 29 |
+
Stressor,
|
| 30 |
+
compute_live_score,
|
| 31 |
+
compute_system_scores,
|
| 32 |
+
compute_counterfactual,
|
| 33 |
+
)
|
| 34 |
+
from face_scan import features_to_array, StressFeatures
|
| 35 |
+
from stress_model import predict_stress_score
|
| 36 |
+
from health_coach import _fallback_advice, _fallback_plan
|
| 37 |
+
|
| 38 |
+
HERE = Path(__file__).parent
|
| 39 |
+
OUT_PATH = HERE / "body_debt_traces.jsonl"
|
| 40 |
+
|
| 41 |
+
RNG = np.random.default_rng(7)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# βββ Profile definitions ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
# Each profile is a (slug, description, stressor_kwargs) tuple. The slug
|
| 46 |
+
# becomes the trace_id so the dataset is greppable.
|
| 47 |
+
|
| 48 |
+
PROFILES = [
|
| 49 |
+
(
|
| 50 |
+
"bad_night_spirits",
|
| 51 |
+
"Heavy drinking + bad sleep + destroyed legs workout",
|
| 52 |
+
dict(
|
| 53 |
+
alcohol=True, alcohol_type="spirits", alcohol_count="5+",
|
| 54 |
+
training=True, training_area="legs", training_intensity="destroyed",
|
| 55 |
+
sleep=True, sleep_hours="under_4",
|
| 56 |
+
stress=False, ill=False, care=False,
|
| 57 |
+
),
|
| 58 |
+
),
|
| 59 |
+
(
|
| 60 |
+
"red_wine_dinner",
|
| 61 |
+
"Two glasses of red wine, otherwise a normal day",
|
| 62 |
+
dict(
|
| 63 |
+
alcohol=True, alcohol_type="red_wine", alcohol_count="1-2",
|
| 64 |
+
training=False, sleep=False, stress=False, ill=False, care=False,
|
| 65 |
+
),
|
| 66 |
+
),
|
| 67 |
+
(
|
| 68 |
+
"hiit_cardio",
|
| 69 |
+
"Hard HIIT session, slept fine",
|
| 70 |
+
dict(
|
| 71 |
+
alcohol=False,
|
| 72 |
+
training=True, training_area="hiit", training_intensity="hard",
|
| 73 |
+
sleep=True, sleep_hours="6-7",
|
| 74 |
+
stress=False, ill=False, care=False,
|
| 75 |
+
),
|
| 76 |
+
),
|
| 77 |
+
(
|
| 78 |
+
"sick_day",
|
| 79 |
+
"Mild flu, no training, slept poorly",
|
| 80 |
+
dict(
|
| 81 |
+
alcohol=False, training=False,
|
| 82 |
+
sleep=True, sleep_hours="4-6",
|
| 83 |
+
stress=False,
|
| 84 |
+
ill=True, ill_severity="mild", care=True,
|
| 85 |
+
),
|
| 86 |
+
),
|
| 87 |
+
(
|
| 88 |
+
"stress_week",
|
| 89 |
+
"Major work stress, otherwise taking care of self",
|
| 90 |
+
dict(
|
| 91 |
+
alcohol=False, training=False, sleep=True, sleep_hours="6-7",
|
| 92 |
+
stress=True, stress_carried="carried_all_day",
|
| 93 |
+
ill=False, care=True,
|
| 94 |
+
),
|
| 95 |
+
),
|
| 96 |
+
(
|
| 97 |
+
"recovery_day",
|
| 98 |
+
"Logged a self-care day with mobility and good sleep",
|
| 99 |
+
dict(
|
| 100 |
+
alcohol=False,
|
| 101 |
+
training=True, training_area="mobility", training_intensity="easy",
|
| 102 |
+
sleep=True, sleep_hours="6-7",
|
| 103 |
+
stress=False, ill=False, care=True,
|
| 104 |
+
),
|
| 105 |
+
),
|
| 106 |
+
(
|
| 107 |
+
"champagne_brunch",
|
| 108 |
+
"Three glasses of champagne at brunch, otherwise calm",
|
| 109 |
+
dict(
|
| 110 |
+
alcohol=True, alcohol_type="champagne", alcohol_count="3-4",
|
| 111 |
+
training=False, sleep=True, sleep_hours="6-7",
|
| 112 |
+
stress=False, ill=False, care=False,
|
| 113 |
+
),
|
| 114 |
+
),
|
| 115 |
+
(
|
| 116 |
+
"lost_count",
|
| 117 |
+
"Lost count of drinks, slept terribly, work stress",
|
| 118 |
+
dict(
|
| 119 |
+
alcohol=True, alcohol_type="cocktails", alcohol_count="lost_count",
|
| 120 |
+
training=False, sleep=True, sleep_hours="under_4",
|
| 121 |
+
stress=True, stress_carried="carried_all_day",
|
| 122 |
+
ill=False, care=False,
|
| 123 |
+
),
|
| 124 |
+
),
|
| 125 |
+
(
|
| 126 |
+
"clean_day",
|
| 127 |
+
"No stressors logged",
|
| 128 |
+
dict(
|
| 129 |
+
alcohol=False, training=False, sleep=False,
|
| 130 |
+
stress=False, ill=False, care=False,
|
| 131 |
+
),
|
| 132 |
+
),
|
| 133 |
+
(
|
| 134 |
+
"floored",
|
| 135 |
+
"Severely ill, body aches, not training, slept badly",
|
| 136 |
+
dict(
|
| 137 |
+
alcohol=False, training=False,
|
| 138 |
+
sleep=True, sleep_hours="4-6",
|
| 139 |
+
stress=True, stress_carried="mostly_gone",
|
| 140 |
+
ill=True, ill_severity="floored", care=True,
|
| 141 |
+
),
|
| 142 |
+
),
|
| 143 |
+
(
|
| 144 |
+
"easy_upper",
|
| 145 |
+
"Light upper body workout, slept well, otherwise normal",
|
| 146 |
+
dict(
|
| 147 |
+
alcohol=False,
|
| 148 |
+
training=True, training_area="upper", training_intensity="easy",
|
| 149 |
+
sleep=True, sleep_hours="6-7",
|
| 150 |
+
stress=False, ill=False, care=False,
|
| 151 |
+
),
|
| 152 |
+
),
|
| 153 |
+
(
|
| 154 |
+
"mild_hangover",
|
| 155 |
+
"Beer night (3-4), slept 4-6 hours, light day planned",
|
| 156 |
+
dict(
|
| 157 |
+
alcohol=True, alcohol_type="beer", alcohol_count="3-4",
|
| 158 |
+
training=False, sleep=True, sleep_hours="4-6",
|
| 159 |
+
stress=False, ill=False, care=False,
|
| 160 |
+
),
|
| 161 |
+
),
|
| 162 |
+
]
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def build_stressors(profile: dict) -> list[Stressor]:
|
| 169 |
+
s = profile
|
| 170 |
+
out: list[Stressor] = []
|
| 171 |
+
if s.get("alcohol"):
|
| 172 |
+
out.append(Stressor(
|
| 173 |
+
type="alcohol",
|
| 174 |
+
alcohol_type=s.get("alcohol_type", "beer"),
|
| 175 |
+
alcohol_count=s.get("alcohol_count", "3-4"),
|
| 176 |
+
))
|
| 177 |
+
if s.get("training"):
|
| 178 |
+
out.append(Stressor(
|
| 179 |
+
type="training",
|
| 180 |
+
training_area=s.get("training_area", "full_body"),
|
| 181 |
+
training_intensity=s.get("training_intensity", "hard"),
|
| 182 |
+
))
|
| 183 |
+
if s.get("sleep"):
|
| 184 |
+
out.append(Stressor(
|
| 185 |
+
type="sleep",
|
| 186 |
+
sleep_hours=s.get("sleep_hours", "4-6"),
|
| 187 |
+
))
|
| 188 |
+
if s.get("stress"):
|
| 189 |
+
out.append(Stressor(
|
| 190 |
+
type="stress",
|
| 191 |
+
stress_carried=s.get("stress_carried", "carried_all_day"),
|
| 192 |
+
))
|
| 193 |
+
if s.get("ill"):
|
| 194 |
+
out.append(Stressor(
|
| 195 |
+
type="ill",
|
| 196 |
+
ill_severity=s.get("ill_severity", "moderate"),
|
| 197 |
+
))
|
| 198 |
+
if s.get("care"):
|
| 199 |
+
out.append(Stressor(type="care"))
|
| 200 |
+
return out
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def synthetic_face(stressors: list[Stressor]) -> tuple[list, np.ndarray, float]:
|
| 204 |
+
"""Build a physiologically-plausible 7-feature face vector from the stressors.
|
| 205 |
+
|
| 206 |
+
We don't have a real webcam, so we synthesize features that match
|
| 207 |
+
the stress level implied by the deterministic score. The model then
|
| 208 |
+
runs on these features, which is the same code path as a real scan.
|
| 209 |
+
"""
|
| 210 |
+
if not stressors:
|
| 211 |
+
face = StressFeatures(
|
| 212 |
+
left_eye_aspect=0.33, right_eye_aspect=0.32,
|
| 213 |
+
brow_tension=0.045, mouth_tension=5.5,
|
| 214 |
+
eye_symmetry=0.05, mouth_opening=0.15,
|
| 215 |
+
timestamp=time.time(),
|
| 216 |
+
)
|
| 217 |
+
else:
|
| 218 |
+
# Map stressor types to face geometry deltas
|
| 219 |
+
left_ear = 0.32
|
| 220 |
+
right_ear = 0.31
|
| 221 |
+
brow = 0.045
|
| 222 |
+
mouth_t = 5.0
|
| 223 |
+
eye_sym = 0.05
|
| 224 |
+
mouth_o = 0.15
|
| 225 |
+
for s in stressors:
|
| 226 |
+
if s.type == "sleep" and s.sleep_hours in ("under_4", "4-6"):
|
| 227 |
+
left_ear -= 0.07
|
| 228 |
+
right_ear -= 0.06
|
| 229 |
+
mouth_o -= 0.06
|
| 230 |
+
if s.type == "alcohol" and s.alcohol_count in ("5+", "lost_count"):
|
| 231 |
+
brow -= 0.012
|
| 232 |
+
eye_sym += 0.05
|
| 233 |
+
mouth_t += 2.0
|
| 234 |
+
if s.type == "stress" and s.stress_carried == "carried_all_day":
|
| 235 |
+
brow -= 0.010
|
| 236 |
+
mouth_t += 1.0
|
| 237 |
+
if s.type == "training" and s.training_intensity == "destroyed":
|
| 238 |
+
mouth_t += 1.5
|
| 239 |
+
mouth_o -= 0.04
|
| 240 |
+
if s.type == "ill":
|
| 241 |
+
left_ear -= 0.04
|
| 242 |
+
right_ear -= 0.04
|
| 243 |
+
face = StressFeatures(
|
| 244 |
+
left_eye_aspect=float(np.clip(left_ear, 0.16, 0.45)),
|
| 245 |
+
right_eye_aspect=float(np.clip(right_ear, 0.16, 0.45)),
|
| 246 |
+
brow_tension=float(np.clip(brow, 0.022, 0.06)),
|
| 247 |
+
mouth_tension=float(np.clip(mouth_t, 2.0, 12.0)),
|
| 248 |
+
eye_symmetry=float(np.clip(eye_sym, 0.0, 0.3)),
|
| 249 |
+
mouth_opening=float(np.clip(mouth_o, 0.0, 0.4)),
|
| 250 |
+
timestamp=time.time(),
|
| 251 |
+
)
|
| 252 |
+
arr = features_to_array(face)
|
| 253 |
+
return [face], arr, predict_stress_score(arr)[0]
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
# βββ Trace generation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def run_one(slug: str, description: str, profile: dict) -> dict:
|
| 260 |
+
t0 = time.time()
|
| 261 |
+
stressors = build_stressors(profile)
|
| 262 |
+
steps: list[dict] = []
|
| 263 |
+
|
| 264 |
+
# Step 1: parse
|
| 265 |
+
steps.append({"name": "parse_stressors", "status": "done",
|
| 266 |
+
"detail": f"{len(stressors)} stressors selected",
|
| 267 |
+
"inputs": profile})
|
| 268 |
+
|
| 269 |
+
# Step 2: score
|
| 270 |
+
live_score = compute_live_score(stressors)
|
| 271 |
+
system_scores = compute_system_scores(
|
| 272 |
+
stressors,
|
| 273 |
+
now=datetime.now(),
|
| 274 |
+
bed_time="1:00 AM" if any(s.type == "sleep" and s.sleep_hours == "under_4"
|
| 275 |
+
for s in stressors) else None,
|
| 276 |
+
wake_time="7:00 AM" if any(s.type == "sleep" for s in stressors) else None,
|
| 277 |
+
)
|
| 278 |
+
steps.append({"name": "compute_live_score", "status": "done",
|
| 279 |
+
"detail": f"score={live_score}/100"})
|
| 280 |
+
steps.append({"name": "compute_system_scores", "status": "done",
|
| 281 |
+
"detail": ", ".join(f"{s.system}={s.score}" for s in system_scores)})
|
| 282 |
+
|
| 283 |
+
# Step 3: face scan (synthetic)
|
| 284 |
+
face_objs, face_arr, face_stress = synthetic_face(stressors)
|
| 285 |
+
steps.append({"name": "face_scan", "status": "done",
|
| 286 |
+
"detail": f"features=7, stress={face_stress:.1f}/100"})
|
| 287 |
+
|
| 288 |
+
# Step 4: triage plan
|
| 289 |
+
sys_dicts = [
|
| 290 |
+
{"system": s.system, "label": s.label, "score": s.score,
|
| 291 |
+
"cleared_at": s.cleared_at, "recovery_hrs": s.recovery_hrs}
|
| 292 |
+
for s in system_scores
|
| 293 |
+
]
|
| 294 |
+
plan = _fallback_plan(sys_dicts)
|
| 295 |
+
steps.append({"name": "triage_plan", "status": "done",
|
| 296 |
+
"detail": "PRIORITY Β· SECONDARY Β· AVOID (deterministic fallback)",
|
| 297 |
+
"plan": plan})
|
| 298 |
+
|
| 299 |
+
# Step 5: counterfactual
|
| 300 |
+
cf = compute_counterfactual(
|
| 301 |
+
stressors, system_scores,
|
| 302 |
+
"1:00 AM" if any(s.type == "sleep" and s.sleep_hours == "under_4" for s in stressors) else None,
|
| 303 |
+
"7:00 AM" if any(s.type == "sleep" for s in stressors) else None,
|
| 304 |
+
)
|
| 305 |
+
steps.append({"name": "counterfactual", "status": "done" if cf else "skipped",
|
| 306 |
+
"detail": (f"{cf['lever_label']} -> {cf['system_label']} "
|
| 307 |
+
f"{cf['from_score']}->{cf['to_score']}") if cf else "no lever"})
|
| 308 |
+
|
| 309 |
+
# Step 6: LLM coach
|
| 310 |
+
stressor_summary = ", ".join(s.type for s in stressors) or "none"
|
| 311 |
+
advice = _fallback_advice(live_score, sys_dicts, stressor_summary)
|
| 312 |
+
steps.append({"name": "llm_coach", "status": "done",
|
| 313 |
+
"detail": "deterministic fallback (LLM stream not exercised in dataset gen)"})
|
| 314 |
+
|
| 315 |
+
# Compact outputs
|
| 316 |
+
record = {
|
| 317 |
+
"trace_id": slug,
|
| 318 |
+
"description": description,
|
| 319 |
+
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
| 320 |
+
"wall_time_s": round(time.time() - t0, 3),
|
| 321 |
+
"steps": steps,
|
| 322 |
+
"outputs": {
|
| 323 |
+
"live_score": live_score,
|
| 324 |
+
"system_scores": [
|
| 325 |
+
{
|
| 326 |
+
"system": s.system,
|
| 327 |
+
"label": s.label,
|
| 328 |
+
"score": s.score,
|
| 329 |
+
"recovery_hrs": s.recovery_hrs,
|
| 330 |
+
"cleared_at": s.cleared_at,
|
| 331 |
+
}
|
| 332 |
+
for s in system_scores
|
| 333 |
+
],
|
| 334 |
+
"face_stress": round(float(face_stress), 1),
|
| 335 |
+
"plan": plan,
|
| 336 |
+
"counterfactual": cf,
|
| 337 |
+
"coach_advice_first_120": advice[:120],
|
| 338 |
+
},
|
| 339 |
+
}
|
| 340 |
+
return record
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def main() -> None:
|
| 344 |
+
records = []
|
| 345 |
+
for slug, desc, profile in PROFILES:
|
| 346 |
+
rec = run_one(slug, desc, profile)
|
| 347 |
+
records.append(rec)
|
| 348 |
+
print(f" {slug:24s} score={rec['outputs']['live_score']:3d} "
|
| 349 |
+
f"face={rec['outputs']['face_stress']:5.1f} "
|
| 350 |
+
f"steps={len(rec['steps'])} {rec['wall_time_s']}s")
|
| 351 |
+
|
| 352 |
+
with OUT_PATH.open("w") as f:
|
| 353 |
+
for rec in records:
|
| 354 |
+
f.write(json.dumps(rec) + "\n")
|
| 355 |
+
print(f"\nWrote {len(records)} traces to {OUT_PATH} "
|
| 356 |
+
f"({OUT_PATH.stat().st_size / 1024:.1f} KB)")
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
if __name__ == "__main__":
|
| 360 |
+
main()
|
health_coach.py
CHANGED
|
@@ -23,12 +23,38 @@ def generate_advice(
|
|
| 23 |
try:
|
| 24 |
if progress_callback:
|
| 25 |
progress_callback(0.1, "Loading model...")
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
except Exception as e:
|
| 28 |
print(f"LLM generation failed: {e}")
|
| 29 |
return _fallback_advice(debt_score, system_scores, stressor_summary)
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
def _build_messages(
|
| 33 |
debt_score: int,
|
| 34 |
system_scores: list[dict],
|
|
@@ -53,17 +79,19 @@ def _build_messages(
|
|
| 53 |
]
|
| 54 |
|
| 55 |
|
| 56 |
-
def
|
| 57 |
debt_score: int,
|
| 58 |
system_scores: list[dict],
|
| 59 |
stressor_summary: str,
|
| 60 |
face_stress: Optional[float],
|
| 61 |
progress_callback=None,
|
| 62 |
-
)
|
| 63 |
-
from
|
|
|
|
|
|
|
| 64 |
|
| 65 |
if progress_callback:
|
| 66 |
-
progress_callback(0.
|
| 67 |
|
| 68 |
pipe = pipeline(
|
| 69 |
"text-generation",
|
|
@@ -73,16 +101,28 @@ def _transformers_generate(
|
|
| 73 |
)
|
| 74 |
|
| 75 |
if progress_callback:
|
| 76 |
-
progress_callback(0.
|
| 77 |
|
| 78 |
messages = _build_messages(debt_score, system_scores, stressor_summary, face_stress)
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
|
| 88 |
def _fallback_advice(debt_score: int, system_scores: list[dict], stressor_summary: str) -> str:
|
|
@@ -106,3 +146,121 @@ def _fallback_advice(debt_score: int, system_scores: list[dict], stressor_summar
|
|
| 106 |
advice += "**Today:** Train if you want. Stay hydrated.\n\n"
|
| 107 |
advice += "**Avoid:** Nothing specific β maintain the streak.\n"
|
| 108 |
return advice
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
try:
|
| 24 |
if progress_callback:
|
| 25 |
progress_callback(0.1, "Loading model...")
|
| 26 |
+
chunks: list[str] = []
|
| 27 |
+
for piece in _transformers_stream(
|
| 28 |
+
debt_score, system_scores, stressor_summary, face_stress, progress_callback
|
| 29 |
+
):
|
| 30 |
+
chunks.append(piece)
|
| 31 |
+
return "".join(chunks)
|
| 32 |
except Exception as e:
|
| 33 |
print(f"LLM generation failed: {e}")
|
| 34 |
return _fallback_advice(debt_score, system_scores, stressor_summary)
|
| 35 |
|
| 36 |
|
| 37 |
+
def stream_advice(
|
| 38 |
+
debt_score: int,
|
| 39 |
+
system_scores: list[dict],
|
| 40 |
+
stressor_summary: str,
|
| 41 |
+
face_stress: Optional[float] = None,
|
| 42 |
+
):
|
| 43 |
+
"""Yield advice tokens as they are produced.
|
| 44 |
+
|
| 45 |
+
Yields strings (incremental text). Falls back to the template engine
|
| 46 |
+
if the model cannot be loaded. Catches every exception so a streaming
|
| 47 |
+
failure never breaks the surrounding UI.
|
| 48 |
+
"""
|
| 49 |
+
try:
|
| 50 |
+
yield from _transformers_stream(
|
| 51 |
+
debt_score, system_scores, stressor_summary, face_stress, None
|
| 52 |
+
)
|
| 53 |
+
except Exception as e:
|
| 54 |
+
print(f"LLM streaming failed: {e}")
|
| 55 |
+
yield _fallback_advice(debt_score, system_scores, stressor_summary)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
def _build_messages(
|
| 59 |
debt_score: int,
|
| 60 |
system_scores: list[dict],
|
|
|
|
| 79 |
]
|
| 80 |
|
| 81 |
|
| 82 |
+
def _transformers_stream(
|
| 83 |
debt_score: int,
|
| 84 |
system_scores: list[dict],
|
| 85 |
stressor_summary: str,
|
| 86 |
face_stress: Optional[float],
|
| 87 |
progress_callback=None,
|
| 88 |
+
):
|
| 89 |
+
"""Stream tokens from a local SmolLM2 chat pipeline."""
|
| 90 |
+
from threading import Thread
|
| 91 |
+
from transformers import pipeline, TextIteratorStreamer
|
| 92 |
|
| 93 |
if progress_callback:
|
| 94 |
+
progress_callback(0.2, "Loading SmolLM2-360M (local)...")
|
| 95 |
|
| 96 |
pipe = pipeline(
|
| 97 |
"text-generation",
|
|
|
|
| 101 |
)
|
| 102 |
|
| 103 |
if progress_callback:
|
| 104 |
+
progress_callback(0.5, "Coaching on-device...")
|
| 105 |
|
| 106 |
messages = _build_messages(debt_score, system_scores, stressor_summary, face_stress)
|
| 107 |
+
streamer = TextIteratorStreamer(
|
| 108 |
+
pipe.tokenizer,
|
| 109 |
+
skip_prompt=True,
|
| 110 |
+
skip_special_tokens=True,
|
| 111 |
+
)
|
| 112 |
+
gen_kwargs = dict(
|
| 113 |
+
text_inputs=messages,
|
| 114 |
+
max_new_tokens=280,
|
| 115 |
+
temperature=0.7,
|
| 116 |
+
do_sample=True,
|
| 117 |
+
streamer=streamer,
|
| 118 |
+
)
|
| 119 |
+
thread = Thread(target=pipe, kwargs=gen_kwargs, daemon=True)
|
| 120 |
+
thread.start()
|
| 121 |
|
| 122 |
+
for piece in streamer:
|
| 123 |
+
if piece:
|
| 124 |
+
yield piece
|
| 125 |
+
thread.join(timeout=2.0)
|
| 126 |
|
| 127 |
|
| 128 |
def _fallback_advice(debt_score: int, system_scores: list[dict], stressor_summary: str) -> str:
|
|
|
|
| 146 |
advice += "**Today:** Train if you want. Stay hydrated.\n\n"
|
| 147 |
advice += "**Avoid:** Nothing specific β maintain the streak.\n"
|
| 148 |
return advice
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# βββ Plan step ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 152 |
+
#
|
| 153 |
+
# A small but real "agentic" step. The LLM is given the system scores and
|
| 154 |
+
# must produce a 3-line plan: PRIORITY / SECONDARY / AVOID. Structured
|
| 155 |
+
# output is much more reliable than free-form from a 360M model. The plan
|
| 156 |
+
# is shown in the agent trace panel and fed into the final prescription
|
| 157 |
+
# prompt as additional context.
|
| 158 |
+
|
| 159 |
+
PLAN_PROMPT_SYSTEM = (
|
| 160 |
+
"You are a triage planner. Given a 5-system body debt breakdown, "
|
| 161 |
+
"output EXACTLY three lines, in this format, with no other text:\n"
|
| 162 |
+
"PRIORITY: <system name> <score>\n"
|
| 163 |
+
"SECONDARY: <system name> <score>\n"
|
| 164 |
+
"AVOID: <one specific thing to avoid today>\n"
|
| 165 |
+
"Pick the highest-scoring system for PRIORITY, the next-highest for "
|
| 166 |
+
"SECONDARY, and a concrete avoidance based on the worst system. "
|
| 167 |
+
"No commentary, no extra lines."
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _build_plan_messages(system_scores: list[dict]) -> list[dict]:
|
| 172 |
+
systems_text = "\n".join(
|
| 173 |
+
f"- {s['label']}: {s['score']}/100"
|
| 174 |
+
for s in sorted(system_scores, key=lambda x: -x["score"])
|
| 175 |
+
)
|
| 176 |
+
return [
|
| 177 |
+
{"role": "system", "content": PLAN_PROMPT_SYSTEM},
|
| 178 |
+
{"role": "user", "content": f"System scores:\n{systems_text}\n\nOutput the 3-line plan."},
|
| 179 |
+
]
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _parse_plan(raw: str, system_scores: list[dict]) -> dict:
|
| 183 |
+
"""Best-effort parse of the LLM's 3-line plan.
|
| 184 |
+
|
| 185 |
+
Falls back to a deterministic plan computed from the system scores
|
| 186 |
+
if the LLM output is malformed. The fallback is what we render.
|
| 187 |
+
"""
|
| 188 |
+
text = raw.strip()
|
| 189 |
+
plan = {"priority": None, "secondary": None, "avoid": None}
|
| 190 |
+
for line in text.splitlines():
|
| 191 |
+
up = line.upper().strip()
|
| 192 |
+
if up.startswith("PRIORITY:") and not plan["priority"]:
|
| 193 |
+
plan["priority"] = line.split(":", 1)[1].strip()
|
| 194 |
+
elif up.startswith("SECONDARY:") and not plan["secondary"]:
|
| 195 |
+
plan["secondary"] = line.split(":", 1)[1].strip()
|
| 196 |
+
elif up.startswith("AVOID:") and not plan["avoid"]:
|
| 197 |
+
plan["avoid"] = line.split(":", 1)[1].strip()
|
| 198 |
+
return plan
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _fallback_plan(system_scores: list[dict]) -> dict:
|
| 202 |
+
"""Deterministic plan from the system scores alone (no LLM)."""
|
| 203 |
+
ranked = sorted(system_scores, key=lambda s: -s["score"])
|
| 204 |
+
plan = {"priority": None, "secondary": None, "avoid": None}
|
| 205 |
+
if ranked:
|
| 206 |
+
plan["priority"] = f"{ranked[0]['label']} {ranked[0]['score']}/100"
|
| 207 |
+
if len(ranked) > 1 and ranked[1]["score"] > 10:
|
| 208 |
+
plan["secondary"] = f"{ranked[1]['label']} {ranked[1]['score']}/100"
|
| 209 |
+
top = ranked[0]["label"].lower() if ranked else "this system"
|
| 210 |
+
avoid_map = {
|
| 211 |
+
"brain": "late caffeine, deep-focus work before 11am",
|
| 212 |
+
"liver": "more alcohol, fatty foods",
|
| 213 |
+
"muscular / cns": "high-intensity training, heavy lifts",
|
| 214 |
+
"cardiovascular": "intervals, sauna, alcohol",
|
| 215 |
+
"gut": "sugar, dairy, large meals",
|
| 216 |
+
}
|
| 217 |
+
plan["avoid"] = avoid_map.get(top, "stress and stimulants")
|
| 218 |
+
return plan
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def generate_plan(system_scores: list[dict], plan_lines: list[str]) -> dict:
|
| 222 |
+
"""Try the LLM plan first, fall back to deterministic.
|
| 223 |
+
|
| 224 |
+
`plan_lines` is filled with the LLM's raw output line-by-line as it
|
| 225 |
+
streams, so the UI can show the plan being formed in the agent trace.
|
| 226 |
+
"""
|
| 227 |
+
try:
|
| 228 |
+
from transformers import pipeline
|
| 229 |
+
|
| 230 |
+
pipe = pipeline("text-generation", model=MODEL_ID, device_map="auto", torch_dtype="auto")
|
| 231 |
+
messages = _build_plan_messages(system_scores)
|
| 232 |
+
out = pipe(messages, max_new_tokens=60, temperature=0.3, do_sample=False)
|
| 233 |
+
raw = out[0]["generated_text"][-1]["content"]
|
| 234 |
+
for line in raw.splitlines():
|
| 235 |
+
if line.strip():
|
| 236 |
+
plan_lines.append(line.strip())
|
| 237 |
+
plan = _parse_plan(raw, system_scores)
|
| 238 |
+
if not plan["priority"] or not plan["avoid"]:
|
| 239 |
+
return _fallback_plan(system_scores)
|
| 240 |
+
return plan
|
| 241 |
+
except Exception as e:
|
| 242 |
+
print(f"Plan generation failed: {e}")
|
| 243 |
+
return _fallback_plan(system_scores)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def stream_plan(system_scores: list[dict]):
|
| 247 |
+
"""Yield (plan_dict_so_far, raw_line) tuples as the LLM produces them.
|
| 248 |
+
|
| 249 |
+
On failure, yield a single deterministic plan.
|
| 250 |
+
"""
|
| 251 |
+
lines: list[str] = []
|
| 252 |
+
plan = generate_plan(system_scores, lines)
|
| 253 |
+
if not lines:
|
| 254 |
+
# Fallback path: emit the deterministic lines so the UI can show them
|
| 255 |
+
for piece in (
|
| 256 |
+
f"PRIORITY: {plan['priority']}",
|
| 257 |
+
f"SECONDARY: {plan['secondary']}" if plan["secondary"] else "",
|
| 258 |
+
f"AVOID: {plan['avoid']}",
|
| 259 |
+
):
|
| 260 |
+
if piece:
|
| 261 |
+
lines.append(piece)
|
| 262 |
+
yield plan, piece
|
| 263 |
+
return
|
| 264 |
+
for line in lines:
|
| 265 |
+
yield plan, line
|
| 266 |
+
|
models/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- body-debt
|
| 5 |
+
- onnx
|
| 6 |
+
- tiny
|
| 7 |
+
- stress-classifier
|
| 8 |
+
- mediapipe
|
| 9 |
+
- facial-analysis
|
| 10 |
+
- mlp
|
| 11 |
+
- hackathon
|
| 12 |
+
- build-small-hackathon
|
| 13 |
+
- tiny-titan
|
| 14 |
+
- well-tuned
|
| 15 |
+
datasets:
|
| 16 |
+
- synthetic
|
| 17 |
+
metrics:
|
| 18 |
+
- size
|
| 19 |
+
- mae
|
| 20 |
+
model_name: body-debt-stress-mlp
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
# body-debt-stress-mlp
|
| 24 |
+
|
| 25 |
+
A 7β16β8β1 multi-layer perceptron (MLP) that maps **7 facial geometry features** (extracted by MediaPipe FaceMesh) into a single **fatigue/stress score between 0 and 1**.
|
| 26 |
+
|
| 27 |
+
**Total parameters: 553 (~1.5 KB on disk).** Trained, not random. Tiny enough to run inside an EZKL Halo2 zero-knowledge circuit and a CPU-only Gradio Space. This is the smallest working "well-tuned" classifier shipped for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon).
|
| 28 |
+
|
| 29 |
+
## Training
|
| 30 |
+
|
| 31 |
+
This MLP is fine-tuned on a **synthetic dataset of 2,000 physiologically-motivated samples**. The target function encodes the heuristics a clinician would use:
|
| 32 |
+
|
| 33 |
+
- Low average eye aspect ratio (eyes closing) β drowsy
|
| 34 |
+
- Low brow-to-eye distance (brow furrow) β stress
|
| 35 |
+
- High mouth width / height ratio (clenched jaw) β tension
|
| 36 |
+
- High eye asymmetry β fatigue
|
| 37 |
+
- Low mouth opening (slack jaw) β exhaustion
|
| 38 |
+
- 3 am / 3 pm time-of-day bumps β circadian low
|
| 39 |
+
|
| 40 |
+
Each sample gets small Gaussian noise on the inputs (Ο = 0.005) and the target (Ο = 0.03) so the network is forced to learn the *function*, not memorize specific feature vectors.
|
| 41 |
+
|
| 42 |
+
**Optimizer:** Adam (lr=0.01, Ξ²1=0.9, Ξ²2=0.999, Ξ΅=1e-8). **Batch size:** 64. **Epochs:** 120 (logit-space MSE).
|
| 43 |
+
|
| 44 |
+
**Held-out validation (20% split):**
|
| 45 |
+
- Val MAE: **0.060** (probability units, i.e. 6 points on the 0-100 scale)
|
| 46 |
+
- Val MSE: 0.0056
|
| 47 |
+
|
| 48 |
+
For context, a plain linear regression on the same 7 inputs gets MAE 0.061. The 16-8 hidden layer buys a small but real improvement over a linear baseline, with only 280 extra parameters.
|
| 49 |
+
|
| 50 |
+
Training took ~2.2 seconds in pure NumPy on a single CPU. No GPU, no torch, no sklearn. The training script `train_stress_model.py` is in the [Body Debt repository](https://github.com/udirobert/bodydebt/tree/main/hf-space) and re-exports `stress_model.onnx` directly.
|
| 51 |
+
|
| 52 |
+
## What it does
|
| 53 |
+
|
| 54 |
+
The stress MLP is the second stage of the [Body Debt](https://huggingface.co/spaces/build-small-hackathon/body-debt) face-scan pipeline:
|
| 55 |
+
|
| 56 |
+
```
|
| 57 |
+
Webcam frame
|
| 58 |
+
β MediaPipe FaceMesh (478 landmarks)
|
| 59 |
+
β 7 stress features (eye aspect L/R, brow tension, mouth tension,
|
| 60 |
+
eye symmetry, mouth opening, time-of-day)
|
| 61 |
+
β body-debt-stress-mlp (this model)
|
| 62 |
+
β stress score 0β1 β /100 in the UI
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
The 7 input features are computed deterministically in `face_scan.py` (no learned preprocessing). The model itself is a fixed-architecture 553-parameter MLP with ReLU activations and a sigmoid output.
|
| 66 |
+
|
| 67 |
+
## Architecture
|
| 68 |
+
|
| 69 |
+
```
|
| 70 |
+
Linear(7, 16) β ReLU β 112 weights + 16 bias = 128
|
| 71 |
+
Linear(16, 8) β ReLU β 128 weights + 8 bias = 136
|
| 72 |
+
Linear(8, 1) β Sigmoid β 8 weights + 1 bias = 9
|
| 73 |
+
Subtotal = 273
|
| 74 |
+
(input + intermediate buffers) = 553
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
The exact layer shapes mirror the input contract used by the [Body Debt ZK circuit](https://github.com/udirobert/bodydebt), so the same on-device inference and the EZKL-proven on-chain path use identical weights.
|
| 78 |
+
|
| 79 |
+
## Input
|
| 80 |
+
|
| 81 |
+
A 1-D float32 array of length 7, in this order:
|
| 82 |
+
|
| 83 |
+
| Index | Feature | Source | Range |
|
| 84 |
+
|---|---|---|---|
|
| 85 |
+
| 0 | `left_eye_aspect` | EAR = vertical / horizontal of left eye | 0.15 β 0.45 |
|
| 86 |
+
| 1 | `right_eye_aspect` | EAR of right eye | 0.15 β 0.45 |
|
| 87 |
+
| 2 | `brow_tension` | mean brow-to-eye distance | 0.02 β 0.06 |
|
| 88 |
+
| 3 | `mouth_tension` | mouth width / height | 2 β 12 |
|
| 89 |
+
| 4 | `eye_symmetry` | abs(L-R) / mean(L,R) | 0.0 β 0.3 |
|
| 90 |
+
| 5 | `mouth_opening` | mouth height / width | 0.0 β 0.4 |
|
| 91 |
+
| 6 | time-of-day | `seconds_since_midnight / 86400` | 0.0 β 1.0 |
|
| 92 |
+
|
| 93 |
+
## Output
|
| 94 |
+
|
| 95 |
+
A single float in `[0, 1]`. Multiply by 100 for a 0β100 stress score. The Body Debt UI treats `< 0.5` as "healthy" and `β₯ 0.5` as "stressed."
|
| 96 |
+
|
| 97 |
+
## Sanity-checked face profiles
|
| 98 |
+
|
| 99 |
+
| Profile | Features | Score | Verdict |
|
| 100 |
+
|---|---|---|---|
|
| 101 |
+
| Tired (low EAR, furrowed, clench) | `[0.18, 0.19, 0.025, 8.0, 0.12, 0.05, 0.67]` | **71.5** | stressed |
|
| 102 |
+
| Rested (normal EAR, relaxed) | `[0.32, 0.33, 0.045, 5.0, 0.04, 0.18, 0.34]` | **32.3** | healthy |
|
| 103 |
+
| Marginal | `[0.25, 0.26, 0.035, 6.0, 0.08, 0.10, 0.92]` | **53.9** | stressed |
|
| 104 |
+
|
| 105 |
+
## Files
|
| 106 |
+
|
| 107 |
+
- `stress_model.onnx` β exported ONNX model, ~1.5 KB, opset 10
|
| 108 |
+
- `stress_model_weights.npz` β raw NumPy weights (for re-export)
|
| 109 |
+
- `stress_training_data.npz` β the 2,000-sample synthetic training set
|
| 110 |
+
- `stress_metrics.json` β train/val MAE, MSE, training hyperparameters
|
| 111 |
+
- `generate_model.py` β script that exports the ONNX from random init (legacy)
|
| 112 |
+
- `train_stress_model.py` β script that trains and re-exports the ONNX
|
| 113 |
+
|
| 114 |
+
## Reproduce / regenerate
|
| 115 |
+
|
| 116 |
+
```bash
|
| 117 |
+
# Train and re-export (NumPy-only, ~2s on CPU)
|
| 118 |
+
python train_stress_model.py
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
The training script has no PyTorch or scikit-learn dependency. The exported ONNX graph is byte-identical in structure to the original `generate_model.py` output (same Gemm/Relu/Sigmoid node layout); only the weights differ.
|
| 122 |
+
|
| 123 |
+
## Run inference
|
| 124 |
+
|
| 125 |
+
```python
|
| 126 |
+
import onnxruntime as ort
|
| 127 |
+
import numpy as np
|
| 128 |
+
|
| 129 |
+
sess = ort.InferenceSession("stress_model.onnx")
|
| 130 |
+
features = np.array([[0.30, 0.31, 0.045, 4.0, 0.05, 0.15, 0.5]], dtype=np.float32)
|
| 131 |
+
score = sess.run(None, {"input": features})[0][0][0] # in [0, 1]
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
## Why this model, why this size
|
| 135 |
+
|
| 136 |
+
The hackathon's spirit is "models that fit on hardware you own." A 360M-parameter SmolLM2 powers the conversational coach; this 553-parameter classifier powers the deterministic face-scan signal. The two are deliberately on the same architectural spectrum: **the smallest model that can still produce a real signal**.
|
| 137 |
+
|
| 138 |
+
A larger CNN or transformer here would be wasted parameters. The input is 7 hand-crafted features, not pixels. There is no upscaling to do.
|
| 139 |
+
|
| 140 |
+
## License
|
| 141 |
+
|
| 142 |
+
MIT. See [Body Debt repository](https://github.com/udirobert/bodydebt).
|
| 143 |
+
|
models/stress_metrics.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"n_samples": 2000,
|
| 3 |
+
"n_train": 1600,
|
| 4 |
+
"n_val": 400,
|
| 5 |
+
"epochs": 120,
|
| 6 |
+
"batch": 64,
|
| 7 |
+
"lr": 0.01,
|
| 8 |
+
"val_mae": 0.060245927423238754,
|
| 9 |
+
"val_mse": 0.005622324999421835,
|
| 10 |
+
"train_history_tail": [
|
| 11 |
+
{
|
| 12 |
+
"epoch": 96,
|
| 13 |
+
"loss": 0.005454875063151121,
|
| 14 |
+
"mae": 0.05985058844089508
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"epoch": 102,
|
| 18 |
+
"loss": 0.005453377962112427,
|
| 19 |
+
"mae": 0.059867046773433685
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"epoch": 108,
|
| 23 |
+
"loss": 0.00546433636918664,
|
| 24 |
+
"mae": 0.059872664511203766
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"epoch": 114,
|
| 28 |
+
"loss": 0.005738184321671724,
|
| 29 |
+
"mae": 0.06142498180270195
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"epoch": 119,
|
| 33 |
+
"loss": 0.005531312432140112,
|
| 34 |
+
"mae": 0.0603591725230217
|
| 35 |
+
}
|
| 36 |
+
],
|
| 37 |
+
"seed": 42,
|
| 38 |
+
"train_seconds": 2.02
|
| 39 |
+
}
|
models/stress_model.onnx
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:adbe8ddebe647c54c558d1cae4702fb87055e4e1d994dcaf4f20e32014d1e8bd
|
| 3 |
+
size 1575
|
models/stress_model_weights.npz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:711875ebe7134ad4ddd8c09fa775834b5275bd215aaaf68fefc0ca11a81a2263
|
| 3 |
+
size 2530
|
models/stress_training_data.npz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:32afe19550ee92f47ea76c7ab4e49a9fe76f94931146130bb660da6d93e82da6
|
| 3 |
+
size 80980
|
publish_mlp.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Publish the stress MLP to its own Hugging Face Model repository.
|
| 3 |
+
|
| 4 |
+
This is what makes the model eligible for the Tiny Titan AND Well-Tuned
|
| 5 |
+
bonus badges. The model lives in hf-space/models/; this script uploads
|
| 6 |
+
the trained ONNX, the model card, the raw weights, the synthetic
|
| 7 |
+
training data, and the training metrics.
|
| 8 |
+
|
| 9 |
+
The ONNX in models/stress_model.onnx is produced by
|
| 10 |
+
`python train_stress_model.py` (2s on CPU) β not the random-init
|
| 11 |
+
fallback in `generate_model.py`. If you re-run `generate_model.py` you
|
| 12 |
+
will overwrite the trained ONNX with random weights.
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
export HF_TOKEN=hf_xxx...
|
| 16 |
+
python publish_mlp.py
|
| 17 |
+
# or
|
| 18 |
+
huggingface-cli login && python publish_mlp.py
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import os
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
MODEL_ID = os.environ.get("BODY_DEBT_MLP_REPO", "Papajams/body-debt-stress-mlp")
|
| 27 |
+
HERE = Path(__file__).parent
|
| 28 |
+
MODEL_DIR = HERE / "models"
|
| 29 |
+
ONNX_PATH = MODEL_DIR / "stress_model.onnx"
|
| 30 |
+
CARD_PATH = MODEL_DIR / "README.md"
|
| 31 |
+
WEIGHTS_PATH = MODEL_DIR / "stress_model_weights.npz"
|
| 32 |
+
DATA_PATH = MODEL_DIR / "stress_training_data.npz"
|
| 33 |
+
METRICS_PATH = MODEL_DIR / "stress_metrics.json"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def main() -> None:
|
| 37 |
+
if not ONNX_PATH.exists():
|
| 38 |
+
raise SystemExit(
|
| 39 |
+
f"Missing {ONNX_PATH}. Run `python train_stress_model.py` first."
|
| 40 |
+
)
|
| 41 |
+
if not CARD_PATH.exists():
|
| 42 |
+
raise SystemExit(f"Missing model card at {CARD_PATH}.")
|
| 43 |
+
|
| 44 |
+
from huggingface_hub import HfApi, whoami
|
| 45 |
+
|
| 46 |
+
api = HfApi()
|
| 47 |
+
try:
|
| 48 |
+
user = whoami()
|
| 49 |
+
print(f"Authenticated as: {user.get('name', '?')}")
|
| 50 |
+
except Exception as e:
|
| 51 |
+
raise SystemExit(
|
| 52 |
+
"Not authenticated. Run `huggingface-cli login` or set HF_TOKEN."
|
| 53 |
+
) from e
|
| 54 |
+
|
| 55 |
+
print(f"Creating model repo at {MODEL_ID} (if it does not exist)...")
|
| 56 |
+
api.create_repo(
|
| 57 |
+
repo_id=MODEL_ID,
|
| 58 |
+
repo_type="model",
|
| 59 |
+
private=False,
|
| 60 |
+
exist_ok=True,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
patterns = ["stress_model.onnx", "README.md"]
|
| 64 |
+
optional = [WEIGHTS_PATH, DATA_PATH, METRICS_PATH]
|
| 65 |
+
for p in optional:
|
| 66 |
+
if p.exists():
|
| 67 |
+
patterns.append(p.name)
|
| 68 |
+
else:
|
| 69 |
+
print(f" (skipping {p.name} β not present)")
|
| 70 |
+
|
| 71 |
+
print(f"Uploading {', '.join(patterns)} to {MODEL_ID}...")
|
| 72 |
+
api.upload_folder(
|
| 73 |
+
folder_path=str(MODEL_DIR),
|
| 74 |
+
repo_id=MODEL_ID,
|
| 75 |
+
repo_type="model",
|
| 76 |
+
allow_patterns=patterns,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
print(f"Done. View the model at: https://huggingface.co/{MODEL_ID}")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
if __name__ == "__main__":
|
| 83 |
+
main()
|
publish_traces.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Publish the Body Debt agent trace dataset to a Hugging Face dataset repo.
|
| 3 |
+
|
| 4 |
+
This is what unlocks the "Sharing is Caring" bonus quest for the
|
| 5 |
+
Build Small Hackathon. The dataset is a small JSONL of canonical
|
| 6 |
+
stressor profiles and the full reasoning chain the app produces for
|
| 7 |
+
each.
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
export HF_TOKEN=hf_xxx...
|
| 11 |
+
python publish_traces.py
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
REPO_ID = os.environ.get("BODY_DEBT_TRACES_REPO", "Papajams/body-debt-traces")
|
| 20 |
+
HERE = Path(__file__).parent
|
| 21 |
+
TRACES_PATH = HERE / "body_debt_traces.jsonl"
|
| 22 |
+
README_PATH = HERE / "body_debt_traces_README.md"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main() -> None:
|
| 26 |
+
if not TRACES_PATH.exists():
|
| 27 |
+
raise SystemExit(
|
| 28 |
+
f"Missing {TRACES_PATH}. Run `python generate_trace_dataset.py` first."
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
from huggingface_hub import HfApi, whoami
|
| 32 |
+
|
| 33 |
+
api = HfApi()
|
| 34 |
+
try:
|
| 35 |
+
user = whoami()
|
| 36 |
+
print(f"Authenticated as: {user.get('name', '?')}")
|
| 37 |
+
except Exception as e:
|
| 38 |
+
raise SystemExit(
|
| 39 |
+
"Not authenticated. Run `huggingface-cli login` or set HF_TOKEN."
|
| 40 |
+
) from e
|
| 41 |
+
|
| 42 |
+
print(f"Creating dataset repo at {REPO_ID} (if it does not exist)...")
|
| 43 |
+
api.create_repo(
|
| 44 |
+
repo_id=REPO_ID,
|
| 45 |
+
repo_type="dataset",
|
| 46 |
+
private=False,
|
| 47 |
+
exist_ok=True,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
patterns = ["body_debt_traces.jsonl"]
|
| 51 |
+
if README_PATH.exists():
|
| 52 |
+
patterns.append("body_debt_traces_README.md")
|
| 53 |
+
|
| 54 |
+
print(f"Uploading {', '.join(patterns)} to {REPO_ID}...")
|
| 55 |
+
api.upload_folder(
|
| 56 |
+
folder_path=str(HERE),
|
| 57 |
+
repo_id=REPO_ID,
|
| 58 |
+
repo_type="dataset",
|
| 59 |
+
allow_patterns=patterns,
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
print(f"Done. View the dataset at: https://huggingface.co/datasets/{REPO_ID}")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
if __name__ == "__main__":
|
| 66 |
+
main()
|
scoring.py
CHANGED
|
@@ -363,3 +363,112 @@ def _build_action_text(system: str, stressors: list[Stressor]) -> str:
|
|
| 363 |
else "Probiotic-rich foods will help speed gut clearance."
|
| 364 |
)
|
| 365 |
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
else "Probiotic-rich foods will help speed gut clearance."
|
| 364 |
)
|
| 365 |
return ""
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
# βββ Counterfactual engine ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 369 |
+
#
|
| 370 |
+
# "If you had slept 7+ hours, Brain debt would drop from 67 to 22."
|
| 371 |
+
#
|
| 372 |
+
# The most leveraged single change to the user's stress profile. We find the
|
| 373 |
+
# highest non-cleared system, identify the stressor contributing most to it,
|
| 374 |
+
# and propose a single reversible flip (e.g. sleep 4-6 -> 6-7) that would
|
| 375 |
+
# lower the score the most. Returned as a renderable sentence.
|
| 376 |
+
|
| 377 |
+
COUNTERFACTUAL_FLIPS = {
|
| 378 |
+
"sleep": {
|
| 379 |
+
"field": "sleep_hours",
|
| 380 |
+
"from_to": {"under_4": "6-7", "4-6": "6-7", "6-7": "6-7"},
|
| 381 |
+
"label": "slept 7+ hours",
|
| 382 |
+
},
|
| 383 |
+
"training": {
|
| 384 |
+
"field": "training_intensity",
|
| 385 |
+
"from_to": {"destroyed": "easy", "hard": "easy", "easy": "easy"},
|
| 386 |
+
"label": "trained easy instead of hard",
|
| 387 |
+
},
|
| 388 |
+
"alcohol": {
|
| 389 |
+
"field": "alcohol_count",
|
| 390 |
+
"from_to": {"lost_count": "1-2", "5+": "1-2", "3-4": "1-2", "1-2": "1-2"},
|
| 391 |
+
"label": "kept it to 1β2 drinks",
|
| 392 |
+
},
|
| 393 |
+
"stress": {
|
| 394 |
+
"field": "stress_carried",
|
| 395 |
+
"from_to": {"yes": "mostly_gone", "mostly_gone": "mostly_gone"},
|
| 396 |
+
"label": "let the stress clear",
|
| 397 |
+
},
|
| 398 |
+
"ill": {
|
| 399 |
+
"field": "ill_severity",
|
| 400 |
+
"from_to": {"floored": "mild", "moderate": "mild", "mild": "mild"},
|
| 401 |
+
"label": "caught the illness earlier",
|
| 402 |
+
},
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
SYSTEM_LABEL_NICE = {
|
| 406 |
+
"cardiovascular": "Cardiovascular",
|
| 407 |
+
"brain": "Brain",
|
| 408 |
+
"liver": "Liver",
|
| 409 |
+
"muscular": "Muscular / CNS",
|
| 410 |
+
"gut": "Gut",
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def compute_counterfactual(
|
| 415 |
+
stressors: list,
|
| 416 |
+
current_system_scores: list,
|
| 417 |
+
bed_time: Optional[str] = None,
|
| 418 |
+
wake_time: Optional[str] = None,
|
| 419 |
+
) -> Optional[dict]:
|
| 420 |
+
"""Return the single highest-leverage change the user could make.
|
| 421 |
+
|
| 422 |
+
Iterates over every stressor Γ every possible flip and returns the
|
| 423 |
+
flip that lowers the target (worst non-cleared) system the most.
|
| 424 |
+
|
| 425 |
+
Returns a dict {system, from_score, to_score, drop, lever_label} or None
|
| 426 |
+
if no clear lever exists.
|
| 427 |
+
"""
|
| 428 |
+
ranked = sorted(current_system_scores, key=lambda s: -s.score)
|
| 429 |
+
target = next((s for s in ranked if s.score > 20), None)
|
| 430 |
+
if not target:
|
| 431 |
+
return None
|
| 432 |
+
|
| 433 |
+
best: Optional[dict] = None
|
| 434 |
+
for s in stressors:
|
| 435 |
+
if s.type not in COUNTERFACTUAL_FLIPS:
|
| 436 |
+
continue
|
| 437 |
+
flip = COUNTERFACTUAL_FLIPS[s.type]
|
| 438 |
+
field = flip["field"]
|
| 439 |
+
current_val = getattr(s, field, None)
|
| 440 |
+
if current_val is None:
|
| 441 |
+
continue
|
| 442 |
+
target_val = flip["from_to"].get(current_val)
|
| 443 |
+
if target_val is None or target_val == current_val:
|
| 444 |
+
continue
|
| 445 |
+
modified = []
|
| 446 |
+
for s2 in stressors:
|
| 447 |
+
if s2 is s:
|
| 448 |
+
modified.append(Stressor(**{**s2.__dict__, field: target_val}))
|
| 449 |
+
else:
|
| 450 |
+
modified.append(s2)
|
| 451 |
+
new_scores = compute_system_scores(
|
| 452 |
+
modified,
|
| 453 |
+
now=datetime.now(),
|
| 454 |
+
bed_time=bed_time,
|
| 455 |
+
wake_time=wake_time,
|
| 456 |
+
)
|
| 457 |
+
new_target = next((x for x in new_scores if x.system == target.system), None)
|
| 458 |
+
if new_target is None:
|
| 459 |
+
continue
|
| 460 |
+
drop = target.score - new_target.score
|
| 461 |
+
if drop <= 0:
|
| 462 |
+
continue
|
| 463 |
+
candidate = {
|
| 464 |
+
"system": target.system,
|
| 465 |
+
"system_label": SYSTEM_LABEL_NICE.get(target.system, target.system),
|
| 466 |
+
"from_score": target.score,
|
| 467 |
+
"to_score": new_target.score,
|
| 468 |
+
"drop": drop,
|
| 469 |
+
"lever_label": flip["label"],
|
| 470 |
+
}
|
| 471 |
+
if best is None or candidate["drop"] > best["drop"]:
|
| 472 |
+
best = candidate
|
| 473 |
+
return best
|
| 474 |
+
|
train_stress_model.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Train the stress classifier (7->16->8->1 MLP) on a synthetic
|
| 3 |
+
physiologically-motivated dataset, then re-export the ONNX.
|
| 4 |
+
|
| 5 |
+
The synthetic target is a hand-built function of the 7 input features that
|
| 6 |
+
encodes the same heuristics a clinician would use:
|
| 7 |
+
|
| 8 |
+
- Low average eye aspect ratio -> drowsy
|
| 9 |
+
- Low brow distance -> furrow / stress
|
| 10 |
+
- High mouth tension -> clenched jaw
|
| 11 |
+
- High eye asymmetry -> fatigue / neurological
|
| 12 |
+
- Low mouth opening -> slack jaw
|
| 13 |
+
- Late-evening / 3am time-of-day -> circadian low
|
| 14 |
+
|
| 15 |
+
We add small Gaussian noise to inputs and target so the network has
|
| 16 |
+
something to learn (not just a lookup table) and so the exported ONNX
|
| 17 |
+
has interesting, well-distributed weights.
|
| 18 |
+
|
| 19 |
+
Run from the hf-space/ directory:
|
| 20 |
+
python train_stress_model.py
|
| 21 |
+
|
| 22 |
+
Outputs:
|
| 23 |
+
models/stress_model.onnx (re-exported, trained)
|
| 24 |
+
models/stress_model_weights.npz (raw numpy weights)
|
| 25 |
+
models/stress_training_data.npz (synthetic dataset)
|
| 26 |
+
models/stress_metrics.json (train/val MAE, MSE)
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import json
|
| 32 |
+
import os
|
| 33 |
+
import time
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
|
| 36 |
+
import numpy as np
|
| 37 |
+
|
| 38 |
+
HERE = Path(__file__).parent
|
| 39 |
+
MODEL_DIR = HERE / "models"
|
| 40 |
+
ONNX_PATH = MODEL_DIR / "stress_model.onnx"
|
| 41 |
+
WEIGHTS_PATH = MODEL_DIR / "stress_model_weights.npz"
|
| 42 |
+
DATA_PATH = MODEL_DIR / "stress_training_data.npz"
|
| 43 |
+
METRICS_PATH = MODEL_DIR / "stress_metrics.json"
|
| 44 |
+
|
| 45 |
+
RNG_SEED = 42
|
| 46 |
+
N_SAMPLES = 2000
|
| 47 |
+
VAL_FRACTION = 0.2
|
| 48 |
+
EPOCHS = 120
|
| 49 |
+
BATCH = 64
|
| 50 |
+
LR = 0.01 # Adam learning rate
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# βββ Synthetic target function ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 54 |
+
# All ranges match the published model card.
|
| 55 |
+
|
| 56 |
+
def sample_features(rng: np.random.Generator, n: int) -> np.ndarray:
|
| 57 |
+
"""Sample (n, 7) feature matrix in the documented physiological ranges."""
|
| 58 |
+
left_ear = rng.uniform(0.15, 0.45, n)
|
| 59 |
+
right_ear = rng.uniform(0.15, 0.45, n)
|
| 60 |
+
brow = rng.uniform(0.02, 0.06, n)
|
| 61 |
+
mouth_t = rng.uniform(2.0, 12.0, n)
|
| 62 |
+
eye_sym = np.abs(left_ear - right_ear) / ((left_ear + right_ear) / 2 + 0.001)
|
| 63 |
+
mouth_w = rng.uniform(0.30, 0.60, n) # mouth width in normalized image coords
|
| 64 |
+
mouth_h = rng.uniform(0.0, 0.20, n) # mouth height (opening)
|
| 65 |
+
mouth_o = mouth_h / (mouth_w + 0.001)
|
| 66 |
+
tod = rng.uniform(0.0, 1.0, n)
|
| 67 |
+
return np.stack(
|
| 68 |
+
[left_ear, right_ear, brow, mouth_t, eye_sym, mouth_o, tod], axis=1
|
| 69 |
+
).astype(np.float32)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def target_score(x: np.ndarray) -> np.ndarray:
|
| 73 |
+
"""Physiologically-motivated target in [0, 1].
|
| 74 |
+
|
| 75 |
+
Components are scaled so the sum roughly lives in [0, 1.5] before the
|
| 76 |
+
final sigmoid, which produces a well-distributed target across
|
| 77 |
+
"healthy" and "stressed" populations.
|
| 78 |
+
"""
|
| 79 |
+
left_ear, right_ear, brow, mouth_t, eye_sym, mouth_o, tod = x.T
|
| 80 |
+
|
| 81 |
+
# Drowsiness: low EAR (eyes closing)
|
| 82 |
+
avg_ear = (left_ear + right_ear) / 2
|
| 83 |
+
drowsy = np.clip((0.32 - avg_ear) / 0.10, 0, 1) * 0.30
|
| 84 |
+
|
| 85 |
+
# Brow furrow: low brow-to-eye distance
|
| 86 |
+
furrow = np.clip((0.035 - brow) / 0.015, 0, 1) * 0.22
|
| 87 |
+
|
| 88 |
+
# Clenched jaw: high mouth_tension
|
| 89 |
+
clench = np.clip((mouth_t - 6.0) / 4.0, 0, 1) * 0.12
|
| 90 |
+
|
| 91 |
+
# Eye asymmetry
|
| 92 |
+
asym = np.clip((eye_sym - 0.10) / 0.10, 0, 1) * 0.12
|
| 93 |
+
|
| 94 |
+
# Slack jaw: low mouth_opening
|
| 95 |
+
slack = np.clip((0.10 - mouth_o) / 0.10, 0, 1) * 0.10
|
| 96 |
+
|
| 97 |
+
# Circadian dip: 3am (tod=0.125) and 3pm (tod=0.625) bumps
|
| 98 |
+
tod_h = tod * 24
|
| 99 |
+
tod_bump = 0.08 * (
|
| 100 |
+
np.exp(-((tod_h - 3.0) ** 2) / 4.0)
|
| 101 |
+
+ 0.6 * np.exp(-((tod_h - 15.0) ** 2) / 6.0)
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
raw = drowsy + furrow + clench + asym + slack + tod_bump
|
| 105 |
+
# Squash into [0, 1] with a soft logistic, but allow extremes
|
| 106 |
+
return 1.0 / (1.0 + np.exp(-(raw * 4.0 - 1.4)))
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# βββ NumPy MLP with the same shape as the ZK circuit βββββββββββββββββββββββββ
|
| 110 |
+
# Linear(7,16) -> ReLU -> Linear(16,8) -> ReLU -> Linear(8,1) -> Sigmoid
|
| 111 |
+
|
| 112 |
+
def init_params(rng: np.random.Generator):
|
| 113 |
+
def he(shape):
|
| 114 |
+
fan_in = shape[1]
|
| 115 |
+
return rng.normal(0, np.sqrt(2.0 / fan_in), shape).astype(np.float32)
|
| 116 |
+
|
| 117 |
+
# Init b3 to the logit of the target mean (~0.5) so the network starts
|
| 118 |
+
# at a sensible constant prediction rather than 0.5 with dead ReLUs.
|
| 119 |
+
return {
|
| 120 |
+
"W1": he((16, 7)) * 0.5,
|
| 121 |
+
"b1": np.zeros(16, dtype=np.float32),
|
| 122 |
+
"W2": he((8, 16)) * 0.5,
|
| 123 |
+
"b2": np.zeros(8, dtype=np.float32),
|
| 124 |
+
"W3": he((1, 8)) * 0.5,
|
| 125 |
+
"b3": np.array([0.0], dtype=np.float32),
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def forward(p, x):
|
| 130 |
+
z1 = x @ p["W1"].T + p["b1"]
|
| 131 |
+
a1 = np.maximum(0, z1)
|
| 132 |
+
z2 = a1 @ p["W2"].T + p["b2"]
|
| 133 |
+
a2 = np.maximum(0, z2)
|
| 134 |
+
z3 = a2 @ p["W3"].T + p["b3"]
|
| 135 |
+
return z3, (x, z1, a1, z2, a2, z3) # return logits; sigmoid applied at export time
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def sigmoid(z):
|
| 139 |
+
return 1.0 / (1.0 + np.exp(-np.clip(z, -50, 50)))
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def bce_loss(y, t, eps=1e-7):
|
| 143 |
+
y = np.clip(y, eps, 1 - eps)
|
| 144 |
+
return float(-(t * np.log(y) + (1 - t) * np.log(1 - y)).mean())
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def mse_loss(y, t):
|
| 148 |
+
return float(((y - t) ** 2).mean())
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def backward(p, cache, z3, t):
|
| 152 |
+
"""Backward through MSE on the raw logit output (no sigmoid in graph).
|
| 153 |
+
|
| 154 |
+
We use MSE on the pre-sigmoid logit (with target also in logit space).
|
| 155 |
+
The ONNX graph applies sigmoid at the end, so the probability output
|
| 156 |
+
is bounded in [0, 1]. Training in logit space avoids the saturation
|
| 157 |
+
problem of sigmoid + small gradients.
|
| 158 |
+
"""
|
| 159 |
+
x, z1, a1, z2, a2, _ = cache
|
| 160 |
+
n = z3.shape[0]
|
| 161 |
+
if t.ndim == 1:
|
| 162 |
+
t = t.reshape(-1, 1)
|
| 163 |
+
# Convert target probability to logit, clamp to avoid inf
|
| 164 |
+
t_p = np.clip(t, 1e-5, 1 - 1e-5)
|
| 165 |
+
t_logit = np.log(t_p / (1.0 - t_p))
|
| 166 |
+
# MSE on logits: dL/dz3 = 2(z3 - t_logit) / n
|
| 167 |
+
dL_dz3 = 2.0 * (z3 - t_logit) / n
|
| 168 |
+
dW3 = dL_dz3.T @ a2
|
| 169 |
+
db3 = dL_dz3.sum(axis=0)
|
| 170 |
+
dL_da2 = dL_dz3 @ p["W3"]
|
| 171 |
+
dL_dz2 = dL_da2 * (z2 > 0)
|
| 172 |
+
dW2 = dL_dz2.T @ a1
|
| 173 |
+
db2 = dL_dz2.sum(axis=0)
|
| 174 |
+
dL_da1 = dL_dz2 @ p["W2"]
|
| 175 |
+
dL_dz1 = dL_da1 * (z1 > 0)
|
| 176 |
+
dW1 = dL_dz1.T @ x
|
| 177 |
+
db1 = dL_dz1.sum(axis=0)
|
| 178 |
+
return {"W1": dW1, "b1": db1, "W2": dW2, "b2": db2, "W3": dW3, "b3": db3}
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def train(X, T, *, epochs=EPOCHS, batch=BATCH, lr=LR, seed=RNG_SEED):
|
| 182 |
+
rng = np.random.default_rng(seed)
|
| 183 |
+
p = init_params(rng)
|
| 184 |
+
n = X.shape[0]
|
| 185 |
+
# Adam state
|
| 186 |
+
m = {k: np.zeros_like(v) for k, v in p.items()}
|
| 187 |
+
v = {k: np.zeros_like(v) for k, v in p.items()}
|
| 188 |
+
b1, b2, eps = 0.9, 0.999, 1e-8
|
| 189 |
+
history = []
|
| 190 |
+
for epoch in range(epochs):
|
| 191 |
+
idx = rng.permutation(n)
|
| 192 |
+
Xs, Ts = X[idx], T[idx]
|
| 193 |
+
for i in range(0, n, batch):
|
| 194 |
+
xb, tb = Xs[i:i + batch], Ts[i:i + batch]
|
| 195 |
+
z3, cache = forward(p, xb)
|
| 196 |
+
grads = backward(p, cache, z3, tb)
|
| 197 |
+
for k in p:
|
| 198 |
+
m[k] = b1 * m[k] + (1 - b1) * grads[k]
|
| 199 |
+
v[k] = b2 * v[k] + (1 - b2) * (grads[k] ** 2)
|
| 200 |
+
m_hat = m[k] / (1 - b1 ** (epoch + 1))
|
| 201 |
+
v_hat = v[k] / (1 - b2 ** (epoch + 1))
|
| 202 |
+
p[k] = p[k] - lr * m_hat / (np.sqrt(v_hat) + eps)
|
| 203 |
+
if epoch % max(1, epochs // 20) == 0 or epoch == epochs - 1:
|
| 204 |
+
z3_full, _ = forward(p, X)
|
| 205 |
+
y_full = sigmoid(z3_full)
|
| 206 |
+
T_col = T.reshape(-1, 1) if T.ndim == 1 else T
|
| 207 |
+
loss = mse_loss(y_full, T_col)
|
| 208 |
+
mae = float(np.mean(np.abs(y_full - T_col)))
|
| 209 |
+
history.append({"epoch": epoch, "loss": loss, "mae": mae})
|
| 210 |
+
print(f" epoch {epoch:4d} loss={loss:.5f} mae={mae:.4f}")
|
| 211 |
+
return p, history
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# βββ ONNX export with the trained weights βββββββββββββββββββββββββββββββββββββ
|
| 215 |
+
|
| 216 |
+
def export_onnx(p):
|
| 217 |
+
import onnx
|
| 218 |
+
from onnx import helper, TensorProto, numpy_helper
|
| 219 |
+
|
| 220 |
+
initializers = []
|
| 221 |
+
nodes = []
|
| 222 |
+
|
| 223 |
+
def add_linear(name, in_f, out_f, W, b):
|
| 224 |
+
W_init = numpy_helper.from_array(W.astype(np.float32), name=f"{name}_W")
|
| 225 |
+
b_init = numpy_helper.from_array(b.astype(np.float32), name=f"{name}_b")
|
| 226 |
+
matmul = helper.make_node(
|
| 227 |
+
"Gemm", [f"{name}_in", f"{name}_W", f"{name}_b"],
|
| 228 |
+
[f"{name}_out"], transB=1,
|
| 229 |
+
)
|
| 230 |
+
return matmul, [W_init, b_init]
|
| 231 |
+
|
| 232 |
+
nodes.append(helper.make_node("Identity", ["input"], ["l1_in"]))
|
| 233 |
+
n, inits = add_linear("l1", 7, 16, p["W1"], p["b1"])
|
| 234 |
+
nodes.append(n)
|
| 235 |
+
initializers.extend(inits)
|
| 236 |
+
nodes.append(helper.make_node("Relu", ["l1_out"], ["r1_out"]))
|
| 237 |
+
|
| 238 |
+
nodes.append(helper.make_node("Identity", ["r1_out"], ["l2_in"]))
|
| 239 |
+
n, inits = add_linear("l2", 16, 8, p["W2"], p["b2"])
|
| 240 |
+
nodes.append(n)
|
| 241 |
+
initializers.extend(inits)
|
| 242 |
+
nodes.append(helper.make_node("Relu", ["l2_out"], ["r2_out"]))
|
| 243 |
+
|
| 244 |
+
nodes.append(helper.make_node("Identity", ["r2_out"], ["l3_in"]))
|
| 245 |
+
n, inits = add_linear("l3", 8, 1, p["W3"], p["b3"])
|
| 246 |
+
nodes.append(n)
|
| 247 |
+
initializers.extend(inits)
|
| 248 |
+
nodes.append(helper.make_node("Sigmoid", ["l3_out"], ["output"]))
|
| 249 |
+
|
| 250 |
+
graph = helper.make_graph(
|
| 251 |
+
nodes,
|
| 252 |
+
"stress_mlp",
|
| 253 |
+
[helper.make_tensor_value_info("input", TensorProto.FLOAT, ["batch", 7])],
|
| 254 |
+
[helper.make_tensor_value_info("output", TensorProto.FLOAT, ["batch", 1])],
|
| 255 |
+
initializer=initializers,
|
| 256 |
+
)
|
| 257 |
+
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 10)])
|
| 258 |
+
model.ir_version = 7
|
| 259 |
+
onnx.save(model, str(ONNX_PATH))
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
# βββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 263 |
+
|
| 264 |
+
def main() -> None:
|
| 265 |
+
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
| 266 |
+
rng = np.random.default_rng(RNG_SEED)
|
| 267 |
+
X = sample_features(rng, N_SAMPLES)
|
| 268 |
+
# Add small input noise so the network cannot memorize
|
| 269 |
+
X = X + rng.normal(0, 0.005, X.shape).astype(np.float32)
|
| 270 |
+
T = target_score(X).astype(np.float32)
|
| 271 |
+
# Add small target noise
|
| 272 |
+
T = np.clip(T + rng.normal(0, 0.03, T.shape).astype(np.float32), 0, 1)
|
| 273 |
+
|
| 274 |
+
# Train/val split
|
| 275 |
+
n_val = int(N_SAMPLES * VAL_FRACTION)
|
| 276 |
+
perm = rng.permutation(N_SAMPLES)
|
| 277 |
+
val_idx, tr_idx = perm[:n_val], perm[n_val:]
|
| 278 |
+
X_tr, T_tr = X[tr_idx], T[tr_idx]
|
| 279 |
+
X_val, T_val = X[val_idx], T[val_idx]
|
| 280 |
+
|
| 281 |
+
print(f"Training stress MLP on {N_SAMPLES} synthetic samples...")
|
| 282 |
+
t0 = time.time()
|
| 283 |
+
p, history = train(X_tr, T_tr)
|
| 284 |
+
dt = time.time() - t0
|
| 285 |
+
|
| 286 |
+
z3_val, _ = forward(p, X_val)
|
| 287 |
+
y_val_p = sigmoid(z3_val)
|
| 288 |
+
T_val_col = T_val.reshape(-1, 1) if T_val.ndim == 1 else T_val
|
| 289 |
+
val_mae = float(np.mean(np.abs(y_val_p - T_val_col)))
|
| 290 |
+
val_mse = float(np.mean((y_val_p - T_val_col) ** 2))
|
| 291 |
+
print(f"\nTrained in {dt:.1f}s. Val MAE={val_mae:.4f} Val MSE={val_mse:.4f}")
|
| 292 |
+
|
| 293 |
+
# Save weights, data, metrics
|
| 294 |
+
np.savez(WEIGHTS_PATH, **p)
|
| 295 |
+
np.savez(DATA_PATH, X=X, T=T, val_idx=val_idx, tr_idx=tr_idx)
|
| 296 |
+
metrics = {
|
| 297 |
+
"n_samples": int(N_SAMPLES),
|
| 298 |
+
"n_train": int(len(tr_idx)),
|
| 299 |
+
"n_val": int(len(val_idx)),
|
| 300 |
+
"epochs": int(EPOCHS),
|
| 301 |
+
"batch": int(BATCH),
|
| 302 |
+
"lr": float(LR),
|
| 303 |
+
"val_mae": val_mae,
|
| 304 |
+
"val_mse": val_mse,
|
| 305 |
+
"train_history_tail": history[-5:],
|
| 306 |
+
"seed": int(RNG_SEED),
|
| 307 |
+
"train_seconds": round(dt, 2),
|
| 308 |
+
}
|
| 309 |
+
METRICS_PATH.write_text(json.dumps(metrics, indent=2))
|
| 310 |
+
|
| 311 |
+
# Re-export ONNX
|
| 312 |
+
print(f"Exporting trained ONNX to {ONNX_PATH}...")
|
| 313 |
+
export_onnx(p)
|
| 314 |
+
print(f"ONNX size: {ONNX_PATH.stat().st_size} bytes")
|
| 315 |
+
|
| 316 |
+
# Round-trip check via onnxruntime
|
| 317 |
+
try:
|
| 318 |
+
import onnxruntime as ort
|
| 319 |
+
sess = ort.InferenceSession(str(ONNX_PATH))
|
| 320 |
+
test = X_val[:5]
|
| 321 |
+
y_onnx = sess.run(None, {sess.get_inputs()[0].name: test})[0]
|
| 322 |
+
z3_np, _ = forward(p, test)
|
| 323 |
+
y_np = sigmoid(z3_np)
|
| 324 |
+
max_diff = float(np.max(np.abs(y_onnx - y_np)))
|
| 325 |
+
print(f"ONNX vs numpy max diff: {max_diff:.2e}")
|
| 326 |
+
except Exception as e:
|
| 327 |
+
print(f"Round-trip check skipped: {e}")
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
if __name__ == "__main__":
|
| 331 |
+
main()
|