Spaces:
Sleeping
Sleeping
Enhance Content Moderation Environment: Update Dockerfile, add client, improve README, and refine environment handling
Browse files- .gitignore +4 -0
- Dockerfile +32 -8
- README.md +29 -0
- client.py +131 -0
- openenv.yaml +6 -14
- requirements.txt +9 -0
- server/app.py +141 -71
- 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
|
| 4 |
-
RUN apt-get update && apt-get install -y
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
# Set working directory to the standard Hugging Face directory
|
| 7 |
WORKDIR /code
|
| 8 |
|
| 9 |
-
# Copy
|
| 10 |
-
COPY . /
|
| 11 |
|
| 12 |
-
# Install
|
| 13 |
-
RUN pip install --no-cache-dir
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
EXPOSE 8000
|
| 16 |
|
| 17 |
-
# Run Uvicorn with
|
| 18 |
-
CMD ["uvicorn", "server.app:app",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 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 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
@app.get("/", response_class=HTMLResponse, tags=["UI"])
|
| 35 |
def root():
|
| 36 |
"""Root endpoint serving the interactive demo UI."""
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
@app.get("/health", tags=["Health"])
|
| 42 |
def health():
|
| 43 |
"""Standard health check endpoint for monitoring."""
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
#
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
|