zrypton commited on
Commit
ecd5b6d
Β·
verified Β·
1 Parent(s): 7a60d53

Upload folder using huggingface_hub

Browse files
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: CiteGuardian
3
- emoji: πŸ‘
4
- colorFrom: gray
5
- colorTo: pink
6
  sdk: docker
7
  pinned: false
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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