yashash04 commited on
Commit
cb33205
·
1 Parent(s): c618a61

Phase 7: FastAPI server + Dockerfile + openenv.yaml

Browse files
Files changed (6) hide show
  1. Dockerfile +14 -3
  2. __init__.py +0 -3
  3. openenv.yaml +14 -7
  4. pyproject.toml +5 -2
  5. server/app.py +119 -3
  6. tests/test_server.py +129 -0
Dockerfile CHANGED
@@ -1,3 +1,14 @@
1
- # Placeholder — will be filled in Phase 7.
2
- # Target base: ghcr.io/meta-pytorch/openenv-base:latest
3
- # Expose port 7860 for HF Spaces Docker SDK.
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM ghcr.io/meta-pytorch/openenv-base:latest
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ RUN pip install -e .
11
+
12
+ EXPOSE 7860
13
+
14
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
__init__.py DELETED
@@ -1,3 +0,0 @@
1
- """SchemaShift — OpenEnv RL environment for adaptive tool use under API drift."""
2
-
3
- __version__ = "0.1.0"
 
 
 
 
openenv.yaml CHANGED
@@ -1,7 +1,14 @@
1
- # Placeholder — will be filled in Phase 7.
2
- # spec_version: 1
3
- # name: schemashift
4
- # type: space
5
- # runtime: fastapi
6
- # app: server.app:app
7
- # port: 7860
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: schemashift
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 7860
7
+
8
+ tasks:
9
+ - id: E1_onboard_new_hire
10
+ difficulty: easy
11
+ - id: E2_meeting_invite_blast
12
+ difficulty: easy
13
+ - id: E3_customer_lookup
14
+ difficulty: easy
pyproject.toml CHANGED
@@ -28,8 +28,11 @@ ui = ["gradio>=4.20"]
28
  eval = ["openai>=1.30"]
29
 
30
  [tool.setuptools]
31
- packages = ["schemashift", "schemashift.tools", "schemashift.server", "schemashift.training"]
32
- package-dir = {"schemashift" = "."}
 
 
 
33
 
34
  [tool.pytest.ini_options]
35
  testpaths = ["tests"]
 
28
  eval = ["openai>=1.30"]
29
 
30
  [tool.setuptools]
31
+ py-modules = ["models", "drift", "scenarios", "graders", "client", "eval"]
32
+ packages = ["tools", "server", "training"]
33
+
34
+ [tool.setuptools.package-dir]
35
+ "" = "."
36
 
37
  [tool.pytest.ini_options]
38
  testpaths = ["tests"]
server/app.py CHANGED
@@ -1,4 +1,120 @@
1
- """FastAPI server + Gradio replay UI. Exposes /health, /reset, /step, /state, /tasks, /grader.
 
2
 
3
- Will be filled in Phase 7.
4
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI server exposing SchemaShiftEnvironment as OpenEnv HTTP service."""
2
+ from __future__ import annotations
3
 
4
+ from fastapi import FastAPI, HTTPException
5
+ from pydantic import BaseModel, Field
6
+
7
+ from models import Action
8
+ from scenarios import SCENARIOS
9
+ from server.environment import SchemaShiftEnvironment
10
+
11
+
12
+ app = FastAPI(
13
+ title="SchemaShift OpenEnv",
14
+ description="RL environment for training adaptive tool use under schema drift.",
15
+ version="0.1.0",
16
+ )
17
+ env = SchemaShiftEnvironment()
18
+
19
+
20
+ # ─────────────────────────────────────────────────────────────────
21
+ # Request / response models
22
+ # ─────────────────────────────────────────────────────────────────
23
+
24
+ class ResetRequest(BaseModel):
25
+ task_id: str
26
+ seed: int = 0
27
+
28
+
29
+ class StepRequest(BaseModel):
30
+ action: Action
31
+ tokens_used: int = Field(
32
+ default=0, ge=0,
33
+ description="Tokens consumed by the agent on this step",
34
+ )
35
+
36
+
37
+ # ─────────────────────────────────────────────────────────────────
38
+ # Endpoints
39
+ # ─────────────────────────────────────────────────────────────────
40
+
41
+ @app.get("/")
42
+ def root() -> dict:
43
+ return {
44
+ "name": "SchemaShift",
45
+ "version": "0.1.0",
46
+ "description": "Adaptive tool use under schema drift",
47
+ "endpoints": ["/health", "/reset", "/step", "/state", "/tasks", "/grader"],
48
+ }
49
+
50
+
51
+ @app.get("/health")
52
+ def health() -> dict:
53
+ return {"status": "ok", "version": "0.1.0"}
54
+
55
+
56
+ @app.post("/reset")
57
+ def reset(req: ResetRequest) -> dict:
58
+ """Start new episode. Returns initial Observation as dict."""
59
+ try:
60
+ obs = env.reset(req.task_id, req.seed)
61
+ return obs.model_dump()
62
+ except ValueError as e:
63
+ raise HTTPException(status_code=400, detail=str(e))
64
+ except Exception as e:
65
+ raise HTTPException(status_code=500, detail=f"Reset failed: {e}")
66
+
67
+
68
+ @app.post("/step")
69
+ def step(req: StepRequest) -> dict:
70
+ """Submit action, get observation + reward."""
71
+ try:
72
+ obs, reward = env.step(req.action, req.tokens_used)
73
+ return {
74
+ "observation": obs.model_dump(),
75
+ "reward": reward.model_dump(),
76
+ }
77
+ except RuntimeError as e:
78
+ raise HTTPException(status_code=400, detail=str(e))
79
+ except Exception as e:
80
+ raise HTTPException(status_code=500, detail=f"Step failed: {e}")
81
+
82
+
83
+ @app.get("/state")
84
+ def get_state() -> dict:
85
+ """Return full current episode state for debugging."""
86
+ if env._state is None:
87
+ raise HTTPException(status_code=400, detail="No active episode. Call /reset first.")
88
+ return env._state.model_dump()
89
+
90
+
91
+ @app.get("/tasks")
92
+ def get_tasks() -> dict:
93
+ """List all available scenarios with metadata."""
94
+ tasks = []
95
+ for task_id, scenario in SCENARIOS.items():
96
+ desc = scenario["task_description"]
97
+ trimmed = desc[:120] + ("..." if len(desc) > 120 else "")
98
+ tasks.append({
99
+ "task_id": task_id,
100
+ "difficulty": scenario["difficulty"],
101
+ "max_steps": scenario["max_steps"],
102
+ "required_tools": scenario["required_tools"],
103
+ "description": trimmed,
104
+ })
105
+ return {"tasks": tasks, "count": len(tasks)}
106
+
107
+
108
+ @app.get("/grader")
109
+ def get_grader_breakdown() -> dict:
110
+ """Return grader scoring for current episode state."""
111
+ if env._state is None:
112
+ raise HTTPException(status_code=400, detail="No active episode.")
113
+ reward = env._grader(env._state)
114
+ return {
115
+ "cumulative_reward": env._state.cumulative_reward,
116
+ "current_breakdown": reward.model_dump(),
117
+ "step": env._state.step,
118
+ "max_steps": env._state.max_steps,
119
+ "done": env._state.done,
120
+ }
tests/test_server.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI server acceptance tests — Phase 7.
2
+
3
+ Each test gets a fresh SchemaShiftEnvironment via monkeypatch so state doesn't
4
+ leak across tests (the default module-level env would pollute test ordering).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import pytest
9
+ from fastapi.testclient import TestClient
10
+
11
+ from server import app as app_module
12
+ from server.environment import SchemaShiftEnvironment
13
+
14
+
15
+ @pytest.fixture
16
+ def client(monkeypatch):
17
+ """Fresh env per test for isolation."""
18
+ fresh_env = SchemaShiftEnvironment()
19
+ monkeypatch.setattr(app_module, "env", fresh_env)
20
+ return TestClient(app_module.app)
21
+
22
+
23
+ def test_root_endpoint(client) -> None:
24
+ r = client.get("/")
25
+ assert r.status_code == 200
26
+ body = r.json()
27
+ assert body["name"] == "SchemaShift"
28
+ assert "endpoints" in body
29
+
30
+
31
+ def test_health_endpoint(client) -> None:
32
+ r = client.get("/health")
33
+ assert r.status_code == 200
34
+ assert r.json() == {"status": "ok", "version": "0.1.0"}
35
+
36
+
37
+ def test_tasks_endpoint(client) -> None:
38
+ r = client.get("/tasks")
39
+ assert r.status_code == 200
40
+ body = r.json()
41
+ assert body["count"] == 3
42
+ task_ids = {t["task_id"] for t in body["tasks"]}
43
+ assert task_ids == {
44
+ "E1_onboard_new_hire",
45
+ "E2_meeting_invite_blast",
46
+ "E3_customer_lookup",
47
+ }
48
+ for t in body["tasks"]:
49
+ assert t["difficulty"] == "easy"
50
+ assert isinstance(t["required_tools"], list)
51
+
52
+
53
+ def test_reset_valid_task(client) -> None:
54
+ r = client.post("/reset", json={"task_id": "E1_onboard_new_hire"})
55
+ assert r.status_code == 200
56
+ body = r.json()
57
+ assert body["task_id"] == "E1_onboard_new_hire"
58
+ assert body["step"] == 0
59
+ assert body["done"] is False
60
+ assert "mail" in body["tool_schemas"]
61
+ assert "calendar" in body["tool_schemas"]
62
+
63
+
64
+ def test_reset_invalid_task(client) -> None:
65
+ r = client.post("/reset", json={"task_id": "nonexistent_task"})
66
+ assert r.status_code == 400
67
+ body = r.json()
68
+ assert "detail" in body
69
+ assert "nonexistent_task" in body["detail"]
70
+
71
+
72
+ def test_step_before_reset_returns_400(client) -> None:
73
+ action_payload = {
74
+ "action": {
75
+ "type": "inspect_schema",
76
+ "inspect": {"tool": "mail"},
77
+ },
78
+ "tokens_used": 0,
79
+ }
80
+ r = client.post("/step", json=action_payload)
81
+ assert r.status_code == 400
82
+ assert "reset" in r.json()["detail"].lower()
83
+
84
+
85
+ def test_step_valid_action_after_reset(client) -> None:
86
+ client.post("/reset", json={"task_id": "E1_onboard_new_hire"})
87
+ action_payload = {
88
+ "action": {
89
+ "type": "inspect_schema",
90
+ "inspect": {"tool": "mail"},
91
+ },
92
+ "tokens_used": 0,
93
+ }
94
+ r = client.post("/step", json=action_payload)
95
+ assert r.status_code == 200
96
+ body = r.json()
97
+ assert "observation" in body
98
+ assert "reward" in body
99
+ assert body["observation"]["step"] == 1
100
+ assert body["observation"]["last_response"]["ok"] is True
101
+
102
+
103
+ def test_state_endpoint_before_reset_returns_400(client) -> None:
104
+ r = client.get("/state")
105
+ assert r.status_code == 400
106
+
107
+
108
+ def test_state_endpoint_after_reset(client) -> None:
109
+ client.post("/reset", json={"task_id": "E1_onboard_new_hire"})
110
+ r = client.get("/state")
111
+ assert r.status_code == 200
112
+ body = r.json()
113
+ assert body["task_id"] == "E1_onboard_new_hire"
114
+ assert body["step"] == 0
115
+ assert body["max_steps"] == 8
116
+ assert body["done"] is False
117
+ assert isinstance(body["drift_plan"], list)
118
+
119
+
120
+ def test_grader_endpoint_after_reset(client) -> None:
121
+ client.post("/reset", json={"task_id": "E1_onboard_new_hire"})
122
+ r = client.get("/grader")
123
+ assert r.status_code == 200
124
+ body = r.json()
125
+ assert body["cumulative_reward"] == 0.0
126
+ assert body["step"] == 0
127
+ assert body["max_steps"] == 8
128
+ assert body["done"] is False
129
+ assert "current_breakdown" in body