Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- Dockerfile +88 -0
- README.md +250 -5
- __init__.py +16 -0
- client.py +75 -0
- inference.py +266 -0
- models.py +106 -0
- openenv.yaml +7 -0
- openenv_citeGuardian.egg-info/PKG-INFO +11 -0
- openenv_citeGuardian.egg-info/SOURCES.txt +19 -0
- openenv_citeGuardian.egg-info/dependency_links.txt +1 -0
- openenv_citeGuardian.egg-info/entry_points.txt +2 -0
- openenv_citeGuardian.egg-info/requires.txt +7 -0
- openenv_citeGuardian.egg-info/top_level.txt +1 -0
- pyproject.toml +47 -0
- server/__init__.py +11 -0
- server/app.py +84 -0
- server/citeGuardian_environment.py +466 -0
- server/requirements.txt +6 -0
- uv.lock +0 -0
Dockerfile
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
# Multi-stage build using openenv-base
|
| 8 |
+
# This Dockerfile is flexible and works for both:
|
| 9 |
+
# - In-repo environments (with local OpenEnv sources)
|
| 10 |
+
# - Standalone environments (with openenv from PyPI/Git)
|
| 11 |
+
# The build script (openenv build) handles context detection and sets appropriate build args.
|
| 12 |
+
|
| 13 |
+
ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
|
| 14 |
+
FROM ${BASE_IMAGE} AS builder
|
| 15 |
+
|
| 16 |
+
WORKDIR /app
|
| 17 |
+
|
| 18 |
+
# Ensure git is available (required for installing dependencies from VCS)
|
| 19 |
+
RUN apt-get update && \
|
| 20 |
+
apt-get install -y --no-install-recommends git && \
|
| 21 |
+
rm -rf /var/lib/apt/lists/*
|
| 22 |
+
|
| 23 |
+
# Build argument to control whether we're building standalone or in-repo
|
| 24 |
+
ARG BUILD_MODE=in-repo
|
| 25 |
+
ARG ENV_NAME=citeGuardian
|
| 26 |
+
|
| 27 |
+
# Copy environment code (always at root of build context)
|
| 28 |
+
COPY . /app/env
|
| 29 |
+
|
| 30 |
+
# For in-repo builds, openenv is already vendored in the build context
|
| 31 |
+
# For standalone builds, openenv will be installed via pyproject.toml
|
| 32 |
+
WORKDIR /app/env
|
| 33 |
+
|
| 34 |
+
# Ensure Python is installed
|
| 35 |
+
RUN apt-get update && apt-get install -y python3 python3-venv
|
| 36 |
+
|
| 37 |
+
# Create the virtual environment
|
| 38 |
+
RUN python3 -m venv /app/env/.venv
|
| 39 |
+
|
| 40 |
+
# If uv.lock exists, use it; otherwise resolve on the fly
|
| 41 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 42 |
+
if [ -f uv.lock ]; then \
|
| 43 |
+
uv sync --frozen --no-install-project --no-editable; \
|
| 44 |
+
else \
|
| 45 |
+
uv sync --no-install-project --no-editable; \
|
| 46 |
+
fi
|
| 47 |
+
# Install dependencies using uv sync
|
| 48 |
+
# If uv.lock exists, use it; otherwise resolve on the fly
|
| 49 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 50 |
+
if [ -f uv.lock ]; then \
|
| 51 |
+
uv sync --frozen --no-install-project --no-editable; \
|
| 52 |
+
else \
|
| 53 |
+
uv sync --no-install-project --no-editable; \
|
| 54 |
+
fi
|
| 55 |
+
|
| 56 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 57 |
+
if [ -f uv.lock ]; then \
|
| 58 |
+
uv sync --frozen --no-editable; \
|
| 59 |
+
else \
|
| 60 |
+
uv sync --no-editable; \
|
| 61 |
+
fi
|
| 62 |
+
|
| 63 |
+
# Final runtime stage
|
| 64 |
+
FROM ${BASE_IMAGE}
|
| 65 |
+
|
| 66 |
+
WORKDIR /app
|
| 67 |
+
|
| 68 |
+
# Copy the virtual environment from builder
|
| 69 |
+
COPY --from=builder /app/env/.venv /app/.venv
|
| 70 |
+
|
| 71 |
+
# Copy the environment code
|
| 72 |
+
COPY --from=builder /app/env /app/env
|
| 73 |
+
|
| 74 |
+
# Set PATH to use the virtual environment
|
| 75 |
+
ENV PATH="/app/.venv/bin:$PATH"
|
| 76 |
+
|
| 77 |
+
# Set PYTHONPATH so imports work correctly
|
| 78 |
+
ENV PYTHONPATH="/app/env:$PYTHONPATH"
|
| 79 |
+
|
| 80 |
+
ENV ENABLE_WEB_INTERFACE=true
|
| 81 |
+
|
| 82 |
+
# Health check
|
| 83 |
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
| 84 |
+
CMD curl -f http://localhost:8000/health || exit 1
|
| 85 |
+
|
| 86 |
+
# Run the FastAPI server
|
| 87 |
+
# The module path is constructed to work with the /app/env structure
|
| 88 |
+
CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
|
README.md
CHANGED
|
@@ -1,10 +1,255 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Citeguardian Environment Server
|
| 3 |
+
emoji: πΊ
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
app_port: 8000
|
| 9 |
+
base_path: /web
|
| 10 |
+
tags:
|
| 11 |
+
- openenv
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# Citeguardian Environment
|
| 15 |
+
|
| 16 |
+
A simple test environment that echoes back messages. Perfect for testing the env APIs as well as demonstrating environment usage patterns.
|
| 17 |
+
|
| 18 |
+
## Quick Start
|
| 19 |
+
|
| 20 |
+
The simplest way to use the Citeguardian environment is through the `CiteguardianEnv` class:
|
| 21 |
+
|
| 22 |
+
```python
|
| 23 |
+
from citeGuardian import CiteguardianAction, CiteguardianEnv
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
# Create environment from Docker image
|
| 27 |
+
citeGuardianenv = CiteguardianEnv.from_docker_image("citeGuardian-env:latest")
|
| 28 |
+
|
| 29 |
+
# Reset
|
| 30 |
+
result = citeGuardianenv.reset()
|
| 31 |
+
print(f"Reset: {result.observation.echoed_message}")
|
| 32 |
+
|
| 33 |
+
# Send multiple messages
|
| 34 |
+
messages = ["Hello, World!", "Testing echo", "Final message"]
|
| 35 |
+
|
| 36 |
+
for msg in messages:
|
| 37 |
+
result = citeGuardianenv.step(CiteguardianAction(message=msg))
|
| 38 |
+
print(f"Sent: '{msg}'")
|
| 39 |
+
print(f" β Echoed: '{result.observation.echoed_message}'")
|
| 40 |
+
print(f" β Length: {result.observation.message_length}")
|
| 41 |
+
print(f" β Reward: {result.reward}")
|
| 42 |
+
|
| 43 |
+
finally:
|
| 44 |
+
# Always clean up
|
| 45 |
+
citeGuardianenv.close()
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
That's it! The `CiteguardianEnv.from_docker_image()` method handles:
|
| 49 |
+
- Starting the Docker container
|
| 50 |
+
- Waiting for the server to be ready
|
| 51 |
+
- Connecting to the environment
|
| 52 |
+
- Container cleanup when you call `close()`
|
| 53 |
+
|
| 54 |
+
## Building the Docker Image
|
| 55 |
+
|
| 56 |
+
Before using the environment, you need to build the Docker image:
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
# From project root
|
| 60 |
+
docker build -t citeGuardian-env:latest -f server/Dockerfile .
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
## Deploying to Hugging Face Spaces
|
| 64 |
+
|
| 65 |
+
You can easily deploy your OpenEnv environment to Hugging Face Spaces using the `openenv push` command:
|
| 66 |
+
|
| 67 |
+
```bash
|
| 68 |
+
# From the environment directory (where openenv.yaml is located)
|
| 69 |
+
openenv push
|
| 70 |
+
|
| 71 |
+
# Or specify options
|
| 72 |
+
openenv push --namespace my-org --private
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
The `openenv push` command will:
|
| 76 |
+
1. Validate that the directory is an OpenEnv environment (checks for `openenv.yaml`)
|
| 77 |
+
2. Prepare a custom build for Hugging Face Docker space (enables web interface)
|
| 78 |
+
3. Upload to Hugging Face (ensuring you're logged in)
|
| 79 |
+
|
| 80 |
+
### Prerequisites
|
| 81 |
+
|
| 82 |
+
- Authenticate with Hugging Face: The command will prompt for login if not already authenticated
|
| 83 |
+
|
| 84 |
+
### Options
|
| 85 |
+
|
| 86 |
+
- `--directory`, `-d`: Directory containing the OpenEnv environment (defaults to current directory)
|
| 87 |
+
- `--repo-id`, `-r`: Repository ID in format 'username/repo-name' (defaults to 'username/env-name' from openenv.yaml)
|
| 88 |
+
- `--base-image`, `-b`: Base Docker image to use (overrides Dockerfile FROM)
|
| 89 |
+
- `--private`: Deploy the space as private (default: public)
|
| 90 |
+
|
| 91 |
+
### Examples
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
# Push to your personal namespace (defaults to username/env-name from openenv.yaml)
|
| 95 |
+
openenv push
|
| 96 |
+
|
| 97 |
+
# Push to a specific repository
|
| 98 |
+
openenv push --repo-id my-org/my-env
|
| 99 |
+
|
| 100 |
+
# Push with a custom base image
|
| 101 |
+
openenv push --base-image ghcr.io/meta-pytorch/openenv-base:latest
|
| 102 |
+
|
| 103 |
+
# Push as a private space
|
| 104 |
+
openenv push --private
|
| 105 |
+
|
| 106 |
+
# Combine options
|
| 107 |
+
openenv push --repo-id my-org/my-env --base-image custom-base:latest --private
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
After deployment, your space will be available at:
|
| 111 |
+
`https://huggingface.co/spaces/<repo-id>`
|
| 112 |
+
|
| 113 |
+
The deployed space includes:
|
| 114 |
+
- **Web Interface** at `/web` - Interactive UI for exploring the environment
|
| 115 |
+
- **API Documentation** at `/docs` - Full OpenAPI/Swagger interface
|
| 116 |
+
- **Health Check** at `/health` - Container health monitoring
|
| 117 |
+
- **WebSocket** at `/ws` - Persistent session endpoint for low-latency interactions
|
| 118 |
+
|
| 119 |
+
## Environment Details
|
| 120 |
+
|
| 121 |
+
### Action
|
| 122 |
+
**CiteguardianAction**: Contains a single field
|
| 123 |
+
- `message` (str) - The message to echo back
|
| 124 |
+
|
| 125 |
+
### Observation
|
| 126 |
+
**CiteguardianObservation**: Contains the echo response and metadata
|
| 127 |
+
- `echoed_message` (str) - The message echoed back
|
| 128 |
+
- `message_length` (int) - Length of the message
|
| 129 |
+
- `reward` (float) - Reward based on message length (length Γ 0.1)
|
| 130 |
+
- `done` (bool) - Always False for echo environment
|
| 131 |
+
- `metadata` (dict) - Additional info like step count
|
| 132 |
+
|
| 133 |
+
### Reward
|
| 134 |
+
The reward is calculated as: `message_length Γ 0.1`
|
| 135 |
+
- "Hi" β reward: 0.2
|
| 136 |
+
- "Hello, World!" β reward: 1.3
|
| 137 |
+
- Empty message β reward: 0.0
|
| 138 |
+
|
| 139 |
+
## Advanced Usage
|
| 140 |
+
|
| 141 |
+
### Connecting to an Existing Server
|
| 142 |
+
|
| 143 |
+
If you already have a Citeguardian environment server running, you can connect directly:
|
| 144 |
+
|
| 145 |
+
```python
|
| 146 |
+
from citeGuardian import CiteguardianEnv
|
| 147 |
+
|
| 148 |
+
# Connect to existing server
|
| 149 |
+
citeGuardianenv = CiteguardianEnv(base_url="<ENV_HTTP_URL_HERE>")
|
| 150 |
+
|
| 151 |
+
# Use as normal
|
| 152 |
+
result = citeGuardianenv.reset()
|
| 153 |
+
result = citeGuardianenv.step(CiteguardianAction(message="Hello!"))
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
Note: When connecting to an existing server, `citeGuardianenv.close()` will NOT stop the server.
|
| 157 |
+
|
| 158 |
+
### Using the Context Manager
|
| 159 |
+
|
| 160 |
+
The client supports context manager usage for automatic connection management:
|
| 161 |
+
|
| 162 |
+
```python
|
| 163 |
+
from citeGuardian import CiteguardianAction, CiteguardianEnv
|
| 164 |
+
|
| 165 |
+
# Connect with context manager (auto-connects and closes)
|
| 166 |
+
with CiteguardianEnv(base_url="http://localhost:8000") as env:
|
| 167 |
+
result = env.reset()
|
| 168 |
+
print(f"Reset: {result.observation.echoed_message}")
|
| 169 |
+
# Multiple steps with low latency
|
| 170 |
+
for msg in ["Hello", "World", "!"]:
|
| 171 |
+
result = env.step(CiteguardianAction(message=msg))
|
| 172 |
+
print(f"Echoed: {result.observation.echoed_message}")
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
The client uses WebSocket connections for:
|
| 176 |
+
- **Lower latency**: No HTTP connection overhead per request
|
| 177 |
+
- **Persistent session**: Server maintains your environment state
|
| 178 |
+
- **Efficient for episodes**: Better for many sequential steps
|
| 179 |
+
|
| 180 |
+
### Concurrent WebSocket Sessions
|
| 181 |
+
|
| 182 |
+
The server supports multiple concurrent WebSocket connections. To enable this,
|
| 183 |
+
modify `server/app.py` to use factory mode:
|
| 184 |
+
|
| 185 |
+
```python
|
| 186 |
+
# In server/app.py - use factory mode for concurrent sessions
|
| 187 |
+
app = create_app(
|
| 188 |
+
CiteguardianEnvironment, # Pass class, not instance
|
| 189 |
+
CiteguardianAction,
|
| 190 |
+
CiteguardianObservation,
|
| 191 |
+
max_concurrent_envs=4, # Allow 4 concurrent sessions
|
| 192 |
+
)
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
Then multiple clients can connect simultaneously:
|
| 196 |
+
|
| 197 |
+
```python
|
| 198 |
+
from citeGuardian import CiteguardianAction, CiteguardianEnv
|
| 199 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 200 |
+
|
| 201 |
+
def run_episode(client_id: int):
|
| 202 |
+
with CiteguardianEnv(base_url="http://localhost:8000") as env:
|
| 203 |
+
result = env.reset()
|
| 204 |
+
for i in range(10):
|
| 205 |
+
result = env.step(CiteguardianAction(message=f"Client {client_id}, step {i}"))
|
| 206 |
+
return client_id, result.observation.message_length
|
| 207 |
+
|
| 208 |
+
# Run 4 episodes concurrently
|
| 209 |
+
with ThreadPoolExecutor(max_workers=4) as executor:
|
| 210 |
+
results = list(executor.map(run_episode, range(4)))
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
## Development & Testing
|
| 214 |
+
|
| 215 |
+
### Direct Environment Testing
|
| 216 |
+
|
| 217 |
+
Test the environment logic directly without starting the HTTP server:
|
| 218 |
+
|
| 219 |
+
```bash
|
| 220 |
+
# From the server directory
|
| 221 |
+
python3 server/citeGuardian_environment.py
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
This verifies that:
|
| 225 |
+
- Environment resets correctly
|
| 226 |
+
- Step executes actions properly
|
| 227 |
+
- State tracking works
|
| 228 |
+
- Rewards are calculated correctly
|
| 229 |
+
|
| 230 |
+
### Running Locally
|
| 231 |
+
|
| 232 |
+
Run the server locally for development:
|
| 233 |
+
|
| 234 |
+
```bash
|
| 235 |
+
uvicorn server.app:app --reload
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
## Project Structure
|
| 239 |
+
|
| 240 |
+
```
|
| 241 |
+
citeGuardian/
|
| 242 |
+
βββ .dockerignore # Docker build exclusions
|
| 243 |
+
βββ __init__.py # Module exports
|
| 244 |
+
βββ README.md # This file
|
| 245 |
+
βββ openenv.yaml # OpenEnv manifest
|
| 246 |
+
βββ pyproject.toml # Project metadata and dependencies
|
| 247 |
+
βββ uv.lock # Locked dependencies (generated)
|
| 248 |
+
βββ client.py # CiteguardianEnv client
|
| 249 |
+
βββ models.py # Action and Observation models
|
| 250 |
+
βββ server/
|
| 251 |
+
βββ __init__.py # Server module exports
|
| 252 |
+
βββ citeGuardian_environment.py # Core environment logic
|
| 253 |
+
βββ app.py # FastAPI application (HTTP + WebSocket endpoints)
|
| 254 |
+
βββ Dockerfile # Container image definition
|
| 255 |
+
```
|
__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Citeguardian Environment."""
|
| 8 |
+
|
| 9 |
+
from .client import CiteguardianEnv
|
| 10 |
+
from .models import CiteguardianAction, CiteguardianObservation
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"CiteguardianAction",
|
| 14 |
+
"CiteguardianObservation",
|
| 15 |
+
"CiteguardianEnv",
|
| 16 |
+
]
|
client.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Citeguardian 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 CiteguardianAction, CiteguardianObservation
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class CiteguardianEnv(
|
| 19 |
+
EnvClient[CiteguardianAction, CiteguardianObservation, State]
|
| 20 |
+
):
|
| 21 |
+
"""
|
| 22 |
+
Client for the CiteGuardian Environment.
|
| 23 |
+
|
| 24 |
+
Maintains a persistent WebSocket connection to the environment server.
|
| 25 |
+
Each instance gets its own dedicated session on the server.
|
| 26 |
+
|
| 27 |
+
Example:
|
| 28 |
+
>>> async with CiteguardianEnv(base_url="http://localhost:8000") as client:
|
| 29 |
+
... result = await client.reset()
|
| 30 |
+
... result = await client.step(CiteguardianAction(action_type="SUBMIT"))
|
| 31 |
+
|
| 32 |
+
Example with Docker:
|
| 33 |
+
>>> client = await CiteguardianEnv.from_docker_image("openenv-citeguardian")
|
| 34 |
+
>>> result = await client.reset()
|
| 35 |
+
>>> result = await client.step(CiteguardianAction(action_type="GO_TO", section_name="Methods"))
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
def _step_payload(self, action: CiteguardianAction) -> Dict:
|
| 39 |
+
"""Convert CiteguardianAction to JSON payload for the step request."""
|
| 40 |
+
return action.model_dump(exclude_none=True)
|
| 41 |
+
|
| 42 |
+
def _parse_result(self, payload: Dict) -> StepResult[CiteguardianObservation]:
|
| 43 |
+
"""Parse server response into StepResult[CiteguardianObservation]."""
|
| 44 |
+
obs_data = payload.get("observation", payload)
|
| 45 |
+
observation = CiteguardianObservation(
|
| 46 |
+
current_view=obs_data.get("current_view", ""),
|
| 47 |
+
metadata=obs_data.get("metadata", {}),
|
| 48 |
+
audit_log=obs_data.get("audit_log", []),
|
| 49 |
+
tool_result=obs_data.get("tool_result"),
|
| 50 |
+
message=obs_data.get("message", ""),
|
| 51 |
+
task_level=obs_data.get("task_level", "A"),
|
| 52 |
+
done=payload.get("done", obs_data.get("done", False)),
|
| 53 |
+
reward=payload.get("reward", obs_data.get("reward", 0.0)),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
return StepResult(
|
| 57 |
+
observation=observation,
|
| 58 |
+
reward=payload.get("reward", obs_data.get("reward", 0.0)),
|
| 59 |
+
done=payload.get("done", obs_data.get("done", False)),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
def _parse_state(self, payload: Dict) -> State:
|
| 63 |
+
"""
|
| 64 |
+
Parse server response into State object.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
payload: JSON response from state request
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
State object with episode_id and step_count
|
| 71 |
+
"""
|
| 72 |
+
return State(
|
| 73 |
+
episode_id=payload.get("episode_id"),
|
| 74 |
+
step_count=payload.get("step_count", 0),
|
| 75 |
+
)
|
inference.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Inference Script β CiteGuardian
|
| 3 |
+
===================================
|
| 4 |
+
MANDATORY
|
| 5 |
+
- Before submitting, ensure the following variables are defined in your environment:
|
| 6 |
+
API_BASE_URL The API endpoint for the LLM.
|
| 7 |
+
MODEL_NAME The model identifier to use for inference.
|
| 8 |
+
HF_TOKEN Your Hugging Face / API key.
|
| 9 |
+
LOCAL_IMAGE_NAME Docker image name (if using from_docker_image())
|
| 10 |
+
|
| 11 |
+
STDOUT FORMAT
|
| 12 |
+
[START] task=<task_name> env=<benchmark> model=<model_name>
|
| 13 |
+
[STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>
|
| 14 |
+
[END] success=<true|false> steps=<n> score=<score> rewards=<r1,r2,...,rn>
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import textwrap
|
| 21 |
+
from typing import List, Optional
|
| 22 |
+
|
| 23 |
+
from dotenv import load_dotenv
|
| 24 |
+
from openai import OpenAI
|
| 25 |
+
|
| 26 |
+
load_dotenv()
|
| 27 |
+
|
| 28 |
+
from citeGuardian import CiteguardianAction, CiteguardianEnv
|
| 29 |
+
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
# Config
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME")
|
| 34 |
+
API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
|
| 35 |
+
API_BASE_URL = os.getenv("API_BASE_URL") or "https://router.huggingface.co/v1"
|
| 36 |
+
MODEL_NAME = os.getenv("MODEL_NAME") or "Qwen/Qwen2.5-72B-Instruct"
|
| 37 |
+
TASK_NAME = os.getenv("CITEGUARDIAN_TASK", "audit")
|
| 38 |
+
BENCHMARK = os.getenv("CITEGUARDIAN_BENCHMARK", "citeGuardian")
|
| 39 |
+
|
| 40 |
+
MAX_STEPS = 30 # enough headroom for a full audit
|
| 41 |
+
TEMPERATURE = 0.2 # low temp for precise reasoning
|
| 42 |
+
MAX_TOKENS = 256
|
| 43 |
+
SUCCESS_SCORE_THRESHOLD = 0.5 # final reward >= 0.5 counts as success
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# Prompts
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
SYSTEM_PROMPT = textwrap.dedent("""
|
| 49 |
+
You are an expert academic peer-reviewer auditing a research paper.
|
| 50 |
+
You have access to the following tools. Respond with EXACTLY one JSON object per turn.
|
| 51 |
+
|
| 52 |
+
TOOLS:
|
| 53 |
+
{"action_type": "GO_TO", "section_name": "<name>"}
|
| 54 |
+
Navigate to a section. Use metadata.available_sections to see what exists.
|
| 55 |
+
|
| 56 |
+
{"action_type": "SCAN_CITATIONS"}
|
| 57 |
+
Returns all citation markers (e.g. [1], [2]) found in the current section.
|
| 58 |
+
|
| 59 |
+
{"action_type": "COMPARE_VALUES", "val1": "<v1>", "val2": "<v2>"}
|
| 60 |
+
Checks if two values conflict. Use for numeric mismatches between sections.
|
| 61 |
+
|
| 62 |
+
{"action_type": "FLAG_ERROR", "error_type": "<type>", "text_snippet": "<snippet>"}
|
| 63 |
+
Flag a confirmed error. Types:
|
| 64 |
+
STRUCTURAL_ERROR β a mandatory section (Abstract/Introduction/Methods/Results/Discussion/References) is missing
|
| 65 |
+
ORPHAN_CITATION β a [N] marker cited in text has no entry in References, OR a References entry is never cited
|
| 66 |
+
LOGICAL_INCONSISTENCY β a numeric/factual value in one section contradicts another
|
| 67 |
+
|
| 68 |
+
{"action_type": "SUBMIT"}
|
| 69 |
+
End the audit. Call this only after you have flagged all errors you are confident about.
|
| 70 |
+
|
| 71 |
+
MANDATORY SECTIONS: Abstract, Introduction, Methods, Results, Discussion, References
|
| 72 |
+
|
| 73 |
+
STRATEGY BY TASK LEVEL (shown in observation as task_level):
|
| 74 |
+
|
| 75 |
+
Task A β Structural Integrity:
|
| 76 |
+
1. Use GO_TO on every mandatory section name.
|
| 77 |
+
2. If a section is NOT in metadata.available_sections, it is missing.
|
| 78 |
+
3. FLAG_ERROR with error_type=STRUCTURAL_ERROR and text_snippet=<missing section name>.
|
| 79 |
+
4. SUBMIT immediately after flagging.
|
| 80 |
+
|
| 81 |
+
Task B β Citation Synchronization:
|
| 82 |
+
1. GO_TO each body section (Abstract, Introduction, Methods, Results, Discussion).
|
| 83 |
+
2. SCAN_CITATIONS in each to collect all [N] markers used in the text.
|
| 84 |
+
3. GO_TO References and SCAN_CITATIONS to collect all [N] markers defined there.
|
| 85 |
+
4. Any [N] cited in text but absent from References β FLAG_ERROR ORPHAN_CITATION, snippet="[N]".
|
| 86 |
+
5. Any [N] in References but never cited in text β FLAG_ERROR ORPHAN_CITATION, snippet="[N]".
|
| 87 |
+
6. SUBMIT after all orphans are flagged.
|
| 88 |
+
|
| 89 |
+
Task C β Factual Contradiction:
|
| 90 |
+
1. GO_TO Methods, note any numeric claims (subject counts, sample sizes).
|
| 91 |
+
2. GO_TO Results, note the same numeric claims.
|
| 92 |
+
3. COMPARE_VALUES with the two numbers.
|
| 93 |
+
4. If conflict_detected=true β FLAG_ERROR LOGICAL_INCONSISTENCY, snippet must contain the number from Results (e.g. "85").
|
| 94 |
+
5. SUBMIT after flagging.
|
| 95 |
+
|
| 96 |
+
CRITICAL RULES:
|
| 97 |
+
- False positives cost -0.10 each. Only flag when certain.
|
| 98 |
+
- Do NOT flag the same error twice.
|
| 99 |
+
- Do NOT flag a LOGICAL_INCONSISTENCY on Task A or B.
|
| 100 |
+
- The text_snippet for FLAG_ERROR must contain the key value/marker that identifies the error.
|
| 101 |
+
- Respond with ONLY a valid JSON object β no markdown, no explanation.
|
| 102 |
+
""").strip()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _obs_to_user_prompt(step: int, obs) -> str:
|
| 106 |
+
"""Convert an observation into a concise user-facing prompt for the LLM."""
|
| 107 |
+
o = obs.observation if hasattr(obs, "observation") else obs
|
| 108 |
+
recent_log = o.audit_log[-5:] if o.audit_log else []
|
| 109 |
+
available = o.metadata.get("available_sections", [])
|
| 110 |
+
visited = o.metadata.get("visited_sections", [])
|
| 111 |
+
missing = [s for s in ["Abstract","Introduction","Methods","Results","Discussion","References"] if s not in available]
|
| 112 |
+
|
| 113 |
+
return textwrap.dedent(f"""
|
| 114 |
+
Step: {step}
|
| 115 |
+
Task level: {o.task_level} β use the strategy for this task level
|
| 116 |
+
Current section: {o.metadata.get('current_section', '?')}
|
| 117 |
+
Available sections: {available}
|
| 118 |
+
MISSING mandatory sections: {missing if missing else 'none'}
|
| 119 |
+
Visited sections: {visited}
|
| 120 |
+
Citation markers in current view: {o.metadata.get('citation_markers_in_view', [])}
|
| 121 |
+
All citation markers across paper: {o.metadata.get('all_paper_citations', [])}
|
| 122 |
+
Flags raised so far: {o.metadata.get('flags_raised', 0)}
|
| 123 |
+
Last tool result: {json.dumps(o.tool_result)}
|
| 124 |
+
Environment message: {o.message}
|
| 125 |
+
Current view:
|
| 126 |
+
{o.current_view[:800]}
|
| 127 |
+
Recent audit log:
|
| 128 |
+
{json.dumps(recent_log, indent=2)}
|
| 129 |
+
What is your next action?
|
| 130 |
+
""").strip()
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ---------------------------------------------------------------------------
|
| 134 |
+
# Logging helpers
|
| 135 |
+
# ---------------------------------------------------------------------------
|
| 136 |
+
|
| 137 |
+
def log_start(task: str, env: str, model: str) -> None:
|
| 138 |
+
print(f"[START] task={task} env={env} model={model}", flush=True)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
|
| 142 |
+
print(
|
| 143 |
+
f"[STEP] step={step} action={action} reward={reward:.2f} "
|
| 144 |
+
f"done={str(done).lower()} error={error or 'null'}",
|
| 145 |
+
flush=True,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
|
| 150 |
+
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
|
| 151 |
+
print(
|
| 152 |
+
f"[END] success={str(success).lower()} steps={steps} "
|
| 153 |
+
f"score={score:.3f} rewards={rewards_str}",
|
| 154 |
+
flush=True,
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ---------------------------------------------------------------------------
|
| 159 |
+
# LLM call
|
| 160 |
+
# ---------------------------------------------------------------------------
|
| 161 |
+
|
| 162 |
+
def get_model_action(
|
| 163 |
+
client: OpenAI,
|
| 164 |
+
step: int,
|
| 165 |
+
obs,
|
| 166 |
+
messages: List[dict],
|
| 167 |
+
) -> CiteguardianAction:
|
| 168 |
+
"""Ask the LLM for the next action and parse it into a CiteguardianAction."""
|
| 169 |
+
user_content = _obs_to_user_prompt(step, obs)
|
| 170 |
+
messages.append({"role": "user", "content": user_content})
|
| 171 |
+
|
| 172 |
+
try:
|
| 173 |
+
completion = client.chat.completions.create(
|
| 174 |
+
model=MODEL_NAME,
|
| 175 |
+
messages=messages,
|
| 176 |
+
temperature=TEMPERATURE,
|
| 177 |
+
max_tokens=MAX_TOKENS,
|
| 178 |
+
stream=False,
|
| 179 |
+
)
|
| 180 |
+
raw = (completion.choices[0].message.content or "").strip()
|
| 181 |
+
messages.append({"role": "assistant", "content": raw})
|
| 182 |
+
|
| 183 |
+
# Strip markdown fences if present
|
| 184 |
+
if raw.startswith("```"):
|
| 185 |
+
raw = raw.split("```")[1]
|
| 186 |
+
if raw.startswith("json"):
|
| 187 |
+
raw = raw[4:]
|
| 188 |
+
data = json.loads(raw)
|
| 189 |
+
return CiteguardianAction(**data)
|
| 190 |
+
|
| 191 |
+
except Exception as exc:
|
| 192 |
+
print(f"[DEBUG] Model/parse error at step {step}: {exc}", flush=True)
|
| 193 |
+
# Safe fallback: navigate to first unvisited section or submit
|
| 194 |
+
return CiteguardianAction(action_type="SUBMIT")
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# ---------------------------------------------------------------------------
|
| 198 |
+
# Main loop
|
| 199 |
+
# ---------------------------------------------------------------------------
|
| 200 |
+
|
| 201 |
+
async def main() -> None:
|
| 202 |
+
client_api = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
|
| 203 |
+
env = await CiteguardianEnv.from_docker_image(IMAGE_NAME)
|
| 204 |
+
|
| 205 |
+
rewards: List[float] = []
|
| 206 |
+
steps_taken = 0
|
| 207 |
+
score = 0.0
|
| 208 |
+
success = False
|
| 209 |
+
|
| 210 |
+
# Persistent conversation history for the LLM
|
| 211 |
+
messages: List[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
|
| 212 |
+
|
| 213 |
+
log_start(task=TASK_NAME, env=BENCHMARK, model=MODEL_NAME)
|
| 214 |
+
|
| 215 |
+
try:
|
| 216 |
+
result = await env.reset()
|
| 217 |
+
obs = result.observation if hasattr(result, "observation") else result
|
| 218 |
+
|
| 219 |
+
for step in range(1, MAX_STEPS + 1):
|
| 220 |
+
done = getattr(result, "done", False) or getattr(obs, "done", False)
|
| 221 |
+
if done:
|
| 222 |
+
break
|
| 223 |
+
|
| 224 |
+
action = get_model_action(client_api, step, result, messages)
|
| 225 |
+
action_str = action.model_dump_json(exclude_none=True)
|
| 226 |
+
|
| 227 |
+
try:
|
| 228 |
+
result = await env.step(action)
|
| 229 |
+
obs = result.observation if hasattr(result, "observation") else result
|
| 230 |
+
reward = float(getattr(result, "reward", None) or getattr(obs, "reward", 0.0))
|
| 231 |
+
done = getattr(result, "done", False) or getattr(obs, "done", False)
|
| 232 |
+
error_msg = None
|
| 233 |
+
except Exception as step_exc:
|
| 234 |
+
print(f"[DEBUG] Step {step} error: {step_exc}", flush=True)
|
| 235 |
+
reward = 0.0
|
| 236 |
+
done = True
|
| 237 |
+
error_msg = str(step_exc)
|
| 238 |
+
|
| 239 |
+
rewards.append(reward)
|
| 240 |
+
steps_taken = step
|
| 241 |
+
log_step(step=step, action=action_str, reward=reward, done=done, error=error_msg)
|
| 242 |
+
|
| 243 |
+
if done:
|
| 244 |
+
break
|
| 245 |
+
|
| 246 |
+
# Final score: use the observation's reward field from the last step
|
| 247 |
+
# (cumulative env reward, already in [0,1] after SUBMIT clamp)
|
| 248 |
+
final_obs = obs if "obs" in dir() else None
|
| 249 |
+
obs_reward = float(getattr(final_obs, "reward", 0.0)) if final_obs else 0.0
|
| 250 |
+
score = max(obs_reward, rewards[-1] if rewards else 0.0)
|
| 251 |
+
score = min(max(score, 0.0), 1.0)
|
| 252 |
+
success = score >= SUCCESS_SCORE_THRESHOLD
|
| 253 |
+
|
| 254 |
+
except Exception as exc:
|
| 255 |
+
print(f"[DEBUG] Episode error: {exc}", flush=True)
|
| 256 |
+
|
| 257 |
+
finally:
|
| 258 |
+
try:
|
| 259 |
+
await env.close()
|
| 260 |
+
except Exception as e:
|
| 261 |
+
print(f"[DEBUG] env.close() error: {e}", flush=True)
|
| 262 |
+
log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
if __name__ == "__main__":
|
| 266 |
+
asyncio.run(main())
|
models.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
Data models for the CiteGuardian Environment.
|
| 9 |
+
|
| 10 |
+
CiteGuardian simulates a peer-review / journal-editing workflow where an agent
|
| 11 |
+
audits a research paper for structural omissions, citation orphans, and
|
| 12 |
+
factual contradictions.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from typing import Any, Literal, Optional
|
| 16 |
+
from openenv.core.env_server.types import Action, Observation
|
| 17 |
+
from pydantic import ConfigDict, Field
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
# Action types
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
|
| 24 |
+
ActionType = Literal[
|
| 25 |
+
"GO_TO",
|
| 26 |
+
"SCAN_CITATIONS",
|
| 27 |
+
"COMPARE_VALUES",
|
| 28 |
+
"FLAG_ERROR",
|
| 29 |
+
"SUBMIT",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
ErrorType = Literal[
|
| 33 |
+
"STRUCTURAL_ERROR",
|
| 34 |
+
"ORPHAN_CITATION",
|
| 35 |
+
"LOGICAL_INCONSISTENCY",
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class CiteguardianAction(Action):
|
| 40 |
+
"""
|
| 41 |
+
Unified action for the CiteGuardian environment.
|
| 42 |
+
|
| 43 |
+
Depending on `action_type`, only the relevant fields are used:
|
| 44 |
+
- GO_TO β section_name
|
| 45 |
+
- SCAN_CITATIONS β (no extra fields)
|
| 46 |
+
- COMPARE_VALUES β val1, val2
|
| 47 |
+
- FLAG_ERROR β error_type, text_snippet
|
| 48 |
+
- SUBMIT β (no extra fields)
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
action_type: ActionType = Field(..., description="Which tool the agent is invoking")
|
| 52 |
+
|
| 53 |
+
# GO_TO
|
| 54 |
+
section_name: Optional[str] = Field(
|
| 55 |
+
default=None, description="Target section name for GO_TO"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# COMPARE_VALUES
|
| 59 |
+
val1: Optional[str] = Field(
|
| 60 |
+
default=None, description="First value for COMPARE_VALUES"
|
| 61 |
+
)
|
| 62 |
+
val2: Optional[str] = Field(
|
| 63 |
+
default=None, description="Second value for COMPARE_VALUES"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# FLAG_ERROR
|
| 67 |
+
error_type: Optional[ErrorType] = Field(
|
| 68 |
+
default=None, description="Category of error being flagged"
|
| 69 |
+
)
|
| 70 |
+
text_snippet: Optional[str] = Field(
|
| 71 |
+
default=None, description="Relevant text excerpt supporting the flag"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ---------------------------------------------------------------------------
|
| 76 |
+
# Observation
|
| 77 |
+
# ---------------------------------------------------------------------------
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class CiteguardianObservation(Observation):
|
| 81 |
+
model_config = ConfigDict(extra="ignore")
|
| 82 |
+
|
| 83 |
+
"""
|
| 84 |
+
Observation returned after each step.
|
| 85 |
+
|
| 86 |
+
current_view β up to ~1000 chars of the current section text.
|
| 87 |
+
metadata β word count, detected citation markers, available sections.
|
| 88 |
+
audit_log β list of (step, action_type, detail) tuples already taken.
|
| 89 |
+
tool_result β output of SCAN_CITATIONS or COMPARE_VALUES, if applicable.
|
| 90 |
+
message β human-readable feedback from the environment.
|
| 91 |
+
task_level β 'A', 'B', or 'C' (difficulty).
|
| 92 |
+
"""
|
| 93 |
+
|
| 94 |
+
current_view: str = Field(default="", description="Text window of the current section")
|
| 95 |
+
metadata: dict[str, Any] = Field(
|
| 96 |
+
default_factory=dict,
|
| 97 |
+
description="word_count, citation_markers, available_sections",
|
| 98 |
+
)
|
| 99 |
+
audit_log: list[dict[str, Any]] = Field(
|
| 100 |
+
default_factory=list, description="History of actions taken this episode"
|
| 101 |
+
)
|
| 102 |
+
tool_result: Optional[Any] = Field(
|
| 103 |
+
default=None, description="Output of SCAN_CITATIONS or COMPARE_VALUES"
|
| 104 |
+
)
|
| 105 |
+
message: str = Field(default="", description="Environment feedback message")
|
| 106 |
+
task_level: str = Field(default="A", description="Task difficulty: A, B, or C")
|
openenv.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
+
name: citeGuardian
|
| 3 |
+
type: space
|
| 4 |
+
runtime: fastapi
|
| 5 |
+
app: server.app:app
|
| 6 |
+
port: 8000
|
| 7 |
+
|
openenv_citeGuardian.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: openenv-citeGuardian
|
| 3 |
+
Version: 0.1.0
|
| 4 |
+
Summary: Citeguardian environment for OpenEnv
|
| 5 |
+
Requires-Python: >=3.10
|
| 6 |
+
Requires-Dist: openai>=2.30.0
|
| 7 |
+
Requires-Dist: openenv-core[core]>=0.2.2
|
| 8 |
+
Requires-Dist: python-dotenv>=1.2.2
|
| 9 |
+
Provides-Extra: dev
|
| 10 |
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
| 11 |
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
openenv_citeGuardian.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
README.md
|
| 2 |
+
__init__.py
|
| 3 |
+
client.py
|
| 4 |
+
inference.py
|
| 5 |
+
models.py
|
| 6 |
+
pyproject.toml
|
| 7 |
+
./__init__.py
|
| 8 |
+
./client.py
|
| 9 |
+
./inference.py
|
| 10 |
+
./models.py
|
| 11 |
+
openenv_citeGuardian.egg-info/PKG-INFO
|
| 12 |
+
openenv_citeGuardian.egg-info/SOURCES.txt
|
| 13 |
+
openenv_citeGuardian.egg-info/dependency_links.txt
|
| 14 |
+
openenv_citeGuardian.egg-info/entry_points.txt
|
| 15 |
+
openenv_citeGuardian.egg-info/requires.txt
|
| 16 |
+
openenv_citeGuardian.egg-info/top_level.txt
|
| 17 |
+
server/__init__.py
|
| 18 |
+
server/app.py
|
| 19 |
+
server/citeGuardian_environment.py
|
openenv_citeGuardian.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
openenv_citeGuardian.egg-info/entry_points.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[console_scripts]
|
| 2 |
+
server = citeGuardian.server.app:main
|
openenv_citeGuardian.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openai>=2.30.0
|
| 2 |
+
openenv-core[core]>=0.2.2
|
| 3 |
+
python-dotenv>=1.2.2
|
| 4 |
+
|
| 5 |
+
[dev]
|
| 6 |
+
pytest>=8.0.0
|
| 7 |
+
pytest-cov>=4.0.0
|
openenv_citeGuardian.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
citeGuardian
|
pyproject.toml
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
[build-system]
|
| 8 |
+
requires = ["setuptools>=45", "wheel"]
|
| 9 |
+
build-backend = "setuptools.build_meta"
|
| 10 |
+
|
| 11 |
+
[project]
|
| 12 |
+
name = "openenv-citeGuardian"
|
| 13 |
+
version = "0.1.0"
|
| 14 |
+
description = "Citeguardian environment for OpenEnv"
|
| 15 |
+
requires-python = ">=3.10"
|
| 16 |
+
dependencies = [
|
| 17 |
+
"openai>=2.30.0",
|
| 18 |
+
# Core OpenEnv runtime (provides FastAPI server + HTTP client types)
|
| 19 |
+
# install from github
|
| 20 |
+
# "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
|
| 21 |
+
"openenv-core[core]>=0.2.2",
|
| 22 |
+
# Environment-specific dependencies
|
| 23 |
+
# Add all dependencies needed for your environment here
|
| 24 |
+
# Examples:
|
| 25 |
+
# "numpy>=1.19.0",
|
| 26 |
+
# "torch>=2.0.0",
|
| 27 |
+
# "gymnasium>=0.29.0",
|
| 28 |
+
# "openspiel>=1.0.0",
|
| 29 |
+
# "smolagents>=1.22.0,<2",
|
| 30 |
+
"python-dotenv>=1.2.2",
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
[project.optional-dependencies]
|
| 34 |
+
dev = [
|
| 35 |
+
"pytest>=8.0.0",
|
| 36 |
+
"pytest-cov>=4.0.0",
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
[project.scripts]
|
| 40 |
+
# Server entry point - enables running via: uv run --project . server
|
| 41 |
+
# or: python -m citeGuardian.server.app
|
| 42 |
+
server = "citeGuardian.server.app:main"
|
| 43 |
+
|
| 44 |
+
[tool.setuptools]
|
| 45 |
+
include-package-data = true
|
| 46 |
+
packages = ["citeGuardian", "citeGuardian.server"]
|
| 47 |
+
package-dir = { "citeGuardian" = ".", "citeGuardian.server" = "server" }
|
server/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Citeguardian environment server components."""
|
| 8 |
+
|
| 9 |
+
from .citeGuardian_environment import CiteguardianEnvironment
|
| 10 |
+
|
| 11 |
+
__all__ = ["CiteguardianEnvironment"]
|
server/app.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
FastAPI application for the Citeguardian Environment.
|
| 9 |
+
|
| 10 |
+
This module creates an HTTP server that exposes the CiteguardianEnvironment
|
| 11 |
+
over HTTP and WebSocket endpoints, compatible with EnvClient.
|
| 12 |
+
|
| 13 |
+
Endpoints:
|
| 14 |
+
- POST /reset: Reset the environment
|
| 15 |
+
- POST /step: Execute an action
|
| 16 |
+
- GET /state: Get current environment state
|
| 17 |
+
- GET /schema: Get action/observation schemas
|
| 18 |
+
- WS /ws: WebSocket endpoint for persistent sessions
|
| 19 |
+
|
| 20 |
+
Usage:
|
| 21 |
+
# Development (with auto-reload):
|
| 22 |
+
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
|
| 23 |
+
|
| 24 |
+
# Production:
|
| 25 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
|
| 26 |
+
|
| 27 |
+
# Or run directly:
|
| 28 |
+
python -m server.app
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
from openenv.core.env_server.http_server import create_app
|
| 33 |
+
except Exception as e: # pragma: no cover
|
| 34 |
+
raise ImportError(
|
| 35 |
+
"openenv is required for the web interface. Install dependencies with '\n uv sync\n'"
|
| 36 |
+
) from e
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
from citeGuardian.models import CiteguardianAction, CiteguardianObservation
|
| 40 |
+
from .citeGuardian_environment import CiteguardianEnvironment
|
| 41 |
+
except ModuleNotFoundError:
|
| 42 |
+
from models import CiteguardianAction, CiteguardianObservation
|
| 43 |
+
from server.citeGuardian_environment import CiteguardianEnvironment
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# Create the app with web interface and README integration
|
| 47 |
+
app = create_app(
|
| 48 |
+
CiteguardianEnvironment,
|
| 49 |
+
CiteguardianAction,
|
| 50 |
+
CiteguardianObservation,
|
| 51 |
+
env_name="citeGuardian",
|
| 52 |
+
max_concurrent_envs=1, # increase this number to allow more concurrent WebSocket sessions
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def main(host: str = "0.0.0.0", port: int = 8000):
|
| 57 |
+
"""
|
| 58 |
+
Entry point for direct execution via uv run or python -m.
|
| 59 |
+
|
| 60 |
+
This function enables running the server without Docker:
|
| 61 |
+
uv run --project . server
|
| 62 |
+
uv run --project . server --port 8001
|
| 63 |
+
python -m citeGuardian.server.app
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
host: Host address to bind to (default: "0.0.0.0")
|
| 67 |
+
port: Port number to listen on (default: 8000)
|
| 68 |
+
|
| 69 |
+
For production deployments, consider using uvicorn directly with
|
| 70 |
+
multiple workers:
|
| 71 |
+
uvicorn citeGuardian.server.app:app --workers 4
|
| 72 |
+
"""
|
| 73 |
+
import uvicorn
|
| 74 |
+
|
| 75 |
+
uvicorn.run(app, host=host, port=port)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
if __name__ == "__main__":
|
| 79 |
+
import argparse
|
| 80 |
+
|
| 81 |
+
parser = argparse.ArgumentParser()
|
| 82 |
+
parser.add_argument("--port", type=int, default=8000)
|
| 83 |
+
args = parser.parse_args()
|
| 84 |
+
main(port=args.port)
|
server/citeGuardian_environment.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
CiteGuardian Environment Implementation.
|
| 9 |
+
|
| 10 |
+
Simulates a professional peer-review / journal-editing workflow.
|
| 11 |
+
The agent audits a research paper for:
|
| 12 |
+
Task A (Easy) β Structural omissions (missing mandatory section)
|
| 13 |
+
Task B (Medium) β Citation orphans (cited but not in References, or vice-versa)
|
| 14 |
+
Task C (Hard) β Factual contradictions across sections (numeric mismatches)
|
| 15 |
+
|
| 16 |
+
Reward structure (cumulative, max 1.0):
|
| 17 |
+
+0.02 Exploration β first visit to each required section
|
| 18 |
+
+0.30 Accuracy β correct FLAG_ERROR on a seeded mistake
|
| 19 |
+
-0.10 False Pos. β FLAG_ERROR where no error exists
|
| 20 |
+
-0.01 Efficiency β every step taken
|
| 21 |
+
+0.10β0.40 Completion β SUBMIT after finding all errors (scales with recall)
|
| 22 |
+
Clamped to 1.0 on perfect run (100 % recall, 0 false positives).
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
import random
|
| 26 |
+
import re
|
| 27 |
+
from uuid import uuid4
|
| 28 |
+
|
| 29 |
+
from openenv.core.env_server.interfaces import Environment
|
| 30 |
+
from openenv.core.env_server.types import State
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
from ..models import CiteguardianAction, CiteguardianObservation
|
| 34 |
+
except ImportError:
|
| 35 |
+
from models import CiteguardianAction, CiteguardianObservation
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# Paper templates
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
_TASK_A_PAPER = {
|
| 43 |
+
"Abstract": (
|
| 44 |
+
"We present a novel deep-learning approach for protein folding prediction. "
|
| 45 |
+
"Our model achieves state-of-the-art accuracy on the CASP14 benchmark. [1][3]"
|
| 46 |
+
),
|
| 47 |
+
"Introduction": (
|
| 48 |
+
"Protein structure prediction has long been a grand challenge in biology. "
|
| 49 |
+
"Recent advances in transformer architectures [1] have enabled breakthroughs. "
|
| 50 |
+
"This paper extends the work of Jumper et al. (2021) [2] by incorporating "
|
| 51 |
+
"multi-scale attention. We recruited 120 domain experts to evaluate outputs."
|
| 52 |
+
),
|
| 53 |
+
"Methods": (
|
| 54 |
+
"We trained a 48-layer transformer on the UniRef90 database. "
|
| 55 |
+
"Training used 128 TPU-v4 chips for 14 days. "
|
| 56 |
+
"Hyperparameters were tuned via Bayesian optimisation [3]. "
|
| 57 |
+
"We recruited 120 subjects for the human-evaluation study."
|
| 58 |
+
),
|
| 59 |
+
# "Results" section intentionally MISSING β this is the seeded error
|
| 60 |
+
"Discussion": (
|
| 61 |
+
"Our approach outperforms prior methods on all benchmarks. "
|
| 62 |
+
"Limitations include high compute cost and limited generalisation to "
|
| 63 |
+
"intrinsically disordered proteins."
|
| 64 |
+
),
|
| 65 |
+
"References": (
|
| 66 |
+
"[1] Vaswani et al. (2017) Attention is All You Need. NeurIPS.\n"
|
| 67 |
+
"[2] Jumper et al. (2021) Highly accurate protein structure prediction. Nature.\n"
|
| 68 |
+
"[3] Snoek et al. (2012) Practical Bayesian Optimization. NeurIPS."
|
| 69 |
+
),
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
_TASK_A_ERRORS = [
|
| 73 |
+
{
|
| 74 |
+
"id": "err_A1",
|
| 75 |
+
"error_type": "STRUCTURAL_ERROR",
|
| 76 |
+
"section": None, # not section-specific
|
| 77 |
+
"hint": "Results", # the missing section name
|
| 78 |
+
"description": "The mandatory 'Results' section is absent from the paper.",
|
| 79 |
+
}
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
# ----
|
| 83 |
+
|
| 84 |
+
_TASK_B_PAPER = {
|
| 85 |
+
"Abstract": (
|
| 86 |
+
"We introduce CiteNet, a citation-graph neural network. "
|
| 87 |
+
"Experiments on three benchmarks confirm superior performance [1][2]."
|
| 88 |
+
),
|
| 89 |
+
"Introduction": (
|
| 90 |
+
"Citation networks encode rich relational information [1]. "
|
| 91 |
+
"Prior work by Kipf & Welling (2017) [2] used GCNs; we extend this "
|
| 92 |
+
"with dynamic edge weighting. See also the survey by Hamilton et al. [4]."
|
| 93 |
+
# [4] is cited here but NOT in References β orphan citation
|
| 94 |
+
),
|
| 95 |
+
"Methods": (
|
| 96 |
+
"Our model stacks three graph-attention layers [3]. "
|
| 97 |
+
"We use the Adam optimiser with lr=0.001. "
|
| 98 |
+
"Datasets: Cora, Citeseer, PubMed."
|
| 99 |
+
),
|
| 100 |
+
"Results": (
|
| 101 |
+
"CiteNet achieves 89.2 % accuracy on Cora, surpassing all baselines. "
|
| 102 |
+
"Full results are in Table 1."
|
| 103 |
+
),
|
| 104 |
+
"Discussion": (
|
| 105 |
+
"The dynamic edge weighting is the key contributor to performance gains. "
|
| 106 |
+
"Future work will explore temporal citation graphs."
|
| 107 |
+
),
|
| 108 |
+
"References": (
|
| 109 |
+
"[1] Perozzi et al. (2014) DeepWalk. KDD.\n"
|
| 110 |
+
"[2] Kipf & Welling (2017) Semi-supervised classification with GCNs. ICLR.\n"
|
| 111 |
+
"[3] Velickovic et al. (2018) Graph Attention Networks. ICLR.\n"
|
| 112 |
+
# [4] Hamilton et al. intentionally MISSING from References
|
| 113 |
+
# [5] listed here but never cited in text β extra orphan
|
| 114 |
+
"[5] Xu et al. (2019) How Powerful are Graph Neural Networks? ICLR."
|
| 115 |
+
),
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
_TASK_B_ERRORS = [
|
| 119 |
+
{
|
| 120 |
+
"id": "err_B1",
|
| 121 |
+
"error_type": "ORPHAN_CITATION",
|
| 122 |
+
"section": "Introduction",
|
| 123 |
+
"hint": "[4]",
|
| 124 |
+
"description": "[4] is cited in Introduction but has no entry in References.",
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"id": "err_B2",
|
| 128 |
+
"error_type": "ORPHAN_CITATION",
|
| 129 |
+
"section": "References",
|
| 130 |
+
"hint": "[5]",
|
| 131 |
+
"description": "[5] appears in References but is never cited in the paper body.",
|
| 132 |
+
},
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
# ----
|
| 136 |
+
|
| 137 |
+
_TASK_C_PAPER = {
|
| 138 |
+
"Abstract": (
|
| 139 |
+
"We conduct a randomised controlled trial on a novel cognitive training "
|
| 140 |
+
"programme. Results show significant improvement in working memory. [1][2]"
|
| 141 |
+
),
|
| 142 |
+
"Introduction": (
|
| 143 |
+
"Cognitive decline affects millions worldwide [1]. "
|
| 144 |
+
"Intervention studies [2] show promise but lack rigorous controls. "
|
| 145 |
+
"We address this gap with a pre-registered RCT."
|
| 146 |
+
),
|
| 147 |
+
"Methods": (
|
| 148 |
+
"We recruited 100 subjects aged 60β75 from three urban clinics. "
|
| 149 |
+
"Participants were randomised 1:1 to treatment and control arms (50 each). "
|
| 150 |
+
"Primary outcome: digit-span score at 12 weeks."
|
| 151 |
+
),
|
| 152 |
+
"Results": (
|
| 153 |
+
# Contradiction: Methods says 100 subjects, Results says 85
|
| 154 |
+
"Table 1 shows data for 85 subjects who completed the 12-week assessment. "
|
| 155 |
+
"The treatment group (n=44) showed a mean improvement of 2.3 points (p<0.001). "
|
| 156 |
+
"No adverse events were recorded."
|
| 157 |
+
# No explanation for the 15-subject drop β this is the seeded error
|
| 158 |
+
),
|
| 159 |
+
"Discussion": (
|
| 160 |
+
"The significant improvement supports the efficacy of the programme. "
|
| 161 |
+
"Limitations include the urban-only sample and short follow-up period."
|
| 162 |
+
),
|
| 163 |
+
"References": (
|
| 164 |
+
"[1] WHO (2023) Global status report on the public health response to dementia.\n"
|
| 165 |
+
"[2] Smith et al. (2021) Cognitive interventions in older adults. Lancet."
|
| 166 |
+
),
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
_TASK_C_ERRORS = [
|
| 170 |
+
{
|
| 171 |
+
"id": "err_C1",
|
| 172 |
+
"error_type": "LOGICAL_INCONSISTENCY",
|
| 173 |
+
"section": "Results",
|
| 174 |
+
"hint": "85",
|
| 175 |
+
"description": (
|
| 176 |
+
"Methods states 100 subjects were recruited, but Results reports data "
|
| 177 |
+
"for only 85 subjects with no explanation for the 15-subject discrepancy."
|
| 178 |
+
),
|
| 179 |
+
}
|
| 180 |
+
]
|
| 181 |
+
|
| 182 |
+
_TASKS = [
|
| 183 |
+
("A", _TASK_A_PAPER, _TASK_A_ERRORS),
|
| 184 |
+
("B", _TASK_B_PAPER, _TASK_B_ERRORS),
|
| 185 |
+
("C", _TASK_C_PAPER, _TASK_C_ERRORS),
|
| 186 |
+
]
|
| 187 |
+
|
| 188 |
+
MANDATORY_SECTIONS = ["Abstract", "Introduction", "Methods", "Results", "Discussion", "References"]
|
| 189 |
+
|
| 190 |
+
# Reward constants
|
| 191 |
+
_EXPLORATION_REWARD = 0.02
|
| 192 |
+
_ACCURACY_REWARD = 0.30
|
| 193 |
+
_FALSE_POSITIVE_PENALTY = -0.10
|
| 194 |
+
_STEP_PENALTY = -0.01
|
| 195 |
+
_MAX_COMPLETION_BONUS = 0.40
|
| 196 |
+
_MIN_COMPLETION_BONUS = 0.10
|
| 197 |
+
_VIEW_MAX_CHARS = 1000
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _extract_citations(text: str) -> list[str]:
|
| 201 |
+
"""Return all citation markers like [1], [2], [12] found in text."""
|
| 202 |
+
return list(dict.fromkeys(re.findall(r"\[\d+\]", text)))
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
class CiteguardianEnvironment(Environment):
|
| 206 |
+
"""
|
| 207 |
+
CiteGuardian: a peer-review RL environment.
|
| 208 |
+
|
| 209 |
+
The agent navigates a research paper, uses audit tools, flags errors,
|
| 210 |
+
and submits when done. Rewards are cumulative and capped at 1.0.
|
| 211 |
+
"""
|
| 212 |
+
|
| 213 |
+
SUPPORTS_CONCURRENT_SESSIONS: bool = True
|
| 214 |
+
|
| 215 |
+
def __init__(self):
|
| 216 |
+
self._state = State(episode_id=str(uuid4()), step_count=0)
|
| 217 |
+
# These are set properly in reset()
|
| 218 |
+
self._task_level: str = "A"
|
| 219 |
+
self._paper: dict[str, str] = {}
|
| 220 |
+
self._hidden_errors: list[dict] = []
|
| 221 |
+
self._current_section: str = ""
|
| 222 |
+
self._visited_sections: set[str] = set()
|
| 223 |
+
self._flagged_errors: list[dict] = [] # what the agent has flagged
|
| 224 |
+
self._cumulative_reward: float = 0.0
|
| 225 |
+
self._done: bool = False
|
| 226 |
+
self._audit_log: list[dict] = []
|
| 227 |
+
|
| 228 |
+
# ------------------------------------------------------------------
|
| 229 |
+
# Public interface
|
| 230 |
+
# ------------------------------------------------------------------
|
| 231 |
+
|
| 232 |
+
def reset(self) -> CiteguardianObservation:
|
| 233 |
+
level, paper, errors = random.choice(_TASKS)
|
| 234 |
+
self._task_level = level
|
| 235 |
+
self._paper = dict(paper)
|
| 236 |
+
self._hidden_errors = list(errors)
|
| 237 |
+
self._current_section = list(self._paper.keys())[0]
|
| 238 |
+
self._visited_sections = set()
|
| 239 |
+
self._flagged_errors = []
|
| 240 |
+
self._cumulative_reward = 0.0
|
| 241 |
+
self._done = False
|
| 242 |
+
self._audit_log = []
|
| 243 |
+
self._state = State(episode_id=str(uuid4()), step_count=0)
|
| 244 |
+
|
| 245 |
+
return self._make_observation(
|
| 246 |
+
message=f"[Task {level}] Paper loaded. Begin your audit. "
|
| 247 |
+
f"Available sections: {list(self._paper.keys())}",
|
| 248 |
+
tool_result=None,
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
def step(self, action: CiteguardianAction) -> CiteguardianObservation: # type: ignore[override]
|
| 252 |
+
if self._done:
|
| 253 |
+
return self._make_observation(
|
| 254 |
+
message="Episode already finished. Call reset() to start a new one.",
|
| 255 |
+
tool_result=None,
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
self._state.step_count += 1
|
| 259 |
+
# Step penalty every action
|
| 260 |
+
self._cumulative_reward += _STEP_PENALTY
|
| 261 |
+
|
| 262 |
+
atype = action.action_type
|
| 263 |
+
tool_result = None
|
| 264 |
+
message = ""
|
| 265 |
+
|
| 266 |
+
if atype == "GO_TO":
|
| 267 |
+
message, tool_result = self._handle_go_to(action)
|
| 268 |
+
|
| 269 |
+
elif atype == "SCAN_CITATIONS":
|
| 270 |
+
message, tool_result = self._handle_scan_citations()
|
| 271 |
+
|
| 272 |
+
elif atype == "COMPARE_VALUES":
|
| 273 |
+
message, tool_result = self._handle_compare_values(action)
|
| 274 |
+
|
| 275 |
+
elif atype == "FLAG_ERROR":
|
| 276 |
+
message, tool_result = self._handle_flag_error(action)
|
| 277 |
+
|
| 278 |
+
elif atype == "SUBMIT":
|
| 279 |
+
message, tool_result = self._handle_submit()
|
| 280 |
+
|
| 281 |
+
else:
|
| 282 |
+
message = f"Unknown action_type '{atype}'."
|
| 283 |
+
|
| 284 |
+
self._audit_log.append(
|
| 285 |
+
{"step": self._state.step_count, "action": atype, "detail": message}
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
obs = self._make_observation(message=message, tool_result=tool_result)
|
| 289 |
+
obs.reward = round(self._cumulative_reward, 4)
|
| 290 |
+
return obs
|
| 291 |
+
|
| 292 |
+
@property
|
| 293 |
+
def state(self) -> State:
|
| 294 |
+
return self._state
|
| 295 |
+
|
| 296 |
+
# ------------------------------------------------------------------
|
| 297 |
+
# Action handlers
|
| 298 |
+
# ------------------------------------------------------------------
|
| 299 |
+
|
| 300 |
+
def _handle_go_to(self, action: CiteguardianAction):
|
| 301 |
+
section = action.section_name or ""
|
| 302 |
+
if section not in self._paper:
|
| 303 |
+
return (
|
| 304 |
+
f"Section '{section}' not found. "
|
| 305 |
+
f"Available: {list(self._paper.keys())}",
|
| 306 |
+
None,
|
| 307 |
+
)
|
| 308 |
+
self._current_section = section
|
| 309 |
+
# Exploration reward β first visit only
|
| 310 |
+
if section not in self._visited_sections:
|
| 311 |
+
self._visited_sections.add(section)
|
| 312 |
+
self._cumulative_reward += _EXPLORATION_REWARD
|
| 313 |
+
extra = f" [+{_EXPLORATION_REWARD} exploration reward]"
|
| 314 |
+
else:
|
| 315 |
+
extra = ""
|
| 316 |
+
return f"Navigated to '{section}'.{extra}", None
|
| 317 |
+
|
| 318 |
+
def _handle_scan_citations(self):
|
| 319 |
+
text = self._paper.get(self._current_section, "")
|
| 320 |
+
citations = _extract_citations(text)
|
| 321 |
+
return (
|
| 322 |
+
f"SCAN_CITATIONS in '{self._current_section}': found {len(citations)} marker(s).",
|
| 323 |
+
citations,
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
def _handle_compare_values(self, action: CiteguardianAction):
|
| 327 |
+
v1 = str(action.val1 or "").strip()
|
| 328 |
+
v2 = str(action.val2 or "").strip()
|
| 329 |
+
try:
|
| 330 |
+
n1, n2 = float(v1), float(v2)
|
| 331 |
+
conflict = abs(n1 - n2) > 1e-9
|
| 332 |
+
except ValueError:
|
| 333 |
+
conflict = v1.lower() != v2.lower()
|
| 334 |
+
|
| 335 |
+
result = {
|
| 336 |
+
"val1": v1,
|
| 337 |
+
"val2": v2,
|
| 338 |
+
"conflict_detected": conflict,
|
| 339 |
+
}
|
| 340 |
+
msg = (
|
| 341 |
+
f"COMPARE_VALUES: '{v1}' vs '{v2}' β "
|
| 342 |
+
f"{'CONFLICT DETECTED' if conflict else 'no conflict'}."
|
| 343 |
+
)
|
| 344 |
+
return msg, result
|
| 345 |
+
|
| 346 |
+
def _handle_flag_error(self, action: CiteguardianAction):
|
| 347 |
+
etype = action.error_type or ""
|
| 348 |
+
snippet = action.text_snippet or ""
|
| 349 |
+
|
| 350 |
+
# Check against hidden errors
|
| 351 |
+
matched = self._match_hidden_error(etype, snippet)
|
| 352 |
+
|
| 353 |
+
if matched:
|
| 354 |
+
if matched["id"] in [f["matched_id"] for f in self._flagged_errors if "matched_id" in f]:
|
| 355 |
+
# Already flagged this one β treat as redundant false positive
|
| 356 |
+
self._cumulative_reward += _FALSE_POSITIVE_PENALTY
|
| 357 |
+
return (
|
| 358 |
+
f"FLAG_ERROR: '{etype}' β already flagged. "
|
| 359 |
+
f"Duplicate flag penalised ({_FALSE_POSITIVE_PENALTY}).",
|
| 360 |
+
None,
|
| 361 |
+
)
|
| 362 |
+
self._cumulative_reward += _ACCURACY_REWARD
|
| 363 |
+
self._flagged_errors.append(
|
| 364 |
+
{"error_type": etype, "snippet": snippet, "matched_id": matched["id"]}
|
| 365 |
+
)
|
| 366 |
+
return (
|
| 367 |
+
f"FLAG_ERROR: '{etype}' β CORRECT. "
|
| 368 |
+
f"Matched seeded error '{matched['id']}'. "
|
| 369 |
+
f"[+{_ACCURACY_REWARD} accuracy reward]",
|
| 370 |
+
None,
|
| 371 |
+
)
|
| 372 |
+
else:
|
| 373 |
+
self._cumulative_reward += _FALSE_POSITIVE_PENALTY
|
| 374 |
+
self._flagged_errors.append(
|
| 375 |
+
{"error_type": etype, "snippet": snippet, "matched_id": None}
|
| 376 |
+
)
|
| 377 |
+
return (
|
| 378 |
+
f"FLAG_ERROR: '{etype}' β FALSE POSITIVE. "
|
| 379 |
+
f"No matching seeded error found. [{_FALSE_POSITIVE_PENALTY} penalty]",
|
| 380 |
+
None,
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
def _handle_submit(self):
|
| 384 |
+
self._done = True
|
| 385 |
+
total_errors = len(self._hidden_errors)
|
| 386 |
+
correct_flags = len([f for f in self._flagged_errors if f.get("matched_id")])
|
| 387 |
+
false_positives = len([f for f in self._flagged_errors if not f.get("matched_id")])
|
| 388 |
+
|
| 389 |
+
recall = correct_flags / total_errors if total_errors > 0 else 0.0
|
| 390 |
+
|
| 391 |
+
# Completion bonus scales linearly with recall
|
| 392 |
+
bonus = _MIN_COMPLETION_BONUS + recall * (_MAX_COMPLETION_BONUS - _MIN_COMPLETION_BONUS)
|
| 393 |
+
self._cumulative_reward += bonus
|
| 394 |
+
|
| 395 |
+
# Perfect run clamp
|
| 396 |
+
if recall == 1.0 and false_positives == 0:
|
| 397 |
+
self._cumulative_reward = 1.0
|
| 398 |
+
verdict = "PERFECT AUDIT"
|
| 399 |
+
else:
|
| 400 |
+
self._cumulative_reward = min(self._cumulative_reward, 1.0)
|
| 401 |
+
verdict = "AUDIT COMPLETE"
|
| 402 |
+
|
| 403 |
+
msg = (
|
| 404 |
+
f"SUBMIT β {verdict}. "
|
| 405 |
+
f"Errors found: {correct_flags}/{total_errors}. "
|
| 406 |
+
f"False positives: {false_positives}. "
|
| 407 |
+
f"Final reward: {round(self._cumulative_reward, 4)}."
|
| 408 |
+
)
|
| 409 |
+
return msg, {
|
| 410 |
+
"recall": recall,
|
| 411 |
+
"false_positives": false_positives,
|
| 412 |
+
"completion_bonus": round(bonus, 4),
|
| 413 |
+
"final_reward": round(self._cumulative_reward, 4),
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
# ------------------------------------------------------------------
|
| 417 |
+
# Helpers
|
| 418 |
+
# ------------------------------------------------------------------
|
| 419 |
+
|
| 420 |
+
def _match_hidden_error(self, etype: str, snippet: str) -> dict | None:
|
| 421 |
+
"""
|
| 422 |
+
Try to match a FLAG_ERROR call against the hidden error list.
|
| 423 |
+
Matching rules:
|
| 424 |
+
- error_type must match exactly.
|
| 425 |
+
- snippet must contain the error's hint string (case-insensitive).
|
| 426 |
+
"""
|
| 427 |
+
for err in self._hidden_errors:
|
| 428 |
+
if err["error_type"] != etype:
|
| 429 |
+
continue
|
| 430 |
+
hint = err.get("hint", "")
|
| 431 |
+
if hint and hint.lower() not in snippet.lower():
|
| 432 |
+
continue
|
| 433 |
+
return err
|
| 434 |
+
return None
|
| 435 |
+
|
| 436 |
+
def _make_observation(self, message: str, tool_result) -> CiteguardianObservation:
|
| 437 |
+
section_text = self._paper.get(self._current_section, "")
|
| 438 |
+
view = section_text[:_VIEW_MAX_CHARS]
|
| 439 |
+
|
| 440 |
+
# Build full-paper citation index for metadata
|
| 441 |
+
all_citations: list[str] = []
|
| 442 |
+
for text in self._paper.values():
|
| 443 |
+
all_citations.extend(_extract_citations(text))
|
| 444 |
+
all_citations = list(dict.fromkeys(all_citations))
|
| 445 |
+
|
| 446 |
+
metadata = {
|
| 447 |
+
"current_section": self._current_section,
|
| 448 |
+
"available_sections": list(self._paper.keys()),
|
| 449 |
+
"word_count": len(section_text.split()),
|
| 450 |
+
"citation_markers_in_view": _extract_citations(section_text),
|
| 451 |
+
"all_paper_citations": all_citations,
|
| 452 |
+
"visited_sections": list(self._visited_sections),
|
| 453 |
+
"flags_raised": len(self._flagged_errors),
|
| 454 |
+
"step": self._state.step_count,
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
return CiteguardianObservation(
|
| 458 |
+
current_view=view,
|
| 459 |
+
metadata=metadata,
|
| 460 |
+
audit_log=list(self._audit_log),
|
| 461 |
+
tool_result=tool_result,
|
| 462 |
+
message=message,
|
| 463 |
+
task_level=self._task_level,
|
| 464 |
+
done=self._done,
|
| 465 |
+
reward=round(self._cumulative_reward, 4),
|
| 466 |
+
)
|
server/requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv[core]>=0.2.0
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
uvicorn>=0.24.0
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|