coffeine16 commited on
Commit
0446283
·
1 Parent(s): 8555ea6

clean initial commit

Browse files
.gitignore ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🔐 Secrets
2
+ .env
3
+ *.env
4
+
5
+ # 🐍 Python
6
+ __pycache__/
7
+ *.pyc
8
+ *.pyo
9
+ *.pyd
10
+
11
+ # 🧪 Virtual environment
12
+ .venv/
13
+ venv/
14
+ env/
15
+
16
+ # 📦 uv
17
+ uv.lock
18
+
19
+ # 📝 Logs
20
+ *.log
21
+
22
+ # 💻 OS files
23
+ .DS_Store
24
+ Thumbs.db
25
+
26
+ # 🧠 IDEs
27
+ .vscode/
28
+ .idea/
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
- title: Fitscript Environment Server
3
- emoji: 🎣
4
- colorFrom: yellow
5
- colorTo: pink
6
  sdk: docker
7
  pinned: false
8
  app_port: 8000
@@ -11,245 +11,207 @@ tags:
11
  - openenv
12
  ---
13
 
14
- # Fitscript Environment
15
 
16
- A simple test environment that echoes back messages. Perfect for testing the env APIs as well as demonstrating environment usage patterns.
17
 
18
- ## Quick Start
19
 
20
- The simplest way to use the Fitscript environment is through the `FitscriptEnv` class:
21
 
22
- ```python
23
- from FitScript import FitscriptAction, FitscriptEnv
24
 
25
- try:
26
- # Create environment from Docker image
27
- FitScriptenv = FitscriptEnv.from_docker_image("FitScript-env:latest")
28
 
29
- # Reset
30
- result = FitScriptenv.reset()
31
- print(f"Reset: {result.observation.echoed_message}")
 
32
 
33
- # Send multiple messages
34
- messages = ["Hello, World!", "Testing echo", "Final message"]
35
 
36
- for msg in messages:
37
- result = FitScriptenv.step(FitscriptAction(message=msg))
38
- print(f"Sent: '{msg}'")
39
- print(f" → Echoed: '{result.observation.echoed_message}'")
40
- print(f" → Length: {result.observation.message_length}")
41
- print(f" → Reward: {result.reward}")
42
 
43
- finally:
44
- # Always clean up
45
- FitScriptenv.close()
46
- ```
47
-
48
- That's it! The `FitscriptEnv.from_docker_image()` method handles:
49
- - Starting the Docker container
50
- - Waiting for the server to be ready
51
- - Connecting to the environment
52
- - Container cleanup when you call `close()`
53
-
54
- ## Building the Docker Image
55
 
56
- Before using the environment, you need to build the Docker image:
57
-
58
- ```bash
59
- # From project root
60
- docker build -t FitScript-env:latest -f server/Dockerfile .
 
 
 
 
 
 
 
 
61
  ```
62
 
63
- ## Deploying to Hugging Face Spaces
64
-
65
- You can easily deploy your OpenEnv environment to Hugging Face Spaces using the `openenv push` command:
66
-
67
- ```bash
68
- # From the environment directory (where openenv.yaml is located)
69
- openenv push
70
-
71
- # Or specify options
72
- openenv push --namespace my-org --private
 
 
 
 
 
 
 
 
 
73
  ```
74
 
75
- The `openenv push` command will:
76
- 1. Validate that the directory is an OpenEnv environment (checks for `openenv.yaml`)
77
- 2. Prepare a custom build for Hugging Face Docker space (enables web interface)
78
- 3. Upload to Hugging Face (ensuring you're logged in)
79
 
80
- ### Prerequisites
81
 
82
- - Authenticate with Hugging Face: The command will prompt for login if not already authenticated
 
 
 
 
 
 
 
 
83
 
84
- ### Options
85
 
86
- - `--directory`, `-d`: Directory containing the OpenEnv environment (defaults to current directory)
87
- - `--repo-id`, `-r`: Repository ID in format 'username/repo-name' (defaults to 'username/env-name' from openenv.yaml)
88
- - `--base-image`, `-b`: Base Docker image to use (overrides Dockerfile FROM)
89
- - `--private`: Deploy the space as private (default: public)
90
 
91
- ### Examples
92
 
93
- ```bash
94
- # Push to your personal namespace (defaults to username/env-name from openenv.yaml)
95
- openenv push
 
 
96
 
97
- # Push to a specific repository
98
- openenv push --repo-id my-org/my-env
99
 
100
- # Push with a custom base image
101
- openenv push --base-image ghcr.io/meta-pytorch/openenv-base:latest
102
 
103
- # Push as a private space
104
- openenv push --private
105
 
106
- # Combine options
107
- openenv push --repo-id my-org/my-env --base-image custom-base:latest --private
108
- ```
109
 
110
- After deployment, your space will be available at:
111
- `https://huggingface.co/spaces/<repo-id>`
 
 
 
112
 
113
- The deployed space includes:
114
- - **Web Interface** at `/web` - Interactive UI for exploring the environment
115
- - **API Documentation** at `/docs` - Full OpenAPI/Swagger interface
116
- - **Health Check** at `/health` - Container health monitoring
117
- - **WebSocket** at `/ws` - Persistent session endpoint for low-latency interactions
118
 
119
- ## Environment Details
120
 
121
- ### Action
122
- **FitscriptAction**: Contains a single field
123
- - `message` (str) - The message to echo back
124
 
125
- ### Observation
126
- **FitscriptObservation**: Contains the echo response and metadata
127
- - `echoed_message` (str) - The message echoed back
128
- - `message_length` (int) - Length of the message
129
- - `reward` (float) - Reward based on message length (length × 0.1)
130
- - `done` (bool) - Always False for echo environment
131
- - `metadata` (dict) - Additional info like step count
132
 
133
- ### Reward
134
- The reward is calculated as: `message_length × 0.1`
135
- - "Hi" reward: 0.2
136
- - "Hello, World!" reward: 1.3
137
- - Empty message reward: 0.0
 
138
 
139
- ## Advanced Usage
 
140
 
141
- ### Connecting to an Existing Server
142
 
143
- If you already have a Fitscript environment server running, you can connect directly:
144
 
145
- ```python
146
- from FitScript import FitscriptEnv
 
 
 
147
 
148
- # Connect to existing server
149
- FitScriptenv = FitscriptEnv(base_url="<ENV_HTTP_URL_HERE>")
150
 
151
- # Use as normal
152
- result = FitScriptenv.reset()
153
- result = FitScriptenv.step(FitscriptAction(message="Hello!"))
154
  ```
155
 
156
- Note: When connecting to an existing server, `FitScriptenv.close()` will NOT stop the server.
157
-
158
- ### Using the Context Manager
159
-
160
- The client supports context manager usage for automatic connection management:
161
-
162
- ```python
163
- from FitScript import FitscriptAction, FitscriptEnv
164
-
165
- # Connect with context manager (auto-connects and closes)
166
- with FitscriptEnv(base_url="http://localhost:8000") as env:
167
- result = env.reset()
168
- print(f"Reset: {result.observation.echoed_message}")
169
- # Multiple steps with low latency
170
- for msg in ["Hello", "World", "!"]:
171
- result = env.step(FitscriptAction(message=msg))
172
- print(f"Echoed: {result.observation.echoed_message}")
173
  ```
174
 
175
- The client uses WebSocket connections for:
176
- - **Lower latency**: No HTTP connection overhead per request
177
- - **Persistent session**: Server maintains your environment state
178
- - **Efficient for episodes**: Better for many sequential steps
179
-
180
- ### Concurrent WebSocket Sessions
181
-
182
- The server supports multiple concurrent WebSocket connections. To enable this,
183
- modify `server/app.py` to use factory mode:
184
 
185
- ```python
186
- # In server/app.py - use factory mode for concurrent sessions
187
- app = create_app(
188
- FitscriptEnvironment, # Pass class, not instance
189
- FitscriptAction,
190
- FitscriptObservation,
191
- max_concurrent_envs=4, # Allow 4 concurrent sessions
192
- )
193
  ```
194
 
195
- Then multiple clients can connect simultaneously:
196
-
197
- ```python
198
- from FitScript import FitscriptAction, FitscriptEnv
199
- from concurrent.futures import ThreadPoolExecutor
200
-
201
- def run_episode(client_id: int):
202
- with FitscriptEnv(base_url="http://localhost:8000") as env:
203
- result = env.reset()
204
- for i in range(10):
205
- result = env.step(FitscriptAction(message=f"Client {client_id}, step {i}"))
206
- return client_id, result.observation.message_length
207
 
208
- # Run 4 episodes concurrently
209
- with ThreadPoolExecutor(max_workers=4) as executor:
210
- results = list(executor.map(run_episode, range(4)))
211
  ```
212
 
213
- ## Development & Testing
214
-
215
- ### Direct Environment Testing
216
-
217
- Test the environment logic directly without starting the HTTP server:
218
 
 
219
  ```bash
220
- # From the server directory
221
- python3 server/FitScript_environment.py
 
 
222
  ```
223
 
224
- This verifies that:
225
- - Environment resets correctly
226
- - Step executes actions properly
227
- - State tracking works
228
- - Rewards are calculated correctly
229
 
230
- ### Running Locally
231
 
232
- Run the server locally for development:
233
-
234
- ```bash
235
- uvicorn server.app:app --reload
236
- ```
237
 
238
  ## Project Structure
239
 
240
  ```
241
  FitScript/
242
- ├── .dockerignore # Docker build exclusions
243
- ├── __init__.py # Module exports
244
- ├── README.md # This file
245
- ├── openenv.yaml # OpenEnv manifest
246
- ├── pyproject.toml # Project metadata and dependencies
247
- ├── uv.lock # Locked dependencies (generated)
248
- ├── client.py # FitscriptEnv client
249
- ├── models.py # Action and Observation models
250
  └── server/
251
- ├── __init__.py # Server module exports
252
- ├── FitScript_environment.py # Core environment logic
253
- ├── app.py # FastAPI application (HTTP + WebSocket endpoints)
254
- └── Dockerfile # Container image definition
255
- ```
 
1
  ---
2
+ title: FitScript Environment Server
3
+ emoji: 🏋️
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
  pinned: false
8
  app_port: 8000
 
11
  - openenv
12
  ---
13
 
14
+ # FitScript Environment
15
 
16
+ ## Environment Description
17
 
18
+ FitScript is an **AI fitness prescription environment** built on the OpenEnv framework. It simulates the real-world task of generating, evaluating, and refining personalized workout plans — work typically performed by personal trainers, physiotherapists, and sports coaches.
19
 
20
+ Given a structured client profile (age, fitness level, goal, available equipment, injuries, days available), an agent must produce a JSON workout plan that satisfies evidence-based exercise-science criteria. The environment grades each submitted plan deterministically and provides step-by-step feedback so the agent can iterate and improve.
21
 
22
+ ## Motivation
 
23
 
24
+ Fitness prescription is a genuine, commercially valuable human-expert task with several properties that make it ideal for RL benchmark training:
 
 
25
 
26
+ - **Objective grading** — exercise science has deterministic rules: volume, frequency, contraindications, and progression targets are verifiable without human labelers.
27
+ - **Natural difficulty gradient** — tasks range from simple beginner plans to complex periodized powerlifting programs.
28
+ - **Safety constraints** — contraindicated exercises for injured clients introduce hard safety penalties, training agents to respect real-world constraints.
29
+ - **Dense reward signal** — partial scores at every step prevent sparse-reward pathology.
30
 
31
+ ## Action Space
 
32
 
33
+ Each step the agent submits a `FitscriptAction`:
 
 
 
 
 
34
 
35
+ | Field | Type | Description |
36
+ |-------|------|-------------|
37
+ | `action_type` | `str` | One of `"generate_plan"`, `"modify_plan"`, `"explain_exercise"` |
38
+ | `plan` | `str` | JSON string of the structured workout plan (exercises, sets, reps, rest) |
39
+ | `reasoning` | `str \| None` | Optional agent justification for the plan choices |
 
 
 
 
 
 
 
40
 
41
+ **Plan JSON schema (basic / injury tasks):**
42
+ ```json
43
+ {
44
+ "days": [
45
+ {
46
+ "name": "Day 1 - Lower Body",
47
+ "focus": "legs",
48
+ "exercises": [
49
+ {"name": "Squat", "sets": 3, "reps": 10, "rest_seconds": 60}
50
+ ]
51
+ }
52
+ ]
53
+ }
54
  ```
55
 
56
+ **Plan JSON schema (periodized program task):**
57
+ ```json
58
+ {
59
+ "weeks": [
60
+ {
61
+ "week": 1,
62
+ "intensity": 72.5,
63
+ "total_sets": 80,
64
+ "days": [
65
+ {
66
+ "name": "Day 1 - Squat",
67
+ "exercises": [
68
+ {"name": "Back Squat", "sets": 5, "reps": 5, "intensity_pct": 72.5}
69
+ ]
70
+ }
71
+ ]
72
+ }
73
+ ]
74
+ }
75
  ```
76
 
77
+ ## Observation Space
 
 
 
78
 
79
+ Each step returns a `FitscriptObservation`:
80
 
81
+ | Field | Type | Description |
82
+ |-------|------|-------------|
83
+ | `client_profile` | `dict` | Age, fitness level, goal, equipment, injuries, days/week |
84
+ | `feedback` | `str` | Human-readable grader feedback on the submitted plan |
85
+ | `score_breakdown` | `dict[str, float]` | Per-criterion partial scores |
86
+ | `task_id` | `str` | Active task identifier |
87
+ | `step_count` | `int` | Current step within the episode |
88
+ | `done` | `bool` | `True` when task complete or max steps reached |
89
+ | `reward` | `float` | Step reward in `[0.0, 1.0]` |
90
 
91
+ ## Task Descriptions
92
 
93
+ ### Task 1 EASY: Basic Plan Generation (`basic_plan`)
 
 
 
94
 
95
+ **Client:** 35-year-old beginner, no injuries, 3 days/week, home, no equipment.
96
 
97
+ **Grader criteria (0.25 each):**
98
+ 1. Plan contains exactly 3 workout days.
99
+ 2. All exercises are bodyweight-only (no equipment required).
100
+ 3. Each day has 4–8 exercises with `sets` and `reps` defined.
101
+ 4. Beginner-appropriate: reps ≤ 15, no advanced movements (muscle-ups, pistol squats, etc.).
102
 
103
+ **Score formula:** `(criteria_met / 4)` → `[0.0, 1.0]`
104
+ **Episode ends:** plan submitted OR after 3 steps.
105
 
106
+ ---
 
107
 
108
+ ### Task 2 MEDIUM: Injury-Safe Plan Modification (`injury_safe_modification`)
 
109
 
110
+ **Client:** 30-year-old intermediate, lower-back injury, pre-generated plan contains back squats, deadlifts, and bent-over rows.
 
 
111
 
112
+ **Grader criteria (0.25 each):**
113
+ 1. Deadlifts removed or replaced (Romanian deadlift / leg press / hip thrust).
114
+ 2. Back squats replaced (goblet squat / wall sit / leg press).
115
+ 3. Bent-over rows replaced (seated cable row / machine row).
116
+ 4. Plan retains same muscle-group targets despite modifications.
117
 
118
+ **Score formula:** `(criteria_met / 4)` → `[0.0, 1.0]`
119
+ **Episode ends:** modification submitted OR after 5 steps.
 
 
 
120
 
121
+ ---
122
 
123
+ ### Task 3 — HARD: Periodized 4-Week Program (`periodized_program`)
 
 
124
 
125
+ **Client:** 27-year-old advanced powerlifter, 5 days/week, full gym, competition in 5 weeks, weak points: upper back and lockout strength.
 
 
 
 
 
 
126
 
127
+ **Grader criteria (0.2 each):**
128
+ 1. 4 distinct weeks, each with 5 training days.
129
+ 2. Weeks 1–3 show progressive overload (increasing intensity/RPE).
130
+ 3. Week 4 is a deload: volume reduced ≥ 40% vs week 3.
131
+ 4. Competition lifts (squat, bench, deadlift) present as primary movements.
132
+ 5. Bonus: accessory work targets weak points (upper back, lockout).
133
 
134
+ **Score formula:** `min(1.0, criteria_met * 0.2)` → `[0.0, 1.0]`
135
+ **Episode ends:** full 4-week program submitted OR after 8 steps.
136
 
137
+ ---
138
 
139
+ ## Reward Design
140
 
141
+ - **Per-step reward:** `max(0.0, partial_score − safety_penalty)`
142
+ - **Safety penalty:** −0.3 if contraindicated exercises are present for an injured client.
143
+ - **Empty plan:** reward = 0.0.
144
+ - **Duplicate plan:** reward = 0.0 (no improvement penalty).
145
+ - All rewards are clamped to `[0.0, 1.0]`.
146
 
147
+ ## Setup Instructions
 
148
 
149
+ ### Build the Docker image
150
+ ```bash
151
+ docker build -t FitScript-env:latest -f server/Dockerfile .
152
  ```
153
 
154
+ ### Run locally
155
+ ```bash
156
+ uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  ```
158
 
159
+ ### Run inference
160
+ ```bash
161
+ export API_BASE_URL=https://api.openai.com/v1
162
+ export MODEL_NAME=gpt-4o
163
+ export HF_TOKEN=<your_key>
164
+ export FITSCRIPT_TASK=basic_plan # or injury_safe_modification / periodized_program
 
 
 
165
 
166
+ python inference.py
 
 
 
 
 
 
 
167
  ```
168
 
169
+ ### Deploy to Hugging Face Spaces
170
+ ```bash
171
+ # From the directory containing openenv.yaml
172
+ openenv push
 
 
 
 
 
 
 
 
173
 
174
+ # With options
175
+ openenv push --repo-id my-org/fitscript-env --private
 
176
  ```
177
 
178
+ The deployed space exposes:
179
+ - **Web Interface** at `/web`
180
+ - **API Docs** at `/docs`
181
+ - **Health Check** at `/health`
182
+ - **WebSocket** at `/ws`
183
 
184
+ ### Pre-submission validation
185
  ```bash
186
+ bash validate.sh <HF_SPACE_URL> <REPO_DIR>
187
+ # Step 1: POST /reset returns HTTP 200
188
+ # Step 2: docker build succeeds
189
+ # Step 3: openenv validate passes
190
  ```
191
 
192
+ ## Baseline Scores
 
 
 
 
193
 
194
+ > Run `python inference.py` for each task and record the `[END] score=...` line.
195
 
196
+ | Task | Difficulty | Baseline Score | Model |
197
+ |------|-----------|---------------|-------|
198
+ | `basic_plan` | Easy | _TBD_ | _fill before submission_ |
199
+ | `injury_safe_modification` | Medium | _TBD_ | _fill before submission_ |
200
+ | `periodized_program` | Hard | _TBD_ | _fill before submission_ |
201
 
202
  ## Project Structure
203
 
204
  ```
205
  FitScript/
206
+ ├── inference.py # Hackathon entry point (REQUIRED)
207
+ ├── openenv.yaml # OpenEnv manifest with tasks section
208
+ ├── pyproject.toml # Project metadata and dependencies
209
+ ├── __init__.py # Module exports
210
+ ├── client.py # FitscriptEnv client
211
+ ├── models.py # FitscriptAction and FitscriptObservation
 
 
212
  └── server/
213
+ ├── __init__.py # Server module exports
214
+ ├── FitScript_environment.py # Core environment + 3 task graders
215
+ ├── app.py # FastAPI application (HTTP + WebSocket)
216
+ └── Dockerfile # Multi-stage container definition
217
+ ```
__pycache__/models.cpython-313.pyc CHANGED
Binary files a/__pycache__/models.cpython-313.pyc and b/__pycache__/models.cpython-313.pyc differ
 
client.py CHANGED
@@ -4,7 +4,7 @@
4
  # This source code is licensed under the BSD-style license found in the
5
  # LICENSE file in the root directory of this source tree.
6
 
7
- """Fitscript Environment Client."""
8
 
9
  from typing import Dict
10
 
@@ -19,27 +19,34 @@ class FitscriptEnv(
19
  EnvClient[FitscriptAction, FitscriptObservation, State]
20
  ):
21
  """
22
- Client for the Fitscript Environment.
23
 
24
- This client maintains a persistent WebSocket connection to the environment server,
25
- enabling efficient multi-step interactions with lower latency.
26
  Each client instance has its own dedicated environment session on the server.
27
 
28
  Example:
29
  >>> # Connect to a running server
30
  >>> with FitscriptEnv(base_url="http://localhost:8000") as client:
31
  ... result = client.reset()
32
- ... print(result.observation.echoed_message)
33
  ...
34
- ... result = client.step(FitscriptAction(message="Hello!"))
35
- ... print(result.observation.echoed_message)
 
 
 
 
 
36
 
37
  Example with Docker:
38
- >>> # Automatically start container and connect
39
  >>> client = FitscriptEnv.from_docker_image("FitScript-env:latest")
40
  >>> try:
41
  ... result = client.reset()
42
- ... result = client.step(FitscriptAction(message="Test"))
 
 
 
43
  ... finally:
44
  ... client.close()
45
  """
@@ -54,9 +61,13 @@ class FitscriptEnv(
54
  Returns:
55
  Dictionary representation suitable for JSON encoding
56
  """
57
- return {
58
- "message": action.message,
 
59
  }
 
 
 
60
 
61
  def _parse_result(self, payload: Dict) -> StepResult[FitscriptObservation]:
62
  """
@@ -70,8 +81,11 @@ class FitscriptEnv(
70
  """
71
  obs_data = payload.get("observation", {})
72
  observation = FitscriptObservation(
73
- echoed_message=obs_data.get("echoed_message", ""),
74
- message_length=obs_data.get("message_length", 0),
 
 
 
75
  done=payload.get("done", False),
76
  reward=payload.get("reward"),
77
  metadata=obs_data.get("metadata", {}),
@@ -96,4 +110,4 @@ class FitscriptEnv(
96
  return State(
97
  episode_id=payload.get("episode_id"),
98
  step_count=payload.get("step_count", 0),
99
- )
 
4
  # This source code is licensed under the BSD-style license found in the
5
  # LICENSE file in the root directory of this source tree.
6
 
7
+ """FitScript Environment Client."""
8
 
9
  from typing import Dict
10
 
 
19
  EnvClient[FitscriptAction, FitscriptObservation, State]
20
  ):
21
  """
22
+ Client for the FitScript Environment.
23
 
24
+ This client maintains a persistent WebSocket connection to the environment
25
+ server, enabling efficient multi-step interactions with lower latency.
26
  Each client instance has its own dedicated environment session on the server.
27
 
28
  Example:
29
  >>> # Connect to a running server
30
  >>> with FitscriptEnv(base_url="http://localhost:8000") as client:
31
  ... result = client.reset()
32
+ ... print(result.observation.client_profile)
33
  ...
34
+ ... result = client.step(FitscriptAction(
35
+ ... action_type="generate_plan",
36
+ ... plan='{"days": [...]}',
37
+ ... reasoning="Beginner-safe bodyweight plan"
38
+ ... ))
39
+ ... print(result.observation.feedback)
40
+ ... print(result.reward)
41
 
42
  Example with Docker:
 
43
  >>> client = FitscriptEnv.from_docker_image("FitScript-env:latest")
44
  >>> try:
45
  ... result = client.reset()
46
+ ... result = client.step(FitscriptAction(
47
+ ... action_type="generate_plan",
48
+ ... plan='{"days": [...]}'
49
+ ... ))
50
  ... finally:
51
  ... client.close()
52
  """
 
61
  Returns:
62
  Dictionary representation suitable for JSON encoding
63
  """
64
+ payload = {
65
+ "action_type": action.action_type,
66
+ "plan": action.plan,
67
  }
68
+ if action.reasoning is not None:
69
+ payload["reasoning"] = action.reasoning
70
+ return payload
71
 
72
  def _parse_result(self, payload: Dict) -> StepResult[FitscriptObservation]:
73
  """
 
81
  """
82
  obs_data = payload.get("observation", {})
83
  observation = FitscriptObservation(
84
+ client_profile=obs_data.get("client_profile", {}),
85
+ feedback=obs_data.get("feedback", ""),
86
+ score_breakdown=obs_data.get("score_breakdown", {}),
87
+ task_id=obs_data.get("task_id", ""),
88
+ step_count=obs_data.get("step_count", 0),
89
  done=payload.get("done", False),
90
  reward=payload.get("reward"),
91
  metadata=obs_data.get("metadata", {}),
 
110
  return State(
111
  episode_id=payload.get("episode_id"),
112
  step_count=payload.get("step_count", 0),
113
+ )
inference.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FitScript inference.py --- required entry point for hackathon evaluation.
3
+
4
+ Usage:
5
+ FITSCRIPT_TASK=basic_plan \\
6
+ API_BASE_URL=https://api.openai.com/v1 \\
7
+ MODEL_NAME=gpt-4o \\
8
+ HF_TOKEN=<your_key> \\
9
+ python inference.py
10
+
11
+ Supported FITSCRIPT_TASK values:
12
+ basic_plan (easy)
13
+ injury_safe_modification (medium)
14
+ periodized_program (hard)
15
+
16
+ Output format (stdout, flush=True on every line):
17
+ [START] task=<task> env=fitscript_env model=<model>
18
+ [STEP] step=<N> action=<text> reward=<R:.2f> done=<true|false> error=<null|msg>
19
+ [END] success=<true|false> steps=<N> score=<S:.3f> rewards=<r1:.2f,...>
20
+ """
21
+
22
+ import asyncio
23
+ import json
24
+ import os
25
+ import sys
26
+
27
+ from openai import OpenAI
28
+
29
+ from dotenv import load_dotenv
30
+ import os
31
+
32
+ load_dotenv()
33
+ # ---------------------------------------------------------------------------
34
+ # Required environment variables (hackathon spec §5.1)
35
+ # ---------------------------------------------------------------------------
36
+ API_BASE_URL: str = os.environ["API_BASE_URL"]
37
+ MODEL_NAME: str = os.environ["MODEL_NAME"]
38
+ API_KEY: str = os.environ["HF_TOKEN"]
39
+
40
+ TASK_NAME: str = os.getenv("FITSCRIPT_TASK", "basic_plan")
41
+ BENCHMARK: str = "fitscript_env"
42
+ IMAGE_NAME: str = os.getenv("FITSCRIPT_IMAGE", "FitScript-env:latest")
43
+ MAX_STEPS: int = int(os.getenv("MAX_STEPS", "8"))
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Structured log helpers (hackathon spec §5.2)
47
+ # ---------------------------------------------------------------------------
48
+
49
+ def log_start(task: str, env: str, model: str) -> None:
50
+ print(f"[START] task={task} env={env} model={model}", flush=True)
51
+
52
+
53
+ def log_step(step: int, action: str, reward: float, done: bool, error) -> None:
54
+ err = error if error else "null"
55
+ # Collapse multiline action text to a single safe token for the log line
56
+ action_token = action.replace("\n", " ").replace("\r", "")[:120]
57
+ print(
58
+ f"[STEP] step={step} action={action_token} reward={reward:.2f}"
59
+ f" done={str(done).lower()} error={err}",
60
+ flush=True,
61
+ )
62
+
63
+
64
+ def log_end(success: bool, steps: int, score: float, rewards: list) -> None:
65
+ r = ",".join(f"{r:.2f}" for r in rewards)
66
+ print(
67
+ f"[END] success={str(success).lower()} steps={steps}"
68
+ f" score={score:.3f} rewards={r}",
69
+ flush=True,
70
+ )
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # System prompt
75
+ # ---------------------------------------------------------------------------
76
+
77
+ SYSTEM_PROMPT = """You are an expert personal trainer and exercise scientist.
78
+ You will receive a client profile and must generate a structured workout plan as JSON.
79
+
80
+ Always respond with ONLY a JSON object representing the workout plan.
81
+ Do NOT include any prose or explanation outside the JSON.
82
+
83
+ JSON schema for a basic/injury plan:
84
+ {
85
+ "days": [
86
+ {
87
+ "name": "Day 1 - ...",
88
+ "focus": "...",
89
+ "exercises": [
90
+ {"name": "...", "sets": <int>, "reps": <int>, "rest_seconds": <int>}
91
+ ]
92
+ }
93
+ ]
94
+ }
95
+
96
+ JSON schema for a periodized 4-week program:
97
+ {
98
+ "weeks": [
99
+ {
100
+ "week": 1,
101
+ "intensity": <float 0-100 representing % 1RM or avg RPE>,
102
+ "total_sets": <int>,
103
+ "days": [
104
+ {
105
+ "name": "Day 1 - ...",
106
+ "exercises": [
107
+ {"name": "...", "sets": <int>, "reps": <int>, "intensity_pct": <float>}
108
+ ]
109
+ }
110
+ ]
111
+ }
112
+ ]
113
+ }
114
+ """
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # LLM agent call
119
+ # ---------------------------------------------------------------------------
120
+
121
+ def call_llm(client: OpenAI, messages: list) -> str:
122
+ """Call the LLM and return the text of the first content block."""
123
+ response = client.chat.completions.create(
124
+ model=MODEL_NAME,
125
+ messages=messages,
126
+ temperature=0.7,
127
+ max_tokens=2048,
128
+ )
129
+ return response.choices[0].message.content or ""
130
+
131
+
132
+ def build_user_message(observation) -> str:
133
+ """Build the user turn from an observation object."""
134
+ profile = observation.client_profile if hasattr(observation, "client_profile") else {}
135
+ feedback = observation.feedback if hasattr(observation, "feedback") else ""
136
+ breakdown = observation.score_breakdown if hasattr(observation, "score_breakdown") else {}
137
+ task_id = observation.task_id if hasattr(observation, "task_id") else ""
138
+
139
+ parts = [
140
+ f"Task: {task_id}",
141
+ f"Client profile: {json.dumps(profile, indent=2)}",
142
+ ]
143
+ if feedback:
144
+ parts.append(f"Environment feedback: {feedback}")
145
+ if breakdown:
146
+ parts.append(f"Score breakdown: {json.dumps(breakdown, indent=2)}")
147
+ parts.append("Please generate or revise the workout plan as JSON only.")
148
+ return "\n\n".join(parts)
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Episode runner
153
+ # ---------------------------------------------------------------------------
154
+
155
+ async def run_episode() -> None:
156
+ from FitScript import FitscriptAction, FitscriptEnv # local import after path is set
157
+
158
+ llm = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
159
+
160
+ log_start(TASK_NAME, BENCHMARK, MODEL_NAME)
161
+
162
+ rewards: list = []
163
+ final_score = 0.0
164
+ success = False
165
+ step = 0
166
+ error_msg = None
167
+
168
+ env = None
169
+ try:
170
+ env = FitscriptEnv.from_docker_image(IMAGE_NAME)
171
+
172
+ # Reset
173
+ reset_result = env.reset()
174
+ obs = reset_result.observation
175
+
176
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
177
+
178
+ for step in range(1, MAX_STEPS + 1):
179
+ # Build user turn from current observation
180
+ user_content = build_user_message(obs)
181
+ messages.append({"role": "user", "content": user_content})
182
+
183
+ # Call LLM
184
+ try:
185
+ assistant_reply = call_llm(llm, messages)
186
+ except Exception as exc:
187
+ error_msg = str(exc)
188
+ log_step(step, "LLM_ERROR", 0.0, True, error_msg)
189
+ break
190
+
191
+ messages.append({"role": "assistant", "content": assistant_reply})
192
+
193
+ # Strip markdown fences if present
194
+ plan_str = assistant_reply.strip()
195
+ if plan_str.startswith("```"):
196
+ lines = plan_str.split("\n")
197
+ plan_str = "\n".join(
198
+ line for line in lines
199
+ if not line.startswith("```")
200
+ ).strip()
201
+
202
+ # Determine action_type from task
203
+ if TASK_NAME == "injury_safe_modification":
204
+ action_type = "modify_plan"
205
+ elif TASK_NAME == "periodized_program":
206
+ action_type = "generate_plan"
207
+ else:
208
+ action_type = "generate_plan"
209
+
210
+ action = FitscriptAction(action_type=action_type, plan=plan_str)
211
+
212
+ # Step in environment
213
+ try:
214
+ result = env.step(action)
215
+ except Exception as exc:
216
+ error_msg = str(exc)
217
+ log_step(step, action_type, 0.0, True, error_msg)
218
+ break
219
+
220
+ obs = result.observation
221
+ reward = float(result.reward or 0.0)
222
+ done = bool(result.done)
223
+ rewards.append(reward)
224
+ final_score = reward
225
+
226
+ log_step(step, action_type, reward, done, None)
227
+
228
+ if done:
229
+ success = reward >= 0.75
230
+ break
231
+
232
+ except Exception as exc:
233
+ error_msg = str(exc)
234
+ print(f"[ERROR] {error_msg}", flush=True, file=sys.stderr)
235
+ finally:
236
+ if env is not None:
237
+ env.close()
238
+
239
+ log_end(success, step, final_score, rewards)
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Entry point
244
+ # ---------------------------------------------------------------------------
245
+
246
+ if __name__ == "__main__":
247
+ asyncio.run(run_episode())
models.py CHANGED
@@ -5,23 +5,55 @@
5
  # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
- Data models for the Fitscript Environment.
9
 
10
- The FitScript environment is a simple test environment that echoes back messages.
 
11
  """
12
 
13
  from openenv.core.env_server.types import Action, Observation
14
  from pydantic import Field
 
15
 
16
 
17
  class FitscriptAction(Action):
18
- """Action for the Fitscript environment - just a message to echo."""
19
-
20
- message: str = Field(..., description="Message to echo back")
 
 
 
 
 
 
 
 
 
 
 
21
 
22
 
23
  class FitscriptObservation(Observation):
24
- """Observation from the Fitscript environment - the echoed message."""
25
-
26
- echoed_message: str = Field(default="", description="The echoed message")
27
- message_length: int = Field(default=0, description="Length of the echoed message")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
+ Data models for the FitScript Environment.
9
 
10
+ FitScript simulates a real-world AI fitness prescription task:
11
+ generating, evaluating, and refining personalized workout plans.
12
  """
13
 
14
  from openenv.core.env_server.types import Action, Observation
15
  from pydantic import Field
16
+ from typing import Optional, Dict, Any
17
 
18
 
19
  class FitscriptAction(Action):
20
+ """Action for the FitScript environment --- fitness plan generation/modification."""
21
+
22
+ action_type: str = Field(
23
+ ...,
24
+ description="One of: 'generate_plan' | 'modify_plan' | 'explain_exercise'"
25
+ )
26
+ plan: str = Field(
27
+ default="",
28
+ description="JSON string of structured workout plan (exercises, sets, reps, rest)"
29
+ )
30
+ reasoning: Optional[str] = Field(
31
+ default=None,
32
+ description="Agent justification for the plan choices"
33
+ )
34
 
35
 
36
  class FitscriptObservation(Observation):
37
+ """Observation from the FitScript environment --- client profile and plan feedback."""
38
+
39
+ client_profile: Dict[str, Any] = Field(
40
+ default_factory=dict,
41
+ description="Client info: age, fitness_level, goal, equipment, injuries, days_per_week"
42
+ )
43
+ feedback: str = Field(
44
+ default="",
45
+ description="Environment feedback on the last submitted plan"
46
+ )
47
+ score_breakdown: Dict[str, float] = Field(
48
+ default_factory=dict,
49
+ description="Partial scores per criterion (safety, completeness, progression)"
50
+ )
51
+ task_id: str = Field(
52
+ default="",
53
+ description="Current task identifier"
54
+ )
55
+ step_count: int = Field(
56
+ default=0,
57
+ description="Current step within the episode"
58
+ )
59
+ # done and reward are inherited from the Observation base class
openenv.yaml CHANGED
@@ -5,3 +5,18 @@ runtime: fastapi
5
  app: server.app:app
6
  port: 8000
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  app: server.app:app
6
  port: 8000
7
 
8
+ tasks:
9
+ - id: basic_plan
10
+ name: Basic Workout Plan Generation
11
+ difficulty: easy
12
+ description: Generate a 3-day bodyweight beginner plan with no equipment.
13
+
14
+ - id: injury_safe_modification
15
+ name: Injury-Safe Plan Modification
16
+ difficulty: medium
17
+ description: Modify a plan to remove lower-back-stressing exercises.
18
+
19
+ - id: periodized_program
20
+ name: Periodized 4-Week Program
21
+ difficulty: hard
22
+ description: Design a 4-week periodized powerlifting block with deload week.
server/FitScript_environment.py CHANGED
@@ -5,13 +5,16 @@
5
  # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
- Fitscript Environment Implementation.
9
 
10
- A simple test environment that echoes back messages sent to it.
11
- Perfect for testing HTTP server infrastructure.
 
12
  """
13
 
 
14
  from uuid import uuid4
 
15
 
16
  from openenv.core.env_server.interfaces import Environment
17
  from openenv.core.env_server.types import State
@@ -22,83 +25,605 @@ except ImportError:
22
  from models import FitscriptAction, FitscriptObservation
23
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  class FitscriptEnvironment(Environment):
26
  """
27
- A simple echo environment that echoes back messages.
28
-
29
- This environment is designed for testing the HTTP server infrastructure.
30
- It maintains minimal state and simply echoes back whatever message it receives.
31
-
32
- Example:
33
- >>> env = FitscriptEnvironment()
34
- >>> obs = env.reset()
35
- >>> print(obs.echoed_message) # "Fitscript environment ready!"
36
- >>>
37
- >>> obs = env.step(FitscriptAction(message="Hello"))
38
- >>> print(obs.echoed_message) # "Hello"
39
- >>> print(obs.message_length) # 5
40
  """
41
 
42
- # Enable concurrent WebSocket sessions.
43
- # Set to True if your environment isolates state between instances.
44
- # When True, multiple WebSocket clients can connect simultaneously, each
45
- # getting their own environment instance (when using factory mode in app.py).
46
  SUPPORTS_CONCURRENT_SESSIONS: bool = True
47
 
48
- def __init__(self):
49
- """Initialize the FitScript environment."""
 
 
 
 
 
 
 
 
 
 
50
  self._state = State(episode_id=str(uuid4()), step_count=0)
51
- self._reset_count = 0
52
 
53
  def reset(self) -> FitscriptObservation:
54
  """
55
- Reset the environment.
56
 
57
  Returns:
58
- FitscriptObservation with a ready message
59
  """
60
  self._state = State(episode_id=str(uuid4()), step_count=0)
61
- self._reset_count += 1
 
 
62
 
63
  return FitscriptObservation(
64
- echoed_message="Fitscript environment ready!",
65
- message_length=0,
 
 
 
66
  done=False,
67
  reward=0.0,
68
  )
69
 
70
  def step(self, action: FitscriptAction) -> FitscriptObservation: # type: ignore[override]
71
  """
72
- Execute a step in the environment by echoing the message.
73
 
74
  Args:
75
- action: FitscriptAction containing the message to echo
76
 
77
  Returns:
78
- FitscriptObservation with the echoed message and its length
79
  """
80
  self._state.step_count += 1
 
81
 
82
- message = action.message
83
- length = len(message)
 
 
 
 
 
 
 
 
 
84
 
85
- # Simple reward: longer messages get higher rewards
86
- reward = length * 0.1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
  return FitscriptObservation(
89
- echoed_message=message,
90
- message_length=length,
91
- done=False,
92
- reward=reward,
93
- metadata={"original_message": message, "step": self._state.step_count},
 
 
94
  )
95
 
96
  @property
97
  def state(self) -> State:
98
- """
99
- Get the current environment state.
100
-
101
- Returns:
102
- Current State with episode_id and step_count
103
- """
104
- return self._state
 
5
  # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
+ FitScript Environment Implementation.
9
 
10
+ Simulates a real-world fitness prescription task: generating, evaluating,
11
+ and refining personalized workout plans. Supports three tasks of increasing
12
+ difficulty with deterministic graders.
13
  """
14
 
15
+ import json
16
  from uuid import uuid4
17
+ from typing import Dict, Any, Tuple
18
 
19
  from openenv.core.env_server.interfaces import Environment
20
  from openenv.core.env_server.types import State
 
25
  from models import FitscriptAction, FitscriptObservation
26
 
27
 
28
+ # ---------------------------------------------------------------------------
29
+ # Grader base class
30
+ # ---------------------------------------------------------------------------
31
+
32
+ class BaseTask:
33
+ """Base class for all FitScript tasks."""
34
+
35
+ client_profile: dict = {}
36
+ max_steps: int = 5
37
+
38
+ def grade(
39
+ self, action: FitscriptAction, step: int
40
+ ) -> Tuple[float, Dict[str, float], str]:
41
+ """
42
+ Returns (score: float in [0,1], breakdown: dict, feedback: str).
43
+ Must be implemented by every concrete task.
44
+ """
45
+ raise NotImplementedError
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Task 1 - EASY: Basic Plan Generation
50
+ # ---------------------------------------------------------------------------
51
+
52
+ class BasicPlanTask(BaseTask):
53
+ """
54
+ Scenario: 35-year-old beginner, no injuries, 3 days/week, home, no equipment.
55
+ Grader: 4 criteria worth 0.25 each.
56
+ Episode ends when plan submitted OR after 3 steps.
57
+ """
58
+
59
+ client_profile = {
60
+ "age": 35,
61
+ "fitness_level": "beginner",
62
+ "goal": "general fitness",
63
+ "equipment": [],
64
+ "injuries": [],
65
+ "days_per_week": 3,
66
+ }
67
+ max_steps = 3
68
+
69
+ # Exercises that require equipment --- flag any appearance
70
+ EQUIPMENT_EXERCISES = {
71
+ "barbell", "dumbbell", "kettlebell", "cable", "machine",
72
+ "bench press", "squat rack", "pull-up bar", "resistance band",
73
+ "treadmill", "stationary bike",
74
+ }
75
+
76
+ # Advanced movements not appropriate for beginners
77
+ ADVANCED_MOVEMENTS = {
78
+ "muscle-up", "muscle up", "handstand push-up", "handstand pushup",
79
+ "pistol squat", "one-arm push-up", "planche", "front lever",
80
+ "back lever", "dragon flag",
81
+ }
82
+
83
+ def grade(self, action: FitscriptAction, step: int) -> Tuple[float, Dict[str, float], str]:
84
+ scores: Dict[str, float] = {}
85
+ feedback_parts = []
86
+
87
+ try:
88
+ plan = json.loads(action.plan) if action.plan else {}
89
+ except json.JSONDecodeError:
90
+ plan = {}
91
+
92
+ # Criterion 1: Plan contains exactly 3 workout days
93
+ days = plan.get("days", plan.get("workout_days", []))
94
+ if isinstance(days, list) and len(days) == 3:
95
+ scores["three_days"] = 0.25
96
+ feedback_parts.append("✓ Plan has exactly 3 workout days.")
97
+ else:
98
+ scores["three_days"] = 0.0
99
+ found = len(days) if isinstance(days, list) else "unknown"
100
+ feedback_parts.append(f"✗ Expected 3 workout days, found {found}.")
101
+
102
+ # Criterion 2: All exercises are bodyweight-only
103
+ all_exercises = _extract_exercises(plan)
104
+ plan_text_lower = action.plan.lower()
105
+ equipment_found = [e for e in self.EQUIPMENT_EXERCISES if e in plan_text_lower]
106
+ if not equipment_found:
107
+ scores["bodyweight_only"] = 0.25
108
+ feedback_parts.append("✓ No equipment required --- all bodyweight exercises.")
109
+ else:
110
+ scores["bodyweight_only"] = 0.0
111
+ feedback_parts.append(f"✗ Equipment-dependent exercises found: {equipment_found[:3]}.")
112
+
113
+ # Criterion 3: Each day has 4-8 exercises with sets and reps defined
114
+ if isinstance(days, list) and len(days) > 0:
115
+ days_ok = 0
116
+ for day in days:
117
+ exs = day.get("exercises", [])
118
+ if 4 <= len(exs) <= 8 and all(
119
+ e.get("sets") and e.get("reps") for e in exs
120
+ ):
121
+ days_ok += 1
122
+ if days_ok == len(days) and len(days) > 0:
123
+ scores["exercise_structure"] = 0.25
124
+ feedback_parts.append("✓ Each day has 4-8 exercises with sets and reps defined.")
125
+ else:
126
+ scores["exercise_structure"] = 0.0
127
+ feedback_parts.append(
128
+ f"✗ {days_ok}/{len(days)} days have 4-8 exercises with sets+reps. "
129
+ "Ensure every exercise has 'sets' and 'reps' fields."
130
+ )
131
+ else:
132
+ scores["exercise_structure"] = 0.0
133
+ feedback_parts.append("✗ Cannot evaluate exercise structure: no days found.")
134
+
135
+ # Criterion 4: Beginner-appropriate (reps <= 15, no advanced movements)
136
+ advanced_found = [m for m in self.ADVANCED_MOVEMENTS if m in plan_text_lower]
137
+ reps_too_high = _check_reps_exceed(plan, max_reps=15)
138
+ if not advanced_found and not reps_too_high:
139
+ scores["beginner_appropriate"] = 0.25
140
+ feedback_parts.append("✓ Plan is beginner-appropriate (no advanced movements, reps ≤ 15).")
141
+ else:
142
+ scores["beginner_appropriate"] = 0.0
143
+ if advanced_found:
144
+ feedback_parts.append(f"✗ Advanced movements not suitable for beginners: {advanced_found}.")
145
+ if reps_too_high:
146
+ feedback_parts.append("✗ Some exercises have reps > 15 --- too high for a beginner.")
147
+
148
+ score = sum(scores.values())
149
+ feedback = " ".join(feedback_parts)
150
+ return score, scores, feedback
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # Task 2 - MEDIUM: Injury-Safe Plan Modification
155
+ # ---------------------------------------------------------------------------
156
+
157
+ class InjurySafeTask(BaseTask):
158
+ """
159
+ Scenario: Intermediate client with lower-back injury. Pre-generated plan
160
+ contains back squats, deadlifts, and bent-over rows. Agent must modify safely.
161
+ Episode ends when modification submitted OR after 5 steps.
162
+ """
163
+
164
+ client_profile = {
165
+ "age": 30,
166
+ "fitness_level": "intermediate",
167
+ "goal": "strength maintenance",
168
+ "equipment": ["barbell", "dumbbells", "cables", "machines"],
169
+ "injuries": ["lower back"],
170
+ "days_per_week": 4,
171
+ "initial_plan": {
172
+ "days": [
173
+ {
174
+ "name": "Day 1 - Lower Body",
175
+ "exercises": [
176
+ {"name": "Back Squat", "sets": 4, "reps": 8},
177
+ {"name": "Deadlift", "sets": 3, "reps": 5},
178
+ {"name": "Leg Press", "sets": 3, "reps": 10},
179
+ {"name": "Calf Raises", "sets": 4, "reps": 15},
180
+ ],
181
+ },
182
+ {
183
+ "name": "Day 2 - Upper Body",
184
+ "exercises": [
185
+ {"name": "Bench Press", "sets": 4, "reps": 8},
186
+ {"name": "Bent-Over Row", "sets": 4, "reps": 8},
187
+ {"name": "Overhead Press", "sets": 3, "reps": 10},
188
+ {"name": "Pull-Up", "sets": 3, "reps": "max"},
189
+ ],
190
+ },
191
+ ]
192
+ },
193
+ }
194
+ max_steps = 5
195
+
196
+ DEADLIFT_REPLACEMENTS = {
197
+ "romanian deadlift", "rdl", "leg press", "leg curl",
198
+ "hip thrust", "glute bridge", "trap bar deadlift",
199
+ }
200
+ SQUAT_REPLACEMENTS = {
201
+ "goblet squat", "wall sit", "wall squat", "leg press",
202
+ "box squat", "safety bar squat", "hack squat",
203
+ }
204
+ ROW_REPLACEMENTS = {
205
+ "seated cable row", "seated row", "machine row",
206
+ "chest-supported row", "chest supported row",
207
+ "t-bar row", "seal row",
208
+ }
209
+ ORIGINAL_MUSCLE_GROUPS = {"quads", "hamstrings", "glutes", "back", "chest", "shoulders"}
210
+
211
+ def grade(self, action: FitscriptAction, step: int) -> Tuple[float, Dict[str, float], str]:
212
+ scores: Dict[str, float] = {}
213
+ feedback_parts = []
214
+ plan_text_lower = action.plan.lower()
215
+
216
+ # Criterion 1: Deadlifts removed or replaced with safe alternatives
217
+ has_deadlift = "deadlift" in plan_text_lower and not any(
218
+ r in plan_text_lower for r in self.DEADLIFT_REPLACEMENTS
219
+ )
220
+ raw_deadlift = "deadlift" in plan_text_lower and "romanian" not in plan_text_lower and "rdl" not in plan_text_lower
221
+ if not raw_deadlift:
222
+ scores["deadlift_removed"] = 0.25
223
+ feedback_parts.append("✓ Conventional deadlift removed or replaced safely.")
224
+ else:
225
+ scores["deadlift_removed"] = 0.0
226
+ feedback_parts.append(
227
+ "✗ Conventional deadlift still present. Replace with Romanian deadlift, leg press, or hip thrust."
228
+ )
229
+
230
+ # Criterion 2: Back squats replaced with safe alternatives
231
+ has_back_squat = "back squat" in plan_text_lower
232
+ if not has_back_squat:
233
+ scores["squat_replaced"] = 0.25
234
+ feedback_parts.append("✓ Back squat removed or replaced safely.")
235
+ else:
236
+ scores["squat_replaced"] = 0.0
237
+ feedback_parts.append(
238
+ "✗ Back squat still present. Replace with goblet squat, wall sit, or leg press."
239
+ )
240
+
241
+ # Criterion 3: Bent-over rows replaced with seated/machine variants
242
+ has_bent_over_row = "bent-over row" in plan_text_lower or "bent over row" in plan_text_lower
243
+ if not has_bent_over_row:
244
+ scores["rows_replaced"] = 0.25
245
+ feedback_parts.append("✓ Bent-over rows removed or replaced with spine-neutral variant.")
246
+ else:
247
+ scores["rows_replaced"] = 0.0
248
+ feedback_parts.append(
249
+ "✗ Bent-over rows still present. Replace with seated cable rows or machine rows."
250
+ )
251
+
252
+ # Criterion 4: Plan retains same muscle group targets
253
+ # Proxy: check that back/leg work still appears in the plan
254
+ back_work = any(
255
+ t in plan_text_lower
256
+ for t in ["row", "pull", "lat", "back", "rhomboid"]
257
+ )
258
+ leg_work = any(
259
+ t in plan_text_lower
260
+ for t in ["squat", "press", "lunge", "hip", "glute", "quad", "hamstring", "leg"]
261
+ )
262
+ if back_work and leg_work:
263
+ scores["muscle_targets_retained"] = 0.25
264
+ feedback_parts.append("✓ Original muscle groups (back, legs) still targeted despite modifications.")
265
+ else:
266
+ scores["muscle_targets_retained"] = 0.0
267
+ missing = []
268
+ if not back_work:
269
+ missing.append("back")
270
+ if not leg_work:
271
+ missing.append("legs")
272
+ feedback_parts.append(
273
+ f"✗ Missing muscle group coverage: {missing}. Ensure modifications keep the same target areas."
274
+ )
275
+
276
+ score = sum(scores.values())
277
+ feedback = " ".join(feedback_parts)
278
+ return score, scores, feedback
279
+
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # Task 3 - HARD: Periodized 4-Week Program
283
+ # ---------------------------------------------------------------------------
284
+
285
+ class PeriodizedProgramTask(BaseTask):
286
+ """
287
+ Scenario: Advanced powerlifter, 5 days/week, full gym, competition in 5 weeks.
288
+ Needs 4-week block with deload in week 4.
289
+ Episode ends when full program submitted OR after 8 steps.
290
+ """
291
+
292
+ client_profile = {
293
+ "age": 27,
294
+ "fitness_level": "advanced",
295
+ "goal": "powerlifting competition prep",
296
+ "equipment": ["full gym", "barbell", "squat rack", "bench", "deadlift platform"],
297
+ "injuries": [],
298
+ "days_per_week": 5,
299
+ "competition_weeks_out": 5,
300
+ "weak_points": ["upper back", "lockout strength"],
301
+ "current_maxes": {"squat": 180, "bench": 120, "deadlift": 220},
302
+ }
303
+ max_steps = 8
304
+
305
+ COMPETITION_LIFTS = {"squat", "bench", "bench press", "deadlift"}
306
+
307
+ def grade(self, action: FitscriptAction, step: int) -> Tuple[float, Dict[str, float], str]:
308
+ scores: Dict[str, float] = {}
309
+ feedback_parts = []
310
+
311
+ try:
312
+ plan = json.loads(action.plan) if action.plan else {}
313
+ except json.JSONDecodeError:
314
+ plan = {}
315
+
316
+ weeks = plan.get("weeks", [])
317
+
318
+ # Criterion 1: 4 distinct weeks, each with 5 training days
319
+ if isinstance(weeks, list) and len(weeks) == 4:
320
+ all_five_days = all(
321
+ len(w.get("days", w.get("training_days", []))) == 5
322
+ for w in weeks
323
+ )
324
+ if all_five_days:
325
+ scores["week_structure"] = 0.2
326
+ feedback_parts.append("✓ 4 weeks present, each with 5 training days.")
327
+ else:
328
+ scores["week_structure"] = 0.1
329
+ feedback_parts.append(
330
+ "~ 4 weeks present but not all weeks have exactly 5 training days."
331
+ )
332
+ else:
333
+ scores["week_structure"] = 0.0
334
+ found_weeks = len(weeks) if isinstance(weeks, list) else "unknown"
335
+ feedback_parts.append(
336
+ f"✗ Expected 4 weeks with 5 days each. Found {found_weeks} weeks."
337
+ )
338
+
339
+ # Criterion 2: Weeks 1-3 show progressive overload
340
+ if isinstance(weeks, list) and len(weeks) >= 3:
341
+ intensities = []
342
+ for w in weeks[:3]:
343
+ # Accept intensity as explicit field or infer from RPE/percentage keywords
344
+ intensity = w.get("intensity") or w.get("avg_rpe") or w.get("percentage")
345
+ if intensity is None:
346
+ # Try to infer from week label/description
347
+ desc = str(w).lower()
348
+ if "heavy" in desc or "high" in desc:
349
+ intensity = 85
350
+ elif "moderate" in desc or "medium" in desc:
351
+ intensity = 75
352
+ else:
353
+ intensity = None
354
+ intensities.append(intensity)
355
+
356
+ if all(i is not None for i in intensities) and intensities[0] < intensities[1] < intensities[2]:
357
+ scores["progressive_overload"] = 0.2
358
+ feedback_parts.append("✓ Weeks 1-3 show clear progressive overload (increasing intensity).")
359
+ elif all(i is not None for i in intensities):
360
+ scores["progressive_overload"] = 0.1
361
+ feedback_parts.append(
362
+ "~ Intensity values present but progressive overload pattern not clearly ascending across weeks 1-3."
363
+ )
364
+ else:
365
+ scores["progressive_overload"] = 0.0
366
+ feedback_parts.append(
367
+ "✗ Cannot verify progressive overload. Add 'intensity', 'avg_rpe', or 'percentage' fields to each week."
368
+ )
369
+ else:
370
+ scores["progressive_overload"] = 0.0
371
+ feedback_parts.append("✗ Fewer than 3 weeks present; cannot verify progressive overload.")
372
+
373
+ # Criterion 3: Week 4 is a deload (volume reduced >= 40% vs week 3)
374
+ if isinstance(weeks, list) and len(weeks) == 4:
375
+ w3 = weeks[2]
376
+ w4 = weeks[3]
377
+ w3_vol = _estimate_volume(w3)
378
+ w4_vol = _estimate_volume(w4)
379
+ is_deload_label = "deload" in str(w4).lower()
380
+ if w3_vol > 0 and w4_vol > 0:
381
+ reduction = (w3_vol - w4_vol) / w3_vol
382
+ if reduction >= 0.40:
383
+ scores["deload_week"] = 0.2
384
+ feedback_parts.append(
385
+ f"✓ Week 4 deload: volume reduced by {reduction*100:.0f}% vs week 3."
386
+ )
387
+ elif is_deload_label:
388
+ scores["deload_week"] = 0.1
389
+ feedback_parts.append(
390
+ "~ Week 4 labeled as deload but volume reduction < 40%. Reduce total sets/volume further."
391
+ )
392
+ else:
393
+ scores["deload_week"] = 0.0
394
+ feedback_parts.append(
395
+ f"✗ Week 4 volume only reduced by {reduction*100:.0f}%. Deload requires >= 40% reduction."
396
+ )
397
+ elif is_deload_label:
398
+ scores["deload_week"] = 0.1
399
+ feedback_parts.append(
400
+ "~ Week 4 labeled as deload but no volume data to verify the 40% reduction threshold."
401
+ )
402
+ else:
403
+ scores["deload_week"] = 0.0
404
+ feedback_parts.append(
405
+ "✗ Week 4 not identified as a deload and volume data insufficient to verify."
406
+ )
407
+ else:
408
+ scores["deload_week"] = 0.0
409
+ feedback_parts.append("✗ Fewer than 4 weeks present; cannot evaluate deload week.")
410
+
411
+ # Criterion 4: Competition lifts appear as primary movements on separate days
412
+ plan_text_lower = action.plan.lower()
413
+ squat_present = "squat" in plan_text_lower
414
+ bench_present = "bench" in plan_text_lower
415
+ deadlift_present = "deadlift" in plan_text_lower
416
+ if squat_present and bench_present and deadlift_present:
417
+ scores["competition_lifts"] = 0.2
418
+ feedback_parts.append("✓ All three competition lifts (squat, bench, deadlift) present as primary movements.")
419
+ else:
420
+ missing = []
421
+ if not squat_present:
422
+ missing.append("squat")
423
+ if not bench_present:
424
+ missing.append("bench press")
425
+ if not deadlift_present:
426
+ missing.append("deadlift")
427
+ scores["competition_lifts"] = 0.0
428
+ feedback_parts.append(f"✗ Missing competition lifts: {missing}.")
429
+
430
+ # Criterion 5 (bonus): Accessory work targets weak points (upper back, lockout)
431
+ weak_point_keywords = ["face pull", "upper back", "row", "rdl", "pause", "lockout", "band pull apart", "rear delt"]
432
+ accessory_bonus = sum(1 for kw in weak_point_keywords if kw in plan_text_lower)
433
+ if accessory_bonus >= 3:
434
+ scores["accessory_weak_points"] = 0.2
435
+ feedback_parts.append("✓ Accessory work targets weak points (upper back, lockout strength).")
436
+ elif accessory_bonus >= 1:
437
+ scores["accessory_weak_points"] = 0.1
438
+ feedback_parts.append("~ Some accessory work present but weak points (upper back, lockout) not fully addressed.")
439
+ else:
440
+ scores["accessory_weak_points"] = 0.0
441
+ feedback_parts.append("✗ No accessory work targeting weak points (upper back, lockout strength).")
442
+
443
+ score = min(1.0, sum(scores.values()))
444
+ feedback = " ".join(feedback_parts)
445
+ return score, scores, feedback
446
+
447
+
448
+ # ---------------------------------------------------------------------------
449
+ # Helper utilities
450
+ # ---------------------------------------------------------------------------
451
+
452
+ def _extract_exercises(plan: dict) -> list:
453
+ """Flatten all exercises from all days in a plan."""
454
+ exercises = []
455
+ for day in plan.get("days", plan.get("workout_days", [])):
456
+ if isinstance(day, dict):
457
+ exercises.extend(day.get("exercises", []))
458
+ return exercises
459
+
460
+
461
+ def _check_reps_exceed(plan: dict, max_reps: int) -> bool:
462
+ """Return True if any exercise in the plan has reps > max_reps."""
463
+ for ex in _extract_exercises(plan):
464
+ reps = ex.get("reps")
465
+ if isinstance(reps, (int, float)) and reps > max_reps:
466
+ return True
467
+ return False
468
+
469
+
470
+ def _estimate_volume(week: dict) -> float:
471
+ """Estimate total volume (sets × reps) across all days in a week."""
472
+ total = 0
473
+ for day in week.get("days", week.get("training_days", [])):
474
+ if isinstance(day, dict):
475
+ for ex in day.get("exercises", []):
476
+ sets = ex.get("sets", 0)
477
+ reps = ex.get("reps", 0)
478
+ if isinstance(sets, (int, float)) and isinstance(reps, (int, float)):
479
+ total += sets * reps
480
+ # Also accept a flat 'total_sets' key on the week
481
+ if total == 0:
482
+ total = week.get("total_sets", 0) * 8 # assume ~8 reps avg if only sets given
483
+ return float(total)
484
+
485
+
486
+ # ---------------------------------------------------------------------------
487
+ # Task registry
488
+ # ---------------------------------------------------------------------------
489
+
490
+ TASKS: Dict[str, BaseTask] = {
491
+ "basic_plan": BasicPlanTask(),
492
+ "injury_safe_modification": InjurySafeTask(),
493
+ "periodized_program": PeriodizedProgramTask(),
494
+ }
495
+
496
+
497
+ # ---------------------------------------------------------------------------
498
+ # Main environment class
499
+ # ---------------------------------------------------------------------------
500
+
501
  class FitscriptEnvironment(Environment):
502
  """
503
+ FitScript fitness prescription environment.
504
+
505
+ Three tasks of increasing difficulty:
506
+ - basic_plan (easy): generate a 3-day bodyweight beginner plan
507
+ - injury_safe_modification (medium): modify a plan for a lower-back-injured client
508
+ - periodized_program (hard): design a 4-week periodized powerlifting block
509
+
510
+ Rewards are always in [0.0, 1.0]. Episodes terminate on task completion
511
+ (score >= 0.99) or when max_steps is reached.
 
 
 
 
512
  """
513
 
 
 
 
 
514
  SUPPORTS_CONCURRENT_SESSIONS: bool = True
515
 
516
+ def __init__(self, task_id: str = "basic_plan"):
517
+ """
518
+ Initialize the FitScript environment.
519
+
520
+ Args:
521
+ task_id: One of 'basic_plan', 'injury_safe_modification', 'periodized_program'.
522
+ """
523
+ if task_id not in TASKS:
524
+ raise ValueError(
525
+ f"Unknown task_id '{task_id}'. Valid options: {list(TASKS.keys())}"
526
+ )
527
+ self._task_id = task_id
528
  self._state = State(episode_id=str(uuid4()), step_count=0)
529
+ self._last_plan: str = ""
530
 
531
  def reset(self) -> FitscriptObservation:
532
  """
533
+ Reset the environment for the current task.
534
 
535
  Returns:
536
+ FitscriptObservation with the client profile and welcome message.
537
  """
538
  self._state = State(episode_id=str(uuid4()), step_count=0)
539
+ self._last_plan = ""
540
+
541
+ task = TASKS[self._task_id]
542
 
543
  return FitscriptObservation(
544
+ client_profile=task.client_profile,
545
+ feedback="Welcome! Review the client profile and generate a plan.",
546
+ score_breakdown={},
547
+ task_id=self._task_id,
548
+ step_count=0,
549
  done=False,
550
  reward=0.0,
551
  )
552
 
553
  def step(self, action: FitscriptAction) -> FitscriptObservation: # type: ignore[override]
554
  """
555
+ Execute a step: grade the submitted plan and return feedback.
556
 
557
  Args:
558
+ action: FitscriptAction with action_type, plan JSON string, and optional reasoning.
559
 
560
  Returns:
561
+ FitscriptObservation with score breakdown and feedback.
562
  """
563
  self._state.step_count += 1
564
+ task = TASKS[self._task_id]
565
 
566
+ # Penalty: empty or null plan
567
+ if not action.plan or action.plan.strip() in ("", "null", "{}"):
568
+ return FitscriptObservation(
569
+ client_profile=task.client_profile,
570
+ feedback="✗ Empty or null plan submitted. Please provide a structured workout plan.",
571
+ score_breakdown={},
572
+ task_id=self._task_id,
573
+ step_count=self._state.step_count,
574
+ done=self._state.step_count >= task.max_steps,
575
+ reward=0.0,
576
+ )
577
 
578
+ # Penalty: identical plan submitted twice in a row
579
+ if action.plan == self._last_plan:
580
+ return FitscriptObservation(
581
+ client_profile=task.client_profile,
582
+ feedback="✗ Identical plan submitted twice. Please revise based on the previous feedback.",
583
+ score_breakdown={},
584
+ task_id=self._task_id,
585
+ step_count=self._state.step_count,
586
+ done=self._state.step_count >= task.max_steps,
587
+ reward=0.0,
588
+ )
589
+
590
+ self._last_plan = action.plan
591
+
592
+ # Grade the plan
593
+ score, breakdown, feedback = task.grade(action, self._state.step_count)
594
+
595
+ # Safety penalty: contraindicated exercises for injured clients
596
+ injuries = task.client_profile.get("injuries", [])
597
+ if injuries:
598
+ plan_lower = action.plan.lower()
599
+ CONTRAINDICATED = {
600
+ "lower back": ["deadlift", "back squat", "good morning", "bent-over row"],
601
+ "knee": ["lunge", "leg press", "deep squat", "box jump"],
602
+ "shoulder": ["overhead press", "upright row", "behind neck"],
603
+ }
604
+ for injury in injuries:
605
+ banned = CONTRAINDICATED.get(injury, [])
606
+ if any(b in plan_lower for b in banned):
607
+ score = max(0.0, score - 0.3)
608
+ feedback += " ⚠️ Safety penalty applied: plan contains exercises contraindicated for the client's injury."
609
+ break
610
+
611
+ # Clamp to [0.0, 1.0]
612
+ score = max(0.0, min(1.0, score))
613
+
614
+ done = score >= 0.99 or self._state.step_count >= task.max_steps
615
 
616
  return FitscriptObservation(
617
+ client_profile=task.client_profile,
618
+ feedback=feedback,
619
+ score_breakdown=breakdown,
620
+ task_id=self._task_id,
621
+ step_count=self._state.step_count,
622
+ done=done,
623
+ reward=score,
624
  )
625
 
626
  @property
627
  def state(self) -> State:
628
+ """Get the current environment state."""
629
+ return self._state
 
 
 
 
 
server/__pycache__/FitScript_environment.cpython-313.pyc CHANGED
Binary files a/server/__pycache__/FitScript_environment.cpython-313.pyc and b/server/__pycache__/FitScript_environment.cpython-313.pyc differ
 
server/__pycache__/__init__.cpython-313.pyc CHANGED
Binary files a/server/__pycache__/__init__.cpython-313.pyc and b/server/__pycache__/__init__.cpython-313.pyc differ
 
server/__pycache__/app.cpython-313.pyc CHANGED
Binary files a/server/__pycache__/app.cpython-313.pyc and b/server/__pycache__/app.cpython-313.pyc differ