modelbuilderhq commited on
Commit
9d47369
·
verified ·
1 Parent(s): 26d8840

Upload folder using huggingface_hub

Browse files
Dockerfile CHANGED
@@ -1,14 +1,83 @@
1
- FROM python:3.11-slim
 
 
 
 
2
 
3
- ENV PYTHONDONTWRITEBYTECODE=1
4
- ENV PYTHONUNBUFFERED=1
 
 
 
 
 
 
5
 
6
  WORKDIR /app
7
 
8
- COPY . /app
9
- RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r /app/requirements.txt
 
 
10
 
11
- EXPOSE 8000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  ENV ENABLE_WEB_INTERFACE=true
14
- CMD ["python", "-m", "supportdesk_env.server.app"]
 
 
 
 
 
 
 
 
 
 
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
- SupportDesk is best thought of as an enterprise operations-desk environment, not a generic support classifier.
15
 
16
- SupportDesk is a real-world RL environment for enterprise support operations. The agent receives a realistic inbound ticket, a small internal knowledge base, and the live case state. It must route the case, set the right priority, decide whether to request more information, draft the customer response, add an internal note, and submit the case with the correct final status.
17
 
18
- One-sentence summary: HyperBrickCaseOps is a deterministic OpenEnv customer-support operations environment that evaluates whether an agent can triage, communicate, escalate, and resolve enterprise cases correctly end to end.
19
 
20
- This environment is intentionally built around work humans actually do every day in B2B SaaS support queues. It is not a toy chat task and it is not a game. The environment includes enterprise mechanics such as SLA countdowns, business-impact context, and distracting secondary concerns, so the agent has to prioritize the primary operational issue instead of just pattern-matching keywords.
21
 
22
- ## Environment Description and Motivation
23
 
24
- The goal of this environment is to model a real operational gap in agent evaluation: many support benchmarks only test whether a model can produce a plausible reply, but real support work also requires correct routing, escalation, information gathering, and final disposition decisions. SupportDesk is designed to evaluate whether an agent can handle enterprise support operations end to end rather than just generate support-sounding text.
25
 
26
- This makes the environment useful for both:
 
 
 
 
 
27
 
28
- - training agents to improve multi-step support operations behavior
29
- - evaluating whether an agent can make safe and business-correct support decisions under pressure
30
 
31
- ## Why this should score well
32
 
33
- - Real-world utility: customer support triage is a real production workflow with immediate evaluation value.
34
- - Deterministic grading: every task has an explicit gold queue, priority, issue type, required follow-up fields, reply markers, note markers, status, and resolution code.
35
- - Dense rewards: each step gets rewarded from the delta in the deterministic grader, which gives partial progress rather than only a binary terminal signal.
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
- ## Architecture Diagram
40
 
41
- ```text
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
- ## Action Space
79
 
80
- Each `step()` takes a typed `SupportDeskAction` with:
81
 
82
- - `operation`: one of `classify`, `request_info`, `draft_reply`, `add_internal_note`, `submit`
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
- The environment allows the agent to update multiple fields in one structured action, which keeps the workflow realistic and helps training.
93
-
94
- ## Observation Space
95
-
96
- Each observation contains:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
- - `task_id`, `difficulty`, and the agent objective
99
- - the inbound `ticket`
100
- - ticket-level urgency metadata such as `affected_users`, `sla_minutes_remaining`, `business_impact`, and `secondary_concerns`
101
- - `knowledge_base` policy snippets
102
- - allowed queues, priorities, statuses, and issue types
103
- - the mutable `case` snapshot
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  - `action_history`
105
  - `feedback`
106
  - `remaining_steps`
107
- - the standard OpenEnv `reward` and `done`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
- ## OpenEnv Interface
110
 
111
- The environment implements the standard OpenEnv API:
112
 
113
- - `reset()` returns the initial typed observation for a new case
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 implementation uses typed Pydantic models for action, observation, and state.
119
 
120
- ## Task Descriptions with Expected Difficulty
121
 
122
- 1. `billing_refund_easy` - Expected difficulty: easy
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
- What makes these tasks less generic than ordinary support-routing demos:
130
 
131
- - They mix queueing, priority, customer communication, internal note-taking, and close-vs-escalate decisions in one trajectory.
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
- ## Deterministic Graders
136
 
137
- The final task score is a weighted total in `[0.0, 1.0]`:
138
 
139
- - Queue correctness: `0.15`
140
- - Priority correctness: `0.10`
141
- - Issue-type correctness: `0.10`
142
- - Requested-fields correctness: `0.15`
143
- - Reply coverage: `0.25`
144
- - Internal-note coverage: `0.10`
145
- - Final status: `0.10`
146
- - Resolution code: `0.05`
147
 
148
- The same grader also drives dense reward shaping during the episode by comparing the current score to the previous score and then subtracting small penalties for no-op or low-signal actions.
149
 
150
- ## Project Layout
 
 
 
 
 
 
 
 
 
 
151
 
152
  ```text
153
  .
154
  |-- inference.py
155
  |-- openenv.yaml
156
  |-- pyproject.toml
157
- |-- requirements.txt
 
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
- `-- uv.lock
 
 
170
  ```
171
 
172
- ## Local Setup
 
 
173
 
174
  ```bash
175
  pip install -r requirements.txt
176
  ```
177
 
178
- Or with uv:
179
 
180
  ```bash
181
  uv sync
182
  ```
183
 
184
- Optional environment variables for the baseline:
 
 
185
 
186
  ```bash
187
- export API_BASE_URL="https://router.huggingface.co/v1"
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
- The baseline uses the OpenAI Python client and supports both `OPENAI_API_KEY` and `HF_TOKEN`.
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
- ## Local RL Playground
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
- python examples/rl/train_q_agent.py
212
  ```
213
 
214
- This script imports the package, instantiates `SupportDeskEnvironment` directly, trains a tiny Q-learning agent over a compact discrete action library, and then prints greedy evaluation results for all three tasks. It is meant as a local experimentation playground, not as the official submission baseline.
215
-
216
- ## Run the Server
217
 
218
  ```bash
219
- python -m supportdesk_env.server.app
220
  ```
221
 
222
- Or with the OpenEnv entrypoint:
223
 
224
  ```bash
225
- server
226
  ```
227
 
228
- ## Run the Baseline
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
  ```bash
 
 
 
231
  python inference.py
232
  ```
233
 
234
- When model credentials are present, the script uses the OpenAI client against `API_BASE_URL` and `MODEL_NAME`. If credentials are missing or a request fails, it falls back to a deterministic heuristic policy so the script still completes and prints reproducible scores.
 
 
 
 
235
 
236
  ## Docker
237
 
 
 
238
  ```bash
239
  docker build -t supportdesk-env .
240
- docker run -p 8000:8000 supportdesk-env
241
  ```
242
 
243
- ## Hugging Face Space Deployment
244
 
245
- Deploy this repo as a Docker Space and keep it public for submission. The Space should include the `openenv` tag and the following environment configuration values:
 
 
246
 
247
- - `API_BASE_URL`
248
- - `MODEL_NAME`
249
- - `HF_TOKEN`
250
 
251
- If the OpenEnv CLI is installed, deployment can be done with:
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
- For a full pre-submission pass against a deployed Space:
 
 
 
 
 
 
264
 
265
  ```bash
266
  ./scripts/validate-submission.sh https://your-space.hf.space .
267
  ```
268
 
269
- ## Submission Checklist
270
 
271
- - Public GitHub repository with this codebase
272
- - Root `inference.py`
273
- - Working Docker build
274
- - Deployed Hugging Face Docker Space tagged `openenv`
275
- - Space secrets configured: `API_BASE_URL`, `MODEL_NAME`, `HF_TOKEN`
276
- - README present with environment overview, action/observation definitions, tasks, setup, and baseline scores
 
 
 
277
 
278
- ## Baseline Scores
279
 
280
- Expected deterministic fallback baseline:
281
 
282
  - `billing_refund_easy`: `1.00`
283
  - `account_takeover_medium`: `1.00`
284
  - `api_incident_hard`: `1.00`
285
- - Average: `1.00`
 
286
 
287
- These scores are deliberately reproducible because the fallback policy follows the gold workflow exactly. A model-backed run will typically be lower unless the prompt or model is improved, which makes the environment useful for both training and evaluation.
 
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 statistics import mean
 
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
- MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4.1-mini")
39
- API_BASE_URL = os.getenv("API_BASE_URL")
40
- API_KEY = os.getenv("OPENAI_API_KEY") or os.getenv("HF_TOKEN") or "not-set"
41
- MAX_STEPS = int(os.getenv("MAX_STEPS", "6"))
42
- TEMPERATURE = float(os.getenv("TEMPERATURE", "0"))
 
 
 
43
 
44
 
45
  def _build_client() -> OpenAI | None:
46
- if OpenAI is None:
47
  return None
48
- if API_KEY == "not-set":
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}" for snippet in observation.knowledge_base
 
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.ticket.sla_minutes_remaining}
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 run_task(task_id: str, client: OpenAI | None) -> float:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  env = SupportDeskEnvironment(task_id=task_id)
129
  observation = env.reset()
 
 
130
 
131
  try:
132
- for _ in range(MAX_STEPS):
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
- print(f"{task_id}: score={final_grade.total_score:.2f} reward={env.state.reward:.2f}")
139
- return final_grade.total_score
 
 
 
140
  finally:
141
  env.close()
142
 
143
 
144
- def main() -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  client = _build_client()
146
- scores = [run_task(task_id, client) for task_id in list_task_ids()]
147
- print(f"average_score={mean(scores):.3f}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = "A real-world OpenEnv environment for customer support triage and escalation."
 
5
  authors = [{ name = "HyperBrick" }]
6
  dependencies = [
7
- "fastapi>=0.115.0",
8
- "openai>=1.54.0",
9
- "openenv-core>=0.2.0",
10
- "pydantic>=2.9.0",
11
- "requests>=2.32.0",
12
- "uvicorn>=0.30.0",
 
 
13
  ]
14
- requires-python = ">=3.10"
15
 
16
  [project.optional-dependencies]
17
  dev = [
18
- "pytest>=8.3.0",
 
19
  ]
20
 
21
  [project.scripts]
22
- server = "main:main"
23
-
24
- [build-system]
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
- """FastAPI app entrypoint for the SupportDesk environment."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- action_cls=SupportDeskAction,
25
- observation_cls=SupportDeskObservation,
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
- """Run the local HTTP server."""
 
 
 
 
 
 
 
 
 
 
66
 
67
- port = int(os.getenv("PORT", "8000"))
68
- uvicorn.run("supportdesk_env.server.app:app", host="0.0.0.0", port=port)
 
 
 
 
69
 
70
 
71
  if __name__ == "__main__":
72
- main()
 
 
 
 
 
 
 
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
- requested_task = task_id or os.getenv("SUPPORTDESK_TASK_ID") or list_task_ids()[0]
 
 
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 support operations environment with deterministic "
223
- "grading, dense rewards, SLA pressure, and escalating ticket difficulty."
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.queue is not None:
232
- self._case.queue = action.queue
233
- if action.priority is not None:
234
- self._case.priority = action.priority
235
- if action.issue_type is not None:
236
- self._case.issue_type = action.issue_type
237
- if action.status is not None:
238
- self._case.status = action.status
239
- if action.resolution_code is not None:
240
- self._case.resolution_code = action.resolution_code
241
- if action.reply is not None:
242
- self._case.reply = action.reply
243
- if action.internal_note is not None:
244
- self._case.internal_note = action.internal_note
245
- if action.requested_fields:
246
- merged = {item for item in self._case.requested_fields}
247
- merged.update(action.requested_fields)
248
- self._case.requested_fields = sorted(merged)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = ["billing_ops", "trust_and_safety", "platform_engineering", "general_support"]
 
 
 
 
 
 
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", "cant get back", "cannot get back"),
 
 
 
 
 
 
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("api_incident_hard")
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.0" },
2506
  { name = "pydantic", specifier = ">=2.9.0" },
2507
- { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" },
 
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
  ]