Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- Dockerfile +76 -7
- README.md +241 -146
- inference.py +165 -27
- openenv.yaml +5 -0
- pyproject.toml +26 -14
- pytest.ini +3 -0
- supportdesk_env/client.py +7 -0
- supportdesk_env/models.py +19 -1
- supportdesk_env/policies.py +14 -2
- supportdesk_env/server/app.py +60 -8
- supportdesk_env/server/supportdesk_environment.py +220 -21
- supportdesk_env/tasks.py +126 -2
- tests/test_supportdesk.py +45 -1
- uv.lock +146 -3
Dockerfile
CHANGED
|
@@ -1,14 +1,83 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
WORKDIR /app
|
| 7 |
|
| 8 |
-
|
| 9 |
-
RUN
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
ENV ENABLE_WEB_INTERFACE=true
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=supportdesk_env
|
| 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 uv is available (for local builds where base image lacks it)
|
| 35 |
+
RUN if ! command -v uv >/dev/null 2>&1; then \
|
| 36 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
| 37 |
+
mv /root/.local/bin/uv /usr/local/bin/uv && \
|
| 38 |
+
mv /root/.local/bin/uvx /usr/local/bin/uvx; \
|
| 39 |
+
fi
|
| 40 |
|
| 41 |
+
# Install dependencies using uv sync
|
| 42 |
+
# If uv.lock exists, use it; otherwise resolve on the fly
|
| 43 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 44 |
+
if [ -f uv.lock ]; then \
|
| 45 |
+
uv sync --frozen --no-install-project --no-editable; \
|
| 46 |
+
else \
|
| 47 |
+
uv sync --no-install-project --no-editable; \
|
| 48 |
+
fi
|
| 49 |
+
|
| 50 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 51 |
+
if [ -f uv.lock ]; then \
|
| 52 |
+
uv sync --frozen --no-editable; \
|
| 53 |
+
else \
|
| 54 |
+
uv sync --no-editable; \
|
| 55 |
+
fi
|
| 56 |
+
|
| 57 |
+
# Final runtime stage
|
| 58 |
+
FROM ${BASE_IMAGE}
|
| 59 |
+
|
| 60 |
+
WORKDIR /app
|
| 61 |
+
|
| 62 |
+
# Copy the virtual environment from builder
|
| 63 |
+
COPY --from=builder /app/env/.venv /app/.venv
|
| 64 |
+
|
| 65 |
+
# Copy the environment code
|
| 66 |
+
COPY --from=builder /app/env /app/env
|
| 67 |
+
|
| 68 |
+
# Set PATH to use the virtual environment
|
| 69 |
+
ENV PATH="/app/.venv/bin:$PATH"
|
| 70 |
+
|
| 71 |
+
# Set PYTHONPATH so imports work correctly
|
| 72 |
+
ENV PYTHONPATH="/app/env:$PYTHONPATH"
|
| 73 |
ENV ENABLE_WEB_INTERFACE=true
|
| 74 |
+
|
| 75 |
+
EXPOSE 8000
|
| 76 |
+
|
| 77 |
+
# Health check
|
| 78 |
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
| 79 |
+
CMD curl -f http://localhost:8000/health || exit 1
|
| 80 |
+
|
| 81 |
+
# Run the FastAPI server
|
| 82 |
+
# The module path is constructed to work with this repo's package layout.
|
| 83 |
+
CMD ["sh", "-c", "cd /app/env && uvicorn supportdesk_env.server.app:app --host 0.0.0.0 --port 8000"]
|
README.md
CHANGED
|
@@ -11,75 +11,42 @@ base_path: /web
|
|
| 11 |
|
| 12 |
# HyperBrickCaseOps
|
| 13 |
|
| 14 |
-
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
-
|
| 19 |
|
| 20 |
-
This environment
|
| 21 |
|
| 22 |
-
|
| 23 |
|
| 24 |
-
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
- evaluating whether an agent can make safe and business-correct support decisions under pressure
|
| 30 |
|
| 31 |
-
|
| 32 |
|
| 33 |
-
-
|
| 34 |
-
-
|
| 35 |
-
-
|
| 36 |
-
- Reproducible baseline: `inference.py` runs all tasks in a fixed order and falls back to a deterministic heuristic policy if model credentials are unavailable.
|
| 37 |
-
- Novel mechanics: observations expose SLA pressure, business impact, and secondary concerns, which makes the environment closer to an enterprise operations desk than a plain support classifier.
|
| 38 |
|
| 39 |
-
|
| 40 |
|
| 41 |
-
|
| 42 |
-
Inbound Task Spec + Ticket + KB
|
| 43 |
-
|
|
| 44 |
-
v
|
| 45 |
-
SupportDeskEnvironment
|
| 46 |
-
- reset()
|
| 47 |
-
- step(action)
|
| 48 |
-
- state()
|
| 49 |
-
|
|
| 50 |
-
+--> SupportDeskObservation
|
| 51 |
-
+--> dense reward shaping
|
| 52 |
-
+--> episode termination
|
| 53 |
-
|
|
| 54 |
-
v
|
| 55 |
-
Deterministic Grader
|
| 56 |
-
- queue correctness
|
| 57 |
-
- priority correctness
|
| 58 |
-
- issue type correctness
|
| 59 |
-
- requested fields
|
| 60 |
-
- reply coverage
|
| 61 |
-
- internal note coverage
|
| 62 |
-
- status / resolution
|
| 63 |
-
|
|
| 64 |
-
v
|
| 65 |
-
Baseline in inference.py
|
| 66 |
-
- OpenAI-compatible client path
|
| 67 |
-
- deterministic fallback path
|
| 68 |
-
```
|
| 69 |
-
|
| 70 |
-
## Why this is more novel than a standard support benchmark
|
| 71 |
-
|
| 72 |
-
- It is not just routing or intent classification. The agent has to combine queueing, urgency, customer communication, internal notes, and final disposition in one trajectory.
|
| 73 |
-
- It models primary-vs-secondary issue prioritization. The hardest task includes a tempting compliance side-question that should not override the live outage.
|
| 74 |
-
- It encodes enterprise pressure directly in the observation through SLA countdowns, affected-user counts, and business-impact context.
|
| 75 |
-
- It evaluates operational judgment, not just answer quality. A polished reply with the wrong queue, wrong escalation choice, or premature resolution still scores poorly.
|
| 76 |
-
- It is built specifically for OpenEnv-style agent learning and evaluation, where the same environment can be used for baseline runs, external agents, and RL experiments.
|
| 77 |
|
| 78 |
-
|
| 79 |
|
| 80 |
-
|
| 81 |
|
| 82 |
-
- `operation`
|
| 83 |
- `queue`
|
| 84 |
- `priority`
|
| 85 |
- `issue_type`
|
|
@@ -89,166 +56,282 @@ Each `step()` takes a typed `SupportDeskAction` with:
|
|
| 89 |
- `reply`
|
| 90 |
- `internal_note`
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
- `action_history`
|
| 105 |
- `feedback`
|
| 106 |
- `remaining_steps`
|
| 107 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
|
| 110 |
|
| 111 |
-
|
| 112 |
|
| 113 |
-
|
| 114 |
-
- `step(action)` returns the next typed observation together with reward and done status
|
| 115 |
-
- `state()` returns the current typed environment state
|
| 116 |
-
- `openenv.yaml` provides environment metadata used by validators and deployment tooling
|
| 117 |
|
| 118 |
-
The
|
| 119 |
|
| 120 |
-
##
|
| 121 |
|
| 122 |
-
|
| 123 |
-
Duplicate-charge billing ticket. The correct path is immediate billing routing, a refund confirmation, and case resolution.
|
| 124 |
-
2. `account_takeover_medium` - Expected difficulty: medium
|
| 125 |
-
Suspicious-login security ticket. The agent must escalate to trust and safety, request verification details, and keep the case waiting on the customer.
|
| 126 |
-
3. `api_incident_hard` - Expected difficulty: hard
|
| 127 |
-
Enterprise production API incident with a distracting compliance mention. The agent must escalate to platform engineering, request the right diagnostics, and open the incident instead of resolving it.
|
| 128 |
|
| 129 |
-
|
| 130 |
|
| 131 |
-
|
| 132 |
-
- They include operational context like customer tier, affected-user count, SLA pressure, and business impact.
|
| 133 |
-
- The harder tasks contain conflicting or distracting signals, so a frontier model has to identify the primary issue instead of treating every mention as equally important.
|
| 134 |
|
| 135 |
-
|
| 136 |
|
| 137 |
-
The
|
| 138 |
|
| 139 |
-
-
|
| 140 |
-
-
|
| 141 |
-
-
|
| 142 |
-
-
|
| 143 |
-
-
|
| 144 |
-
-
|
| 145 |
-
-
|
| 146 |
-
-
|
| 147 |
|
| 148 |
-
The
|
| 149 |
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
```text
|
| 153 |
.
|
| 154 |
|-- inference.py
|
| 155 |
|-- openenv.yaml
|
| 156 |
|-- pyproject.toml
|
| 157 |
-
|--
|
|
|
|
| 158 |
|-- supportdesk_env
|
| 159 |
| |-- __init__.py
|
| 160 |
-
| |-- client.py
|
| 161 |
| |-- graders.py
|
| 162 |
| |-- models.py
|
|
|
|
| 163 |
| |-- tasks.py
|
| 164 |
| `-- server
|
| 165 |
| |-- app.py
|
| 166 |
| `-- supportdesk_environment.py
|
| 167 |
|-- tests
|
| 168 |
| `-- test_supportdesk.py
|
| 169 |
-
`--
|
|
|
|
|
|
|
| 170 |
```
|
| 171 |
|
| 172 |
-
##
|
|
|
|
|
|
|
| 173 |
|
| 174 |
```bash
|
| 175 |
pip install -r requirements.txt
|
| 176 |
```
|
| 177 |
|
| 178 |
-
|
| 179 |
|
| 180 |
```bash
|
| 181 |
uv sync
|
| 182 |
```
|
| 183 |
|
| 184 |
-
|
|
|
|
|
|
|
| 185 |
|
| 186 |
```bash
|
| 187 |
-
|
| 188 |
-
export MODEL_NAME="openai/gpt-oss-120b"
|
| 189 |
-
export OPENAI_API_KEY="sk-..." # Or use HF_TOKEN with a compatible router
|
| 190 |
-
export HF_TOKEN="hf_..."
|
| 191 |
```
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
## Setup and Usage Instructions
|
| 196 |
-
|
| 197 |
-
Typical local workflow:
|
| 198 |
|
| 199 |
```bash
|
| 200 |
-
pip install -r requirements.txt
|
| 201 |
-
python -m openenv.cli validate .
|
| 202 |
-
python inference.py
|
| 203 |
python -m supportdesk_env.server.app
|
| 204 |
```
|
| 205 |
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
If you want to import the package directly and train against the local environment without going through the HTTP server, use the tabular Q-learning example:
|
| 209 |
|
| 210 |
```bash
|
| 211 |
-
|
| 212 |
```
|
| 213 |
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
## Run the Server
|
| 217 |
|
| 218 |
```bash
|
| 219 |
-
python
|
| 220 |
```
|
| 221 |
|
| 222 |
-
|
| 223 |
|
| 224 |
```bash
|
| 225 |
-
|
| 226 |
```
|
| 227 |
|
| 228 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
```bash
|
|
|
|
|
|
|
|
|
|
| 231 |
python inference.py
|
| 232 |
```
|
| 233 |
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
## Docker
|
| 237 |
|
|
|
|
|
|
|
| 238 |
```bash
|
| 239 |
docker build -t supportdesk-env .
|
| 240 |
-
docker run -p 8000:8000 supportdesk-env
|
| 241 |
```
|
| 242 |
|
| 243 |
-
|
| 244 |
|
| 245 |
-
|
|
|
|
|
|
|
| 246 |
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
|
| 251 |
-
If the OpenEnv CLI
|
| 252 |
|
| 253 |
```bash
|
| 254 |
openenv push --repo-id your-username/HyperBrickCaseOps
|
|
@@ -256,32 +339,44 @@ openenv push --repo-id your-username/HyperBrickCaseOps
|
|
| 256 |
|
| 257 |
## Validation
|
| 258 |
|
|
|
|
|
|
|
| 259 |
```bash
|
| 260 |
openenv validate .
|
| 261 |
```
|
| 262 |
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
```bash
|
| 266 |
./scripts/validate-submission.sh https://your-space.hf.space .
|
| 267 |
```
|
| 268 |
|
| 269 |
-
## Submission
|
| 270 |
|
| 271 |
-
-
|
| 272 |
-
-
|
| 273 |
-
-
|
| 274 |
-
-
|
| 275 |
-
-
|
| 276 |
-
-
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
-
## Baseline
|
| 279 |
|
| 280 |
-
|
| 281 |
|
| 282 |
- `billing_refund_easy`: `1.00`
|
| 283 |
- `account_takeover_medium`: `1.00`
|
| 284 |
- `api_incident_hard`: `1.00`
|
| 285 |
-
-
|
|
|
|
| 286 |
|
| 287 |
-
These scores are
|
|
|
|
| 11 |
|
| 12 |
# HyperBrickCaseOps
|
| 13 |
|
| 14 |
+
HyperBrickCaseOps is an OpenEnv environment for enterprise support operations. The agent gets a real support ticket, a few policy snippets, and the current case state. From there it has to do the same kind of work a human support or operations teammate would do: route the case, set urgency, ask for missing details, write the customer reply, leave an internal note, and decide whether the case should stay open, be resolved, or be escalated.
|
| 15 |
|
| 16 |
+
The main idea is simple: good support work is not just writing a polite reply. It also means making the right operational decision.
|
| 17 |
|
| 18 |
+
## Environment description and motivation
|
| 19 |
|
| 20 |
+
This environment was built around a gap that shows up in a lot of support benchmarks. Many benchmarks check whether a model can produce a plausible response, but real support work also needs correct routing, escalation, information gathering, and final case handling.
|
| 21 |
|
| 22 |
+
HyperBrickCaseOps is meant to test that full workflow.
|
| 23 |
|
| 24 |
+
It is not a toy game and it is not a chat-only task. The cases include things like:
|
| 25 |
|
| 26 |
+
- SLA pressure
|
| 27 |
+
- affected user counts
|
| 28 |
+
- customer tier
|
| 29 |
+
- secondary concerns that should not distract the agent from the main issue
|
| 30 |
+
- delayed customer follow-up turns
|
| 31 |
+
- unsafe requests that should not be approved just because the customer sounds urgent
|
| 32 |
|
| 33 |
+
## OpenEnv interface
|
|
|
|
| 34 |
|
| 35 |
+
The environment uses the standard OpenEnv flow:
|
| 36 |
|
| 37 |
+
- `reset()` starts a new case and returns the first observation
|
| 38 |
+
- `step(action)` applies one typed action and returns the next observation
|
| 39 |
+
- `state()` returns the current typed internal state
|
|
|
|
|
|
|
| 40 |
|
| 41 |
+
The metadata is defined in `openenv.yaml`, and the HTTP app is created through `create_app(...)`.
|
| 42 |
|
| 43 |
+
## Action space
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
Each step takes a typed `SupportDeskAction`.
|
| 46 |
|
| 47 |
+
Fields:
|
| 48 |
|
| 49 |
+
- `operation`
|
| 50 |
- `queue`
|
| 51 |
- `priority`
|
| 52 |
- `issue_type`
|
|
|
|
| 56 |
- `reply`
|
| 57 |
- `internal_note`
|
| 58 |
|
| 59 |
+
Supported operations:
|
| 60 |
+
|
| 61 |
+
- `classify`
|
| 62 |
+
Sets `queue`, `priority`, and `issue_type`.
|
| 63 |
+
- `request_info`
|
| 64 |
+
Requests missing fields from the customer.
|
| 65 |
+
- `draft_reply`
|
| 66 |
+
Writes the customer-facing reply.
|
| 67 |
+
- `add_internal_note`
|
| 68 |
+
Writes the internal note for handoff or auditability.
|
| 69 |
+
- `submit`
|
| 70 |
+
Sets the final `status` and `resolution_code`.
|
| 71 |
+
- `wait`
|
| 72 |
+
Advances the environment when a customer follow-up is pending.
|
| 73 |
+
|
| 74 |
+
Example action:
|
| 75 |
+
|
| 76 |
+
```json
|
| 77 |
+
{
|
| 78 |
+
"operation": "classify",
|
| 79 |
+
"queue": "trust_and_safety",
|
| 80 |
+
"priority": "urgent",
|
| 81 |
+
"issue_type": "account_compromise",
|
| 82 |
+
"status": null,
|
| 83 |
+
"resolution_code": null,
|
| 84 |
+
"requested_fields": [],
|
| 85 |
+
"reply": null,
|
| 86 |
+
"internal_note": null
|
| 87 |
+
}
|
| 88 |
+
```
|
| 89 |
|
| 90 |
+
## Observation space
|
| 91 |
+
|
| 92 |
+
Each observation is a typed `SupportDeskObservation`.
|
| 93 |
+
|
| 94 |
+
Main fields:
|
| 95 |
+
|
| 96 |
+
- `task_id`
|
| 97 |
+
- `difficulty`
|
| 98 |
+
- `objective`
|
| 99 |
+
- `ticket`
|
| 100 |
+
- `knowledge_base`
|
| 101 |
+
- `available_queues`
|
| 102 |
+
- `available_priorities`
|
| 103 |
+
- `available_statuses`
|
| 104 |
+
- `available_issue_types`
|
| 105 |
+
- `case`
|
| 106 |
+
- `current_sla_minutes_remaining`
|
| 107 |
+
- `workflow_stage`
|
| 108 |
+
- `required_next_actions`
|
| 109 |
+
- `risk_flags`
|
| 110 |
- `action_history`
|
| 111 |
- `feedback`
|
| 112 |
- `remaining_steps`
|
| 113 |
+
- `reward`
|
| 114 |
+
- `done`
|
| 115 |
+
|
| 116 |
+
The `case` object is the mutable operational state. It contains:
|
| 117 |
+
|
| 118 |
+
- current queue, priority, and issue type
|
| 119 |
+
- requested fields
|
| 120 |
+
- reply draft
|
| 121 |
+
- internal note
|
| 122 |
+
- final status and resolution code
|
| 123 |
+
- customer follow-up state
|
| 124 |
+
|
| 125 |
+
Customer follow-up can move through:
|
| 126 |
+
|
| 127 |
+
- `none`
|
| 128 |
+
- `pending`
|
| 129 |
+
- `partial`
|
| 130 |
+
- `complete`
|
| 131 |
+
- `incorrect`
|
| 132 |
+
|
| 133 |
+
The observation is designed to help the agent reason about process, not just text:
|
| 134 |
+
|
| 135 |
+
- `workflow_stage` shows whether the agent is still classifying, waiting on a reply, drafting communication, or ready to submit
|
| 136 |
+
- `required_next_actions` tells the agent which steps are still missing
|
| 137 |
+
- `risk_flags` surfaces urgency and safety issues like SLA risk, unsafe unlock pressure, and irrelevant customer follow-up
|
| 138 |
+
|
| 139 |
+
## State space
|
| 140 |
+
|
| 141 |
+
`state()` returns the typed `SupportDeskState`.
|
| 142 |
+
|
| 143 |
+
Main fields:
|
| 144 |
+
|
| 145 |
+
- `episode_id`
|
| 146 |
+
- `task_id`
|
| 147 |
+
- `difficulty`
|
| 148 |
+
- `step_count`
|
| 149 |
+
- `reward`
|
| 150 |
+
- `done`
|
| 151 |
+
- `current_score`
|
| 152 |
+
- `max_steps`
|
| 153 |
+
- `case`
|
| 154 |
+
- `current_sla_minutes_remaining`
|
| 155 |
+
- `workflow_stage`
|
| 156 |
+
- `required_next_actions`
|
| 157 |
+
- `risk_flags`
|
| 158 |
+
- `action_history`
|
| 159 |
+
- `completed_milestones`
|
| 160 |
+
- `last_feedback`
|
| 161 |
+
|
| 162 |
+
## Task descriptions
|
| 163 |
+
|
| 164 |
+
There are four deterministic tasks in a fixed order.
|
| 165 |
+
|
| 166 |
+
### 1. `billing_refund_easy`
|
| 167 |
+
|
| 168 |
+
Difficulty: easy
|
| 169 |
+
|
| 170 |
+
A customer was charged twice after cancellation. The right workflow is to route the case to billing, confirm the refund path, leave a useful note, and resolve the case without asking for unnecessary extra information.
|
| 171 |
+
|
| 172 |
+
### 2. `account_takeover_medium`
|
| 173 |
+
|
| 174 |
+
Difficulty: medium
|
| 175 |
|
| 176 |
+
This is a suspicious-login recovery case. The agent has to route it to trust and safety, request verification details, handle a delayed partial follow-up from the customer, and keep the case open until the missing information is provided. Unlocking the account immediately would be unsafe.
|
| 177 |
|
| 178 |
+
### 3. `api_incident_hard`
|
| 179 |
|
| 180 |
+
Difficulty: hard
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
+
This task simulates a live enterprise API incident. The ticket includes a secondary compliance concern, but the primary issue is the outage. The agent needs to escalate to engineering, request the right diagnostics, communicate clearly, and keep the incident open rather than marking it resolved.
|
| 183 |
|
| 184 |
+
### 4. `regulated_export_exception_hard`
|
| 185 |
|
| 186 |
+
Difficulty: hard
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
+
This is a regulated exception request. The customer wants a shortcut around an export restriction, but the correct workflow is to route the case to compliance, request legal approval details, and keep the case open pending review. Sending it straight to engineering for a workaround is the wrong move.
|
| 189 |
|
| 190 |
+
## Reward and grader design
|
|
|
|
|
|
|
| 191 |
|
| 192 |
+
Each task has a deterministic grader that returns a score in `[0.0, 1.0]`.
|
| 193 |
|
| 194 |
+
The grader checks:
|
| 195 |
|
| 196 |
+
- queue correctness
|
| 197 |
+
- priority correctness
|
| 198 |
+
- issue type correctness
|
| 199 |
+
- requested fields
|
| 200 |
+
- reply coverage
|
| 201 |
+
- internal note coverage
|
| 202 |
+
- final status
|
| 203 |
+
- resolution code
|
| 204 |
|
| 205 |
+
The environment uses the grader score delta as the main dense reward signal. On top of that, it adds smaller process-aware bonuses and penalties so that the full trajectory matters, not just the final snapshot.
|
| 206 |
|
| 207 |
+
Examples:
|
| 208 |
+
|
| 209 |
+
- bonus for early correct routing on urgent tasks
|
| 210 |
+
- bonus for moving through the workflow in the right order
|
| 211 |
+
- bonus when `wait` correctly reveals a scripted customer follow-up
|
| 212 |
+
- penalty for premature submit
|
| 213 |
+
- penalty for over-escalation
|
| 214 |
+
- penalty for mixed or sloppy actions
|
| 215 |
+
- penalty when the SLA gets critically low
|
| 216 |
+
|
| 217 |
+
## Project layout
|
| 218 |
|
| 219 |
```text
|
| 220 |
.
|
| 221 |
|-- inference.py
|
| 222 |
|-- openenv.yaml
|
| 223 |
|-- pyproject.toml
|
| 224 |
+
|-- Dockerfile
|
| 225 |
+
|-- uv.lock
|
| 226 |
|-- supportdesk_env
|
| 227 |
| |-- __init__.py
|
|
|
|
| 228 |
| |-- graders.py
|
| 229 |
| |-- models.py
|
| 230 |
+
| |-- policies.py
|
| 231 |
| |-- tasks.py
|
| 232 |
| `-- server
|
| 233 |
| |-- app.py
|
| 234 |
| `-- supportdesk_environment.py
|
| 235 |
|-- tests
|
| 236 |
| `-- test_supportdesk.py
|
| 237 |
+
`-- examples
|
| 238 |
+
`-- rl
|
| 239 |
+
`-- train_q_agent.py
|
| 240 |
```
|
| 241 |
|
| 242 |
+
## Setup instructions
|
| 243 |
+
|
| 244 |
+
### Option 1: pip
|
| 245 |
|
| 246 |
```bash
|
| 247 |
pip install -r requirements.txt
|
| 248 |
```
|
| 249 |
|
| 250 |
+
### Option 2: uv
|
| 251 |
|
| 252 |
```bash
|
| 253 |
uv sync
|
| 254 |
```
|
| 255 |
|
| 256 |
+
## Usage instructions
|
| 257 |
+
|
| 258 |
+
Validate the repo:
|
| 259 |
|
| 260 |
```bash
|
| 261 |
+
python -m openenv.cli validate .
|
|
|
|
|
|
|
|
|
|
| 262 |
```
|
| 263 |
|
| 264 |
+
Start the local server:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
```bash
|
|
|
|
|
|
|
|
|
|
| 267 |
python -m supportdesk_env.server.app
|
| 268 |
```
|
| 269 |
|
| 270 |
+
Or use the entrypoint:
|
|
|
|
|
|
|
| 271 |
|
| 272 |
```bash
|
| 273 |
+
server
|
| 274 |
```
|
| 275 |
|
| 276 |
+
Run the baseline:
|
|
|
|
|
|
|
| 277 |
|
| 278 |
```bash
|
| 279 |
+
python inference.py
|
| 280 |
```
|
| 281 |
|
| 282 |
+
There is also a small local RL example:
|
| 283 |
|
| 284 |
```bash
|
| 285 |
+
python examples/rl/train_q_agent.py
|
| 286 |
```
|
| 287 |
|
| 288 |
+
## Baseline and environment variables
|
| 289 |
+
|
| 290 |
+
`inference.py` uses the OpenAI Python client when model configuration is supplied externally at runtime.
|
| 291 |
+
|
| 292 |
+
Supported variables:
|
| 293 |
+
|
| 294 |
+
- `API_BASE_URL`
|
| 295 |
+
- `MODEL_NAME`
|
| 296 |
+
- `HF_TOKEN`
|
| 297 |
+
- `OPENAI_API_KEY`
|
| 298 |
+
- `MAX_STEPS`
|
| 299 |
+
- `TEMPERATURE`
|
| 300 |
+
|
| 301 |
+
Example:
|
| 302 |
|
| 303 |
```bash
|
| 304 |
+
export API_BASE_URL="https://router.huggingface.co/v1"
|
| 305 |
+
export MODEL_NAME="openai/gpt-4.1-mini"
|
| 306 |
+
export HF_TOKEN="your-token-here"
|
| 307 |
python inference.py
|
| 308 |
```
|
| 309 |
|
| 310 |
+
Important:
|
| 311 |
+
|
| 312 |
+
- the repo does not depend on hardcoded credentials
|
| 313 |
+
- the expected evaluation setup is environment-variable driven
|
| 314 |
+
- if credentials are missing or the model call fails, the baseline falls back to a deterministic heuristic policy so the script still completes
|
| 315 |
|
| 316 |
## Docker
|
| 317 |
|
| 318 |
+
Build:
|
| 319 |
+
|
| 320 |
```bash
|
| 321 |
docker build -t supportdesk-env .
|
|
|
|
| 322 |
```
|
| 323 |
|
| 324 |
+
Run:
|
| 325 |
|
| 326 |
+
```bash
|
| 327 |
+
docker run -p 8000:8000 supportdesk-env
|
| 328 |
+
```
|
| 329 |
|
| 330 |
+
## Hugging Face Space deployment
|
| 331 |
+
|
| 332 |
+
This repo is meant to run as a Docker Space. Keep both the GitHub repository and the Hugging Face Space public for submission.
|
| 333 |
|
| 334 |
+
If you have the OpenEnv CLI installed, a typical deployment command is:
|
| 335 |
|
| 336 |
```bash
|
| 337 |
openenv push --repo-id your-username/HyperBrickCaseOps
|
|
|
|
| 339 |
|
| 340 |
## Validation
|
| 341 |
|
| 342 |
+
Local validation:
|
| 343 |
+
|
| 344 |
```bash
|
| 345 |
openenv validate .
|
| 346 |
```
|
| 347 |
|
| 348 |
+
Validation against a running environment:
|
| 349 |
+
|
| 350 |
+
```bash
|
| 351 |
+
openenv validate http://127.0.0.1:8000
|
| 352 |
+
```
|
| 353 |
+
|
| 354 |
+
Pre-submission script:
|
| 355 |
|
| 356 |
```bash
|
| 357 |
./scripts/validate-submission.sh https://your-space.hf.space .
|
| 358 |
```
|
| 359 |
|
| 360 |
+
## Submission checklist
|
| 361 |
|
| 362 |
+
- real-world environment, not a toy or game
|
| 363 |
+
- typed OpenEnv action, observation, and state models
|
| 364 |
+
- working `reset`, `step`, and `state`
|
| 365 |
+
- at least 3 tasks with deterministic graders
|
| 366 |
+
- meaningful reward over the trajectory
|
| 367 |
+
- root `inference.py`
|
| 368 |
+
- working `Dockerfile`
|
| 369 |
+
- `openenv.yaml` present
|
| 370 |
+
- README includes environment description, motivation, action space, observation space, task descriptions, setup instructions, and baseline scores
|
| 371 |
|
| 372 |
+
## Baseline scores
|
| 373 |
|
| 374 |
+
Current deterministic fallback baseline:
|
| 375 |
|
| 376 |
- `billing_refund_easy`: `1.00`
|
| 377 |
- `account_takeover_medium`: `1.00`
|
| 378 |
- `api_incident_hard`: `1.00`
|
| 379 |
+
- `regulated_export_exception_hard`: `1.00`
|
| 380 |
+
- average: `1.00`
|
| 381 |
|
| 382 |
+
These scores are intentionally reproducible. The fallback policy exists to show that the environment, reward shaping, and graders all work end to end. Model-backed runs can be lower, which is useful for evaluation.
|
inference.py
CHANGED
|
@@ -2,26 +2,41 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import json
|
| 6 |
import os
|
| 7 |
import re
|
| 8 |
-
from
|
|
|
|
| 9 |
|
| 10 |
try:
|
| 11 |
from openai import OpenAI
|
| 12 |
except ImportError: # pragma: no cover - local fallback mode
|
| 13 |
OpenAI = None # type: ignore[assignment]
|
| 14 |
|
|
|
|
| 15 |
from supportdesk_env.graders import grade_case
|
| 16 |
from supportdesk_env.models import SupportDeskAction, SupportDeskObservation
|
| 17 |
from supportdesk_env.policies import heuristic_action
|
| 18 |
from supportdesk_env.server.supportdesk_environment import SupportDeskEnvironment
|
| 19 |
from supportdesk_env.tasks import get_task, list_task_ids
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
SYSTEM_PROMPT = """You are a support operations agent solving one triage ticket.
|
| 22 |
Return exactly one JSON object with this schema:
|
| 23 |
{
|
| 24 |
-
"operation": "classify|request_info|draft_reply|add_internal_note|submit",
|
| 25 |
"queue": string or null,
|
| 26 |
"priority": string or null,
|
| 27 |
"issue_type": string or null,
|
|
@@ -35,25 +50,23 @@ Return exactly one JSON object with this schema:
|
|
| 35 |
Use the policy snippets in the observation. Keep customer replies short, precise, and professional.
|
| 36 |
"""
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
def _build_client() -> OpenAI | None:
|
| 46 |
-
if OpenAI is None:
|
| 47 |
return None
|
| 48 |
-
|
| 49 |
-
return None
|
| 50 |
-
kwargs = {"api_key": API_KEY}
|
| 51 |
-
if API_BASE_URL:
|
| 52 |
-
kwargs["base_url"] = API_BASE_URL
|
| 53 |
-
return OpenAI(**kwargs)
|
| 54 |
|
| 55 |
|
| 56 |
-
def _extract_json(text: str) -> dict:
|
| 57 |
try:
|
| 58 |
return json.loads(text)
|
| 59 |
except json.JSONDecodeError:
|
|
@@ -65,7 +78,8 @@ def _extract_json(text: str) -> dict:
|
|
| 65 |
|
| 66 |
def _observation_prompt(observation: SupportDeskObservation) -> str:
|
| 67 |
kb_lines = "\n".join(
|
| 68 |
-
f"- {snippet.article_id}: {snippet.title}: {snippet.content}"
|
|
|
|
| 69 |
)
|
| 70 |
history_lines = "\n".join(
|
| 71 |
f"- step {entry.step}: {entry.summary} ({entry.reward_delta:+.2f})"
|
|
@@ -79,7 +93,7 @@ Ticket body: {observation.ticket.body}
|
|
| 79 |
Customer tier: {observation.ticket.customer_tier}
|
| 80 |
Region: {observation.ticket.region}
|
| 81 |
Affected users: {observation.ticket.affected_users}
|
| 82 |
-
SLA minutes remaining: {observation.
|
| 83 |
Business impact: {observation.ticket.business_impact}
|
| 84 |
Secondary concerns: {observation.ticket.secondary_concerns}
|
| 85 |
|
|
@@ -95,7 +109,11 @@ Current case state:
|
|
| 95 |
- requested_fields: {observation.case.requested_fields}
|
| 96 |
- reply: {observation.case.reply}
|
| 97 |
- internal_note: {observation.case.internal_note}
|
|
|
|
| 98 |
|
|
|
|
|
|
|
|
|
|
| 99 |
Feedback: {observation.feedback}
|
| 100 |
Remaining steps: {observation.remaining_steps}
|
| 101 |
|
|
@@ -112,6 +130,7 @@ def _model_action(client: OpenAI | None, observation: SupportDeskObservation) ->
|
|
| 112 |
completion = client.chat.completions.create(
|
| 113 |
model=MODEL_NAME,
|
| 114 |
temperature=TEMPERATURE,
|
|
|
|
| 115 |
messages=[
|
| 116 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 117 |
{"role": "user", "content": _observation_prompt(observation)},
|
|
@@ -124,28 +143,147 @@ def _model_action(client: OpenAI | None, observation: SupportDeskObservation) ->
|
|
| 124 |
return heuristic_action(observation)
|
| 125 |
|
| 126 |
|
| 127 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
env = SupportDeskEnvironment(task_id=task_id)
|
| 129 |
observation = env.reset()
|
|
|
|
|
|
|
| 130 |
|
| 131 |
try:
|
| 132 |
-
for
|
| 133 |
-
action = _model_action(client, observation)
|
| 134 |
-
observation = env.step(action)
|
| 135 |
if observation.done:
|
| 136 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
final_grade = grade_case(get_task(task_id), env.state.case)
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
| 140 |
finally:
|
| 141 |
env.close()
|
| 142 |
|
| 143 |
|
| 144 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
client = _build_client()
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
|
| 150 |
if __name__ == "__main__":
|
| 151 |
-
main()
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import asyncio
|
| 6 |
import json
|
| 7 |
import os
|
| 8 |
import re
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Any
|
| 11 |
|
| 12 |
try:
|
| 13 |
from openai import OpenAI
|
| 14 |
except ImportError: # pragma: no cover - local fallback mode
|
| 15 |
OpenAI = None # type: ignore[assignment]
|
| 16 |
|
| 17 |
+
from supportdesk_env.client import SupportDeskEnv
|
| 18 |
from supportdesk_env.graders import grade_case
|
| 19 |
from supportdesk_env.models import SupportDeskAction, SupportDeskObservation
|
| 20 |
from supportdesk_env.policies import heuristic_action
|
| 21 |
from supportdesk_env.server.supportdesk_environment import SupportDeskEnvironment
|
| 22 |
from supportdesk_env.tasks import get_task, list_task_ids
|
| 23 |
|
| 24 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 25 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4.1-mini")
|
| 26 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 27 |
+
API_KEY = HF_TOKEN
|
| 28 |
+
LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME")
|
| 29 |
+
TASK_NAME = os.getenv("SUPPORTDESK_TASK_ID", "billing_refund_easy")
|
| 30 |
+
BENCHMARK = os.getenv("SUPPORTDESK_BENCHMARK", "supportdesk_env")
|
| 31 |
+
MAX_STEPS = int(os.getenv("MAX_STEPS", "8"))
|
| 32 |
+
TEMPERATURE = float(os.getenv("TEMPERATURE", "0"))
|
| 33 |
+
MAX_TOKENS = int(os.getenv("MAX_TOKENS", "300"))
|
| 34 |
+
SUCCESS_SCORE_THRESHOLD = float(os.getenv("SUCCESS_SCORE_THRESHOLD", "0.1"))
|
| 35 |
+
|
| 36 |
SYSTEM_PROMPT = """You are a support operations agent solving one triage ticket.
|
| 37 |
Return exactly one JSON object with this schema:
|
| 38 |
{
|
| 39 |
+
"operation": "classify|request_info|draft_reply|add_internal_note|submit|wait",
|
| 40 |
"queue": string or null,
|
| 41 |
"priority": string or null,
|
| 42 |
"issue_type": string or null,
|
|
|
|
| 50 |
Use the policy snippets in the observation. Keep customer replies short, precise, and professional.
|
| 51 |
"""
|
| 52 |
|
| 53 |
+
|
| 54 |
+
@dataclass
|
| 55 |
+
class EpisodeResult:
|
| 56 |
+
"""Compact result used for final success logging."""
|
| 57 |
+
|
| 58 |
+
final_score: float
|
| 59 |
+
steps_taken: int
|
| 60 |
+
rewards: list[float]
|
| 61 |
|
| 62 |
|
| 63 |
def _build_client() -> OpenAI | None:
|
| 64 |
+
if OpenAI is None or not API_KEY:
|
| 65 |
return None
|
| 66 |
+
return OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
+
def _extract_json(text: str) -> dict[str, Any]:
|
| 70 |
try:
|
| 71 |
return json.loads(text)
|
| 72 |
except json.JSONDecodeError:
|
|
|
|
| 78 |
|
| 79 |
def _observation_prompt(observation: SupportDeskObservation) -> str:
|
| 80 |
kb_lines = "\n".join(
|
| 81 |
+
f"- {snippet.article_id}: {snippet.title}: {snippet.content}"
|
| 82 |
+
for snippet in observation.knowledge_base
|
| 83 |
)
|
| 84 |
history_lines = "\n".join(
|
| 85 |
f"- step {entry.step}: {entry.summary} ({entry.reward_delta:+.2f})"
|
|
|
|
| 93 |
Customer tier: {observation.ticket.customer_tier}
|
| 94 |
Region: {observation.ticket.region}
|
| 95 |
Affected users: {observation.ticket.affected_users}
|
| 96 |
+
SLA minutes remaining: {observation.current_sla_minutes_remaining}
|
| 97 |
Business impact: {observation.ticket.business_impact}
|
| 98 |
Secondary concerns: {observation.ticket.secondary_concerns}
|
| 99 |
|
|
|
|
| 109 |
- requested_fields: {observation.case.requested_fields}
|
| 110 |
- reply: {observation.case.reply}
|
| 111 |
- internal_note: {observation.case.internal_note}
|
| 112 |
+
- customer_follow_up: {observation.case.customer_follow_up.status}
|
| 113 |
|
| 114 |
+
Workflow stage: {observation.workflow_stage}
|
| 115 |
+
Required next actions: {observation.required_next_actions}
|
| 116 |
+
Risk flags: {observation.risk_flags}
|
| 117 |
Feedback: {observation.feedback}
|
| 118 |
Remaining steps: {observation.remaining_steps}
|
| 119 |
|
|
|
|
| 130 |
completion = client.chat.completions.create(
|
| 131 |
model=MODEL_NAME,
|
| 132 |
temperature=TEMPERATURE,
|
| 133 |
+
max_tokens=MAX_TOKENS,
|
| 134 |
messages=[
|
| 135 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 136 |
{"role": "user", "content": _observation_prompt(observation)},
|
|
|
|
| 143 |
return heuristic_action(observation)
|
| 144 |
|
| 145 |
|
| 146 |
+
def _action_to_log_string(action: SupportDeskAction) -> str:
|
| 147 |
+
if hasattr(action, "model_dump_json"):
|
| 148 |
+
payload = action.model_dump(
|
| 149 |
+
exclude_none=True,
|
| 150 |
+
exclude_defaults=True,
|
| 151 |
+
exclude={"metadata"},
|
| 152 |
+
)
|
| 153 |
+
else:
|
| 154 |
+
payload = action.dict(
|
| 155 |
+
exclude_none=True,
|
| 156 |
+
exclude_defaults=True,
|
| 157 |
+
)
|
| 158 |
+
payload.pop("metadata", None)
|
| 159 |
+
return json.dumps(payload, separators=(",", ":"))
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _log_start(task: str) -> None:
|
| 163 |
+
print(f"[START] task={task} env={BENCHMARK} model={MODEL_NAME}", flush=True)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _log_step(step: int, action_str: str, reward: float, done: bool, error: str | None) -> None:
|
| 167 |
+
error_value = error if error else "null"
|
| 168 |
+
print(
|
| 169 |
+
f"[STEP] step={step} action={action_str} reward={reward:.2f} "
|
| 170 |
+
f"done={str(done).lower()} error={error_value}",
|
| 171 |
+
flush=True,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _log_end(success: bool, steps: int, rewards: list[float]) -> None:
|
| 176 |
+
reward_text = ",".join(f"{reward:.2f}" for reward in rewards)
|
| 177 |
+
print(
|
| 178 |
+
f"[END] success={str(success).lower()} steps={steps} rewards={reward_text}",
|
| 179 |
+
flush=True,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _run_local_episode(task_id: str, client: OpenAI | None) -> EpisodeResult:
|
| 184 |
env = SupportDeskEnvironment(task_id=task_id)
|
| 185 |
observation = env.reset()
|
| 186 |
+
rewards: list[float] = []
|
| 187 |
+
steps_taken = 0
|
| 188 |
|
| 189 |
try:
|
| 190 |
+
for step in range(1, MAX_STEPS + 1):
|
|
|
|
|
|
|
| 191 |
if observation.done:
|
| 192 |
break
|
| 193 |
+
|
| 194 |
+
action = _model_action(client, observation)
|
| 195 |
+
action_str = _action_to_log_string(action)
|
| 196 |
+
|
| 197 |
+
try:
|
| 198 |
+
observation = env.step(action)
|
| 199 |
+
reward = observation.reward or 0.0
|
| 200 |
+
done = observation.done
|
| 201 |
+
error = None
|
| 202 |
+
except Exception as exc:
|
| 203 |
+
raise RuntimeError(str(exc)) from exc
|
| 204 |
+
|
| 205 |
+
_log_step(step, action_str, reward, done, error)
|
| 206 |
+
rewards.append(reward)
|
| 207 |
+
steps_taken = step
|
| 208 |
+
|
| 209 |
+
if done:
|
| 210 |
+
break
|
| 211 |
+
|
| 212 |
final_grade = grade_case(get_task(task_id), env.state.case)
|
| 213 |
+
return EpisodeResult(
|
| 214 |
+
final_score=final_grade.total_score,
|
| 215 |
+
steps_taken=steps_taken,
|
| 216 |
+
rewards=rewards,
|
| 217 |
+
)
|
| 218 |
finally:
|
| 219 |
env.close()
|
| 220 |
|
| 221 |
|
| 222 |
+
async def _run_docker_episode(task_id: str, client: OpenAI | None) -> EpisodeResult:
|
| 223 |
+
env = await SupportDeskEnv.from_docker_image(
|
| 224 |
+
LOCAL_IMAGE_NAME,
|
| 225 |
+
env_vars={"SUPPORTDESK_TASK_ID": task_id},
|
| 226 |
+
)
|
| 227 |
+
rewards: list[float] = []
|
| 228 |
+
steps_taken = 0
|
| 229 |
+
|
| 230 |
+
try:
|
| 231 |
+
result = await env.reset()
|
| 232 |
+
observation = result.observation
|
| 233 |
+
|
| 234 |
+
for step in range(1, MAX_STEPS + 1):
|
| 235 |
+
if result.done:
|
| 236 |
+
break
|
| 237 |
+
|
| 238 |
+
action = _model_action(client, observation)
|
| 239 |
+
action_str = _action_to_log_string(action)
|
| 240 |
+
|
| 241 |
+
try:
|
| 242 |
+
result = await env.step(action)
|
| 243 |
+
observation = result.observation
|
| 244 |
+
reward = result.reward or 0.0
|
| 245 |
+
done = result.done
|
| 246 |
+
error = None
|
| 247 |
+
except Exception as exc:
|
| 248 |
+
raise RuntimeError(str(exc)) from exc
|
| 249 |
+
|
| 250 |
+
_log_step(step, action_str, reward, done, error)
|
| 251 |
+
rewards.append(reward)
|
| 252 |
+
steps_taken = step
|
| 253 |
+
|
| 254 |
+
if done:
|
| 255 |
+
break
|
| 256 |
+
|
| 257 |
+
state = await env.state()
|
| 258 |
+
final_grade = grade_case(get_task(task_id), state.case)
|
| 259 |
+
return EpisodeResult(
|
| 260 |
+
final_score=final_grade.total_score,
|
| 261 |
+
steps_taken=steps_taken,
|
| 262 |
+
rewards=rewards,
|
| 263 |
+
)
|
| 264 |
+
finally:
|
| 265 |
+
await env.close()
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
async def main() -> None:
|
| 269 |
client = _build_client()
|
| 270 |
+
success = False
|
| 271 |
+
steps_taken = 0
|
| 272 |
+
rewards: list[float] = []
|
| 273 |
+
|
| 274 |
+
_log_start(TASK_NAME)
|
| 275 |
+
|
| 276 |
+
try:
|
| 277 |
+
if LOCAL_IMAGE_NAME:
|
| 278 |
+
episode = await _run_docker_episode(TASK_NAME, client)
|
| 279 |
+
else:
|
| 280 |
+
episode = _run_local_episode(TASK_NAME, client)
|
| 281 |
+
success = episode.final_score >= SUCCESS_SCORE_THRESHOLD
|
| 282 |
+
steps_taken = episode.steps_taken
|
| 283 |
+
rewards = episode.rewards
|
| 284 |
+
finally:
|
| 285 |
+
_log_end(success=success, steps=steps_taken, rewards=rewards)
|
| 286 |
|
| 287 |
|
| 288 |
if __name__ == "__main__":
|
| 289 |
+
asyncio.run(main())
|
openenv.yaml
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
|
|
| 1 |
name: HyperBrickCaseOps
|
| 2 |
env_name: supportdesk_env
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
description: Enterprise support operations environment with SLA pressure, business-impact aware triage, and primary-vs-secondary issue prioritization.
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
name: HyperBrickCaseOps
|
| 3 |
env_name: supportdesk_env
|
| 4 |
+
type: space
|
| 5 |
+
runtime: fastapi
|
| 6 |
+
app: supportdesk_env.server.app:app
|
| 7 |
+
port: 8000
|
| 8 |
description: Enterprise support operations environment with SLA pressure, business-impact aware triage, and primary-vs-secondary issue prioritization.
|
pyproject.toml
CHANGED
|
@@ -1,29 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
[project]
|
| 2 |
name = "supportdesk-env"
|
| 3 |
version = "0.1.0"
|
| 4 |
-
description = "
|
|
|
|
| 5 |
authors = [{ name = "HyperBrick" }]
|
| 6 |
dependencies = [
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
| 13 |
]
|
| 14 |
-
requires-python = ">=3.10"
|
| 15 |
|
| 16 |
[project.optional-dependencies]
|
| 17 |
dev = [
|
| 18 |
-
|
|
|
|
| 19 |
]
|
| 20 |
|
| 21 |
[project.scripts]
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
requires = ["setuptools"]
|
| 26 |
-
build-backend = "setuptools.build_meta"
|
| 27 |
|
| 28 |
[tool.setuptools]
|
|
|
|
| 29 |
packages = ["supportdesk_env", "supportdesk_env.server"]
|
|
|
|
| 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 = "supportdesk-env"
|
| 13 |
version = "0.1.0"
|
| 14 |
+
description = "Enterprise support operations environment for OpenEnv"
|
| 15 |
+
requires-python = ">=3.10"
|
| 16 |
authors = [{ name = "HyperBrick" }]
|
| 17 |
dependencies = [
|
| 18 |
+
# Core OpenEnv runtime (provides FastAPI server + HTTP client types)
|
| 19 |
+
"openenv-core[core]>=0.2.2",
|
| 20 |
+
# Environment-specific dependencies
|
| 21 |
+
"fastapi>=0.115.0",
|
| 22 |
+
"openai>=1.54.0",
|
| 23 |
+
"pydantic>=2.9.0",
|
| 24 |
+
"requests>=2.32.0",
|
| 25 |
+
"uvicorn>=0.30.0",
|
| 26 |
]
|
|
|
|
| 27 |
|
| 28 |
[project.optional-dependencies]
|
| 29 |
dev = [
|
| 30 |
+
"pytest>=8.0.0",
|
| 31 |
+
"pytest-cov>=4.0.0",
|
| 32 |
]
|
| 33 |
|
| 34 |
[project.scripts]
|
| 35 |
+
# Server entry point - enables running via: uv run --project . server
|
| 36 |
+
# or: python -m supportdesk_env.server.app
|
| 37 |
+
server = "supportdesk_env.server.app:main"
|
|
|
|
|
|
|
| 38 |
|
| 39 |
[tool.setuptools]
|
| 40 |
+
include-package-data = true
|
| 41 |
packages = ["supportdesk_env", "supportdesk_env.server"]
|
pytest.ini
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
addopts = -p no:cacheprovider
|
| 3 |
+
testpaths = tests
|
supportdesk_env/client.py
CHANGED
|
@@ -15,6 +15,13 @@ def _validate(model_cls, payload):
|
|
| 15 |
class SupportDeskEnv(EnvClient[SupportDeskAction, SupportDeskObservation, SupportDeskState]):
|
| 16 |
"""Typed client for a locally running or deployed OpenEnv server."""
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
def _parse_state(self, payload) -> SupportDeskState:
|
| 19 |
return _validate(SupportDeskState, payload)
|
| 20 |
|
|
|
|
| 15 |
class SupportDeskEnv(EnvClient[SupportDeskAction, SupportDeskObservation, SupportDeskState]):
|
| 16 |
"""Typed client for a locally running or deployed OpenEnv server."""
|
| 17 |
|
| 18 |
+
def _step_payload(self, action: SupportDeskAction) -> dict:
|
| 19 |
+
"""Convert a typed action into the JSON payload expected by the server."""
|
| 20 |
+
|
| 21 |
+
if hasattr(action, "model_dump"):
|
| 22 |
+
return action.model_dump()
|
| 23 |
+
return action.dict()
|
| 24 |
+
|
| 25 |
def _parse_state(self, payload) -> SupportDeskState:
|
| 26 |
return _validate(SupportDeskState, payload)
|
| 27 |
|
supportdesk_env/models.py
CHANGED
|
@@ -42,6 +42,15 @@ class ActionHistoryEntry(BaseModel):
|
|
| 42 |
reward_delta: float = 0.0
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
class SupportCaseProgress(BaseModel):
|
| 46 |
"""Mutable case state that graders score against."""
|
| 47 |
|
|
@@ -53,12 +62,13 @@ class SupportCaseProgress(BaseModel):
|
|
| 53 |
requested_fields: list[str] = Field(default_factory=list)
|
| 54 |
reply: str | None = None
|
| 55 |
internal_note: str | None = None
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
class SupportDeskAction(Action):
|
| 59 |
"""One structured action the agent can take at each step."""
|
| 60 |
|
| 61 |
-
operation: Literal["classify", "request_info", "draft_reply", "add_internal_note", "submit"]
|
| 62 |
queue: str | None = None
|
| 63 |
priority: str | None = None
|
| 64 |
issue_type: str | None = None
|
|
@@ -82,6 +92,10 @@ class SupportDeskObservation(Observation):
|
|
| 82 |
available_statuses: list[str]
|
| 83 |
available_issue_types: list[str]
|
| 84 |
case: SupportCaseProgress
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
action_history: list[ActionHistoryEntry] = Field(default_factory=list)
|
| 86 |
feedback: str = ""
|
| 87 |
remaining_steps: int = 0
|
|
@@ -99,6 +113,10 @@ class SupportDeskState(State):
|
|
| 99 |
current_score: float = 0.0
|
| 100 |
max_steps: int = 0
|
| 101 |
case: SupportCaseProgress
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
action_history: list[ActionHistoryEntry] = Field(default_factory=list)
|
| 103 |
completed_milestones: list[str] = Field(default_factory=list)
|
| 104 |
last_feedback: str = ""
|
|
|
|
| 42 |
reward_delta: float = 0.0
|
| 43 |
|
| 44 |
|
| 45 |
+
class CustomerFollowUp(BaseModel):
|
| 46 |
+
"""A scripted customer response that arrives after a request for more information."""
|
| 47 |
+
|
| 48 |
+
status: Literal["none", "pending", "partial", "complete", "incorrect"] = "none"
|
| 49 |
+
message: str | None = None
|
| 50 |
+
provided_fields: list[str] = Field(default_factory=list)
|
| 51 |
+
wrong_fields: list[str] = Field(default_factory=list)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
class SupportCaseProgress(BaseModel):
|
| 55 |
"""Mutable case state that graders score against."""
|
| 56 |
|
|
|
|
| 62 |
requested_fields: list[str] = Field(default_factory=list)
|
| 63 |
reply: str | None = None
|
| 64 |
internal_note: str | None = None
|
| 65 |
+
customer_follow_up: CustomerFollowUp = Field(default_factory=CustomerFollowUp)
|
| 66 |
|
| 67 |
|
| 68 |
class SupportDeskAction(Action):
|
| 69 |
"""One structured action the agent can take at each step."""
|
| 70 |
|
| 71 |
+
operation: Literal["classify", "request_info", "draft_reply", "add_internal_note", "submit", "wait"]
|
| 72 |
queue: str | None = None
|
| 73 |
priority: str | None = None
|
| 74 |
issue_type: str | None = None
|
|
|
|
| 92 |
available_statuses: list[str]
|
| 93 |
available_issue_types: list[str]
|
| 94 |
case: SupportCaseProgress
|
| 95 |
+
current_sla_minutes_remaining: int | None = None
|
| 96 |
+
workflow_stage: str
|
| 97 |
+
required_next_actions: list[str] = Field(default_factory=list)
|
| 98 |
+
risk_flags: list[str] = Field(default_factory=list)
|
| 99 |
action_history: list[ActionHistoryEntry] = Field(default_factory=list)
|
| 100 |
feedback: str = ""
|
| 101 |
remaining_steps: int = 0
|
|
|
|
| 113 |
current_score: float = 0.0
|
| 114 |
max_steps: int = 0
|
| 115 |
case: SupportCaseProgress
|
| 116 |
+
current_sla_minutes_remaining: int | None = None
|
| 117 |
+
workflow_stage: str
|
| 118 |
+
required_next_actions: list[str] = Field(default_factory=list)
|
| 119 |
+
risk_flags: list[str] = Field(default_factory=list)
|
| 120 |
action_history: list[ActionHistoryEntry] = Field(default_factory=list)
|
| 121 |
completed_milestones: list[str] = Field(default_factory=list)
|
| 122 |
last_feedback: str = ""
|
supportdesk_env/policies.py
CHANGED
|
@@ -20,6 +20,12 @@ def default_reply(task_id: str) -> str:
|
|
| 20 |
"device for malware, and reply with your workspace_id, last successful login time, "
|
| 21 |
"and billing email so we can verify the account safely."
|
| 22 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
return (
|
| 24 |
"We are treating this as an active incident and our on-call engineering team is engaged. "
|
| 25 |
"Please send the affected request IDs, UTC timestamps, and the impacted region so we can "
|
|
@@ -34,6 +40,11 @@ def default_note(task_id: str) -> str:
|
|
| 34 |
return "Duplicate charge confirmed from attached invoice; refund approved."
|
| 35 |
if task_id == "account_takeover_medium":
|
| 36 |
return "Suspicious login alert reported and customer is locked out."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
return "EU data residency rollout hit intermittent HTTP 500s and the customer launches tonight."
|
| 38 |
|
| 39 |
|
|
@@ -55,10 +66,11 @@ def heuristic_action(observation: SupportDeskObservation) -> SupportDeskAction:
|
|
| 55 |
return SupportDeskAction(
|
| 56 |
operation="request_info",
|
| 57 |
requested_fields=list(task.required_requested_fields),
|
| 58 |
-
status=task.gold_status,
|
| 59 |
-
reply=default_reply(observation.task_id),
|
| 60 |
)
|
| 61 |
|
|
|
|
|
|
|
|
|
|
| 62 |
if not case.reply:
|
| 63 |
return SupportDeskAction(operation="draft_reply", reply=default_reply(observation.task_id))
|
| 64 |
|
|
|
|
| 20 |
"device for malware, and reply with your workspace_id, last successful login time, "
|
| 21 |
"and billing email so we can verify the account safely."
|
| 22 |
)
|
| 23 |
+
if task_id == "regulated_export_exception_hard":
|
| 24 |
+
return (
|
| 25 |
+
"We cannot provide a bypass or temporary unlock yet. Our compliance team is running "
|
| 26 |
+
"a compliance review, and we need your tenant_region, dpa_amendment_id, and "
|
| 27 |
+
"legal_contact_email to continue that review."
|
| 28 |
+
)
|
| 29 |
return (
|
| 30 |
"We are treating this as an active incident and our on-call engineering team is engaged. "
|
| 31 |
"Please send the affected request IDs, UTC timestamps, and the impacted region so we can "
|
|
|
|
| 40 |
return "Duplicate charge confirmed from attached invoice; refund approved."
|
| 41 |
if task_id == "account_takeover_medium":
|
| 42 |
return "Suspicious login alert reported and customer is locked out."
|
| 43 |
+
if task_id == "regulated_export_exception_hard":
|
| 44 |
+
return (
|
| 45 |
+
"Audit-driven export exception request tied to an EU residency policy block; "
|
| 46 |
+
"customer asked for a manual bypass before legal approval."
|
| 47 |
+
)
|
| 48 |
return "EU data residency rollout hit intermittent HTTP 500s and the customer launches tonight."
|
| 49 |
|
| 50 |
|
|
|
|
| 66 |
return SupportDeskAction(
|
| 67 |
operation="request_info",
|
| 68 |
requested_fields=list(task.required_requested_fields),
|
|
|
|
|
|
|
| 69 |
)
|
| 70 |
|
| 71 |
+
if case.customer_follow_up.status == "pending":
|
| 72 |
+
return SupportDeskAction(operation="wait")
|
| 73 |
+
|
| 74 |
if not case.reply:
|
| 75 |
return SupportDeskAction(operation="draft_reply", reply=default_reply(observation.task_id))
|
| 76 |
|
supportdesk_env/server/app.py
CHANGED
|
@@ -1,4 +1,33 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -16,14 +45,17 @@ from supportdesk_env.models import SupportDeskAction, SupportDeskObservation, Su
|
|
| 16 |
from supportdesk_env.server.supportdesk_environment import SupportDeskEnvironment
|
| 17 |
from supportdesk_env.tasks import TASKS
|
| 18 |
|
|
|
|
| 19 |
openenv_http_server.State = SupportDeskState
|
| 20 |
create_app = openenv_http_server.create_app
|
| 21 |
|
|
|
|
| 22 |
app = create_app(
|
| 23 |
SupportDeskEnvironment,
|
| 24 |
-
|
| 25 |
-
|
| 26 |
env_name="supportdesk_env",
|
|
|
|
| 27 |
)
|
| 28 |
|
| 29 |
|
|
@@ -61,12 +93,32 @@ def list_tasks() -> dict[str, Any]:
|
|
| 61 |
}
|
| 62 |
|
| 63 |
|
| 64 |
-
def main() -> None:
|
| 65 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
if __name__ == "__main__":
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 SupportDesk environment.
|
| 9 |
+
|
| 10 |
+
This module creates an HTTP server that exposes the SupportDeskEnvironment
|
| 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 |
+
- GET /tasks: Get task catalog metadata
|
| 20 |
+
|
| 21 |
+
Usage:
|
| 22 |
+
# Development (with auto-reload):
|
| 23 |
+
uvicorn supportdesk_env.server.app:app --reload --host 0.0.0.0 --port 8000
|
| 24 |
+
|
| 25 |
+
# Production:
|
| 26 |
+
uvicorn supportdesk_env.server.app:app --host 0.0.0.0 --port 8000 --workers 4
|
| 27 |
+
|
| 28 |
+
# Or run directly:
|
| 29 |
+
python -m supportdesk_env.server.app
|
| 30 |
+
"""
|
| 31 |
|
| 32 |
from __future__ import annotations
|
| 33 |
|
|
|
|
| 45 |
from supportdesk_env.server.supportdesk_environment import SupportDeskEnvironment
|
| 46 |
from supportdesk_env.tasks import TASKS
|
| 47 |
|
| 48 |
+
# Bind the default OpenEnv /state route to the full typed state model.
|
| 49 |
openenv_http_server.State = SupportDeskState
|
| 50 |
create_app = openenv_http_server.create_app
|
| 51 |
|
| 52 |
+
# Create the app with web interface and README integration.
|
| 53 |
app = create_app(
|
| 54 |
SupportDeskEnvironment,
|
| 55 |
+
SupportDeskAction,
|
| 56 |
+
SupportDeskObservation,
|
| 57 |
env_name="supportdesk_env",
|
| 58 |
+
max_concurrent_envs=1, # increase this number to allow more concurrent WebSocket sessions
|
| 59 |
)
|
| 60 |
|
| 61 |
|
|
|
|
| 93 |
}
|
| 94 |
|
| 95 |
|
| 96 |
+
def main(host: str = "0.0.0.0", port: int = 8000) -> None:
|
| 97 |
+
"""
|
| 98 |
+
Entry point for direct execution via uv run or python -m.
|
| 99 |
+
|
| 100 |
+
This function enables running the server without Docker:
|
| 101 |
+
uv run --project . server
|
| 102 |
+
uv run --project . server --port 8001
|
| 103 |
+
python -m supportdesk_env.server.app
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
host: Host address to bind to (default: "0.0.0.0")
|
| 107 |
+
port: Port number to listen on (default: 8000)
|
| 108 |
|
| 109 |
+
For production deployments, consider using uvicorn directly with
|
| 110 |
+
multiple workers:
|
| 111 |
+
uvicorn supportdesk_env.server.app:app --workers 4
|
| 112 |
+
"""
|
| 113 |
+
|
| 114 |
+
uvicorn.run("supportdesk_env.server.app:app", host=host, port=port)
|
| 115 |
|
| 116 |
|
| 117 |
if __name__ == "__main__":
|
| 118 |
+
import argparse
|
| 119 |
+
|
| 120 |
+
parser = argparse.ArgumentParser()
|
| 121 |
+
parser.add_argument("--host", default=os.getenv("HOST", "0.0.0.0"))
|
| 122 |
+
parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "8000")))
|
| 123 |
+
args = parser.parse_args()
|
| 124 |
+
main(host=args.host, port=args.port)
|
supportdesk_env/server/supportdesk_environment.py
CHANGED
|
@@ -10,6 +10,7 @@ from pathlib import Path
|
|
| 10 |
from supportdesk_env.graders import grade_case
|
| 11 |
from supportdesk_env.models import (
|
| 12 |
ActionHistoryEntry,
|
|
|
|
| 13 |
SupportCaseProgress,
|
| 14 |
SupportDeskAction,
|
| 15 |
SupportDeskObservation,
|
|
@@ -43,10 +44,14 @@ class SupportDeskEnvironment(
|
|
| 43 |
_shared_episode_id: str | None = None
|
| 44 |
_shared_score = 0.0
|
| 45 |
_shared_completed_milestones: list[str] = []
|
|
|
|
|
|
|
| 46 |
|
| 47 |
def __init__(self, task_id: str | None = None):
|
| 48 |
super().__init__()
|
| 49 |
-
|
|
|
|
|
|
|
| 50 |
self.task: SupportTaskSpec = get_task(requested_task)
|
| 51 |
self._max_steps = self.task.max_steps
|
| 52 |
self._step_count = 0
|
|
@@ -56,6 +61,7 @@ class SupportDeskEnvironment(
|
|
| 56 |
self._history: list[ActionHistoryEntry] = []
|
| 57 |
self._case = SupportCaseProgress()
|
| 58 |
self._episode_id: str | None = None
|
|
|
|
| 59 |
initial_grade = grade_case(self.task, self._case)
|
| 60 |
self._score = initial_grade.total_score
|
| 61 |
self._completed_milestones = list(initial_grade.completed_milestones)
|
|
@@ -82,6 +88,7 @@ class SupportDeskEnvironment(
|
|
| 82 |
cls._shared_episode_id = episode_id
|
| 83 |
cls._shared_score = initial_grade.total_score
|
| 84 |
cls._shared_completed_milestones = list(initial_grade.completed_milestones)
|
|
|
|
| 85 |
|
| 86 |
@classmethod
|
| 87 |
def _ensure_shared_state(cls, task: SupportTaskSpec) -> None:
|
|
@@ -102,6 +109,7 @@ class SupportDeskEnvironment(
|
|
| 102 |
self._episode_id = self.__class__._shared_episode_id
|
| 103 |
self._score = self.__class__._shared_score
|
| 104 |
self._completed_milestones = list(self.__class__._shared_completed_milestones)
|
|
|
|
| 105 |
|
| 106 |
def _sync_to_shared(self) -> None:
|
| 107 |
self.__class__._shared_task_id = self.task.task_id
|
|
@@ -114,6 +122,7 @@ class SupportDeskEnvironment(
|
|
| 114 |
self.__class__._shared_episode_id = self._episode_id
|
| 115 |
self.__class__._shared_score = self._score
|
| 116 |
self.__class__._shared_completed_milestones = list(self._completed_milestones)
|
|
|
|
| 117 |
|
| 118 |
@property
|
| 119 |
def state(self) -> SupportDeskState:
|
|
@@ -129,6 +138,10 @@ class SupportDeskEnvironment(
|
|
| 129 |
current_score=round(self._score, 4),
|
| 130 |
max_steps=self._max_steps,
|
| 131 |
case=self._case.model_copy(deep=True),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
action_history=[entry.model_copy(deep=True) for entry in self._history],
|
| 133 |
completed_milestones=list(self._completed_milestones),
|
| 134 |
last_feedback=self._last_feedback,
|
|
@@ -141,6 +154,12 @@ class SupportDeskEnvironment(
|
|
| 141 |
**kwargs,
|
| 142 |
) -> SupportDeskObservation:
|
| 143 |
with self.__class__._state_lock:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
self.__class__._initialize_shared_state(
|
| 145 |
self.task,
|
| 146 |
episode_id=episode_id or f"{self.task.task_id}-{uuid.uuid4().hex[:8]}",
|
|
@@ -165,11 +184,15 @@ class SupportDeskEnvironment(
|
|
| 165 |
)
|
| 166 |
|
| 167 |
previous_grade = grade_case(self.task, self._case)
|
|
|
|
| 168 |
self._apply_action(action)
|
| 169 |
self._step_count += 1
|
|
|
|
|
|
|
| 170 |
|
| 171 |
current_grade = grade_case(self.task, self._case)
|
| 172 |
reward = current_grade.total_score - previous_grade.total_score
|
|
|
|
| 173 |
reward += self._action_penalty(
|
| 174 |
action,
|
| 175 |
current_grade.total_score,
|
|
@@ -219,8 +242,8 @@ class SupportDeskEnvironment(
|
|
| 219 |
return EnvironmentMetadata(
|
| 220 |
name="supportdesk_env",
|
| 221 |
description=(
|
| 222 |
-
"A policy-heavy enterprise
|
| 223 |
-
"
|
| 224 |
),
|
| 225 |
readme_content=readme_content,
|
| 226 |
version="0.1.0",
|
|
@@ -228,24 +251,56 @@ class SupportDeskEnvironment(
|
|
| 228 |
)
|
| 229 |
|
| 230 |
def _apply_action(self, action: SupportDeskAction) -> None:
|
| 231 |
-
if action.
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
def _action_penalty(
|
| 251 |
self,
|
|
@@ -256,6 +311,8 @@ class SupportDeskEnvironment(
|
|
| 256 |
penalty = 0.0
|
| 257 |
if current_score <= previous_score:
|
| 258 |
penalty -= 0.03
|
|
|
|
|
|
|
| 259 |
if action.operation == "draft_reply" and not action.reply:
|
| 260 |
penalty -= 0.03
|
| 261 |
if action.operation == "request_info" and not action.requested_fields:
|
|
@@ -266,11 +323,27 @@ class SupportDeskEnvironment(
|
|
| 266 |
[action.queue, action.priority, action.issue_type, action.status, action.resolution_code]
|
| 267 |
):
|
| 268 |
penalty -= 0.03
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
return round(penalty, 4)
|
| 270 |
|
| 271 |
def _build_feedback(self, grade, reward: float) -> str:
|
| 272 |
return (
|
| 273 |
f"Reward delta {reward:+.2f}. Current score {grade.total_score:.2f}. "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
f"Completed milestones: {', '.join(grade.completed_milestones) or 'none yet'}."
|
| 275 |
)
|
| 276 |
|
|
@@ -311,9 +384,135 @@ class SupportDeskEnvironment(
|
|
| 311 |
available_statuses=list(ALL_STATUSES),
|
| 312 |
available_issue_types=list(ALL_ISSUE_TYPES),
|
| 313 |
case=self._case.model_copy(deep=True),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
action_history=[entry.model_copy(deep=True) for entry in self._history],
|
| 315 |
feedback=feedback or self._last_feedback,
|
| 316 |
remaining_steps=max(self._max_steps - self._step_count, 0),
|
| 317 |
reward=reward,
|
| 318 |
done=done,
|
| 319 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from supportdesk_env.graders import grade_case
|
| 11 |
from supportdesk_env.models import (
|
| 12 |
ActionHistoryEntry,
|
| 13 |
+
CustomerFollowUp,
|
| 14 |
SupportCaseProgress,
|
| 15 |
SupportDeskAction,
|
| 16 |
SupportDeskObservation,
|
|
|
|
| 44 |
_shared_episode_id: str | None = None
|
| 45 |
_shared_score = 0.0
|
| 46 |
_shared_completed_milestones: list[str] = []
|
| 47 |
+
_shared_current_sla_minutes_remaining: int | None = None
|
| 48 |
+
_shared_reset_counter = 0
|
| 49 |
|
| 50 |
def __init__(self, task_id: str | None = None):
|
| 51 |
super().__init__()
|
| 52 |
+
env_task_id = os.getenv("SUPPORTDESK_TASK_ID")
|
| 53 |
+
self._explicit_task_id = task_id is not None or env_task_id is not None
|
| 54 |
+
requested_task = task_id or env_task_id or list_task_ids()[0]
|
| 55 |
self.task: SupportTaskSpec = get_task(requested_task)
|
| 56 |
self._max_steps = self.task.max_steps
|
| 57 |
self._step_count = 0
|
|
|
|
| 61 |
self._history: list[ActionHistoryEntry] = []
|
| 62 |
self._case = SupportCaseProgress()
|
| 63 |
self._episode_id: str | None = None
|
| 64 |
+
self._current_sla_minutes_remaining = self.task.ticket.sla_minutes_remaining
|
| 65 |
initial_grade = grade_case(self.task, self._case)
|
| 66 |
self._score = initial_grade.total_score
|
| 67 |
self._completed_milestones = list(initial_grade.completed_milestones)
|
|
|
|
| 88 |
cls._shared_episode_id = episode_id
|
| 89 |
cls._shared_score = initial_grade.total_score
|
| 90 |
cls._shared_completed_milestones = list(initial_grade.completed_milestones)
|
| 91 |
+
cls._shared_current_sla_minutes_remaining = task.ticket.sla_minutes_remaining
|
| 92 |
|
| 93 |
@classmethod
|
| 94 |
def _ensure_shared_state(cls, task: SupportTaskSpec) -> None:
|
|
|
|
| 109 |
self._episode_id = self.__class__._shared_episode_id
|
| 110 |
self._score = self.__class__._shared_score
|
| 111 |
self._completed_milestones = list(self.__class__._shared_completed_milestones)
|
| 112 |
+
self._current_sla_minutes_remaining = self.__class__._shared_current_sla_minutes_remaining
|
| 113 |
|
| 114 |
def _sync_to_shared(self) -> None:
|
| 115 |
self.__class__._shared_task_id = self.task.task_id
|
|
|
|
| 122 |
self.__class__._shared_episode_id = self._episode_id
|
| 123 |
self.__class__._shared_score = self._score
|
| 124 |
self.__class__._shared_completed_milestones = list(self._completed_milestones)
|
| 125 |
+
self.__class__._shared_current_sla_minutes_remaining = self._current_sla_minutes_remaining
|
| 126 |
|
| 127 |
@property
|
| 128 |
def state(self) -> SupportDeskState:
|
|
|
|
| 138 |
current_score=round(self._score, 4),
|
| 139 |
max_steps=self._max_steps,
|
| 140 |
case=self._case.model_copy(deep=True),
|
| 141 |
+
current_sla_minutes_remaining=self._current_sla_minutes_remaining,
|
| 142 |
+
workflow_stage=self._workflow_stage(),
|
| 143 |
+
required_next_actions=self._required_next_actions(),
|
| 144 |
+
risk_flags=self._risk_flags(),
|
| 145 |
action_history=[entry.model_copy(deep=True) for entry in self._history],
|
| 146 |
completed_milestones=list(self._completed_milestones),
|
| 147 |
last_feedback=self._last_feedback,
|
|
|
|
| 154 |
**kwargs,
|
| 155 |
) -> SupportDeskObservation:
|
| 156 |
with self.__class__._state_lock:
|
| 157 |
+
if not self._explicit_task_id:
|
| 158 |
+
task_ids = list_task_ids()
|
| 159 |
+
next_task_id = task_ids[self.__class__._shared_reset_counter % len(task_ids)]
|
| 160 |
+
self.__class__._shared_reset_counter += 1
|
| 161 |
+
self.task = get_task(next_task_id)
|
| 162 |
+
self._max_steps = self.task.max_steps
|
| 163 |
self.__class__._initialize_shared_state(
|
| 164 |
self.task,
|
| 165 |
episode_id=episode_id or f"{self.task.task_id}-{uuid.uuid4().hex[:8]}",
|
|
|
|
| 184 |
)
|
| 185 |
|
| 186 |
previous_grade = grade_case(self.task, self._case)
|
| 187 |
+
previous_stage = self._workflow_stage()
|
| 188 |
self._apply_action(action)
|
| 189 |
self._step_count += 1
|
| 190 |
+
self._advance_external_events(action)
|
| 191 |
+
self._degrade_sla()
|
| 192 |
|
| 193 |
current_grade = grade_case(self.task, self._case)
|
| 194 |
reward = current_grade.total_score - previous_grade.total_score
|
| 195 |
+
reward += self._process_bonus(action, previous_stage, current_grade.total_score)
|
| 196 |
reward += self._action_penalty(
|
| 197 |
action,
|
| 198 |
current_grade.total_score,
|
|
|
|
| 242 |
return EnvironmentMetadata(
|
| 243 |
name="supportdesk_env",
|
| 244 |
description=(
|
| 245 |
+
"A policy-heavy enterprise operations desk with deterministic grading, delayed "
|
| 246 |
+
"customer follow-ups, SLA pressure, escalation tradeoffs, and sharper cross-functional triage."
|
| 247 |
),
|
| 248 |
readme_content=readme_content,
|
| 249 |
version="0.1.0",
|
|
|
|
| 251 |
)
|
| 252 |
|
| 253 |
def _apply_action(self, action: SupportDeskAction) -> None:
|
| 254 |
+
if action.operation == "classify":
|
| 255 |
+
if action.queue is not None:
|
| 256 |
+
self._case.queue = action.queue
|
| 257 |
+
if action.priority is not None:
|
| 258 |
+
self._case.priority = action.priority
|
| 259 |
+
if action.issue_type is not None:
|
| 260 |
+
self._case.issue_type = action.issue_type
|
| 261 |
+
return
|
| 262 |
+
|
| 263 |
+
if action.operation == "request_info":
|
| 264 |
+
if action.requested_fields:
|
| 265 |
+
merged = {item for item in self._case.requested_fields}
|
| 266 |
+
merged.update(action.requested_fields)
|
| 267 |
+
self._case.requested_fields = sorted(merged)
|
| 268 |
+
if self.task.follow_up_outcome != "none" and self._case.customer_follow_up.status == "none":
|
| 269 |
+
self._case.customer_follow_up = CustomerFollowUp(status="pending")
|
| 270 |
+
return
|
| 271 |
+
|
| 272 |
+
if action.operation == "draft_reply":
|
| 273 |
+
if action.reply is not None:
|
| 274 |
+
self._case.reply = action.reply
|
| 275 |
+
return
|
| 276 |
+
|
| 277 |
+
if action.operation == "add_internal_note":
|
| 278 |
+
if action.internal_note is not None:
|
| 279 |
+
self._case.internal_note = action.internal_note
|
| 280 |
+
return
|
| 281 |
+
|
| 282 |
+
if action.operation == "submit":
|
| 283 |
+
if action.status is not None:
|
| 284 |
+
self._case.status = action.status
|
| 285 |
+
if action.resolution_code is not None:
|
| 286 |
+
self._case.resolution_code = action.resolution_code
|
| 287 |
+
|
| 288 |
+
def _advance_external_events(self, action: SupportDeskAction) -> None:
|
| 289 |
+
if self._case.customer_follow_up.status == "pending" and action.operation == "wait":
|
| 290 |
+
self._case.customer_follow_up = CustomerFollowUp(
|
| 291 |
+
status=self.task.follow_up_outcome,
|
| 292 |
+
message=self.task.follow_up_message or None,
|
| 293 |
+
provided_fields=list(self.task.follow_up_provided_fields),
|
| 294 |
+
wrong_fields=list(self.task.follow_up_wrong_fields),
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
def _degrade_sla(self) -> None:
|
| 298 |
+
if self._current_sla_minutes_remaining is None:
|
| 299 |
+
return
|
| 300 |
+
self._current_sla_minutes_remaining = max(
|
| 301 |
+
0,
|
| 302 |
+
self._current_sla_minutes_remaining - self.task.sla_step_cost,
|
| 303 |
+
)
|
| 304 |
|
| 305 |
def _action_penalty(
|
| 306 |
self,
|
|
|
|
| 311 |
penalty = 0.0
|
| 312 |
if current_score <= previous_score:
|
| 313 |
penalty -= 0.03
|
| 314 |
+
penalty -= self._mixed_action_penalty(action)
|
| 315 |
+
penalty -= self._escalation_tradeoff_penalty()
|
| 316 |
if action.operation == "draft_reply" and not action.reply:
|
| 317 |
penalty -= 0.03
|
| 318 |
if action.operation == "request_info" and not action.requested_fields:
|
|
|
|
| 323 |
[action.queue, action.priority, action.issue_type, action.status, action.resolution_code]
|
| 324 |
):
|
| 325 |
penalty -= 0.03
|
| 326 |
+
if action.operation == "wait" and self._case.customer_follow_up.status != "pending":
|
| 327 |
+
penalty -= 0.02
|
| 328 |
+
if action.operation == "submit" and self._required_next_actions():
|
| 329 |
+
penalty -= 0.08
|
| 330 |
+
if (
|
| 331 |
+
self.task.under_escalation_deadline_step is not None
|
| 332 |
+
and self._step_count >= self.task.under_escalation_deadline_step
|
| 333 |
+
and (self._case.queue != self.task.gold_queue or self._case.priority != self.task.gold_priority)
|
| 334 |
+
):
|
| 335 |
+
penalty -= 0.04
|
| 336 |
+
if self._current_sla_minutes_remaining is not None and self._current_sla_minutes_remaining <= 15:
|
| 337 |
+
penalty -= 0.02
|
| 338 |
return round(penalty, 4)
|
| 339 |
|
| 340 |
def _build_feedback(self, grade, reward: float) -> str:
|
| 341 |
return (
|
| 342 |
f"Reward delta {reward:+.2f}. Current score {grade.total_score:.2f}. "
|
| 343 |
+
f"SLA remaining: {self._current_sla_minutes_remaining if self._current_sla_minutes_remaining is not None else 'n/a'} minutes. "
|
| 344 |
+
f"Stage: {self._workflow_stage()}. "
|
| 345 |
+
f"Customer follow-up: {self._case.customer_follow_up.status}. "
|
| 346 |
+
f"Next actions: {', '.join(self._required_next_actions()) or 'none'}. "
|
| 347 |
f"Completed milestones: {', '.join(grade.completed_milestones) or 'none yet'}."
|
| 348 |
)
|
| 349 |
|
|
|
|
| 384 |
available_statuses=list(ALL_STATUSES),
|
| 385 |
available_issue_types=list(ALL_ISSUE_TYPES),
|
| 386 |
case=self._case.model_copy(deep=True),
|
| 387 |
+
current_sla_minutes_remaining=self._current_sla_minutes_remaining,
|
| 388 |
+
workflow_stage=self._workflow_stage(),
|
| 389 |
+
required_next_actions=self._required_next_actions(),
|
| 390 |
+
risk_flags=self._risk_flags(),
|
| 391 |
action_history=[entry.model_copy(deep=True) for entry in self._history],
|
| 392 |
feedback=feedback or self._last_feedback,
|
| 393 |
remaining_steps=max(self._max_steps - self._step_count, 0),
|
| 394 |
reward=reward,
|
| 395 |
done=done,
|
| 396 |
)
|
| 397 |
+
|
| 398 |
+
def _workflow_stage(self) -> str:
|
| 399 |
+
if self._done:
|
| 400 |
+
return "closed"
|
| 401 |
+
if self._case.queue is None or self._case.priority is None or self._case.issue_type is None:
|
| 402 |
+
return "intake"
|
| 403 |
+
if self.task.required_requested_fields and sorted(self._case.requested_fields) != sorted(self.task.required_requested_fields):
|
| 404 |
+
return "verification"
|
| 405 |
+
if self._case.customer_follow_up.status == "pending":
|
| 406 |
+
return "awaiting_customer"
|
| 407 |
+
if self._case.customer_follow_up.status in {"partial", "incorrect"}:
|
| 408 |
+
return "follow_up_review"
|
| 409 |
+
if not self._case.reply:
|
| 410 |
+
return "customer_communication"
|
| 411 |
+
if not self._case.internal_note:
|
| 412 |
+
return "internal_handoff"
|
| 413 |
+
if self._case.status != self.task.gold_status or self._case.resolution_code != self.task.gold_resolution_code:
|
| 414 |
+
return "final_resolution"
|
| 415 |
+
return "ready_to_submit"
|
| 416 |
+
|
| 417 |
+
def _required_next_actions(self) -> list[str]:
|
| 418 |
+
if self._case.queue is None or self._case.priority is None or self._case.issue_type is None:
|
| 419 |
+
return ["classify"]
|
| 420 |
+
if self.task.required_requested_fields and sorted(self._case.requested_fields) != sorted(self.task.required_requested_fields):
|
| 421 |
+
return ["request_info"]
|
| 422 |
+
if self._case.customer_follow_up.status == "pending":
|
| 423 |
+
return ["wait"]
|
| 424 |
+
needed: list[str] = []
|
| 425 |
+
if not self._case.reply:
|
| 426 |
+
needed.append("draft_reply")
|
| 427 |
+
if not self._case.internal_note:
|
| 428 |
+
needed.append("add_internal_note")
|
| 429 |
+
if self._case.status != self.task.gold_status or self._case.resolution_code != self.task.gold_resolution_code:
|
| 430 |
+
needed.append("submit")
|
| 431 |
+
return needed
|
| 432 |
+
|
| 433 |
+
def _risk_flags(self) -> list[str]:
|
| 434 |
+
flags = list(self.task.risk_flags)
|
| 435 |
+
if self._current_sla_minutes_remaining is not None and self._current_sla_minutes_remaining <= 30:
|
| 436 |
+
flags.append("sla_breach_risk")
|
| 437 |
+
if self.task.ticket.affected_users and self.task.ticket.affected_users >= 1000:
|
| 438 |
+
flags.append("high_customer_impact")
|
| 439 |
+
if self.task.ticket.secondary_concerns:
|
| 440 |
+
flags.append("secondary_issue_present")
|
| 441 |
+
if self._case.customer_follow_up.status == "partial":
|
| 442 |
+
flags.append("customer_reply_incomplete")
|
| 443 |
+
if self._case.customer_follow_up.status == "incorrect":
|
| 444 |
+
flags.append("customer_reply_irrelevant")
|
| 445 |
+
return sorted(set(flags))
|
| 446 |
+
|
| 447 |
+
def _process_bonus(
|
| 448 |
+
self,
|
| 449 |
+
action: SupportDeskAction,
|
| 450 |
+
previous_stage: str,
|
| 451 |
+
current_score: float,
|
| 452 |
+
) -> float:
|
| 453 |
+
bonus = 0.0
|
| 454 |
+
stage_rank = {
|
| 455 |
+
"intake": 0,
|
| 456 |
+
"verification": 1,
|
| 457 |
+
"awaiting_customer": 2,
|
| 458 |
+
"follow_up_review": 3,
|
| 459 |
+
"customer_communication": 4,
|
| 460 |
+
"internal_handoff": 5,
|
| 461 |
+
"final_resolution": 6,
|
| 462 |
+
"ready_to_submit": 7,
|
| 463 |
+
"closed": 8,
|
| 464 |
+
}
|
| 465 |
+
current_stage = self._workflow_stage()
|
| 466 |
+
if stage_rank.get(current_stage, 0) > stage_rank.get(previous_stage, 0):
|
| 467 |
+
bonus += 0.02
|
| 468 |
+
if action.operation == "classify" and self._step_count == 1:
|
| 469 |
+
if self._case.queue == self.task.gold_queue and self._case.priority == self.task.gold_priority:
|
| 470 |
+
bonus += 0.03
|
| 471 |
+
if action.operation == "request_info" and current_score > 0 and self.task.required_requested_fields:
|
| 472 |
+
bonus += 0.02
|
| 473 |
+
if action.operation == "wait" and self._case.customer_follow_up.status in {"partial", "complete", "incorrect"}:
|
| 474 |
+
bonus += 0.02
|
| 475 |
+
if action.operation == "submit" and not self._required_next_actions():
|
| 476 |
+
bonus += 0.03
|
| 477 |
+
if self._current_sla_minutes_remaining is not None and self._current_sla_minutes_remaining > 0:
|
| 478 |
+
if self.task.gold_priority == "urgent" and self._step_count <= 2 and self._case.queue == self.task.gold_queue:
|
| 479 |
+
bonus += 0.02
|
| 480 |
+
return round(bonus, 4)
|
| 481 |
+
|
| 482 |
+
def _mixed_action_penalty(self, action: SupportDeskAction) -> float:
|
| 483 |
+
allowed_fields = {
|
| 484 |
+
"classify": {"queue", "priority", "issue_type"},
|
| 485 |
+
"request_info": {"requested_fields"},
|
| 486 |
+
"draft_reply": {"reply"},
|
| 487 |
+
"add_internal_note": {"internal_note"},
|
| 488 |
+
"submit": {"status", "resolution_code"},
|
| 489 |
+
"wait": set(),
|
| 490 |
+
}
|
| 491 |
+
populated_fields = {
|
| 492 |
+
"queue": action.queue,
|
| 493 |
+
"priority": action.priority,
|
| 494 |
+
"issue_type": action.issue_type,
|
| 495 |
+
"status": action.status,
|
| 496 |
+
"resolution_code": action.resolution_code,
|
| 497 |
+
"requested_fields": action.requested_fields,
|
| 498 |
+
"reply": action.reply,
|
| 499 |
+
"internal_note": action.internal_note,
|
| 500 |
+
}
|
| 501 |
+
extras = 0
|
| 502 |
+
for field_name, value in populated_fields.items():
|
| 503 |
+
if field_name in allowed_fields[action.operation]:
|
| 504 |
+
continue
|
| 505 |
+
if value is None:
|
| 506 |
+
continue
|
| 507 |
+
if isinstance(value, list) and not value:
|
| 508 |
+
continue
|
| 509 |
+
if isinstance(value, str) and not value:
|
| 510 |
+
continue
|
| 511 |
+
extras += 1
|
| 512 |
+
return min(0.06, extras * 0.02)
|
| 513 |
+
|
| 514 |
+
def _escalation_tradeoff_penalty(self) -> float:
|
| 515 |
+
penalty = 0.0
|
| 516 |
+
if self._case.queue in self.task.over_escalation_queues and self._case.queue != self.task.gold_queue:
|
| 517 |
+
penalty += 0.06
|
| 518 |
+
return round(penalty, 4)
|
supportdesk_env/tasks.py
CHANGED
|
@@ -8,13 +8,20 @@ from typing import Literal
|
|
| 8 |
from supportdesk_env.models import KnowledgeSnippet, SupportTicket
|
| 9 |
|
| 10 |
|
| 11 |
-
ALL_QUEUES = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
ALL_PRIORITIES = ["low", "normal", "high", "urgent"]
|
| 13 |
ALL_STATUSES = ["new", "waiting_on_customer", "resolved", "escalated"]
|
| 14 |
ALL_ISSUE_TYPES = [
|
| 15 |
"duplicate_charge",
|
| 16 |
"account_compromise",
|
| 17 |
"production_incident",
|
|
|
|
| 18 |
"general_question",
|
| 19 |
]
|
| 20 |
|
|
@@ -38,6 +45,14 @@ class SupportTaskSpec:
|
|
| 38 |
required_reply_markers: tuple[tuple[str, ...], ...]
|
| 39 |
required_note_markers: tuple[tuple[str, ...], ...]
|
| 40 |
forbidden_reply_markers: tuple[str, ...] = ()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
max_steps: int = 6
|
| 42 |
|
| 43 |
|
|
@@ -112,6 +127,10 @@ TASKS: dict[str, SupportTaskSpec] = {
|
|
| 112 |
("refund", "refund approved"),
|
| 113 |
),
|
| 114 |
forbidden_reply_markers=("chargeback", "security team"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
),
|
| 116 |
"account_takeover_medium": SupportTaskSpec(
|
| 117 |
task_id="account_takeover_medium",
|
|
@@ -179,8 +198,18 @@ TASKS: dict[str, SupportTaskSpec] = {
|
|
| 179 |
),
|
| 180 |
required_note_markers=(
|
| 181 |
("suspicious login", "strange login"),
|
| 182 |
-
("locked out", "can
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
),
|
| 185 |
"api_incident_hard": SupportTaskSpec(
|
| 186 |
task_id="api_incident_hard",
|
|
@@ -261,6 +290,101 @@ TASKS: dict[str, SupportTaskSpec] = {
|
|
| 261 |
("500", "http 500"),
|
| 262 |
("launch tonight", "tonight"),
|
| 263 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
),
|
| 265 |
}
|
| 266 |
|
|
|
|
| 8 |
from supportdesk_env.models import KnowledgeSnippet, SupportTicket
|
| 9 |
|
| 10 |
|
| 11 |
+
ALL_QUEUES = [
|
| 12 |
+
"billing_ops",
|
| 13 |
+
"trust_and_safety",
|
| 14 |
+
"platform_engineering",
|
| 15 |
+
"compliance_ops",
|
| 16 |
+
"general_support",
|
| 17 |
+
]
|
| 18 |
ALL_PRIORITIES = ["low", "normal", "high", "urgent"]
|
| 19 |
ALL_STATUSES = ["new", "waiting_on_customer", "resolved", "escalated"]
|
| 20 |
ALL_ISSUE_TYPES = [
|
| 21 |
"duplicate_charge",
|
| 22 |
"account_compromise",
|
| 23 |
"production_incident",
|
| 24 |
+
"regulated_exception",
|
| 25 |
"general_question",
|
| 26 |
]
|
| 27 |
|
|
|
|
| 45 |
required_reply_markers: tuple[tuple[str, ...], ...]
|
| 46 |
required_note_markers: tuple[tuple[str, ...], ...]
|
| 47 |
forbidden_reply_markers: tuple[str, ...] = ()
|
| 48 |
+
risk_flags: tuple[str, ...] = ()
|
| 49 |
+
follow_up_outcome: Literal["none", "partial", "complete", "incorrect"] = "none"
|
| 50 |
+
follow_up_message: str = ""
|
| 51 |
+
follow_up_provided_fields: tuple[str, ...] = ()
|
| 52 |
+
follow_up_wrong_fields: tuple[str, ...] = ()
|
| 53 |
+
sla_step_cost: int = 15
|
| 54 |
+
over_escalation_queues: tuple[str, ...] = ()
|
| 55 |
+
under_escalation_deadline_step: int | None = None
|
| 56 |
max_steps: int = 6
|
| 57 |
|
| 58 |
|
|
|
|
| 127 |
("refund", "refund approved"),
|
| 128 |
),
|
| 129 |
forbidden_reply_markers=("chargeback", "security team"),
|
| 130 |
+
risk_flags=("finance_close_risk", "avoid_unnecessary_back_and_forth"),
|
| 131 |
+
over_escalation_queues=("trust_and_safety", "platform_engineering", "compliance_ops"),
|
| 132 |
+
sla_step_cost=10,
|
| 133 |
+
max_steps=6,
|
| 134 |
),
|
| 135 |
"account_takeover_medium": SupportTaskSpec(
|
| 136 |
task_id="account_takeover_medium",
|
|
|
|
| 198 |
),
|
| 199 |
required_note_markers=(
|
| 200 |
("suspicious login", "strange login"),
|
| 201 |
+
("locked out", "can't get back", "cannot get back"),
|
| 202 |
+
),
|
| 203 |
+
risk_flags=("unsafe_unlock_request", "identity_verification_required"),
|
| 204 |
+
follow_up_outcome="partial",
|
| 205 |
+
follow_up_message=(
|
| 206 |
+
"Customer follow-up: workspace_id=ws_9021 and billing email confirmed, "
|
| 207 |
+
"but they could not provide the last successful login time yet."
|
| 208 |
),
|
| 209 |
+
follow_up_provided_fields=("workspace_id", "billing_email"),
|
| 210 |
+
sla_step_cost=18,
|
| 211 |
+
under_escalation_deadline_step=2,
|
| 212 |
+
max_steps=7,
|
| 213 |
),
|
| 214 |
"api_incident_hard": SupportTaskSpec(
|
| 215 |
task_id="api_incident_hard",
|
|
|
|
| 290 |
("500", "http 500"),
|
| 291 |
("launch tonight", "tonight"),
|
| 292 |
),
|
| 293 |
+
risk_flags=("sev1_launch_risk", "secondary_issue_distraction", "engineering_escalation_required"),
|
| 294 |
+
follow_up_outcome="complete",
|
| 295 |
+
follow_up_message=(
|
| 296 |
+
"Customer follow-up: request_ids=req_991, req_998; UTC timestamps=2026-04-01T19:20Z, "
|
| 297 |
+
"2026-04-01T19:27Z; region=eu-west-1."
|
| 298 |
+
),
|
| 299 |
+
follow_up_provided_fields=("request_ids", "timestamp_utc", "region"),
|
| 300 |
+
sla_step_cost=25,
|
| 301 |
+
under_escalation_deadline_step=2,
|
| 302 |
+
max_steps=8,
|
| 303 |
+
),
|
| 304 |
+
"regulated_export_exception_hard": SupportTaskSpec(
|
| 305 |
+
task_id="regulated_export_exception_hard",
|
| 306 |
+
difficulty="hard",
|
| 307 |
+
title="Regulated export exception triage",
|
| 308 |
+
objective=(
|
| 309 |
+
"Handle a regulated export-access request where the customer wants a manual bypass. "
|
| 310 |
+
"The agent must avoid escalating to engineering just because an export error is mentioned, "
|
| 311 |
+
"route to compliance operations, request the right legal details, and keep the case open "
|
| 312 |
+
"until the required approvals are supplied."
|
| 313 |
+
),
|
| 314 |
+
ticket=SupportTicket(
|
| 315 |
+
customer_name="Dr. Elena Kovacs",
|
| 316 |
+
customer_tier="enterprise",
|
| 317 |
+
company="NorthSea Clinics",
|
| 318 |
+
subject="Need urgent export unlock before audit review",
|
| 319 |
+
body=(
|
| 320 |
+
"Our audit team needs tonight's export package, but after enabling EU residency the "
|
| 321 |
+
"export workflow now shows a policy block. Can you temporarily disable the restriction "
|
| 322 |
+
"or give us a manual bypass just for this audit? We are under pressure and can share "
|
| 323 |
+
"screenshots if needed."
|
| 324 |
+
),
|
| 325 |
+
region="eu-central-1",
|
| 326 |
+
affected_users=240,
|
| 327 |
+
sla_minutes_remaining=55,
|
| 328 |
+
business_impact="A regulated healthcare audit review may slip tonight if the export path remains blocked.",
|
| 329 |
+
secondary_concerns=[
|
| 330 |
+
"The customer suggests an engineering workaround, but legal approval is the real gating factor."
|
| 331 |
+
],
|
| 332 |
+
attachments=["policy_block.png"],
|
| 333 |
+
),
|
| 334 |
+
knowledge_base=(
|
| 335 |
+
KnowledgeSnippet(
|
| 336 |
+
article_id="CMP-401",
|
| 337 |
+
title="Export policy exceptions",
|
| 338 |
+
content=(
|
| 339 |
+
"Requests to bypass region or export restrictions route to compliance_ops. "
|
| 340 |
+
"Do not promise a manual engineering workaround before approvals are verified."
|
| 341 |
+
),
|
| 342 |
+
),
|
| 343 |
+
KnowledgeSnippet(
|
| 344 |
+
article_id="CMP-402",
|
| 345 |
+
title="Minimum legal details for export review",
|
| 346 |
+
content=(
|
| 347 |
+
"Ask for the tenant_region, dpa_amendment_id, and legal_contact_email before "
|
| 348 |
+
"compliance can review an export exception."
|
| 349 |
+
),
|
| 350 |
+
),
|
| 351 |
+
KnowledgeSnippet(
|
| 352 |
+
article_id="CMP-403",
|
| 353 |
+
title="Customer response rules for regulated exceptions",
|
| 354 |
+
content=(
|
| 355 |
+
"Replies must explain that no temporary bypass can be granted yet, mention the "
|
| 356 |
+
"compliance review, and request the required legal approval details."
|
| 357 |
+
),
|
| 358 |
+
),
|
| 359 |
+
),
|
| 360 |
+
gold_queue="compliance_ops",
|
| 361 |
+
gold_priority="high",
|
| 362 |
+
gold_issue_type="regulated_exception",
|
| 363 |
+
gold_status="waiting_on_customer",
|
| 364 |
+
gold_resolution_code="legal_approval_required",
|
| 365 |
+
required_requested_fields=("tenant_region", "dpa_amendment_id", "legal_contact_email"),
|
| 366 |
+
required_reply_markers=(
|
| 367 |
+
("no temporary bypass", "cannot provide a bypass", "can’t provide a bypass"),
|
| 368 |
+
("compliance review", "compliance team"),
|
| 369 |
+
("tenant_region", "tenant region"),
|
| 370 |
+
("dpa_amendment_id", "dpa amendment", "amendment id"),
|
| 371 |
+
),
|
| 372 |
+
required_note_markers=(
|
| 373 |
+
("audit", "audit review"),
|
| 374 |
+
("eu residency", "policy block"),
|
| 375 |
+
("manual bypass", "workaround"),
|
| 376 |
+
),
|
| 377 |
+
forbidden_reply_markers=("engineering workaround", "disable the restriction", "temporary unlock approved"),
|
| 378 |
+
risk_flags=("regulated_data_risk", "unsafe_shortcut_pressure", "over_escalation_risk"),
|
| 379 |
+
follow_up_outcome="incorrect",
|
| 380 |
+
follow_up_message=(
|
| 381 |
+
"Customer follow-up: sent a screenshot and export job ID, but did not include the DPA "
|
| 382 |
+
"amendment ID or legal contact."
|
| 383 |
+
),
|
| 384 |
+
follow_up_wrong_fields=("screenshot", "job_id"),
|
| 385 |
+
sla_step_cost=16,
|
| 386 |
+
over_escalation_queues=("platform_engineering",),
|
| 387 |
+
max_steps=8,
|
| 388 |
),
|
| 389 |
}
|
| 390 |
|
tests/test_supportdesk.py
CHANGED
|
@@ -18,6 +18,7 @@ def test_all_tasks_are_registered():
|
|
| 18 |
"billing_refund_easy",
|
| 19 |
"account_takeover_medium",
|
| 20 |
"api_incident_hard",
|
|
|
|
| 21 |
]
|
| 22 |
|
| 23 |
|
|
@@ -25,6 +26,9 @@ def test_environment_reset_and_state():
|
|
| 25 |
env = SupportDeskEnvironment(task_id="billing_refund_easy")
|
| 26 |
observation = env.reset()
|
| 27 |
assert observation.task_id == "billing_refund_easy"
|
|
|
|
|
|
|
|
|
|
| 28 |
assert env.state.step_count == 0
|
| 29 |
assert env.state.current_score == 0.15
|
| 30 |
|
|
@@ -75,7 +79,7 @@ def test_max_steps_ends_episode():
|
|
| 75 |
|
| 76 |
|
| 77 |
def test_grade_is_bounded_between_zero_and_one():
|
| 78 |
-
task = get_task("
|
| 79 |
env = SupportDeskEnvironment(task_id=task.task_id)
|
| 80 |
env.reset()
|
| 81 |
breakdown = grade_case(task, env.state.case)
|
|
@@ -86,6 +90,46 @@ def test_state_includes_episode_id_after_reset():
|
|
| 86 |
env = SupportDeskEnvironment(task_id="billing_refund_easy")
|
| 87 |
env.reset(episode_id="episode-123")
|
| 88 |
assert env.state.episode_id == "episode-123"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
|
| 91 |
@pytest.mark.skipif(TestClient is None, reason="httpx is not installed for FastAPI TestClient")
|
|
|
|
| 18 |
"billing_refund_easy",
|
| 19 |
"account_takeover_medium",
|
| 20 |
"api_incident_hard",
|
| 21 |
+
"regulated_export_exception_hard",
|
| 22 |
]
|
| 23 |
|
| 24 |
|
|
|
|
| 26 |
env = SupportDeskEnvironment(task_id="billing_refund_easy")
|
| 27 |
observation = env.reset()
|
| 28 |
assert observation.task_id == "billing_refund_easy"
|
| 29 |
+
assert observation.workflow_stage == "intake"
|
| 30 |
+
assert "classify" in observation.required_next_actions
|
| 31 |
+
assert observation.current_sla_minutes_remaining == 240
|
| 32 |
assert env.state.step_count == 0
|
| 33 |
assert env.state.current_score == 0.15
|
| 34 |
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
def test_grade_is_bounded_between_zero_and_one():
|
| 82 |
+
task = get_task("regulated_export_exception_hard")
|
| 83 |
env = SupportDeskEnvironment(task_id=task.task_id)
|
| 84 |
env.reset()
|
| 85 |
breakdown = grade_case(task, env.state.case)
|
|
|
|
| 90 |
env = SupportDeskEnvironment(task_id="billing_refund_easy")
|
| 91 |
env.reset(episode_id="episode-123")
|
| 92 |
assert env.state.episode_id == "episode-123"
|
| 93 |
+
assert env.state.workflow_stage == "intake"
|
| 94 |
+
assert "finance_close_risk" in env.state.risk_flags
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_premature_submit_gets_penalized():
|
| 98 |
+
env = SupportDeskEnvironment(task_id="api_incident_hard")
|
| 99 |
+
env.reset()
|
| 100 |
+
observation = env.step(
|
| 101 |
+
SupportDeskAction(
|
| 102 |
+
operation="submit",
|
| 103 |
+
status="resolved",
|
| 104 |
+
resolution_code="incident_opened",
|
| 105 |
+
)
|
| 106 |
+
)
|
| 107 |
+
assert observation.reward < 0
|
| 108 |
+
assert observation.done is True
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_follow_up_arrives_after_wait():
|
| 112 |
+
env = SupportDeskEnvironment(task_id="account_takeover_medium")
|
| 113 |
+
env.reset()
|
| 114 |
+
env.step(
|
| 115 |
+
SupportDeskAction(
|
| 116 |
+
operation="classify",
|
| 117 |
+
queue="trust_and_safety",
|
| 118 |
+
priority="urgent",
|
| 119 |
+
issue_type="account_compromise",
|
| 120 |
+
)
|
| 121 |
+
)
|
| 122 |
+
observation = env.step(
|
| 123 |
+
SupportDeskAction(
|
| 124 |
+
operation="request_info",
|
| 125 |
+
requested_fields=["workspace_id", "last_successful_login", "billing_email"],
|
| 126 |
+
)
|
| 127 |
+
)
|
| 128 |
+
assert observation.case.customer_follow_up.status == "pending"
|
| 129 |
+
|
| 130 |
+
observation = env.step(SupportDeskAction(operation="wait"))
|
| 131 |
+
assert observation.case.customer_follow_up.status == "partial"
|
| 132 |
+
assert "customer_reply_incomplete" in observation.risk_flags
|
| 133 |
|
| 134 |
|
| 135 |
@pytest.mark.skipif(TestClient is None, reason="httpx is not installed for FastAPI TestClient")
|
uv.lock
CHANGED
|
@@ -475,6 +475,124 @@ wheels = [
|
|
| 475 |
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
| 476 |
]
|
| 477 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
[[package]]
|
| 479 |
name = "cryptography"
|
| 480 |
version = "46.0.6"
|
|
@@ -1471,6 +1589,15 @@ wheels = [
|
|
| 1471 |
{ url = "https://files.pythonhosted.org/packages/2f/22/38c339e370d198008f2c17ebdda1ae8f23bb4e1509dc7ae8eab6dc9b9cbe/openenv_core-0.2.3-py3-none-any.whl", hash = "sha256:f75a20c94452057a5f53a86e6d71a9f6a461524c3d6a865aa9344d257a92b795", size = 174557, upload-time = "2026-03-28T18:56:26.874Z" },
|
| 1472 |
]
|
| 1473 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1474 |
[[package]]
|
| 1475 |
name = "opentelemetry-api"
|
| 1476 |
version = "1.40.0"
|
|
@@ -2082,6 +2209,20 @@ wheels = [
|
|
| 2082 |
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
| 2083 |
]
|
| 2084 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2085 |
[[package]]
|
| 2086 |
name = "python-dateutil"
|
| 2087 |
version = "2.9.0.post0"
|
|
@@ -2487,7 +2628,7 @@ source = { editable = "." }
|
|
| 2487 |
dependencies = [
|
| 2488 |
{ name = "fastapi" },
|
| 2489 |
{ name = "openai" },
|
| 2490 |
-
{ name = "openenv-core" },
|
| 2491 |
{ name = "pydantic" },
|
| 2492 |
{ name = "requests" },
|
| 2493 |
{ name = "uvicorn" },
|
|
@@ -2496,15 +2637,17 @@ dependencies = [
|
|
| 2496 |
[package.optional-dependencies]
|
| 2497 |
dev = [
|
| 2498 |
{ name = "pytest" },
|
|
|
|
| 2499 |
]
|
| 2500 |
|
| 2501 |
[package.metadata]
|
| 2502 |
requires-dist = [
|
| 2503 |
{ name = "fastapi", specifier = ">=0.115.0" },
|
| 2504 |
{ name = "openai", specifier = ">=1.54.0" },
|
| 2505 |
-
{ name = "openenv-core", specifier = ">=0.2.
|
| 2506 |
{ name = "pydantic", specifier = ">=2.9.0" },
|
| 2507 |
-
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.
|
|
|
|
| 2508 |
{ name = "requests", specifier = ">=2.32.0" },
|
| 2509 |
{ name = "uvicorn", specifier = ">=0.30.0" },
|
| 2510 |
]
|
|
|
|
| 475 |
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
| 476 |
]
|
| 477 |
|
| 478 |
+
[[package]]
|
| 479 |
+
name = "coverage"
|
| 480 |
+
version = "7.13.5"
|
| 481 |
+
source = { registry = "https://pypi.org/simple" }
|
| 482 |
+
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
|
| 483 |
+
wheels = [
|
| 484 |
+
{ url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" },
|
| 485 |
+
{ url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" },
|
| 486 |
+
{ url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" },
|
| 487 |
+
{ url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" },
|
| 488 |
+
{ url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" },
|
| 489 |
+
{ url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" },
|
| 490 |
+
{ url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" },
|
| 491 |
+
{ url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" },
|
| 492 |
+
{ url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" },
|
| 493 |
+
{ url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" },
|
| 494 |
+
{ url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" },
|
| 495 |
+
{ url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" },
|
| 496 |
+
{ url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" },
|
| 497 |
+
{ url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" },
|
| 498 |
+
{ url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
|
| 499 |
+
{ url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
|
| 500 |
+
{ url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
|
| 501 |
+
{ url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
|
| 502 |
+
{ url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
|
| 503 |
+
{ url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
|
| 504 |
+
{ url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
|
| 505 |
+
{ url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
|
| 506 |
+
{ url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
|
| 507 |
+
{ url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
|
| 508 |
+
{ url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
|
| 509 |
+
{ url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
|
| 510 |
+
{ url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
|
| 511 |
+
{ url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
|
| 512 |
+
{ url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
|
| 513 |
+
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
|
| 514 |
+
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
|
| 515 |
+
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
|
| 516 |
+
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
|
| 517 |
+
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
|
| 518 |
+
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
|
| 519 |
+
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
|
| 520 |
+
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
|
| 521 |
+
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
|
| 522 |
+
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
|
| 523 |
+
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
|
| 524 |
+
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
|
| 525 |
+
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
|
| 526 |
+
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
|
| 527 |
+
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
|
| 528 |
+
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
|
| 529 |
+
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
|
| 530 |
+
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
|
| 531 |
+
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
|
| 532 |
+
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
|
| 533 |
+
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
|
| 534 |
+
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
|
| 535 |
+
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
|
| 536 |
+
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
|
| 537 |
+
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
|
| 538 |
+
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
|
| 539 |
+
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
|
| 540 |
+
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
|
| 541 |
+
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
|
| 542 |
+
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
|
| 543 |
+
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
|
| 544 |
+
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
|
| 545 |
+
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
|
| 546 |
+
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
|
| 547 |
+
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
|
| 548 |
+
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
|
| 549 |
+
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
|
| 550 |
+
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
|
| 551 |
+
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
|
| 552 |
+
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
|
| 553 |
+
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
|
| 554 |
+
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
|
| 555 |
+
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
|
| 556 |
+
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
|
| 557 |
+
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
|
| 558 |
+
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
|
| 559 |
+
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
|
| 560 |
+
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
|
| 561 |
+
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
|
| 562 |
+
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
|
| 563 |
+
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
|
| 564 |
+
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
|
| 565 |
+
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
|
| 566 |
+
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
|
| 567 |
+
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
|
| 568 |
+
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
|
| 569 |
+
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
|
| 570 |
+
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
|
| 571 |
+
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
|
| 572 |
+
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
|
| 573 |
+
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
|
| 574 |
+
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
|
| 575 |
+
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
|
| 576 |
+
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
|
| 577 |
+
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
|
| 578 |
+
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
|
| 579 |
+
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
|
| 580 |
+
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
|
| 581 |
+
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
|
| 582 |
+
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
|
| 583 |
+
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
|
| 584 |
+
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
|
| 585 |
+
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
|
| 586 |
+
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
|
| 587 |
+
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
|
| 588 |
+
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
| 589 |
+
]
|
| 590 |
+
|
| 591 |
+
[package.optional-dependencies]
|
| 592 |
+
toml = [
|
| 593 |
+
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
| 594 |
+
]
|
| 595 |
+
|
| 596 |
[[package]]
|
| 597 |
name = "cryptography"
|
| 598 |
version = "46.0.6"
|
|
|
|
| 1589 |
{ url = "https://files.pythonhosted.org/packages/2f/22/38c339e370d198008f2c17ebdda1ae8f23bb4e1509dc7ae8eab6dc9b9cbe/openenv_core-0.2.3-py3-none-any.whl", hash = "sha256:f75a20c94452057a5f53a86e6d71a9f6a461524c3d6a865aa9344d257a92b795", size = 174557, upload-time = "2026-03-28T18:56:26.874Z" },
|
| 1590 |
]
|
| 1591 |
|
| 1592 |
+
[package.optional-dependencies]
|
| 1593 |
+
core = [
|
| 1594 |
+
{ name = "fastapi" },
|
| 1595 |
+
{ name = "pydantic" },
|
| 1596 |
+
{ name = "requests" },
|
| 1597 |
+
{ name = "uvicorn" },
|
| 1598 |
+
{ name = "websockets" },
|
| 1599 |
+
]
|
| 1600 |
+
|
| 1601 |
[[package]]
|
| 1602 |
name = "opentelemetry-api"
|
| 1603 |
version = "1.40.0"
|
|
|
|
| 2209 |
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
| 2210 |
]
|
| 2211 |
|
| 2212 |
+
[[package]]
|
| 2213 |
+
name = "pytest-cov"
|
| 2214 |
+
version = "7.1.0"
|
| 2215 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2216 |
+
dependencies = [
|
| 2217 |
+
{ name = "coverage", extra = ["toml"] },
|
| 2218 |
+
{ name = "pluggy" },
|
| 2219 |
+
{ name = "pytest" },
|
| 2220 |
+
]
|
| 2221 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
| 2222 |
+
wheels = [
|
| 2223 |
+
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
| 2224 |
+
]
|
| 2225 |
+
|
| 2226 |
[[package]]
|
| 2227 |
name = "python-dateutil"
|
| 2228 |
version = "2.9.0.post0"
|
|
|
|
| 2628 |
dependencies = [
|
| 2629 |
{ name = "fastapi" },
|
| 2630 |
{ name = "openai" },
|
| 2631 |
+
{ name = "openenv-core", extra = ["core"] },
|
| 2632 |
{ name = "pydantic" },
|
| 2633 |
{ name = "requests" },
|
| 2634 |
{ name = "uvicorn" },
|
|
|
|
| 2637 |
[package.optional-dependencies]
|
| 2638 |
dev = [
|
| 2639 |
{ name = "pytest" },
|
| 2640 |
+
{ name = "pytest-cov" },
|
| 2641 |
]
|
| 2642 |
|
| 2643 |
[package.metadata]
|
| 2644 |
requires-dist = [
|
| 2645 |
{ name = "fastapi", specifier = ">=0.115.0" },
|
| 2646 |
{ name = "openai", specifier = ">=1.54.0" },
|
| 2647 |
+
{ name = "openenv-core", extras = ["core"], specifier = ">=0.2.2" },
|
| 2648 |
{ name = "pydantic", specifier = ">=2.9.0" },
|
| 2649 |
+
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
| 2650 |
+
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" },
|
| 2651 |
{ name = "requests", specifier = ">=2.32.0" },
|
| 2652 |
{ name = "uvicorn", specifier = ">=0.30.0" },
|
| 2653 |
]
|