DecentSanage commited on
Commit
6293ebc
Β·
verified Β·
1 Parent(s): ecb3d9d

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/image.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -9,12 +9,19 @@ license: mit
9
  short_description: RL training env β€” natural language to constraint AST
10
  base_path: /web
11
  ---
 
12
  [Space UI & Interface](https://huggingface.co/spaces/DecentSanage/constraint-env)
13
 
14
  # Constraint Environment
15
 
16
  This is the environment for training LLMs to learn a specific DSL made for time table scheduling. Model can then directly output constraints from natural language. Why this is needed? Usually time table generation is an NP hard problem, for humans it could take weeks to generate a conflict free time table. To solve this problem, tools are created to generate them in reasonable time. One example of those tools is CP SAT. Users can write the hardcoded constraints and the solver will generate a time table based on those constraints. Well, what happens when you want to add new constraints? Yes, you have to directly change the code. What if there is a way to directly define constraints in natural language and the solver understands that automatically? That's what we have tried to do with this project. LLM might not be good at scheduling time tables which have dozens of constraints but what it is good at is understanding natural language. For the specific purpose of defining constraints for university time tables a DSL was created whose specification is as follows:
17
 
 
 
 
 
 
 
18
  ```
19
  program ::= { constraint }
20
 
@@ -64,9 +71,14 @@ identifier ::= letter { letter | digit | "" }
64
  number ::= digit { digit }
65
 
66
  ```
67
-
68
  The model outputs a json which follows the above format which can directly be converted into CP-SAT constraints. We have also included `generator.py` which implements the DSL compiler engine directly into the timetable matrix grid natively!
69
 
 
 
 
 
 
 
70
  ## Action and Observation
71
  The dataset for the training operates using dynamic deep AST JSON nodes:
72
 
@@ -199,23 +211,43 @@ INFO: 10.16.33.124:5187 - "GET /web HTTP/1.1" 307 Temporary Redirect
199
  INFO: 10.16.24.44:32462 - "GET /web/ HTTP/1.1" 200 OK
200
  ```
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  ## Project Structure
203
 
204
  ```
205
  constraint_env/
206
- β”œβ”€β”€ .dockerignore # Docker build exclusions
207
- β”œβ”€β”€ __init__.py # Module exports
208
- β”œβ”€β”€ README.md # This file
209
- β”œβ”€β”€ openenv.yaml # OpenEnv manifest
210
- β”œβ”€β”€ pyproject.toml # Project metadata and dependencies
211
- β”œβ”€β”€ uv.lock # Locked dependencies (generated)
212
- β”œβ”€β”€ client.py # ConstraintEnv client
213
- β”œβ”€β”€ generator.py # Native Time Table generator for target compiling
214
- β”œβ”€β”€ models.py # Action and Observation models
 
 
215
  └─��� server/
216
- β”œβ”€β”€ __init__.py # Server module exports
217
- β”œβ”€β”€ graders.py # Advanced threshold limits scaling for evaluations
218
- β”œβ”€β”€ constraint_env_environment.py # Core environment logic
219
- β”œβ”€β”€ app.py # FastAPI application (HTTP + WebSocket endpoints)
220
- └── Dockerfile # Container image definition
 
221
  ```
 
9
  short_description: RL training env β€” natural language to constraint AST
10
  base_path: /web
11
  ---
12
+
13
  [Space UI & Interface](https://huggingface.co/spaces/DecentSanage/constraint-env)
14
 
15
  # Constraint Environment
16
 
17
  This is the environment for training LLMs to learn a specific DSL made for time table scheduling. Model can then directly output constraints from natural language. Why this is needed? Usually time table generation is an NP hard problem, for humans it could take weeks to generate a conflict free time table. To solve this problem, tools are created to generate them in reasonable time. One example of those tools is CP SAT. Users can write the hardcoded constraints and the solver will generate a time table based on those constraints. Well, what happens when you want to add new constraints? Yes, you have to directly change the code. What if there is a way to directly define constraints in natural language and the solver understands that automatically? That's what we have tried to do with this project. LLM might not be good at scheduling time tables which have dozens of constraints but what it is good at is understanding natural language. For the specific purpose of defining constraints for university time tables a DSL was created whose specification is as follows:
18
 
19
+ ### Reviewer Quick Links
20
+ * **Hugging Face Space:** https://huggingface.co/spaces/DecentSanage/constraint-env
21
+ * **Playground UI:** https://decentSanage-constraint-env.hf.space/web
22
+ * **Health Endpoint:** https://decentSanage-constraint-env.hf.space/health
23
+ * **OpenAPI Schema:** https://decentSanage-constraint-env.hf.space/openapi.json
24
+
25
  ```
26
  program ::= { constraint }
27
 
 
71
  number ::= digit { digit }
72
 
73
  ```
 
74
  The model outputs a json which follows the above format which can directly be converted into CP-SAT constraints. We have also included `generator.py` which implements the DSL compiler engine directly into the timetable matrix grid natively!
75
 
76
+ ## System Workflow: The Interactive Compiler Loop
77
+
78
+ This environment operates as a multi-step interactive compiler. The agent submits an AST, receives deterministic feedback from the OpenEnv server, and iteratively debugs its logic to maximize its reward.
79
+
80
+ <img width="1440" height="3008" alt="image" src="https://github.com/user-attachments/assets/3e469652-08f6-440a-96db-0555067a0af0" />
81
+
82
  ## Action and Observation
83
  The dataset for the training operates using dynamic deep AST JSON nodes:
84
 
 
211
  INFO: 10.16.24.44:32462 - "GET /web/ HTTP/1.1" 200 OK
212
  ```
213
 
214
+ ## Custom Web UI
215
+
216
+ When `ENABLE_WEB_INTERFACE=true` the server mounts a **tabbed Gradio interface** at `/web`:
217
+
218
+ | Tab | Description |
219
+ |-----|-------------|
220
+ | **Playground** | Default OpenEnv UI with Reset / Step / Get State controls |
221
+ | **Constraint Compiler** | Our custom tab β€” task selector, full AST code editor, sample loaders, node-structure reference, and a real-time compiler chatbot |
222
+
223
+ ### Trying the Compiler tab
224
+ 1. Select a difficulty (`easy / medium / hard`) and click **Reset / Load Task**.
225
+ 2. The prompt appears and the editor is pre-filled with a correct sample AST.
226
+ 3. Edit the AST and click **β–Ά Submit to Compiler** β€” the chatbot shows the reward, error code, and exact compiler message.
227
+ 4. Use **πŸ“š Load Sample ASTs** to reload any of the 3 canonical examples instantly.
228
+
229
+ > The custom tab is implemented in `server/gradio_ui.py` via the `gradio_builder` extension point provided by OpenEnv core.
230
+
231
  ## Project Structure
232
 
233
  ```
234
  constraint_env/
235
+ β”œβ”€β”€ .dockerignore # Docker build exclusions
236
+ β”œβ”€β”€ __init__.py # Module exports
237
+ β”œβ”€β”€ README.md # This file
238
+ β”œβ”€β”€ openenv.yaml # OpenEnv manifest
239
+ β”œβ”€β”€ pyproject.toml # Project metadata and dependencies
240
+ β”œβ”€β”€ uv.lock # Locked dependencies (generated)
241
+ β”œβ”€β”€ client.py # ConstraintEnv WebSocket client
242
+ β”œβ”€β”€ generator.py # DSL β†’ timetable matrix compiler
243
+ β”œβ”€β”€ models.py # Pydantic Action / Observation / State models
244
+ β”œβ”€β”€ inference.py # Baseline evaluation loop (OpenEnv compatible)
245
+ β”œβ”€β”€ dataset_example.py # Training data β€” 3 difficulty tiers
246
  └─��� server/
247
+ β”œβ”€β”€ __init__.py # Server module exports
248
+ β”œβ”€β”€ app.py # FastAPI app (HTTP + WebSocket + Gradio)
249
+ β”œβ”€β”€ gradio_ui.py # Custom Gradio "Constraint Compiler" tab
250
+ β”œβ”€β”€ graders.py # Reward calculation & pass/fail thresholds
251
+ β”œβ”€β”€ constraint_env_environment.py # Core step/reset/validate logic
252
+ └── Dockerfile # Container image definition
253
  ```
assets/image.png ADDED

Git LFS Details

  • SHA256: d97c190699240b930a123a51fc16bca4dcd6a145d3ccfa80e42d12dc298b951c
  • Pointer size: 131 Bytes
  • Size of remote file: 339 kB
client.py CHANGED
@@ -30,12 +30,6 @@ class ConstraintEnv(
30
  def _step_payload(self, action: ConstraintAction) -> Dict:
31
  """
32
  Convert ConstraintAction to JSON payload for step message.
33
-
34
- Args:
35
- action: ConstraintAction instance
36
-
37
- Returns:
38
- Dictionary representation suitable for JSON encoding
39
  """
40
  return {
41
  "ast_output": action.ast_output,
@@ -44,37 +38,38 @@ class ConstraintEnv(
44
  def _parse_result(self, payload: Dict) -> StepResult[ConstraintObservation]:
45
  """
46
  Parse server response into StepResult[ConstraintObservation].
47
-
48
- Args:
49
- payload: JSON response data from server
50
-
51
- Returns:
52
- StepResult with ConstraintObservation
53
  """
 
 
 
 
54
  obs_data = payload.get("observation", {})
 
 
 
55
  observation = ConstraintObservation(
56
  prompt=obs_data.get("prompt", ""),
57
- info=obs_data.get("info", 0),
58
  done=payload.get("done", False),
59
- reward=payload.get("reward"),
 
60
  )
61
 
62
  return StepResult(
63
  observation=observation,
64
- reward=payload.get("reward"),
65
  done=payload.get("done", False),
66
  )
67
 
68
  def _parse_state(self, payload: Dict) -> ConstraintState:
69
  """
70
  Parse server response into State object.
71
-
72
- Args:
73
- payload: JSON response from state request
74
-
75
- Returns:
76
- State object with episode_id and step_count
77
  """
 
 
 
78
  return ConstraintState(
79
  episode_id=payload.get("episode_id"),
80
- )
 
 
 
30
  def _step_payload(self, action: ConstraintAction) -> Dict:
31
  """
32
  Convert ConstraintAction to JSON payload for step message.
 
 
 
 
 
 
33
  """
34
  return {
35
  "ast_output": action.ast_output,
 
38
  def _parse_result(self, payload: Dict) -> StepResult[ConstraintObservation]:
39
  """
40
  Parse server response into StepResult[ConstraintObservation].
 
 
 
 
 
 
41
  """
42
+
43
+ if isinstance(payload, str):
44
+ raise ValueError(f"Server returned an error string instead of JSON: {payload}")
45
+
46
  obs_data = payload.get("observation", {})
47
+ if isinstance(obs_data, str):
48
+ obs_data = {}
49
+
50
  observation = ConstraintObservation(
51
  prompt=obs_data.get("prompt", ""),
52
+ info=obs_data.get("info", {}), # FIX: Changed from 0 to {}
53
  done=payload.get("done", False),
54
+ reward=payload.get("reward", 0.01),
55
+ messages=obs_data.get("messages", []) # FIX: Added the missing messages array
56
  )
57
 
58
  return StepResult(
59
  observation=observation,
60
+ reward=payload.get("reward", 0.01),
61
  done=payload.get("done", False),
62
  )
63
 
64
  def _parse_state(self, payload: Dict) -> ConstraintState:
65
  """
66
  Parse server response into State object.
 
 
 
 
 
 
67
  """
68
+ if isinstance(payload, str):
69
+ raise ValueError(f"Server returned an error string instead of JSON: {payload}")
70
+
71
  return ConstraintState(
72
  episode_id=payload.get("episode_id"),
73
+ step_count=payload.get("step_count", 0), # FIX: Added missing step tracking
74
+ max_steps=payload.get("max_steps", 5) # FIX: Added missing max step bounds
75
+ )
models.py CHANGED
@@ -11,7 +11,9 @@ The constraint_env environment is a simple test environment that echoes back mes
11
  """
12
 
13
  from openenv.core.env_server.types import Action, Observation, State
14
- from typing import Dict, Any, Optional
 
 
15
 
16
 
17
  class ConstraintAction(Action):
@@ -19,6 +21,16 @@ class ConstraintAction(Action):
19
 
20
  ast_output: str
21
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  class ConstraintObservation(Observation):
24
  """Observation from the environment, user prompt and rewards"""
 
11
  """
12
 
13
  from openenv.core.env_server.types import Action, Observation, State
14
+ from typing import Dict, Any, Optional, Union
15
+ import json as _json
16
+ from pydantic import field_validator
17
 
18
 
19
  class ConstraintAction(Action):
 
21
 
22
  ast_output: str
23
 
24
+ @field_validator("ast_output", mode="before")
25
+ @classmethod
26
+ def _normalise(cls, v: Any) -> str:
27
+ """Accept dict (from Gradio UI) or str (from LLM/API), always store as JSON string."""
28
+ if isinstance(v, dict):
29
+ return _json.dumps(v)
30
+ if not isinstance(v, str):
31
+ return _json.dumps(v)
32
+ return v
33
+
34
 
35
  class ConstraintObservation(Observation):
36
  """Observation from the environment, user prompt and rewards"""
server/app.py CHANGED
@@ -57,13 +57,22 @@ def _make_env():
57
  return ConstraintEnvironment(dataset=_DATASET)
58
 
59
 
 
 
 
 
 
 
 
 
60
  # Create the app – pass the factory so create_app calls _make_env() per session.
61
  app = create_app(
62
  _make_env,
63
  ConstraintAction,
64
  ConstraintObservation,
65
- env_name="constraint_env",
66
  max_concurrent_envs=1,
 
67
  )
68
 
69
 
@@ -71,15 +80,22 @@ app = create_app(
71
  # PWA manifest – browsers request this at root level for the web UI
72
  # ---------------------------------------------------------------------------
73
 
74
- from fastapi.responses import JSONResponse # noqa: E402
 
 
 
 
 
 
 
75
 
76
 
77
  @app.get("/manifest.json", include_in_schema=False)
78
  async def web_manifest():
79
  return JSONResponse(
80
  content={
81
- "name": "Constraint Environment",
82
- "short_name": "ConstraintEnv",
83
  "description": "RL training environment: natural-language β†’ constraint AST",
84
  "start_url": "/web/",
85
  "display": "standalone",
 
57
  return ConstraintEnvironment(dataset=_DATASET)
58
 
59
 
60
+ try:
61
+ from .gradio_ui import build_constraint_gradio_ui as _gradio_builder
62
+ except ImportError:
63
+ try:
64
+ from constraint_env.server.gradio_ui import build_constraint_gradio_ui as _gradio_builder
65
+ except ImportError:
66
+ _gradio_builder = None
67
+
68
  # Create the app – pass the factory so create_app calls _make_env() per session.
69
  app = create_app(
70
  _make_env,
71
  ConstraintAction,
72
  ConstraintObservation,
73
+ env_name="Timetable Constraint Environment (NL-to-AST)",
74
  max_concurrent_envs=1,
75
+ gradio_builder=_gradio_builder,
76
  )
77
 
78
 
 
80
  # PWA manifest – browsers request this at root level for the web UI
81
  # ---------------------------------------------------------------------------
82
 
83
+ import os
84
+ from pathlib import Path
85
+ from fastapi.staticfiles import StaticFiles
86
+ from fastapi.responses import JSONResponse
87
+
88
+ _ASSETS_DIR = Path(__file__).parent.parent / "assets"
89
+ if _ASSETS_DIR.exists():
90
+ app.mount("/assets", StaticFiles(directory=str(_ASSETS_DIR)), name="assets")
91
 
92
 
93
  @app.get("/manifest.json", include_in_schema=False)
94
  async def web_manifest():
95
  return JSONResponse(
96
  content={
97
+ "name": "Timetable Constraint Environment (NL-to-AST)",
98
+ "short_name": "NL-to-AST",
99
  "description": "RL training environment: natural-language β†’ constraint AST",
100
  "start_url": "/web/",
101
  "display": "standalone",
server/constraint_env_environment.py CHANGED
@@ -107,9 +107,10 @@ class ConstraintEnvironment(Environment):
107
  self._difficulty = random.choice(["easy", "medium", "hard"])
108
 
109
  pool = self._dataset[self._difficulty]
110
- idx = self._indexes[self._difficulty]
 
111
  self._current_sample = pool[idx]
112
- self._indexes[self._difficulty] = (idx + 1) % len(pool)
113
  self._state = ConstraintState(
114
  episode_id=str(uuid4()),
115
  step_count=0,
@@ -128,6 +129,8 @@ class ConstraintEnvironment(Environment):
128
  """
129
  Evaluate the agent's AST output and return a scored observation.
130
  """
 
 
131
  self._state.step_count += 1
132
  info: Dict[str, Any] = {"difficulty": self._difficulty}
133
  messages: List[str] = []
@@ -140,20 +143,28 @@ class ConstraintEnvironment(Environment):
140
 
141
  # ── 1. Parse JSON ────────────────────────────────────────────
142
  try:
143
- ast = json.loads(action.ast_output)
144
- if isinstance(ast, str):
145
- ast = json.loads(ast)
 
 
 
 
 
 
 
 
146
  is_valid_json = True
147
- except (json.JSONDecodeError, TypeError):
148
  info["error"] = "invalid_json"
149
  messages.extend([
150
  "Your last submitted AST:",
151
- action.ast_output,
152
- "Compiler Error: Syntax Error. Invalid JSON."
153
  ])
154
 
155
  # ── 2. Logic match (ignores "name") ──────────────────────────
156
- if is_valid_json and "target_ast" in self._current_sample:
157
  if self._logic_match(ast, self._current_sample["target_ast"]):
158
  is_exact_match = True
159
  info["exact_match"] = True
@@ -203,7 +214,7 @@ class ConstraintEnvironment(Environment):
203
  # ------------------------------------------------------------------
204
 
205
  @staticmethod
206
- def _logic_match(ast: Dict[str, Any], target: Dict[str, Any]) -> bool:
207
  """
208
  Compare two ASTs on every logically meaningful field, ignoring "name".
209
 
@@ -219,6 +230,10 @@ class ConstraintEnvironment(Environment):
219
  """
220
  _LOGIC_KEYS = {"type", "forall", "where", "assert", "minimize"}
221
 
 
 
 
 
222
  # Collect only logic keys present in either dict
223
  all_keys = (set(ast.keys()) | set(target.keys())) & _LOGIC_KEYS
224
 
 
107
  self._difficulty = random.choice(["easy", "medium", "hard"])
108
 
109
  pool = self._dataset[self._difficulty]
110
+ # idx = self._indexes[self._difficulty]
111
+ idx = 0
112
  self._current_sample = pool[idx]
113
+ # self._indexes[self._difficulty] = (idx + 1) % len(pool)
114
  self._state = ConstraintState(
115
  episode_id=str(uuid4()),
116
  step_count=0,
 
129
  """
130
  Evaluate the agent's AST output and return a scored observation.
131
  """
132
+ if self._current_sample is None:
133
+ self.reset()
134
  self._state.step_count += 1
135
  info: Dict[str, Any] = {"difficulty": self._difficulty}
136
  messages: List[str] = []
 
143
 
144
  # ── 1. Parse JSON ────────────────────────────────────────────
145
  try:
146
+ raw = action.ast_output
147
+ # The Gradio/WebUI may pass the action already parsed as a dict
148
+ if isinstance(raw, dict):
149
+ ast = raw
150
+ else:
151
+ ast = json.loads(raw)
152
+ # Handle double-encoded JSON (string that contains JSON)
153
+ if isinstance(ast, str):
154
+ ast = json.loads(ast)
155
+ if not isinstance(ast, dict):
156
+ raise TypeError(f"Expected a JSON object, got {type(ast).__name__}")
157
  is_valid_json = True
158
+ except (json.JSONDecodeError, TypeError) as exc:
159
  info["error"] = "invalid_json"
160
  messages.extend([
161
  "Your last submitted AST:",
162
+ str(action.ast_output),
163
+ f"Compiler Error: Syntax Error. Invalid JSON β€” {exc}"
164
  ])
165
 
166
  # ── 2. Logic match (ignores "name") ──────────────────────────
167
+ if is_valid_json and isinstance(ast, dict) and "target_ast" in self._current_sample:
168
  if self._logic_match(ast, self._current_sample["target_ast"]):
169
  is_exact_match = True
170
  info["exact_match"] = True
 
214
  # ------------------------------------------------------------------
215
 
216
  @staticmethod
217
+ def _logic_match(ast: Any, target: Dict[str, Any]) -> bool:
218
  """
219
  Compare two ASTs on every logically meaningful field, ignoring "name".
220
 
 
230
  """
231
  _LOGIC_KEYS = {"type", "forall", "where", "assert", "minimize"}
232
 
233
+ # Guard: both sides must be dicts
234
+ if not isinstance(ast, dict) or not isinstance(target, dict):
235
+ return False
236
+
237
  # Collect only logic keys present in either dict
238
  all_keys = (set(ast.keys()) | set(target.keys())) & _LOGIC_KEYS
239
 
server/graders.py CHANGED
@@ -56,4 +56,4 @@ def check_passed(difficulty: str, score: float) -> bool:
56
  }
57
 
58
  req_threshold = thresholds.get(difficulty, 0.8)
59
- return score > req_threshold
 
56
  }
57
 
58
  req_threshold = thresholds.get(difficulty, 0.8)
59
+ return score >= req_threshold
server/gradio_ui.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Custom Gradio UI for the Constraint Environment.
3
+
4
+ Provides a rich "Constraint Compiler" tab alongside the default OpenEnv Playground.
5
+ Registered via the gradio_builder extension point in create_app().
6
+ """
7
+
8
+ import json
9
+ import gradio as gr
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Sample AST pre-loaded in the editor for judges to try immediately
13
+ # ---------------------------------------------------------------------------
14
+
15
+ _SAMPLE_EASY = json.dumps({
16
+ "type": "hard",
17
+ "name": "cs_department_meeting",
18
+ "forall": [
19
+ {"b": "branches"},
20
+ {"sub": {"subjects": "b"}},
21
+ {"d": "days"},
22
+ {"s": "slots"}
23
+ ],
24
+ "where": {
25
+ "operator": "AND",
26
+ "left": {
27
+ "operator": "==",
28
+ "left": {"name": "b"},
29
+ "right": "CS"
30
+ },
31
+ "right": {
32
+ "operator": "AND",
33
+ "left": {"operator": "==", "left": "d", "right": 2},
34
+ "right": {"operator": "==", "left": "s", "right": 3}
35
+ }
36
+ },
37
+ "assert": {
38
+ "operator": "==",
39
+ "left": {
40
+ "target": "schedule",
41
+ "args": [{"name": "b"}, {"name": "sub"}, "d", "s"]
42
+ },
43
+ "right": 0
44
+ }
45
+ }, indent=2)
46
+
47
+ _SAMPLE_MEDIUM = json.dumps({
48
+ "type": "hard",
49
+ "name": "subject_weekly_frequency",
50
+ "forall": [
51
+ {"b": "branches"},
52
+ {"sub": {"subjects": "b"}}
53
+ ],
54
+ "assert": {
55
+ "operator": "==",
56
+ "left": {
57
+ "operator": "sum",
58
+ "over": [{"d": "days"}, {"s": "slots"}],
59
+ "expression": {
60
+ "target": "schedule",
61
+ "args": [{"name": "b"}, {"name": "sub"}, "d", "s"]
62
+ }
63
+ },
64
+ "right": {"frequency": "sub"}
65
+ }
66
+ }, indent=2)
67
+
68
+ _SAMPLE_HARD = json.dumps({
69
+ "type": "hard",
70
+ "name": "no_classes_on_saturday",
71
+ "forall": [
72
+ {"b": "branches"},
73
+ {"sub": {"subjects": "b"}},
74
+ {"d": "days"},
75
+ {"s": "slots"}
76
+ ],
77
+ "where": {
78
+ "operator": "AND",
79
+ "left": {"operator": "==", "left": "d", "right": 5},
80
+ "right": {
81
+ "operator": "!=",
82
+ "left": {"type": "sub"},
83
+ "right": "online"
84
+ }
85
+ },
86
+ "assert": {
87
+ "operator": "==",
88
+ "left": {
89
+ "target": "schedule",
90
+ "args": [{"name": "b"}, {"name": "sub"}, "d", "s"]
91
+ },
92
+ "right": 0
93
+ }
94
+ }, indent=2)
95
+
96
+ _INDEX_MAPPINGS = """\
97
+ **Day Index:** `Mon=0 Tue=1 Wed=2 Thu=3 Fri=4 Sat=5`
98
+ **Slot Index:** `9:00=0 10:00=1 11:00=2 12:00=3 BREAK=4 2:00=5 3:00=6 4:00=7 5:00=8`
99
+ """
100
+
101
+ _TASK_INFO = {
102
+ "easy": ("🟒 EASY", "The branch CS must not have classes on Wednesday and on 12:00.", _SAMPLE_EASY),
103
+ "medium": ("🟑 MEDIUM", "Subjects must be equal to their defined frequency.", _SAMPLE_MEDIUM),
104
+ "hard": ("πŸ”΄ HARD", "No classes should be scheduled on Saturday, except for online classes.", _SAMPLE_HARD),
105
+ }
106
+
107
+ _CSS = """
108
+ #compiler-panel { border-left: 3px solid #7c3aed; padding-left: 12px; }
109
+ .reward-good { color: #22c55e !important; font-weight: bold; }
110
+ .reward-bad { color: #ef4444 !important; font-weight: bold; }
111
+ .task-badge { font-size: 1.1em; font-weight: bold; }
112
+ """
113
+
114
+
115
+ def build_constraint_gradio_ui(web_manager, action_fields, metadata, is_chat_env, title, quick_start_md):
116
+ """Custom Gradio builder β€” returns a gr.Blocks shown in the 'Custom' tab."""
117
+
118
+ def _run_async(coro):
119
+ """Run an async coroutine from a sync Gradio handler."""
120
+ import asyncio, concurrent.futures
121
+ try:
122
+ loop = asyncio.get_running_loop()
123
+ except RuntimeError:
124
+ loop = None
125
+ if loop and loop.is_running():
126
+ # We are inside an async context β€” run in a fresh thread with its own loop
127
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
128
+ future = pool.submit(asyncio.run, coro)
129
+ return future.result()
130
+ else:
131
+ return asyncio.run(coro)
132
+
133
+ def _do_reset(task_id):
134
+ label, prompt, sample_ast = _TASK_INFO.get(task_id, _TASK_INFO["easy"])
135
+ try:
136
+ result = _run_async(web_manager.reset_environment({"task_id": task_id}))
137
+ obs = result if isinstance(result, dict) else {}
138
+ prompt_out = obs.get("observation", {}).get("prompt", prompt)
139
+ except Exception as e:
140
+ prompt_out = f"⚠️ Reset error: {e}\n\nDefault prompt: {prompt}"
141
+ return (
142
+ f"**Task:** {label}\n\n**Prompt:** {prompt_out}",
143
+ sample_ast,
144
+ "β€”",
145
+ "β€”",
146
+ "",
147
+ "" # history is a plain string log
148
+ )
149
+
150
+ def _do_step(ast_text, history_log):
151
+ if not ast_text.strip():
152
+ return "β€”", "β€”", "⚠️ Please enter an AST JSON.", history_log
153
+
154
+ sep = "\n" + "─" * 60 + "\n"
155
+
156
+ try:
157
+ ast_obj = json.loads(ast_text)
158
+ except json.JSONDecodeError as e:
159
+ entry = f"[YOU]\n{ast_text[:300]}\n\n[COMPILER]\n❌ Invalid JSON: {e}"
160
+ return "0.01", "❌ invalid_json", str(e), history_log + sep + entry
161
+
162
+ try:
163
+ result = _run_async(web_manager.step_environment({"ast_output": json.dumps(ast_obj)}))
164
+ payload = result if isinstance(result, dict) else {}
165
+ reward = payload.get("reward", 0.01)
166
+ done = payload.get("done", False)
167
+ obs = payload.get("observation", {})
168
+ msgs = obs.get("messages", [])
169
+ info = obs.get("info", {})
170
+ error = info.get("error", "null")
171
+ exact = info.get("exact_match", False)
172
+ except Exception as e:
173
+ entry = f"[YOU]\n{ast_text[:300]}\n\n[COMPILER]\n⚠️ Server error: {e}"
174
+ return "β€”", "β€”", f"⚠️ {e}", history_log + sep + entry
175
+
176
+ reward_str = f"{reward:.3f}"
177
+ if exact:
178
+ status = "βœ… exact_match"
179
+ elif error == "logic_mismatch":
180
+ status = "⚠️ logic_mismatch"
181
+ elif error == "bad_structure":
182
+ status = "❌ bad_structure"
183
+ else:
184
+ status = f"ℹ️ {error}"
185
+
186
+ compiler_msg = "\n".join(msgs) if msgs else ("βœ… Correct! Episode complete." if exact else "No compiler feedback.")
187
+ done_badge = " 🏁 Episode Done" if done else ""
188
+ entry = (
189
+ f"[YOU]\n{ast_text[:400]}\n\n"
190
+ f"[COMPILER] Reward={reward_str} | {status}{done_badge}\n{compiler_msg}"
191
+ )
192
+ return reward_str, status, compiler_msg, history_log + sep + entry
193
+
194
+ with gr.Blocks(title="Constraint Compiler") as demo:
195
+ gr.HTML(f"<style>{_CSS}</style>")
196
+ gr.HTML(
197
+ '<div style="display:flex;align-items:center;gap:20px;margin-bottom:8px;">'
198
+ '<img src="/assets/image.png" style="height:110px;border-radius:10px;"/>'
199
+ '<div>'
200
+ '<h1 style="margin:0;font-size:1.6em;color:#e2e8f0;">Timetable Constraint Environment</h1>'
201
+ 'Convert natural-language scheduling rules into a <strong>JSON AST</strong> and get instant compiler feedback.</p>'
202
+ '</div></div>'
203
+ )
204
+ gr.Markdown(_INDEX_MAPPINGS)
205
+
206
+ with gr.Row():
207
+ with gr.Column(scale=1):
208
+ gr.Markdown("### βš™οΈ Task Control")
209
+ task_dd = gr.Dropdown(
210
+ choices=["easy", "medium", "hard"],
211
+ value="easy",
212
+ label="Select Difficulty",
213
+ interactive=True
214
+ )
215
+ reset_btn = gr.Button("πŸ”„ Reset / Load Task", variant="primary", size="lg")
216
+
217
+ gr.Markdown("### πŸ“‹ Active Prompt")
218
+ prompt_box = gr.Markdown("*Click Reset to load a task…*")
219
+
220
+ gr.Markdown("### πŸ“Š Last Step Result")
221
+ with gr.Row():
222
+ reward_box = gr.Textbox(label="Reward", value="β€”", interactive=False, scale=1)
223
+ status_box = gr.Textbox(label="Status", value="β€”", interactive=False, scale=2)
224
+ compiler_feedback = gr.Textbox(
225
+ label="Compiler Feedback",
226
+ lines=5,
227
+ interactive=False,
228
+ placeholder="Compiler messages appear here…"
229
+ )
230
+
231
+ gr.Markdown("### πŸ—ΊοΈ Node Structure Reference")
232
+ gr.Markdown(
233
+ "**Operator:** `{\"operator\": \"==\", \"left\": …, \"right\": …}`\n\n"
234
+ "**Function:** `{\"target\": \"schedule\", \"args\": [{\"name\":\"b\"}, …]}`\n\n"
235
+ "**Property:** `{\"frequency\": \"sub\"}` or `{\"type\": \"sub\"}`\n\n"
236
+ "**Sum:** `{\"operator\": \"sum\", \"over\": [{\"d\":\"days\"}], \"expression\": …}`\n\n"
237
+ "**Variable Ref:** `{\"name\": \"b\"}`"
238
+ )
239
+
240
+ with gr.Column(scale=2, elem_id="compiler-panel"):
241
+ gr.Markdown("### ✏️ AST Editor")
242
+ ast_editor = gr.Code(
243
+ value=_SAMPLE_EASY,
244
+ language="json",
245
+ label="Your AST JSON",
246
+ lines=30,
247
+ interactive=True
248
+ )
249
+ with gr.Row():
250
+ step_btn = gr.Button("β–Ά Submit to Compiler", variant="primary", size="lg")
251
+ clear_btn = gr.Button("πŸ—‘ Clear History", size="lg")
252
+
253
+ gr.Markdown("### πŸ’¬ Compiler Conversation Log")
254
+ chat_box = gr.Textbox(
255
+ label="Interaction Log",
256
+ lines=18,
257
+ interactive=False,
258
+ placeholder="Submit to the compiler to see step-by-step feedback here…",
259
+ )
260
+
261
+ # ── Sample loaders ─────────────────────────────────────────��────
262
+ with gr.Accordion("πŸ“š Load Sample ASTs", open=False):
263
+ with gr.Row():
264
+ sample_easy_btn = gr.Button("🟒 Easy Sample", size="sm")
265
+ sample_medium_btn = gr.Button("🟑 Medium Sample", size="sm")
266
+ sample_hard_btn = gr.Button("πŸ”΄ Hard Sample", size="sm")
267
+
268
+ history_state = gr.State("")
269
+
270
+ # ── Events ──────────────────────────────────────────────────────
271
+ reset_btn.click(
272
+ fn=_do_reset,
273
+ inputs=[task_dd],
274
+ outputs=[prompt_box, ast_editor, reward_box, status_box, compiler_feedback, history_state]
275
+ ).then(lambda h: h, inputs=[history_state], outputs=[chat_box])
276
+
277
+ step_btn.click(
278
+ fn=_do_step,
279
+ inputs=[ast_editor, history_state],
280
+ outputs=[reward_box, status_box, compiler_feedback, history_state]
281
+ ).then(lambda h: h, inputs=[history_state], outputs=[chat_box])
282
+
283
+ clear_btn.click(fn=lambda: ("", ""), outputs=[history_state, chat_box])
284
+
285
+ sample_easy_btn.click(fn=lambda: _SAMPLE_EASY, outputs=[ast_editor])
286
+ sample_medium_btn.click(fn=lambda: _SAMPLE_MEDIUM, outputs=[ast_editor])
287
+ sample_hard_btn.click(fn=lambda: _SAMPLE_HARD, outputs=[ast_editor])
288
+
289
+ return demo