saheb commited on
Commit
06dfa9c
·
1 Parent(s): 971d57f

Enhance Content Moderation Environment: Update Dockerfile, add client, improve README, and refine environment handling

Browse files
Files changed (8) hide show
  1. .gitignore +4 -0
  2. Dockerfile +32 -8
  3. README.md +29 -0
  4. client.py +131 -0
  5. openenv.yaml +6 -14
  6. requirements.txt +9 -0
  7. server/app.py +141 -71
  8. server/environment.py +6 -1
.gitignore CHANGED
@@ -40,3 +40,7 @@ MANIFEST
40
 
41
  # uv
42
  uv.lock
 
 
 
 
 
40
 
41
  # uv
42
  uv.lock
43
+
44
+ # llms
45
+ .claude/
46
+ .kilo/
Dockerfile CHANGED
@@ -1,18 +1,42 @@
1
  FROM python:3.11-slim
2
 
3
- # Install git and other build deps just in case
4
- RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
 
 
 
 
5
 
6
  # Set working directory to the standard Hugging Face directory
7
  WORKDIR /code
8
 
9
- # Copy all the current files from the repo root to /code
10
- COPY . /code
11
 
12
- # Install the project locally along with OpenEnv
13
- RUN pip install --no-cache-dir -e .
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  EXPOSE 8000
16
 
17
- # Run Uvicorn with factory mode
18
- CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
 
 
 
 
 
 
 
1
  FROM python:3.11-slim
2
 
3
+ # Install system dependencies and cleanup
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ git \
6
+ curl \
7
+ && rm -rf /var/lib/apt/lists/* \
8
+ && apt-get clean
9
 
10
  # Set working directory to the standard Hugging Face directory
11
  WORKDIR /code
12
 
13
+ # Copy requirements first for better caching
14
+ COPY pyproject.toml uv.lock ./
15
 
16
+ # Install dependencies with uv for faster builds
17
+ RUN pip install --no-cache-dir uv && \
18
+ uv sync --frozen && \
19
+ uv pip install --no-cache-dir -e .
20
+
21
+ # Copy the rest of the application
22
+ COPY . .
23
+
24
+ # Create non-root user for security
25
+ RUN useradd --create-home --shell /bin/bash app && \
26
+ chown -R app:app /code
27
+ USER app
28
+
29
+ # Health check endpoint
30
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
31
+ CMD curl -f http://localhost:8000/health || exit 1
32
 
33
  EXPOSE 8000
34
 
35
+ # Run Uvicorn with optimized settings for production
36
+ CMD ["uvicorn", "server.app:app", \
37
+ "--host", "0.0.0.0", \
38
+ "--port", "8000", \
39
+ "--workers", "1", \
40
+ "--timeout", "300", \
41
+ "--timeout-keep-alive", "65", \
42
+ "--log-level", "info"]
README.md CHANGED
@@ -26,6 +26,35 @@ A small, deterministic OpenEnv for evaluating AI agents on user-generated conten
26
 
27
  ---
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  ## Why content moderation?
30
 
31
  Content moderation is one of the few domains where:
 
26
 
27
  ---
28
 
29
+ ## Environment Specification
30
+
31
+ **OpenEnv Configuration**: Minimal specification in `openenv.yaml` matching reference environments:
32
+ - `spec_version: 1`
33
+ - `name: content_moderation_env`
34
+ - `type: space`
35
+ - `runtime: fastapi`
36
+ - `app: server.app:app`
37
+ - `port: 8000`
38
+
39
+ **Technical Details**:
40
+ - **Version**: 0.1.0
41
+ - **Python**: >=3.10
42
+ - **License**: MIT
43
+ - **Authors**: Saheb Motiani ([@saheb](https://github.com/saheb))
44
+ - **Environment Type**: Sequential decision making
45
+ - **Observation Space**: Structured text with Pydantic models
46
+ - **Action Space**: Discrete moderation actions
47
+ - **Max Episode Length**: 150 steps (very_hard)
48
+ - **Deterministic**: Yes - fully reproducible task packs
49
+ - **Reward Shaping**: Yes - dense rewards with policy bonuses
50
+ - **Stateful**: Yes - maintains episode state
51
+
52
+ **Tags**: content-moderation, reasoning, policy-grounded, multi-step, deterministic
53
+
54
+ **Keywords**: content moderation, AI safety, policy reasoning, context awareness, reward shaping, thread context, multi-step reasoning
55
+
56
+ ---
57
+
58
  ## Why content moderation?
59
 
60
  Content moderation is one of the few domains where:
client.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Saheb Motiani
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the MIT license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Content Moderation Environment Client."""
8
+
9
+ from typing import Dict
10
+
11
+ from openenv.core import EnvClient
12
+ from openenv.core.client_types import StepResult
13
+ from openenv.core.env_server.types import State
14
+
15
+ from models import ModerationAction, ModerationObservation
16
+
17
+
18
+ class ContentModerationEnv(EnvClient[ModerationAction, ModerationObservation, State]):
19
+ """
20
+ Client for the Content Moderation Environment.
21
+
22
+ This client maintains a persistent WebSocket connection to the environment server,
23
+ enabling efficient multi-step interactions with lower latency.
24
+ Each client instance has its own dedicated environment session on the server.
25
+
26
+ Example:
27
+ >>> # Connect to a running server
28
+ >>> from content_moderation_env import ContentModerationEnv, ModerationAction
29
+ >>>
30
+ >>> env = ContentModerationEnv(base_url="http://localhost:8000")
31
+ >>> # Start with easy task
32
+ >>> result = env.reset(task_name="easy")
33
+ >>> print(f"Active post: {result.observation.active_post_summary}")
34
+ >>>
35
+ >>> # View the full post
36
+ >>> result = env.step(ModerationAction(type="view_post", post_id="p1"))
37
+ >>> print(f"Post content: {result.observation.current_post}")
38
+ >>>
39
+ >>> # View thread context if needed
40
+ >>> result = env.step(ModerationAction(type="view_thread", post_id="p1"))
41
+ >>> print(f"Thread: {result.observation.thread_context}")
42
+ >>>
43
+ >>> # Make moderation decision
44
+ >>> result = env.step(ModerationAction(
45
+ ... type="moderate",
46
+ ... post_id="p1",
47
+ ... decision="keep",
48
+ ... rationale="P4"
49
+ ... ))
50
+ >>> print(f"Score: {result.observation.grader_score}")
51
+ >>> print(f"Done: {result.observation.done}")
52
+ >>> env.close()
53
+
54
+ Example with Docker:
55
+ >>> # Automatically start container and connect
56
+ >>> client = ContentModerationEnv.from_docker_image("content-moderation-env:latest")
57
+ >>> try:
58
+ ... result = client.reset(task_name="medium")
59
+ ... # Process posts...
60
+ ... finally:
61
+ ... client.close()
62
+ """
63
+
64
+ def _step_payload(self, action: ModerationAction) -> Dict:
65
+ """
66
+ Convert ModerationAction to JSON payload for step message.
67
+
68
+ Args:
69
+ action: ModerationAction instance
70
+
71
+ Returns:
72
+ Dictionary representation suitable for JSON encoding
73
+ """
74
+ payload = {"type": action.type}
75
+
76
+ if action.post_id:
77
+ payload["post_id"] = action.post_id
78
+ if action.decision:
79
+ payload["decision"] = action.decision
80
+ if action.rationale:
81
+ payload["rationale"] = action.rationale
82
+ if action.policy_id:
83
+ payload["policy_id"] = action.policy_id
84
+ if action.category:
85
+ payload["category"] = action.category
86
+
87
+ return payload
88
+
89
+ def _parse_result(self, payload: Dict) -> StepResult[ModerationObservation]:
90
+ """
91
+ Parse server response into StepResult[ModerationObservation].
92
+
93
+ Args:
94
+ payload: JSON response data from server
95
+
96
+ Returns:
97
+ StepResult with ModerationObservation
98
+ """
99
+ obs_data = payload.get("observation", {})
100
+ observation = ModerationObservation(
101
+ active_post_summary=obs_data.get("active_post_summary"),
102
+ failed_attempts=obs_data.get("failed_attempts", []),
103
+ current_post=obs_data.get("current_post"),
104
+ thread_context=obs_data.get("thread_context"),
105
+ last_action_result=obs_data.get("last_action_result", ""),
106
+ grader_score=obs_data.get("grader_score"),
107
+ reward=payload.get("reward"),
108
+ done=payload.get("done", False),
109
+ metadata=obs_data.get("metadata", {}),
110
+ )
111
+
112
+ return StepResult(
113
+ observation=observation,
114
+ reward=payload.get("reward"),
115
+ done=payload.get("done", False),
116
+ )
117
+
118
+ def _parse_state(self, payload: Dict) -> State:
119
+ """
120
+ Parse server response into State object.
121
+
122
+ Args:
123
+ payload: JSON response from state request
124
+
125
+ Returns:
126
+ State object with episode_id and step_count
127
+ """
128
+ return State(
129
+ episode_id=payload.get("episode_id"),
130
+ step_count=payload.get("step_count", 0),
131
+ )
openenv.yaml CHANGED
@@ -1,14 +1,6 @@
1
- name: content-moderation-env
2
- version: 0.1.0
3
- description: "OpenEnv package for UGC content moderation (VibeNet mock)"
4
- authors:
5
- - name: "Saheb Motiani"
6
- github: "https://github.com/saheb"
7
- entrypoint: server.app:app
8
- python: ">=3.10"
9
- dependencies:
10
- - openenv-core>=0.2.0
11
- - pydantic>=2.0
12
- - fastapi>=0.110
13
- - uvicorn
14
- license: MIT
 
1
+ spec_version: 1
2
+ name: content_moderation_env
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
 
 
 
 
 
 
 
 
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Core dependencies for content-moderation-env
2
+ openenv-core>=0.2.0
3
+ pydantic>=2.0
4
+ fastapi>=0.110
5
+ uvicorn[standard]>=0.25.0
6
+ python-dotenv>=1.0.0
7
+
8
+ # Optional dependencies for baseline inference
9
+ openai>=1.0
server/app.py CHANGED
@@ -1,6 +1,8 @@
1
  import os
2
  import uuid
3
  import logging
 
 
4
 
5
  import uvicorn
6
  from fastapi import HTTPException
@@ -11,11 +13,22 @@ from env_loader import load_environment
11
  from .environment import ContentModerationEnvironment
12
  from models import ModerationAction, ModerationObservation
13
 
 
 
 
 
 
 
14
  logger = logging.getLogger(__name__)
15
 
16
- _loaded_env_file = load_environment()
17
- if _loaded_env_file:
18
- logger.info("Loaded environment variables from %s", _loaded_env_file)
 
 
 
 
 
19
 
20
  # Demo session store — keyed by session UUID, capped at 50 concurrent sessions
21
  _demo_sessions: dict = {}
@@ -25,23 +38,56 @@ _MAX_DEMO_SESSIONS = 50
25
  def create_app():
26
  """Factory function for creating the FastAPI app instance."""
27
  logger.info("Creating FastAPI app...")
28
- app = create_fastapi_app(
29
- lambda: ContentModerationEnvironment(),
30
- action_cls=ModerationAction,
31
- observation_cls=ModerationObservation,
32
- )
 
 
 
 
 
 
33
 
34
  @app.get("/", response_class=HTMLResponse, tags=["UI"])
35
  def root():
36
  """Root endpoint serving the interactive demo UI."""
37
- demo_html_path = os.path.join(os.path.dirname(__file__), "..", "demo.html")
38
- with open(demo_html_path, encoding="utf-8") as f:
39
- return HTMLResponse(content=f.read())
 
 
 
 
 
 
 
40
 
41
  @app.get("/health", tags=["Health"])
42
  def health():
43
  """Standard health check endpoint for monitoring."""
44
- return {"status": "healthy"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  logger.info("FastAPI app created successfully")
47
  return app
@@ -57,62 +103,86 @@ if __name__ == "__main__":
57
  main()
58
 
59
  # Module-level app for runners that expect `server.app:app` (no --factory)
60
- app = create_app()
61
-
62
-
63
- @app.get("/ready", tags=["Health"])
64
- def ready():
65
- """Readiness endpoint often used by cloud platforms for startup probes."""
66
- return {"status": "ready"}
67
-
68
-
69
- # ── Demo endpoints ────────────────────────────────────────────────────────────
70
-
71
- @app.get("/demo", response_class=HTMLResponse, tags=["Demo"])
72
- def demo_page():
73
- """Serve the interactive human-playable demo UI."""
74
- demo_html_path = os.path.join(os.path.dirname(__file__), "..", "demo.html")
75
- with open(demo_html_path, encoding="utf-8") as f:
76
- return HTMLResponse(content=f.read())
77
-
78
-
79
- @app.get("/benchmark.html", response_class=HTMLResponse, tags=["UI"])
80
- def benchmark_page():
81
- """Serve the interactive benchmark visualization UI."""
82
- benchmark_html_path = os.path.join(os.path.dirname(__file__), "..", "benchmark.html")
83
- with open(benchmark_html_path, encoding="utf-8") as f:
84
- return HTMLResponse(content=f.read())
85
-
86
-
87
- @app.post("/demo/reset", tags=["Demo"])
88
- def demo_reset(task: str = "easy"):
89
- """Start a new demo session. Returns session_id and initial observation."""
90
- if task not in ("easy", "medium", "hard", "very_hard"):
91
- raise HTTPException(status_code=400, detail=f"Unknown task: {task}")
92
-
93
- # Evict oldest session if at capacity
94
- if len(_demo_sessions) >= _MAX_DEMO_SESSIONS:
95
- oldest_key = next(iter(_demo_sessions))
96
- del _demo_sessions[oldest_key]
97
-
98
- session_id = str(uuid.uuid4())
99
- env = ContentModerationEnvironment()
100
- obs = env.reset(task_name=task)
101
- _demo_sessions[session_id] = env
102
-
103
- return {"session_id": session_id, "observation": obs.model_dump()}
104
-
105
-
106
- @app.post("/demo/step/{session_id}", tags=["Demo"])
107
- def demo_step(session_id: str, action: ModerationAction):
108
- """Submit one action in an existing demo session."""
109
- env = _demo_sessions.get(session_id)
110
- if env is None:
111
- raise HTTPException(status_code=404, detail="Session not found or expired. Start a new session via /demo/reset.")
112
-
113
- obs = env.step(action)
114
-
115
- if obs.done:
116
- del _demo_sessions[session_id]
117
-
118
- return {"observation": obs.model_dump()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import uuid
3
  import logging
4
+ import sys
5
+ from typing import Optional
6
 
7
  import uvicorn
8
  from fastapi import HTTPException
 
13
  from .environment import ContentModerationEnvironment
14
  from models import ModerationAction, ModerationObservation
15
 
16
+ # Configure logging
17
+ logging.basicConfig(
18
+ level=logging.INFO,
19
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
20
+ handlers=[logging.StreamHandler(sys.stdout)]
21
+ )
22
  logger = logging.getLogger(__name__)
23
 
24
+ # Load environment variables safely
25
+ _loaded_env_file: Optional[str] = None
26
+ try:
27
+ _loaded_env_file = load_environment()
28
+ if _loaded_env_file:
29
+ logger.info("Loaded environment variables from %s", _loaded_env_file)
30
+ except Exception as e:
31
+ logger.warning("Failed to load environment variables: %s", e)
32
 
33
  # Demo session store — keyed by session UUID, capped at 50 concurrent sessions
34
  _demo_sessions: dict = {}
 
38
  def create_app():
39
  """Factory function for creating the FastAPI app instance."""
40
  logger.info("Creating FastAPI app...")
41
+
42
+ try:
43
+ app = create_fastapi_app(
44
+ lambda: ContentModerationEnvironment(),
45
+ action_cls=ModerationAction,
46
+ observation_cls=ModerationObservation,
47
+ )
48
+ logger.info("OpenEnv app created successfully")
49
+ except Exception as e:
50
+ logger.error("Failed to create OpenEnv app: %s", e)
51
+ raise
52
 
53
  @app.get("/", response_class=HTMLResponse, tags=["UI"])
54
  def root():
55
  """Root endpoint serving the interactive demo UI."""
56
+ try:
57
+ demo_html_path = os.path.join(os.path.dirname(__file__), "..", "demo.html")
58
+ with open(demo_html_path, encoding="utf-8") as f:
59
+ return HTMLResponse(content=f.read())
60
+ except FileNotFoundError:
61
+ logger.warning("demo.html not found, serving simple response")
62
+ return HTMLResponse(content="<h1>Content Moderation Environment</h1><p>Interactive demo not available</p>")
63
+ except Exception as e:
64
+ logger.error("Error serving demo: %s", e)
65
+ return HTMLResponse(content="<h1>Content Moderation Environment</h1><p>Error loading demo</p>", status_code=500)
66
 
67
  @app.get("/health", tags=["Health"])
68
  def health():
69
  """Standard health check endpoint for monitoring."""
70
+ try:
71
+ # Test environment creation to ensure it's working
72
+ test_env = ContentModerationEnvironment()
73
+ test_obs = test_env.reset(task_name="easy")
74
+ return {
75
+ "status": "healthy",
76
+ "service": "content-moderation-env",
77
+ "environment_test": "passed"
78
+ }
79
+ except Exception as e:
80
+ logger.error("Health check failed: %s", e)
81
+ return {
82
+ "status": "unhealthy",
83
+ "service": "content-moderation-env",
84
+ "error": str(e)
85
+ }, 500
86
+
87
+ @app.get("/ready", tags=["Health"])
88
+ def ready():
89
+ """Readiness endpoint often used by cloud platforms for startup probes."""
90
+ return {"status": "ready"}
91
 
92
  logger.info("FastAPI app created successfully")
93
  return app
 
103
  main()
104
 
105
  # Module-level app for runners that expect `server.app:app` (no --factory)
106
+ # Create lazily to avoid import-time errors
107
+ _app = None
108
+
109
+ def get_app():
110
+ global _app
111
+ if _app is None:
112
+ _app = create_app()
113
+ return _app
114
+
115
+ # For direct imports
116
+ app = get_app()
117
+
118
+ # Demo endpoints - add them to the app with error handling
119
+ try:
120
+ current_app = get_app()
121
+
122
+ @current_app.get("/demo", response_class=HTMLResponse, tags=["Demo"])
123
+ def demo_page():
124
+ """Serve the interactive human-playable demo UI."""
125
+ try:
126
+ demo_html_path = os.path.join(os.path.dirname(__file__), "..", "demo.html")
127
+ with open(demo_html_path, encoding="utf-8") as f:
128
+ return HTMLResponse(content=f.read())
129
+ except FileNotFoundError:
130
+ logger.warning("demo.html not found")
131
+ return HTMLResponse(content="<h1>Demo Not Available</h1><p>demo.html file not found</p>")
132
+ except Exception as e:
133
+ logger.error("Error serving demo page: %s", e)
134
+ return HTMLResponse(content="<h1>Demo Error</h1><p>Failed to load demo</p>", status_code=500)
135
+
136
+ @current_app.get("/benchmark.html", response_class=HTMLResponse, tags=["UI"])
137
+ def benchmark_page():
138
+ """Serve the interactive benchmark visualization UI."""
139
+ try:
140
+ benchmark_html_path = os.path.join(os.path.dirname(__file__), "..", "benchmark.html")
141
+ with open(benchmark_html_path, encoding="utf-8") as f:
142
+ return HTMLResponse(content=f.read())
143
+ except FileNotFoundError:
144
+ logger.warning("benchmark.html not found")
145
+ return HTMLResponse(content="<h1>Benchmark Not Available</h1><p>benchmark.html file not found</p>")
146
+ except Exception as e:
147
+ logger.error("Error serving benchmark page: %s", e)
148
+ return HTMLResponse(content="<h1>Benchmark Error</h1><p>Failed to load benchmark</p>", status_code=500)
149
+
150
+ @current_app.post("/demo/reset", tags=["Demo"])
151
+ def demo_reset(task: str = "easy"):
152
+ """Start a new demo session. Returns session_id and initial observation."""
153
+ if task not in ("easy", "medium", "hard", "very_hard"):
154
+ raise HTTPException(status_code=400, detail=f"Unknown task: {task}")
155
+
156
+ # Evict oldest session if at capacity
157
+ if len(_demo_sessions) >= _MAX_DEMO_SESSIONS:
158
+ oldest_key = next(iter(_demo_sessions))
159
+ del _demo_sessions[oldest_key]
160
+
161
+ session_id = str(uuid.uuid4())
162
+ try:
163
+ env = ContentModerationEnvironment()
164
+ obs = env.reset(task_name=task)
165
+ _demo_sessions[session_id] = env
166
+ return {"session_id": session_id, "observation": obs.model_dump()}
167
+ except Exception as e:
168
+ logger.error("Error creating demo session: %s", e)
169
+ raise HTTPException(status_code=500, detail=f"Failed to create demo session: {e}")
170
+
171
+ @current_app.post("/demo/step/{session_id}", tags=["Demo"])
172
+ def demo_step(session_id: str, action: ModerationAction):
173
+ """Submit one action in an existing demo session."""
174
+ env = _demo_sessions.get(session_id)
175
+ if env is None:
176
+ raise HTTPException(status_code=404, detail="Session not found or expired. Start a new session via /demo/reset.")
177
+
178
+ try:
179
+ obs = env.step(action)
180
+ if obs.done:
181
+ del _demo_sessions[session_id]
182
+ return {"observation": obs.model_dump()}
183
+ except Exception as e:
184
+ logger.error("Error in demo step: %s", e)
185
+ raise HTTPException(status_code=500, detail=f"Failed to process action: {e}")
186
+
187
+ except Exception as e:
188
+ logger.error("Failed to register demo endpoints: %s", e)
server/environment.py CHANGED
@@ -61,12 +61,16 @@ class ContentModerationEnvironment(Environment):
61
 
62
  active_post = self._get_active_post()
63
 
 
 
 
64
  return ModerationObservation(
65
  active_post_summary=active_post,
66
  failed_attempts=self.post_failures.get(active_post["id"], []) if active_post else [],
67
  last_action_result="Episode started — VibeNet moderation pipeline ready",
68
  current_post=None,
69
  thread_context=None,
 
70
  done=False,
71
  reward=0.0,
72
  metadata={"task": task_name, "total_posts": len(self.task_data["items"])}
@@ -209,7 +213,8 @@ class ContentModerationEnvironment(Environment):
209
  # Episode termination
210
  done = len(self.moderated) == len(self.task_data["items"]) or self.step_count >= self.task_data["max_steps"] or loop_exceeded
211
 
212
- grader = self._compute_grader_score() if done else None
 
213
 
214
  active_post = self._get_active_post()
215
 
 
61
 
62
  active_post = self._get_active_post()
63
 
64
+ # Always compute grader score (never None) - required for OpenEnv compatibility
65
+ initial_grader_score = self._compute_grader_score()
66
+
67
  return ModerationObservation(
68
  active_post_summary=active_post,
69
  failed_attempts=self.post_failures.get(active_post["id"], []) if active_post else [],
70
  last_action_result="Episode started — VibeNet moderation pipeline ready",
71
  current_post=None,
72
  thread_context=None,
73
+ grader_score=initial_grader_score,
74
  done=False,
75
  reward=0.0,
76
  metadata={"task": task_name, "total_posts": len(self.task_data["items"])}
 
213
  # Episode termination
214
  done = len(self.moderated) == len(self.task_data["items"]) or self.step_count >= self.task_data["max_steps"] or loop_exceeded
215
 
216
+ # Always compute grader score (never None) - required for OpenEnv compatibility
217
+ grader = self._compute_grader_score()
218
 
219
  active_post = self._get_active_post()
220