AzraelH commited on
Commit
88a3484
·
1 Parent(s): ea0c9f2

Prepare OpenEnv Space submission

Browse files
Files changed (7) hide show
  1. Dockerfile +12 -8
  2. README.md +0 -1
  3. client.py +4 -1
  4. inference.py +277 -0
  5. pyproject.toml +1 -0
  6. requirements.txt +7 -0
  7. server/app.py +422 -3
Dockerfile CHANGED
@@ -2,16 +2,20 @@ FROM python:3.11-slim
2
 
3
  WORKDIR /app
4
 
5
- RUN pip install --no-cache-dir \
6
- fastapi \
7
- numpy \
8
- "openenv-core[core]" \
9
- pydantic \
10
- streamlit \
11
- uvicorn
 
 
 
 
12
 
13
  COPY . /app
14
 
15
  EXPOSE 8000
16
 
17
- CMD ["python", "-m", "server.app", "--host", "0.0.0.0", "--port", "8000"]
 
2
 
3
  WORKDIR /app
4
 
5
+ ENV PYTHONDONTWRITEBYTECODE=1
6
+ ENV PYTHONUNBUFFERED=1
7
+ ENV PIP_NO_CACHE_DIR=1
8
+ ENV ENABLE_WEB_INTERFACE=true
9
+
10
+ COPY pyproject.toml README.md /app/
11
+ COPY server /app/server
12
+ COPY models.py __init__.py /app/
13
+
14
+ RUN pip install --upgrade pip && \
15
+ pip install .
16
 
17
  COPY . /app
18
 
19
  EXPOSE 8000
20
 
21
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
README.md CHANGED
@@ -6,7 +6,6 @@ colorTo: yellow
6
  sdk: docker
7
  pinned: false
8
  app_port: 8000
9
- base_path: /web
10
  tags:
11
  - openenv
12
  - scheduling
 
6
  sdk: docker
7
  pinned: false
8
  app_port: 8000
 
9
  tags:
10
  - openenv
11
  - scheduling
client.py CHANGED
@@ -8,7 +8,10 @@ from openenv.core import EnvClient
8
  from openenv.core.client_types import StepResult
9
  from openenv.core.env_server.types import State
10
 
11
- from .models import EngineerManagerAction, EngineerManagerObservation
 
 
 
12
 
13
 
14
  class EngineerManagerEnv(
 
8
  from openenv.core.client_types import StepResult
9
  from openenv.core.env_server.types import State
10
 
11
+ try:
12
+ from .models import EngineerManagerAction, EngineerManagerObservation
13
+ except ImportError:
14
+ from models import EngineerManagerAction, EngineerManagerObservation
15
 
16
 
17
  class EngineerManagerEnv(
inference.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import math
4
+ import os
5
+ import textwrap
6
+ from typing import Any
7
+
8
+ from openai import OpenAI
9
+ from openenv.core.generic_client import GenericEnvClient
10
+
11
+
12
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
13
+ MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
14
+ HF_TOKEN = os.getenv("HF_TOKEN")
15
+ LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME")
16
+ OPENENV_BASE_URL = os.getenv("OPENENV_BASE_URL")
17
+ TASK_NAME = os.getenv("TASK_NAME", "engineer-manager")
18
+ BENCHMARK = os.getenv("BENCHMARK", "openenv")
19
+ MAX_STEPS = int(os.getenv("MAX_STEPS", "32"))
20
+ TEMPERATURE = float(os.getenv("TEMPERATURE", "0.1"))
21
+ MAX_TOKENS = int(os.getenv("MAX_TOKENS", "120"))
22
+
23
+ SYSTEM_PROMPT = textwrap.dedent(
24
+ """
25
+ You are selecting actions for an environment that simulates an engineer-manager workday.
26
+ Return exactly one compact JSON object with integer keys:
27
+ {"target_slot": <int>, "operation": <int>}
28
+
29
+ Operations:
30
+ 0 = idle
31
+ 1 = schedule work at target_slot
32
+ 2 = reschedule a meeting at target_slot
33
+ 3 = toggle mute comms
34
+
35
+ Goals:
36
+ - Maximize sustained deep work flow_score.
37
+ - Avoid unnecessary social_debt and calendar_churn.
38
+ - Prefer scheduling work into future empty slots.
39
+ - Use reschedule_meeting only when it clearly helps.
40
+ - Toggle mute comms early if distractions are high and it is currently off.
41
+
42
+ Rules:
43
+ - target_slot must be within the timeline bounds.
44
+ - Return JSON only. No markdown. No explanation.
45
+ """
46
+ ).strip()
47
+
48
+
49
+ def _require_env(name: str, value: str | None) -> str:
50
+ if value:
51
+ return value
52
+ raise RuntimeError(f"Missing required environment variable: {name}")
53
+
54
+
55
+ def _sanitize_field(value: Any) -> str:
56
+ text = str(value)
57
+ return text.replace("\r", " ").replace("\n", " ").strip()
58
+
59
+
60
+ def log_start(task: str, env: str, model: str) -> None:
61
+ print(
62
+ f"[START] task={_sanitize_field(task)} env={_sanitize_field(env)} model={_sanitize_field(model)}",
63
+ flush=True,
64
+ )
65
+
66
+
67
+ def log_step(
68
+ step: int,
69
+ action: str,
70
+ reward: float,
71
+ done: bool,
72
+ error: str | None,
73
+ ) -> None:
74
+ error_text = "null" if error in (None, "") else _sanitize_field(error)
75
+ print(
76
+ f"[STEP] step={step} action={_sanitize_field(action)} reward={reward:.2f} "
77
+ f"done={str(done).lower()} error={error_text}",
78
+ flush=True,
79
+ )
80
+
81
+
82
+ def log_end(success: bool, steps: int, score: float, rewards: list[float]) -> None:
83
+ rewards_text = ",".join(f"{reward:.2f}" for reward in rewards)
84
+ print(
85
+ f"[END] success={str(success).lower()} steps={steps} score={score:.2f} rewards={rewards_text}",
86
+ flush=True,
87
+ )
88
+
89
+
90
+ def estimate_max_flow_score(timeline: list[int]) -> float:
91
+ slot_count = len(timeline)
92
+ if slot_count <= 0:
93
+ return 1.0
94
+ hours = slot_count * 0.5
95
+ return max(1.0, hours * hours)
96
+
97
+
98
+ def normalize_score(total_reward: float, observation: dict[str, Any]) -> float:
99
+ timeline = observation.get("timeline") or []
100
+ max_score = estimate_max_flow_score(timeline)
101
+ normalized = total_reward / max_score
102
+ return min(1.0, max(0.0, normalized))
103
+
104
+
105
+ def first_future_slot(observation: dict[str, Any], kind: int) -> int | None:
106
+ timeline = observation.get("timeline") or []
107
+ current_slot = int(observation.get("current_slot", 0))
108
+ for index in range(current_slot, len(timeline)):
109
+ if int(timeline[index]) == kind:
110
+ return index
111
+ return None
112
+
113
+
114
+ def first_future_empty_slot(observation: dict[str, Any]) -> int | None:
115
+ return first_future_slot(observation, 0)
116
+
117
+
118
+ def build_user_prompt(
119
+ step: int,
120
+ observation: dict[str, Any],
121
+ rewards: list[float],
122
+ history: list[str],
123
+ ) -> str:
124
+ timeline = observation.get("timeline") or []
125
+ metadata = observation.get("metadata") or {}
126
+ return textwrap.dedent(
127
+ f"""
128
+ step={step}
129
+ current_slot={int(observation.get("current_slot", 0))}
130
+ current_time={observation.get("current_time", "unknown")}
131
+ mute_comms={bool(observation.get("mute_comms", False))}
132
+ distraction_risk={float(observation.get("distraction_risk", 0.0))}
133
+ flow_score={float(observation.get("flow_score", 0.0)):.2f}
134
+ social_debt={float(observation.get("social_debt", 0.0)):.2f}
135
+ calendar_churn={int(observation.get("calendar_churn", 0))}
136
+ recovery_state={int(observation.get("recovery_state", 0))}
137
+ timeline={timeline}
138
+ task_buffer={json.dumps(observation.get("task_buffer", []), separators=(",", ":"))}
139
+ last_rewards={",".join(f"{reward:.2f}" for reward in rewards[-5:]) or "none"}
140
+ recent_history={json.dumps(history[-5:])}
141
+ last_metadata={json.dumps(metadata, separators=(",", ":"))}
142
+ Choose the single next action.
143
+ """
144
+ ).strip()
145
+
146
+
147
+ def choose_fallback_action(observation: dict[str, Any]) -> dict[str, int]:
148
+ current_slot = int(observation.get("current_slot", 0))
149
+ distraction_risk = float(observation.get("distraction_risk", 0.0))
150
+ mute_comms = bool(observation.get("mute_comms", False))
151
+ if distraction_risk >= 0.2 and not mute_comms:
152
+ return {"target_slot": current_slot, "operation": 3}
153
+
154
+ empty_slot = first_future_empty_slot(observation)
155
+ if empty_slot is not None and observation.get("task_buffer"):
156
+ return {"target_slot": empty_slot, "operation": 1}
157
+
158
+ meeting_slot = first_future_slot(observation, 2)
159
+ if meeting_slot is not None and current_slot <= meeting_slot:
160
+ return {"target_slot": meeting_slot, "operation": 2}
161
+
162
+ return {"target_slot": current_slot, "operation": 0}
163
+
164
+
165
+ def coerce_action(raw_text: str, observation: dict[str, Any]) -> dict[str, int]:
166
+ timeline = observation.get("timeline") or []
167
+ max_slot = max(0, len(timeline) - 1)
168
+ fallback = choose_fallback_action(observation)
169
+ try:
170
+ data = json.loads(raw_text)
171
+ target_slot = int(data["target_slot"])
172
+ operation = int(data["operation"])
173
+ except Exception:
174
+ return fallback
175
+
176
+ if operation not in {0, 1, 2, 3}:
177
+ return fallback
178
+ target_slot = min(max(target_slot, 0), max_slot)
179
+ return {"target_slot": target_slot, "operation": operation}
180
+
181
+
182
+ def get_model_action(
183
+ client: OpenAI,
184
+ step: int,
185
+ observation: dict[str, Any],
186
+ rewards: list[float],
187
+ history: list[str],
188
+ ) -> dict[str, int]:
189
+ user_prompt = build_user_prompt(step, observation, rewards, history)
190
+ try:
191
+ completion = client.chat.completions.create(
192
+ model=MODEL_NAME,
193
+ messages=[
194
+ {"role": "system", "content": SYSTEM_PROMPT},
195
+ {"role": "user", "content": user_prompt},
196
+ ],
197
+ temperature=TEMPERATURE,
198
+ max_tokens=MAX_TOKENS,
199
+ )
200
+ content = (completion.choices[0].message.content or "").strip()
201
+ return coerce_action(content, observation)
202
+ except Exception:
203
+ return choose_fallback_action(observation)
204
+
205
+
206
+ async def create_env() -> GenericEnvClient:
207
+ if OPENENV_BASE_URL:
208
+ env = GenericEnvClient(base_url=OPENENV_BASE_URL)
209
+ await env.connect()
210
+ return env
211
+
212
+ image_name = _require_env("LOCAL_IMAGE_NAME", LOCAL_IMAGE_NAME)
213
+ return await GenericEnvClient.from_docker_image(image_name)
214
+
215
+
216
+ async def main() -> None:
217
+ api_key = _require_env("HF_TOKEN", HF_TOKEN)
218
+ client = OpenAI(base_url=API_BASE_URL, api_key=api_key)
219
+ env = None
220
+ rewards: list[float] = []
221
+ history: list[str] = []
222
+ steps_taken = 0
223
+ success = False
224
+ score = 0.0
225
+
226
+ log_start(TASK_NAME, BENCHMARK, MODEL_NAME)
227
+
228
+ try:
229
+ env = await create_env()
230
+ result = await env.reset()
231
+ observation = dict(result.observation)
232
+
233
+ for step in range(1, MAX_STEPS + 1):
234
+ if result.done:
235
+ break
236
+
237
+ action = get_model_action(client, step, observation, rewards, history)
238
+ result = await env.step(action)
239
+ observation = dict(result.observation)
240
+
241
+ reward = float(result.reward or 0.0)
242
+ done = bool(result.done)
243
+ metadata = observation.get("metadata") or {}
244
+ error = metadata.get("last_action_error")
245
+
246
+ rewards.append(reward)
247
+ steps_taken = step
248
+
249
+ action_text = (
250
+ f"target_slot={int(action['target_slot'])},operation={int(action['operation'])}"
251
+ )
252
+ log_step(step, action_text, reward, done, error)
253
+
254
+ history.append(
255
+ f"step={step} action={action_text} reward={reward:.2f} "
256
+ f"flow={float(observation.get('flow_score', 0.0)):.2f} "
257
+ f"debt={float(observation.get('social_debt', 0.0)):.2f}"
258
+ )
259
+
260
+ if done:
261
+ break
262
+
263
+ total_reward = math.fsum(rewards)
264
+ score = normalize_score(total_reward, observation if "observation" in locals() else {})
265
+ score = round(score, 2)
266
+ success = score > 0.0
267
+ finally:
268
+ if env is not None:
269
+ try:
270
+ await env.close()
271
+ except Exception:
272
+ pass
273
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
274
+
275
+
276
+ if __name__ == "__main__":
277
+ asyncio.run(main())
pyproject.toml CHANGED
@@ -11,6 +11,7 @@ requires-python = ">=3.11"
11
  dependencies = [
12
  "fastapi>=0.135.0",
13
  "numpy>=2.0.0",
 
14
  "openenv-core[core]>=0.2.0",
15
  "pydantic>=2.0.0",
16
  "streamlit>=1.40.0",
 
11
  dependencies = [
12
  "fastapi>=0.135.0",
13
  "numpy>=2.0.0",
14
+ "openai>=1.0.0",
15
  "openenv-core[core]>=0.2.0",
16
  "pydantic>=2.0.0",
17
  "streamlit>=1.40.0",
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi>=0.135.0
2
+ numpy>=2.0.0
3
+ openai>=1.0.0
4
+ openenv-core[core]>=0.2.0
5
+ pydantic>=2.0.0
6
+ streamlit>=1.40.0
7
+ uvicorn>=0.30.0
server/app.py CHANGED
@@ -2,8 +2,11 @@
2
 
3
  from __future__ import annotations
4
 
 
 
5
  import uvicorn
6
- from openenv.core import create_app
 
7
 
8
  try:
9
  from ..models import EngineerManagerAction, EngineerManagerObservation
@@ -19,14 +22,430 @@ except ImportError:
19
  from server.engineer_manager_environment import EngineerManagerEnvironment
20
 
21
 
22
- app = create_app(
23
  EngineerManagerEnvironment,
24
  EngineerManagerAction,
25
  EngineerManagerObservation,
26
- env_name="engineer-manager",
27
  max_concurrent_envs=2,
28
  )
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  def run(host: str = "0.0.0.0", port: int = 8000) -> None:
32
  """Run the OpenEnv HTTP server."""
 
2
 
3
  from __future__ import annotations
4
 
5
+ from textwrap import dedent
6
+
7
  import uvicorn
8
+ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
9
+ from openenv.core.env_server.http_server import create_fastapi_app
10
 
11
  try:
12
  from ..models import EngineerManagerAction, EngineerManagerObservation
 
22
  from server.engineer_manager_environment import EngineerManagerEnvironment
23
 
24
 
25
+ app = create_fastapi_app(
26
  EngineerManagerEnvironment,
27
  EngineerManagerAction,
28
  EngineerManagerObservation,
 
29
  max_concurrent_envs=2,
30
  )
31
 
32
+ WEB_CSS = dedent(
33
+ """\
34
+ :root {
35
+ color-scheme: dark;
36
+ --bg: #07111f;
37
+ --panel: rgba(10, 21, 38, 0.88);
38
+ --panel-2: rgba(14, 28, 48, 0.96);
39
+ --line: rgba(124, 231, 255, 0.18);
40
+ --text: #eaf4ff;
41
+ --muted: #99abc7;
42
+ --accent: #7ce7ff;
43
+ --accent-2: #86f0ca;
44
+ --meeting: #f5c76a;
45
+ --empty: rgba(255, 255, 255, 0.06);
46
+ --danger: #ff8d8d;
47
+ --shell-max: 1180px;
48
+ --sidebar-width: 300px;
49
+ --content-min: 620px;
50
+ }
51
+ * { box-sizing: border-box; }
52
+ html {
53
+ min-height: 100%;
54
+ background: #07111f;
55
+ }
56
+ body {
57
+ margin: 0;
58
+ min-height: 100%;
59
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
60
+ color: var(--text);
61
+ background:
62
+ radial-gradient(circle at top, rgba(124, 231, 255, 0.12), transparent 30rem),
63
+ linear-gradient(180deg, #09111d 0%, #07111f 100%);
64
+ }
65
+ .shell {
66
+ width: min(100%, var(--shell-max));
67
+ margin: 0 auto;
68
+ padding: 32px 20px 40px;
69
+ }
70
+ .hero {
71
+ padding: 24px;
72
+ border: 1px solid var(--line);
73
+ border-radius: 24px;
74
+ background: linear-gradient(135deg, rgba(9, 20, 38, 0.95), rgba(12, 31, 54, 0.92));
75
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
76
+ }
77
+ .eyebrow {
78
+ color: var(--accent);
79
+ font-size: 12px;
80
+ letter-spacing: 0.18em;
81
+ text-transform: uppercase;
82
+ margin-bottom: 10px;
83
+ }
84
+ h1 {
85
+ margin: 0 0 10px;
86
+ font-size: clamp(2rem, 5vw, 3.8rem);
87
+ line-height: 0.95;
88
+ }
89
+ .sub {
90
+ margin: 0;
91
+ color: var(--muted);
92
+ max-width: 62ch;
93
+ line-height: 1.5;
94
+ }
95
+ .grid {
96
+ display: grid;
97
+ grid-template-columns: minmax(260px, var(--sidebar-width)) minmax(var(--content-min), 1fr);
98
+ align-items: start;
99
+ gap: 18px;
100
+ margin-top: 18px;
101
+ }
102
+ .panel {
103
+ border: 1px solid var(--line);
104
+ border-radius: 22px;
105
+ background: var(--panel);
106
+ box-shadow: 0 12px 36px rgba(0, 0, 0, 0.22);
107
+ backdrop-filter: blur(10px);
108
+ }
109
+ .panel-inner { padding: 18px; }
110
+ .section-title {
111
+ margin: 0 0 14px;
112
+ font-size: 0.92rem;
113
+ letter-spacing: 0.12em;
114
+ text-transform: uppercase;
115
+ color: var(--accent);
116
+ }
117
+ .metrics {
118
+ display: grid;
119
+ grid-template-columns: repeat(4, minmax(0, 1fr));
120
+ gap: 12px;
121
+ margin-bottom: 16px;
122
+ }
123
+ .metric {
124
+ padding: 14px;
125
+ border-radius: 18px;
126
+ border: 1px solid var(--line);
127
+ background: var(--panel-2);
128
+ }
129
+ .metric small {
130
+ display: block;
131
+ color: var(--muted);
132
+ margin-bottom: 8px;
133
+ text-transform: uppercase;
134
+ letter-spacing: 0.08em;
135
+ font-size: 0.7rem;
136
+ }
137
+ .metric strong {
138
+ font-size: 1.35rem;
139
+ }
140
+ label {
141
+ display: block;
142
+ margin-bottom: 10px;
143
+ color: var(--muted);
144
+ font-size: 0.92rem;
145
+ }
146
+ input, select, button {
147
+ width: 100%;
148
+ border-radius: 14px;
149
+ border: 1px solid var(--line);
150
+ background: rgba(255, 255, 255, 0.04);
151
+ color: var(--text);
152
+ padding: 12px 14px;
153
+ font: inherit;
154
+ }
155
+ button {
156
+ cursor: pointer;
157
+ transition: transform 120ms ease, background 120ms ease;
158
+ background: linear-gradient(135deg, rgba(124, 231, 255, 0.22), rgba(134, 240, 202, 0.16));
159
+ }
160
+ button:hover { transform: translateY(-1px); }
161
+ .actions {
162
+ display: grid;
163
+ grid-template-columns: repeat(2, minmax(0, 1fr));
164
+ gap: 10px;
165
+ margin-top: 12px;
166
+ }
167
+ .actions button[data-op="0"] { grid-column: span 2; }
168
+ .timeline {
169
+ display: grid;
170
+ grid-template-columns: repeat(auto-fit, minmax(58px, 1fr));
171
+ gap: 8px;
172
+ }
173
+ .slot {
174
+ border-radius: 16px;
175
+ min-height: 84px;
176
+ padding: 10px 8px;
177
+ border: 1px solid transparent;
178
+ background: var(--empty);
179
+ display: flex;
180
+ flex-direction: column;
181
+ justify-content: space-between;
182
+ }
183
+ .slot.current { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(124, 231, 255, 0.25) inset; }
184
+ .slot.work { background: linear-gradient(180deg, rgba(124, 240, 202, 0.22), rgba(124, 240, 202, 0.10)); }
185
+ .slot.meeting { background: linear-gradient(180deg, rgba(245, 199, 106, 0.28), rgba(245, 199, 106, 0.10)); }
186
+ .slot small { color: var(--muted); }
187
+ .slot strong { font-size: 0.82rem; }
188
+ .task-list, pre {
189
+ margin: 0;
190
+ padding: 14px;
191
+ border-radius: 18px;
192
+ border: 1px solid var(--line);
193
+ background: rgba(255, 255, 255, 0.03);
194
+ }
195
+ .task-list li { margin: 0 0 8px; color: var(--muted); }
196
+ .task-list li:last-child { margin-bottom: 0; }
197
+ pre {
198
+ overflow: auto;
199
+ max-height: 260px;
200
+ color: #d7e6ff;
201
+ }
202
+ .details-grid {
203
+ display: grid;
204
+ grid-template-columns: repeat(2, minmax(0, 1fr));
205
+ gap: 16px;
206
+ margin-top: 16px;
207
+ }
208
+ .status {
209
+ margin-top: 12px;
210
+ min-height: 24px;
211
+ color: var(--muted);
212
+ }
213
+ .status.error { color: var(--danger); }
214
+ @media (max-width: 1040px) {
215
+ :root {
216
+ --content-min: 0px;
217
+ }
218
+ .metrics {
219
+ grid-template-columns: repeat(2, minmax(0, 1fr));
220
+ }
221
+ }
222
+ @media (max-width: 760px) {
223
+ .grid,
224
+ .metrics,
225
+ .details-grid {
226
+ grid-template-columns: 1fr;
227
+ }
228
+ .actions { grid-template-columns: 1fr; }
229
+ .actions button[data-op="0"] { grid-column: span 1; }
230
+ }
231
+ """
232
+ )
233
+
234
+ WEB_JS = dedent(
235
+ """\
236
+ const statusEl = document.getElementById("status");
237
+ const targetSlotEl = document.getElementById("targetSlot");
238
+ const timelineEl = document.getElementById("timeline");
239
+ const tasksEl = document.getElementById("tasks");
240
+ const payloadEl = document.getElementById("payload");
241
+ const metrics = {
242
+ time: document.getElementById("m-time"),
243
+ slot: document.getElementById("m-slot"),
244
+ flow: document.getElementById("m-flow"),
245
+ debt: document.getElementById("m-debt"),
246
+ };
247
+
248
+ function setStatus(message, isError = false) {
249
+ statusEl.textContent = message;
250
+ statusEl.className = isError ? "status error" : "status";
251
+ }
252
+
253
+ function kindLabel(value) {
254
+ if (value === 1) return "Deep Work";
255
+ if (value === 2) return "Meeting";
256
+ return "Open";
257
+ }
258
+
259
+ function renderObservation(response) {
260
+ const obs = response.observation || {};
261
+ metrics.time.textContent = obs.current_time ?? "-";
262
+ metrics.slot.textContent = String(obs.current_slot ?? "-");
263
+ metrics.flow.textContent = Number(obs.flow_score ?? 0).toFixed(2);
264
+ metrics.debt.textContent = Number(obs.social_debt ?? 0).toFixed(2);
265
+ targetSlotEl.max = Math.max(0, (obs.timeline || []).length - 1);
266
+
267
+ timelineEl.innerHTML = "";
268
+ (obs.timeline || []).forEach((value, index) => {
269
+ const slot = document.createElement("div");
270
+ slot.className = "slot " + (value === 1 ? "work" : value === 2 ? "meeting" : "");
271
+ if (index === obs.current_slot) slot.classList.add("current");
272
+ slot.innerHTML = "<small>Slot " + index + "</small><strong>" + kindLabel(value) + "</strong>";
273
+ timelineEl.appendChild(slot);
274
+ });
275
+
276
+ tasksEl.innerHTML = "";
277
+ const tasks = obs.task_buffer || [];
278
+ if (!tasks.length) {
279
+ const empty = document.createElement("li");
280
+ empty.textContent = "No queued tasks.";
281
+ tasksEl.appendChild(empty);
282
+ } else {
283
+ tasks.forEach((task, index) => {
284
+ const item = document.createElement("li");
285
+ item.textContent = "Task " + (index + 1) + ": " + task.duration + " slots, complexity " + task.hidden_complexity;
286
+ tasksEl.appendChild(item);
287
+ });
288
+ }
289
+
290
+ payloadEl.textContent = JSON.stringify(response, null, 2);
291
+ }
292
+
293
+ async function callJson(url, body) {
294
+ const response = await fetch(url, {
295
+ method: "POST",
296
+ headers: { "Content-Type": "application/json" },
297
+ body: JSON.stringify(body),
298
+ });
299
+ const data = await response.json();
300
+ if (!response.ok) throw new Error(JSON.stringify(data));
301
+ return data;
302
+ }
303
+
304
+ async function resetEnv() {
305
+ setStatus("Resetting environment...");
306
+ try {
307
+ const data = await callJson("/reset", {});
308
+ renderObservation(data);
309
+ setStatus("Environment reset.");
310
+ } catch (error) {
311
+ setStatus("Reset failed: " + error.message, true);
312
+ }
313
+ }
314
+
315
+ async function stepEnv(operation) {
316
+ setStatus("Applying action...");
317
+ try {
318
+ const payload = {
319
+ action: {
320
+ target_slot: Number(targetSlotEl.value || 0),
321
+ operation: Number(operation),
322
+ },
323
+ };
324
+ const data = await callJson("/step", payload);
325
+ renderObservation(data);
326
+ setStatus("Action applied.");
327
+ } catch (error) {
328
+ setStatus("Action failed: " + error.message, true);
329
+ }
330
+ }
331
+
332
+ document.getElementById("resetBtn").addEventListener("click", resetEnv);
333
+ document.querySelectorAll("[data-op]").forEach((button) => {
334
+ button.addEventListener("click", () => stepEnv(button.dataset.op));
335
+ });
336
+
337
+ resetEnv();
338
+ """
339
+ )
340
+
341
+ WEB_PAGE = dedent(
342
+ """\
343
+ <!doctype html>
344
+ <html lang="en">
345
+ <head>
346
+ <meta charset="utf-8">
347
+ <meta name="viewport" content="width=device-width, initial-scale=1">
348
+ <title>Engineer Manager</title>
349
+ <link rel="stylesheet" href="/assets/app.css">
350
+ </head>
351
+ <body>
352
+ <div class="shell">
353
+ <section class="hero">
354
+ <div class="eyebrow">Engineer Manager Simulator</div>
355
+ <h1>Run the day before the day runs you.</h1>
356
+ <p class="sub">This is a direct native UI for the Space. It talks to the environment API without the Gradio bridge, so it avoids the Hugging Face wrapper errors that were breaking the previous page.</p>
357
+ </section>
358
+
359
+ <div class="grid">
360
+ <aside class="panel">
361
+ <div class="panel-inner">
362
+ <h2 class="section-title">Controls</h2>
363
+ <label>Target slot
364
+ <input id="targetSlot" type="number" min="0" value="0">
365
+ </label>
366
+ <button id="resetBtn">Reset Environment</button>
367
+ <div class="actions">
368
+ <button data-op="0">Advance Time</button>
369
+ <button data-op="1">Schedule Work</button>
370
+ <button data-op="2">Move Meeting</button>
371
+ <button data-op="3">Mute Comms</button>
372
+ </div>
373
+ <div id="status" class="status">Ready.</div>
374
+ </div>
375
+ </aside>
376
+
377
+ <main class="panel">
378
+ <div class="panel-inner">
379
+ <div class="metrics">
380
+ <div class="metric"><small>Current Time</small><strong id="m-time">-</strong></div>
381
+ <div class="metric"><small>Current Slot</small><strong id="m-slot">-</strong></div>
382
+ <div class="metric"><small>Flow Score</small><strong id="m-flow">-</strong></div>
383
+ <div class="metric"><small>Social Debt</small><strong id="m-debt">-</strong></div>
384
+ </div>
385
+
386
+ <h2 class="section-title">Timeline</h2>
387
+ <div id="timeline" class="timeline"></div>
388
+
389
+ <div class="details-grid">
390
+ <section>
391
+ <h2 class="section-title">Task Buffer</h2>
392
+ <ul id="tasks" class="task-list"></ul>
393
+ </section>
394
+ <section>
395
+ <h2 class="section-title">Last Response</h2>
396
+ <pre id="payload">{}</pre>
397
+ </section>
398
+ </div>
399
+ </div>
400
+ </main>
401
+ </div>
402
+ </div>
403
+ <script src="/assets/app.js" defer></script>
404
+ </body>
405
+ </html>
406
+ """
407
+ )
408
+
409
+
410
+ @app.get("/", include_in_schema=False)
411
+ def root() -> RedirectResponse:
412
+ return RedirectResponse(url="/web/")
413
+
414
+
415
+ @app.get("/web", include_in_schema=False)
416
+ def web_root() -> RedirectResponse:
417
+ return RedirectResponse(url="/web/")
418
+
419
+
420
+ @app.get("/web/", include_in_schema=False)
421
+ def web_ui() -> HTMLResponse:
422
+ return HTMLResponse(WEB_PAGE)
423
+
424
+
425
+ @app.get("/assets/app.css", include_in_schema=False)
426
+ def web_css() -> PlainTextResponse:
427
+ return PlainTextResponse(WEB_CSS, media_type="text/css")
428
+
429
+
430
+ @app.get("/assets/app.js", include_in_schema=False)
431
+ def web_js() -> PlainTextResponse:
432
+ return PlainTextResponse(WEB_JS, media_type="application/javascript")
433
+
434
+
435
+ @app.get("/manifest.json", include_in_schema=False)
436
+ def manifest() -> JSONResponse:
437
+ return JSONResponse(
438
+ {
439
+ "name": "Engineer Manager",
440
+ "short_name": "Engineer Manager",
441
+ "start_url": "/web/",
442
+ "display": "standalone",
443
+ "background_color": "#0b1224",
444
+ "theme_color": "#0b1224",
445
+ "icons": [],
446
+ }
447
+ )
448
+
449
 
450
  def run(host: str = "0.0.0.0", port: int = 8000) -> None:
451
  """Run the OpenEnv HTTP server."""