Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .gitattributes +1 -0
- README.md +47 -15
- assets/image.png +3 -0
- client.py +17 -22
- models.py +13 -1
- server/app.py +20 -4
- server/constraint_env_environment.py +25 -10
- server/graders.py +1 -1
- server/gradio_ui.py +289 -0
.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
|
| 207 |
-
βββ __init__.py
|
| 208 |
-
βββ README.md
|
| 209 |
-
βββ openenv.yaml
|
| 210 |
-
βββ pyproject.toml
|
| 211 |
-
βββ uv.lock
|
| 212 |
-
βββ client.py
|
| 213 |
-
βββ generator.py
|
| 214 |
-
βββ models.py
|
|
|
|
|
|
|
| 215 |
ββοΏ½οΏ½οΏ½ server/
|
| 216 |
-
βββ __init__.py
|
| 217 |
-
βββ
|
| 218 |
-
βββ
|
| 219 |
-
βββ
|
| 220 |
-
|
|
|
|
| 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
|
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",
|
| 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="
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 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 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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
|