Spaces:
Sleeping
Sleeping
Aditya Guntur commited on
Commit ·
1f213fe
1
Parent(s): 48d089d
base iteration
Browse files- .dockerignore +10 -0
- .gitignore +14 -0
- Dockerfile +11 -0
- README.md +0 -0
- __init__.py +0 -0
- client.py +14 -0
- inference.py +122 -0
- models.py +49 -0
- openenv.yaml +6 -0
- pyproject.toml +25 -0
- server/__init__.py +0 -0
- server/app.py +30 -0
- server/apps/__init__.py +0 -0
- server/apps/chat.py +48 -0
- server/apps/codebase.py +60 -0
- server/apps/ticketing.py +107 -0
- server/pm_ops_environment.py +202 -0
- server/tasks/__init__.py +0 -0
- server/tasks/base_task.py +8 -0
- server/tasks/dep_update_task.py +40 -0
- server/tasks/incident_routing_task.py +33 -0
- server/tasks/release_notes_task.py +42 -0
- server/tasks/triage_task.py +39 -0
- server/verifiers/__init__.py +0 -0
- server/verifiers/state_verifier.py +23 -0
- server/world/__init__.py +0 -0
- server/world/org_generator.py +76 -0
- server/world/scenario_gen.py +98 -0
- uv.lock +0 -0
.dockerignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
*.pyo
|
| 6 |
+
.git/
|
| 7 |
+
.gitignore
|
| 8 |
+
*.md
|
| 9 |
+
.env
|
| 10 |
+
pdf_pages/
|
.gitignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
*.pyo
|
| 6 |
+
*.pyd
|
| 7 |
+
.env
|
| 8 |
+
.claude/
|
| 9 |
+
*.log
|
| 10 |
+
pdf_pages/
|
| 11 |
+
*.egg-info/
|
| 12 |
+
dist/
|
| 13 |
+
build/
|
| 14 |
+
.pytest_cache/
|
Dockerfile
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM ghcr.io/meta-pytorch/openenv-base:latest
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
COPY . .
|
| 5 |
+
|
| 6 |
+
RUN uv sync --frozen
|
| 7 |
+
|
| 8 |
+
EXPOSE 8000
|
| 9 |
+
|
| 10 |
+
CMD ["uv", "run", "uvicorn", "server.app:app", \
|
| 11 |
+
"--host", "0.0.0.0", "--port", "8000"]
|
README.md
CHANGED
|
Binary files a/README.md and b/README.md differ
|
|
|
__init__.py
ADDED
|
File without changes
|
client.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenEnv client wrapper for PM-Ops environment."""
|
| 2 |
+
import os
|
| 3 |
+
from openenv.core.client import EnvClient
|
| 4 |
+
from pm_ops.models import PMOpsAction, PMOpsObservation
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class PMOpsEnv(EnvClient):
|
| 8 |
+
action_class = PMOpsAction
|
| 9 |
+
observation_class = PMOpsObservation
|
| 10 |
+
|
| 11 |
+
def __init__(self, base_url: str = None, token: str = None):
|
| 12 |
+
url = base_url or os.getenv("API_BASE_URL", "https://adityaguntur-pm-ops.hf.space")
|
| 13 |
+
tok = token or os.getenv("HF_TOKEN")
|
| 14 |
+
super().__init__(base_url=url, token=tok)
|
inference.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Baseline inference script for PM-Ops environment."""
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import requests
|
| 5 |
+
|
| 6 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://adityaguntur-pm-ops.hf.space")
|
| 7 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "claude-opus-4-5")
|
| 8 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 9 |
+
LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME")
|
| 10 |
+
|
| 11 |
+
MAX_STEPS = 40
|
| 12 |
+
TASK_IDS = ["triage", "incident_routing", "release_notes", "dep_update"]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def call_action(session, base_url, action_type, args=None):
|
| 16 |
+
payload = {"action": {"action_type": action_type, "args": args or {}}}
|
| 17 |
+
resp = session.post(f"{base_url}/step", json=payload)
|
| 18 |
+
resp.raise_for_status()
|
| 19 |
+
return resp.json()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def baseline_agent(obs, session, base_url):
|
| 23 |
+
"""Simple heuristic baseline: read runbook, then create ticket + notify."""
|
| 24 |
+
step = obs.get("step", 0)
|
| 25 |
+
task_brief = obs.get("task_brief", "")
|
| 26 |
+
|
| 27 |
+
if step == 0:
|
| 28 |
+
return "meta.read_runbook", {}
|
| 29 |
+
|
| 30 |
+
last = obs.get("last_action_result", {})
|
| 31 |
+
data = last.get("data", {})
|
| 32 |
+
|
| 33 |
+
if step == 1 and isinstance(data, dict) and "org_config" in data:
|
| 34 |
+
org = data["org_config"]
|
| 35 |
+
label_taxonomy = org.get("label_taxonomy", {})
|
| 36 |
+
priority_levels = org.get("priority_levels", ["P1"])
|
| 37 |
+
label = list(label_taxonomy.values())[0] if label_taxonomy else "bug"
|
| 38 |
+
priority = priority_levels[1] if len(priority_levels) > 1 else priority_levels[0]
|
| 39 |
+
return "ticketing.create_ticket", {
|
| 40 |
+
"summary": f"Issue from task: {task_brief[:80]}",
|
| 41 |
+
"description": task_brief,
|
| 42 |
+
"label": label,
|
| 43 |
+
"priority": priority,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
if step == 2 and isinstance(data, dict) and "id" in data:
|
| 47 |
+
ticket_id = data["id"]
|
| 48 |
+
return "ticketing.assign_ticket", {"ticket_id": ticket_id, "team": "backend"}
|
| 49 |
+
|
| 50 |
+
if step == 3:
|
| 51 |
+
last_result = obs.get("last_action_result", {})
|
| 52 |
+
org_data = {}
|
| 53 |
+
return "chat.list_channels", {}
|
| 54 |
+
|
| 55 |
+
if step == 4:
|
| 56 |
+
channels = last.get("data", [])
|
| 57 |
+
if channels:
|
| 58 |
+
channel = channels[0]
|
| 59 |
+
return "chat.post_message", {
|
| 60 |
+
"channel": channel,
|
| 61 |
+
"text": f"Incident notification: {task_brief[:120]}",
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
return "meta.finish", {}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def run_episode(task_id, session, base_url):
|
| 68 |
+
resp = session.post(f"{base_url}/reset", json={})
|
| 69 |
+
resp.raise_for_status()
|
| 70 |
+
obs = resp.json()
|
| 71 |
+
|
| 72 |
+
rewards = []
|
| 73 |
+
n = 0
|
| 74 |
+
done = False
|
| 75 |
+
success = False
|
| 76 |
+
score = 0.0
|
| 77 |
+
|
| 78 |
+
print(f"[START] task={task_id} env=pm_ops model={MODEL_NAME}", flush=True)
|
| 79 |
+
|
| 80 |
+
while not done and n < MAX_STEPS:
|
| 81 |
+
action_type, args = baseline_agent(obs, session, base_url)
|
| 82 |
+
action_str = json.dumps({"action_type": action_type, "args": args})
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
result = call_action(session, base_url, action_type, args)
|
| 86 |
+
r = float(result.get("reward", 0.0))
|
| 87 |
+
done = bool(result.get("done", False))
|
| 88 |
+
err = result.get("last_action_result", {}).get("error", "none")
|
| 89 |
+
obs = result
|
| 90 |
+
except Exception as e:
|
| 91 |
+
r = 0.0
|
| 92 |
+
done = True
|
| 93 |
+
err = str(e)
|
| 94 |
+
|
| 95 |
+
rewards.append(r)
|
| 96 |
+
n += 1
|
| 97 |
+
print(f"[STEP] step={n} action={action_type} reward={r:.2f} done={done} error={err}", flush=True)
|
| 98 |
+
|
| 99 |
+
if done:
|
| 100 |
+
score = r
|
| 101 |
+
success = r > 0.5
|
| 102 |
+
|
| 103 |
+
rewards_str = ",".join(f"{x:.2f}" for x in rewards)
|
| 104 |
+
print(f"[END] success={success} steps={n} score={score:.2f} rewards={rewards_str}", flush=True)
|
| 105 |
+
return score
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def main():
|
| 109 |
+
session = requests.Session()
|
| 110 |
+
if HF_TOKEN:
|
| 111 |
+
session.headers["Authorization"] = f"Bearer {HF_TOKEN}"
|
| 112 |
+
|
| 113 |
+
total_score = 0.0
|
| 114 |
+
for task_id in TASK_IDS:
|
| 115 |
+
score = run_episode(task_id, session, API_BASE_URL)
|
| 116 |
+
total_score += score
|
| 117 |
+
|
| 118 |
+
print(f"[SUMMARY] avg_score={total_score / len(TASK_IDS):.3f}", flush=True)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
if __name__ == "__main__":
|
| 122 |
+
main()
|
models.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List, Literal, Optional
|
| 2 |
+
from pydantic import Field
|
| 3 |
+
from openenv.core.env_server.types import Action, Observation, State
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class PMOpsAction(Action):
|
| 7 |
+
action_type: Literal[
|
| 8 |
+
"ticketing.create_ticket",
|
| 9 |
+
"ticketing.update_ticket",
|
| 10 |
+
"ticketing.get_ticket",
|
| 11 |
+
"ticketing.list_tickets",
|
| 12 |
+
"ticketing.assign_ticket",
|
| 13 |
+
"ticketing.comment_ticket",
|
| 14 |
+
"ticketing.transition_ticket",
|
| 15 |
+
"codebase.list_commits",
|
| 16 |
+
"codebase.get_commit",
|
| 17 |
+
"codebase.list_prs",
|
| 18 |
+
"chat.post_message",
|
| 19 |
+
"chat.read_channel",
|
| 20 |
+
"chat.list_channels",
|
| 21 |
+
"chat.search",
|
| 22 |
+
"meta.read_runbook",
|
| 23 |
+
"meta.finish",
|
| 24 |
+
"meta.noop",
|
| 25 |
+
] = Field(default="meta.noop")
|
| 26 |
+
args: Dict[str, Any] = Field(default_factory=dict)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class PMOpsObservation(Observation):
|
| 30 |
+
step: int = Field(default=0)
|
| 31 |
+
max_steps: int = Field(default=40)
|
| 32 |
+
task_brief: str = Field(default="")
|
| 33 |
+
last_action_result: Dict[str, Any] = Field(default_factory=dict)
|
| 34 |
+
app_state_deltas: Dict[str, List[Any]] = Field(
|
| 35 |
+
default_factory=lambda: {"ticketing": [], "chat": [], "codebase": []}
|
| 36 |
+
)
|
| 37 |
+
steps_remaining: int = Field(default=40)
|
| 38 |
+
token_budget_remaining: int = Field(default=12000)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class PMOpsState(State):
|
| 42 |
+
org_config: Dict[str, Any] = Field(default_factory=dict)
|
| 43 |
+
task_config: Dict[str, Any] = Field(default_factory=dict)
|
| 44 |
+
ticketing: Dict[str, Any] = Field(default_factory=dict)
|
| 45 |
+
chat: Dict[str, Any] = Field(default_factory=dict)
|
| 46 |
+
codebase: Dict[str, Any] = Field(default_factory=dict)
|
| 47 |
+
step_count: int = Field(default=0)
|
| 48 |
+
finished: bool = Field(default=False)
|
| 49 |
+
episode_id: str = Field(default="")
|
openenv.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
+
name: pm_ops
|
| 3 |
+
type: space
|
| 4 |
+
runtime: fastapi
|
| 5 |
+
app: server.app:app
|
| 6 |
+
port: 8000
|
pyproject.toml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68", "wheel"]
|
| 3 |
+
build-backend = "setuptools.backends.legacy:build"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "pm_ops"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
requires-python = ">=3.11"
|
| 9 |
+
dependencies = [
|
| 10 |
+
"openenv-core>=0.2.2",
|
| 11 |
+
"fastapi>=0.110.0",
|
| 12 |
+
"uvicorn[standard]>=0.29.0",
|
| 13 |
+
"pydantic>=2.0.0",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
[tool.setuptools]
|
| 17 |
+
packages = ["pm_ops", "pm_ops.server", "pm_ops.server.apps", "pm_ops.server.world", "pm_ops.server.tasks", "pm_ops.server.verifiers"]
|
| 18 |
+
|
| 19 |
+
[tool.setuptools.package-dir]
|
| 20 |
+
"pm_ops" = "."
|
| 21 |
+
"pm_ops.server" = "server"
|
| 22 |
+
"pm_ops.server.apps" = "server/apps"
|
| 23 |
+
"pm_ops.server.world" = "server/world"
|
| 24 |
+
"pm_ops.server.tasks" = "server/tasks"
|
| 25 |
+
"pm_ops.server.verifiers" = "server/verifiers"
|
server/__init__.py
ADDED
|
File without changes
|
server/app.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from openenv.core.env_server.http_server import create_app
|
| 2 |
+
from pm_ops.models import PMOpsAction, PMOpsObservation
|
| 3 |
+
from pm_ops.server.pm_ops_environment import PMOpsEnvironment
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def create_environment():
|
| 7 |
+
return PMOpsEnvironment()
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
app = create_app(
|
| 11 |
+
create_environment,
|
| 12 |
+
PMOpsAction,
|
| 13 |
+
PMOpsObservation,
|
| 14 |
+
env_name="pm_ops",
|
| 15 |
+
max_concurrent_envs=8,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@app.get("/")
|
| 20 |
+
def root():
|
| 21 |
+
return {"name": "pm_ops", "status": "running"}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def main():
|
| 25 |
+
import uvicorn
|
| 26 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
if __name__ == "__main__":
|
| 30 |
+
main()
|
server/apps/__init__.py
ADDED
|
File without changes
|
server/apps/chat.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
from typing import Any, Dict, List
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class ChatApp:
|
| 6 |
+
def __init__(self, channels: List[str], noise_channels: List[str]):
|
| 7 |
+
all_channels = list(dict.fromkeys(channels + noise_channels))
|
| 8 |
+
self.channels = all_channels
|
| 9 |
+
self.messages: Dict[str, List[Dict]] = {ch: [] for ch in all_channels}
|
| 10 |
+
self._episode_messages: List[Dict] = []
|
| 11 |
+
|
| 12 |
+
def snapshot(self) -> Dict[str, Any]:
|
| 13 |
+
return {
|
| 14 |
+
"channels": self.channels,
|
| 15 |
+
"messages_posted_this_episode": copy.deepcopy(self._episode_messages),
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
def post_message(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
| 19 |
+
channel = args.get("channel", "")
|
| 20 |
+
text = args.get("text", "")
|
| 21 |
+
if not channel or not text:
|
| 22 |
+
return {"ok": False, "error": "Both 'channel' and 'text' are required"}
|
| 23 |
+
if channel not in self.channels:
|
| 24 |
+
return {"ok": False, "error": f"Channel '{channel}' not found. Use chat.list_channels to see available channels."}
|
| 25 |
+
msg = {"channel": channel, "text": text}
|
| 26 |
+
self.messages[channel].append(msg)
|
| 27 |
+
self._episode_messages.append(msg)
|
| 28 |
+
return {"ok": True, "data": {"channel": channel, "delivered": True}, "side_effects": [f"message posted to {channel}"]}
|
| 29 |
+
|
| 30 |
+
def read_channel(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
| 31 |
+
channel = args.get("channel", "")
|
| 32 |
+
limit = min(args.get("limit", 20), 50)
|
| 33 |
+
if channel not in self.channels:
|
| 34 |
+
return {"ok": False, "error": f"Channel '{channel}' not found"}
|
| 35 |
+
msgs = self.messages[channel][-limit:]
|
| 36 |
+
return {"ok": True, "data": {"channel": channel, "messages": copy.deepcopy(msgs)}}
|
| 37 |
+
|
| 38 |
+
def list_channels(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
| 39 |
+
return {"ok": True, "data": self.channels}
|
| 40 |
+
|
| 41 |
+
def search(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
| 42 |
+
query = args.get("query", "").lower()
|
| 43 |
+
results = []
|
| 44 |
+
for ch, msgs in self.messages.items():
|
| 45 |
+
for m in msgs:
|
| 46 |
+
if query in m["text"].lower():
|
| 47 |
+
results.append({"channel": ch, "text": m["text"]})
|
| 48 |
+
return {"ok": True, "data": results[:20]}
|
server/apps/codebase.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import random
|
| 3 |
+
from typing import Any, Dict, List
|
| 4 |
+
|
| 5 |
+
_COMMIT_POOL = [
|
| 6 |
+
{"hash": "a1b2c3d", "message": "fix: null pointer in auth middleware", "author": "alice", "files": ["auth/middleware.py"], "service": "auth"},
|
| 7 |
+
{"hash": "e4f5g6h", "message": "feat: rate limiting for API gateway", "author": "bob", "files": ["gateway/ratelimit.py"], "service": "api-gateway"},
|
| 8 |
+
{"hash": "i7j8k9l", "message": "refactor: database connection pool", "author": "carol", "files": ["db/pool.py"], "service": "auth"},
|
| 9 |
+
{"hash": "m1n2o3p", "message": "fix: SQL injection in user search", "author": "dave", "files": ["search/query.py"], "service": "search"},
|
| 10 |
+
{"hash": "q4r5s6t", "message": "perf: Redis session caching", "author": "eve", "files": ["payments/session.py"], "service": "payments"},
|
| 11 |
+
{"hash": "u7v8w9x", "message": "feat: dark mode dashboard", "author": "frank", "files": ["frontend/theme.js"], "service": "notifications"},
|
| 12 |
+
{"hash": "y1z2a3b", "message": "fix: XSS in comment renderer", "author": "alice", "files": ["frontend/comments.jsx"], "service": "notifications"},
|
| 13 |
+
{"hash": "c4d5e6f", "message": "docs: API authentication guide", "author": "bob", "files": ["docs/auth.md"], "service": "auth"},
|
| 14 |
+
{"hash": "g7h8i9j", "message": "feat: CSV data export endpoint", "author": "carol", "files": ["api/export.py"], "service": "api-gateway"},
|
| 15 |
+
{"hash": "k1l2m3n", "message": "fix: websocket memory leak", "author": "dave", "files": ["payments/ws.py"], "service": "payments"},
|
| 16 |
+
{"hash": "p2q3r4s", "message": "fix: checkout timeout on slow networks", "author": "eve", "files": ["checkout/flow.py"], "service": "checkout"},
|
| 17 |
+
{"hash": "t5u6v7w", "message": "perf: inventory cache warm-up", "author": "frank", "files": ["inventory/cache.py"], "service": "inventory"},
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
_PR_POOL = [
|
| 21 |
+
{"id": "PR-101", "title": "Fix auth rate limiting bug", "author": "alice", "status": "merged", "service": "auth"},
|
| 22 |
+
{"id": "PR-102", "title": "Add payment retry logic", "author": "bob", "status": "open", "service": "payments"},
|
| 23 |
+
{"id": "PR-103", "title": "Search index optimization", "author": "carol", "status": "merged", "service": "search"},
|
| 24 |
+
{"id": "PR-104", "title": "Gateway health check endpoint", "author": "dave", "status": "open", "service": "api-gateway"},
|
| 25 |
+
{"id": "PR-105", "title": "Checkout flow improvements", "author": "eve", "status": "merged", "service": "checkout"},
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class CodebaseApp:
|
| 30 |
+
def __init__(self, seed: int, services: List[str]):
|
| 31 |
+
rng = random.Random(seed + 1337)
|
| 32 |
+
relevant = [c for c in _COMMIT_POOL if c["service"] in services]
|
| 33 |
+
other = [c for c in _COMMIT_POOL if c["service"] not in services]
|
| 34 |
+
pool = relevant + rng.sample(other, min(3, len(other)))
|
| 35 |
+
self.commits = rng.sample(pool, min(8, len(pool)))
|
| 36 |
+
self.prs = [p for p in _PR_POOL if p["service"] in services]
|
| 37 |
+
|
| 38 |
+
def list_commits(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
| 39 |
+
query = args.get("query", "").lower()
|
| 40 |
+
service = args.get("service")
|
| 41 |
+
limit = min(args.get("limit", 10), 20)
|
| 42 |
+
offset = args.get("offset", 0)
|
| 43 |
+
results = self.commits
|
| 44 |
+
if query:
|
| 45 |
+
results = [c for c in results if query in c["message"].lower() or query in c["hash"]]
|
| 46 |
+
if service:
|
| 47 |
+
results = [c for c in results if c.get("service") == service]
|
| 48 |
+
return {"ok": True, "data": results[offset: offset + limit], "total": len(results)}
|
| 49 |
+
|
| 50 |
+
def get_commit(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
| 51 |
+
h = args.get("hash", "")
|
| 52 |
+
for c in self.commits:
|
| 53 |
+
if c["hash"].startswith(h):
|
| 54 |
+
return {"ok": True, "data": copy.deepcopy(c)}
|
| 55 |
+
return {"ok": False, "error": f"Commit '{h}' not found"}
|
| 56 |
+
|
| 57 |
+
def list_prs(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
| 58 |
+
status = args.get("status")
|
| 59 |
+
results = self.prs if not status else [p for p in self.prs if p["status"] == status]
|
| 60 |
+
return {"ok": True, "data": copy.deepcopy(results)}
|
server/apps/ticketing.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
from typing import Any, Dict, List, Optional
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class TicketingApp:
|
| 6 |
+
def __init__(self, org_config: Dict[str, Any]):
|
| 7 |
+
self.org = org_config
|
| 8 |
+
self.tickets: Dict[str, Dict] = {}
|
| 9 |
+
self.counter = 0
|
| 10 |
+
self._episode_created: List[str] = []
|
| 11 |
+
|
| 12 |
+
def snapshot(self) -> Dict[str, Any]:
|
| 13 |
+
return copy.deepcopy({
|
| 14 |
+
"tickets": list(self.tickets.values()),
|
| 15 |
+
"episode_created_ids": self._episode_created,
|
| 16 |
+
})
|
| 17 |
+
|
| 18 |
+
def create_ticket(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
| 19 |
+
required = self.org.get("required_ticket_fields", ["summary"])
|
| 20 |
+
missing = [f for f in required if not args.get(f)]
|
| 21 |
+
if missing:
|
| 22 |
+
return {"ok": False, "error": f"Missing required fields: {missing}. Required: {required}"}
|
| 23 |
+
|
| 24 |
+
self.counter += 1
|
| 25 |
+
ticket_id = f"T-{self.counter:04d}"
|
| 26 |
+
ticket = {
|
| 27 |
+
"id": ticket_id,
|
| 28 |
+
"summary": args.get("summary", ""),
|
| 29 |
+
"description": args.get("description", ""),
|
| 30 |
+
"label": args.get("label"),
|
| 31 |
+
"priority": args.get("priority"),
|
| 32 |
+
"status": "open",
|
| 33 |
+
"assigned_team": None,
|
| 34 |
+
"linked_pr": args.get("linked_pr"),
|
| 35 |
+
"comments": [],
|
| 36 |
+
"created_this_episode": True,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
valid_labels = list(self.org["label_taxonomy"].values())
|
| 40 |
+
if ticket["label"] and ticket["label"] not in valid_labels:
|
| 41 |
+
return {"ok": False, "error": f"Invalid label '{ticket['label']}'. Valid labels: {valid_labels}"}
|
| 42 |
+
|
| 43 |
+
valid_priorities = self.org["priority_levels"]
|
| 44 |
+
if ticket["priority"] and ticket["priority"] not in valid_priorities:
|
| 45 |
+
return {"ok": False, "error": f"Invalid priority '{ticket['priority']}'. Valid: {valid_priorities}"}
|
| 46 |
+
|
| 47 |
+
self.tickets[ticket_id] = ticket
|
| 48 |
+
self._episode_created.append(ticket_id)
|
| 49 |
+
return {"ok": True, "data": copy.deepcopy(ticket), "side_effects": [f"ticket {ticket_id} created"]}
|
| 50 |
+
|
| 51 |
+
def update_ticket(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
| 52 |
+
tid = args.get("ticket_id")
|
| 53 |
+
if not tid or tid not in self.tickets:
|
| 54 |
+
return {"ok": False, "error": f"Ticket '{tid}' not found"}
|
| 55 |
+
t = self.tickets[tid]
|
| 56 |
+
for k in ("summary", "description", "label", "priority", "linked_pr"):
|
| 57 |
+
if k in args:
|
| 58 |
+
t[k] = args[k]
|
| 59 |
+
return {"ok": True, "data": copy.deepcopy(t), "side_effects": [f"ticket {tid} updated"]}
|
| 60 |
+
|
| 61 |
+
def get_ticket(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
| 62 |
+
tid = args.get("ticket_id")
|
| 63 |
+
t = self.tickets.get(tid)
|
| 64 |
+
if not t:
|
| 65 |
+
return {"ok": False, "error": f"Ticket '{tid}' not found"}
|
| 66 |
+
return {"ok": True, "data": copy.deepcopy(t)}
|
| 67 |
+
|
| 68 |
+
def list_tickets(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
| 69 |
+
tickets = list(self.tickets.values())
|
| 70 |
+
if args.get("status"):
|
| 71 |
+
tickets = [t for t in tickets if t.get("status") == args["status"]]
|
| 72 |
+
if args.get("label"):
|
| 73 |
+
tickets = [t for t in tickets if t.get("label") == args["label"]]
|
| 74 |
+
limit = min(args.get("limit", 20), 50)
|
| 75 |
+
offset = args.get("offset", 0)
|
| 76 |
+
page = tickets[offset: offset + limit]
|
| 77 |
+
return {"ok": True, "data": page, "total": len(tickets)}
|
| 78 |
+
|
| 79 |
+
def assign_ticket(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
| 80 |
+
tid = args.get("ticket_id")
|
| 81 |
+
team = args.get("team")
|
| 82 |
+
if not tid or tid not in self.tickets:
|
| 83 |
+
return {"ok": False, "error": f"Ticket '{tid}' not found"}
|
| 84 |
+
valid_teams = list(self.org["team_map"].values())
|
| 85 |
+
if team not in valid_teams:
|
| 86 |
+
return {"ok": False, "error": f"Team '{team}' not found. Valid teams: {valid_teams}"}
|
| 87 |
+
self.tickets[tid]["assigned_team"] = team
|
| 88 |
+
return {"ok": True, "data": {"ticket_id": tid, "assigned_team": team}, "side_effects": [f"ticket {tid} assigned to {team}"]}
|
| 89 |
+
|
| 90 |
+
def comment_ticket(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
| 91 |
+
tid = args.get("ticket_id")
|
| 92 |
+
text = args.get("text", "")
|
| 93 |
+
if not tid or tid not in self.tickets:
|
| 94 |
+
return {"ok": False, "error": f"Ticket '{tid}' not found"}
|
| 95 |
+
self.tickets[tid]["comments"].append({"text": text})
|
| 96 |
+
return {"ok": True, "data": {"comment": "added"}, "side_effects": [f"comment added to {tid}"]}
|
| 97 |
+
|
| 98 |
+
def transition_ticket(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
| 99 |
+
tid = args.get("ticket_id")
|
| 100 |
+
status = args.get("status", "closed")
|
| 101 |
+
valid = ["open", "in_progress", "review", "closed", "wont_fix"]
|
| 102 |
+
if status not in valid:
|
| 103 |
+
return {"ok": False, "error": f"Invalid status. Valid: {valid}"}
|
| 104 |
+
if not tid or tid not in self.tickets:
|
| 105 |
+
return {"ok": False, "error": f"Ticket '{tid}' not found"}
|
| 106 |
+
self.tickets[tid]["status"] = status
|
| 107 |
+
return {"ok": True, "data": {"ticket_id": tid, "status": status}, "side_effects": [f"ticket {tid} transitioned to {status}"]}
|
server/pm_ops_environment.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import random
|
| 3 |
+
import uuid
|
| 4 |
+
from typing import Any, Dict, Optional
|
| 5 |
+
|
| 6 |
+
from pm_ops.models import PMOpsAction, PMOpsObservation
|
| 7 |
+
from pm_ops.server.apps.ticketing import TicketingApp
|
| 8 |
+
from pm_ops.server.apps.codebase import CodebaseApp
|
| 9 |
+
from pm_ops.server.apps.chat import ChatApp
|
| 10 |
+
from pm_ops.server.world.org_generator import generate_org_config
|
| 11 |
+
from pm_ops.server.world.scenario_gen import generate_scenario
|
| 12 |
+
from pm_ops.server.tasks.triage_task import TriageTask
|
| 13 |
+
from pm_ops.server.tasks.incident_routing_task import IncidentRoutingTask
|
| 14 |
+
from pm_ops.server.tasks.release_notes_task import ReleaseNotesTask
|
| 15 |
+
from pm_ops.server.tasks.dep_update_task import DepUpdateTask
|
| 16 |
+
|
| 17 |
+
MAX_STEPS = 40
|
| 18 |
+
_TASK_TYPES = ["triage", "incident_routing", "release_notes", "dep_update"]
|
| 19 |
+
_DIFFICULTY_POOL = ["easy", "medium", "medium", "hard"]
|
| 20 |
+
_TASK_GRADERS = {
|
| 21 |
+
"triage": TriageTask(),
|
| 22 |
+
"incident_routing": IncidentRoutingTask(),
|
| 23 |
+
"release_notes": ReleaseNotesTask(),
|
| 24 |
+
"dep_update": DepUpdateTask(),
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _oracle_check(scenario: Dict[str, Any]) -> bool:
|
| 29 |
+
expected = scenario.get("expected", {})
|
| 30 |
+
if not expected:
|
| 31 |
+
return False
|
| 32 |
+
channel = expected.get("channel") or expected.get("channels")
|
| 33 |
+
team = expected.get("team") or expected.get("teams_to_notify")
|
| 34 |
+
return bool(channel) and (bool(team) or scenario["type"] == "release_notes")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class PMOpsEnvironment:
|
| 38 |
+
def __init__(self):
|
| 39 |
+
self._ticketing: Optional[TicketingApp] = None
|
| 40 |
+
self._codebase: Optional[CodebaseApp] = None
|
| 41 |
+
self._chat: Optional[ChatApp] = None
|
| 42 |
+
self._org_config: Optional[Dict[str, Any]] = None
|
| 43 |
+
self._scenario: Optional[Dict[str, Any]] = None
|
| 44 |
+
self._step_count: int = 0
|
| 45 |
+
self._done: bool = False
|
| 46 |
+
|
| 47 |
+
def reset(self) -> PMOpsObservation:
|
| 48 |
+
seed = random.randint(0, 2 ** 31)
|
| 49 |
+
rng = random.Random(seed)
|
| 50 |
+
difficulty = rng.choice(_DIFFICULTY_POOL)
|
| 51 |
+
task_type = rng.choice(_TASK_TYPES)
|
| 52 |
+
|
| 53 |
+
for attempt in range(10):
|
| 54 |
+
org = generate_org_config(seed + attempt, difficulty)
|
| 55 |
+
scenario = generate_scenario(task_type, org, seed + attempt)
|
| 56 |
+
if _oracle_check(scenario):
|
| 57 |
+
break
|
| 58 |
+
|
| 59 |
+
channels = list(org["oncall_channels"].values())
|
| 60 |
+
noise = org.get("noise_channels", [])
|
| 61 |
+
|
| 62 |
+
self._org_config = org
|
| 63 |
+
self._scenario = scenario
|
| 64 |
+
self._ticketing = TicketingApp(org)
|
| 65 |
+
self._codebase = CodebaseApp(seed, org["services"])
|
| 66 |
+
self._chat = ChatApp(channels, noise)
|
| 67 |
+
self._step_count = 0
|
| 68 |
+
self._done = False
|
| 69 |
+
|
| 70 |
+
return PMOpsObservation(
|
| 71 |
+
step=0,
|
| 72 |
+
max_steps=MAX_STEPS,
|
| 73 |
+
task_brief=scenario["brief"],
|
| 74 |
+
last_action_result={
|
| 75 |
+
"ok": True,
|
| 76 |
+
"data": "Environment ready. Call meta.read_runbook to learn this org's conventions.",
|
| 77 |
+
},
|
| 78 |
+
app_state_deltas={"ticketing": [], "chat": [], "codebase": []},
|
| 79 |
+
steps_remaining=MAX_STEPS,
|
| 80 |
+
token_budget_remaining=12000,
|
| 81 |
+
reward=0.0,
|
| 82 |
+
done=False,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
def step(self, action: PMOpsAction) -> PMOpsObservation:
|
| 86 |
+
if self._ticketing is None:
|
| 87 |
+
raise RuntimeError("Call reset() before step()")
|
| 88 |
+
|
| 89 |
+
if self._done:
|
| 90 |
+
return PMOpsObservation(
|
| 91 |
+
step=self._step_count,
|
| 92 |
+
max_steps=MAX_STEPS,
|
| 93 |
+
task_brief=self._scenario["brief"],
|
| 94 |
+
last_action_result={"ok": False, "error": "Episode already finished"},
|
| 95 |
+
app_state_deltas={"ticketing": [], "chat": [], "codebase": []},
|
| 96 |
+
steps_remaining=0,
|
| 97 |
+
token_budget_remaining=0,
|
| 98 |
+
reward=0.0,
|
| 99 |
+
done=True,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
self._step_count += 1
|
| 103 |
+
result = self._dispatch(action)
|
| 104 |
+
|
| 105 |
+
done = action.action_type == "meta.finish" or self._step_count >= MAX_STEPS
|
| 106 |
+
reward = 0.0
|
| 107 |
+
if done:
|
| 108 |
+
reward = self._grade()
|
| 109 |
+
self._done = True
|
| 110 |
+
|
| 111 |
+
side_effects = result.get("side_effects", [])
|
| 112 |
+
deltas = {
|
| 113 |
+
"ticketing": side_effects if any("ticket" in s for s in side_effects) else [],
|
| 114 |
+
"chat": side_effects if any("message" in s for s in side_effects) else [],
|
| 115 |
+
"codebase": [],
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
return PMOpsObservation(
|
| 119 |
+
step=self._step_count,
|
| 120 |
+
max_steps=MAX_STEPS,
|
| 121 |
+
task_brief=self._scenario["brief"],
|
| 122 |
+
last_action_result=result,
|
| 123 |
+
app_state_deltas=deltas,
|
| 124 |
+
steps_remaining=max(0, MAX_STEPS - self._step_count),
|
| 125 |
+
token_budget_remaining=max(0, 12000 - self._step_count * 300),
|
| 126 |
+
reward=reward,
|
| 127 |
+
done=done,
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
def _dispatch(self, action: PMOpsAction) -> Dict[str, Any]:
|
| 131 |
+
at = action.action_type
|
| 132 |
+
args = action.args or {}
|
| 133 |
+
|
| 134 |
+
if at == "meta.noop":
|
| 135 |
+
return {"ok": True, "data": "No operation."}
|
| 136 |
+
|
| 137 |
+
if at == "meta.read_runbook":
|
| 138 |
+
return {
|
| 139 |
+
"ok": True,
|
| 140 |
+
"data": {
|
| 141 |
+
"org_config": copy.deepcopy(self._org_config),
|
| 142 |
+
"hint": (
|
| 143 |
+
"Use label_taxonomy for valid ticket labels, "
|
| 144 |
+
"priority_levels for valid priorities, "
|
| 145 |
+
"team_map[service] to find the owning team, "
|
| 146 |
+
"oncall_channels[service] to find the channel to notify."
|
| 147 |
+
),
|
| 148 |
+
},
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
if at == "meta.finish":
|
| 152 |
+
return {"ok": True, "data": "Episode finishing. Score will be computed."}
|
| 153 |
+
|
| 154 |
+
if at.startswith("ticketing."):
|
| 155 |
+
op = at.split(".", 1)[1]
|
| 156 |
+
handlers = {
|
| 157 |
+
"create_ticket": self._ticketing.create_ticket,
|
| 158 |
+
"update_ticket": self._ticketing.update_ticket,
|
| 159 |
+
"get_ticket": self._ticketing.get_ticket,
|
| 160 |
+
"list_tickets": self._ticketing.list_tickets,
|
| 161 |
+
"assign_ticket": self._ticketing.assign_ticket,
|
| 162 |
+
"comment_ticket": self._ticketing.comment_ticket,
|
| 163 |
+
"transition_ticket": self._ticketing.transition_ticket,
|
| 164 |
+
}
|
| 165 |
+
if op not in handlers:
|
| 166 |
+
return {"ok": False, "error": f"Unknown ticketing action: {op}"}
|
| 167 |
+
return handlers[op](args)
|
| 168 |
+
|
| 169 |
+
if at.startswith("codebase."):
|
| 170 |
+
op = at.split(".", 1)[1]
|
| 171 |
+
handlers = {
|
| 172 |
+
"list_commits": self._codebase.list_commits,
|
| 173 |
+
"get_commit": self._codebase.get_commit,
|
| 174 |
+
"list_prs": self._codebase.list_prs,
|
| 175 |
+
}
|
| 176 |
+
if op not in handlers:
|
| 177 |
+
return {"ok": False, "error": f"Unknown codebase action: {op}"}
|
| 178 |
+
return handlers[op](args)
|
| 179 |
+
|
| 180 |
+
if at.startswith("chat."):
|
| 181 |
+
op = at.split(".", 1)[1]
|
| 182 |
+
handlers = {
|
| 183 |
+
"post_message": self._chat.post_message,
|
| 184 |
+
"read_channel": self._chat.read_channel,
|
| 185 |
+
"list_channels": self._chat.list_channels,
|
| 186 |
+
"search": self._chat.search,
|
| 187 |
+
}
|
| 188 |
+
if op not in handlers:
|
| 189 |
+
return {"ok": False, "error": f"Unknown chat action: {op}"}
|
| 190 |
+
return handlers[op](args)
|
| 191 |
+
|
| 192 |
+
return {"ok": False, "error": f"Unknown action_type: {at}"}
|
| 193 |
+
|
| 194 |
+
def _grade(self) -> float:
|
| 195 |
+
final_state = {
|
| 196 |
+
"ticketing": self._ticketing.snapshot(),
|
| 197 |
+
"chat": self._chat.snapshot(),
|
| 198 |
+
}
|
| 199 |
+
grader = _TASK_GRADERS.get(self._scenario["type"])
|
| 200 |
+
if not grader:
|
| 201 |
+
return 0.0
|
| 202 |
+
return grader.grade(final_state, self._org_config, self._scenario)
|
server/tasks/__init__.py
ADDED
|
File without changes
|
server/tasks/base_task.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from typing import Any, Dict
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class BaseTask(ABC):
|
| 6 |
+
@abstractmethod
|
| 7 |
+
def grade(self, final_state: Dict[str, Any], org_config: Dict[str, Any], scenario: Dict[str, Any]) -> float:
|
| 8 |
+
...
|
server/tasks/dep_update_task.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict
|
| 2 |
+
from pm_ops.server.tasks.base_task import BaseTask
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class DepUpdateTask(BaseTask):
|
| 6 |
+
def grade(self, final_state: Dict[str, Any], org_config: Dict[str, Any], scenario: Dict[str, Any]) -> float:
|
| 7 |
+
score = 0.0
|
| 8 |
+
expected = scenario["expected"]
|
| 9 |
+
|
| 10 |
+
msgs = final_state["chat"]["messages_posted_this_episode"]
|
| 11 |
+
tickets = final_state["ticketing"]["tickets"]
|
| 12 |
+
new_tickets = [t for t in tickets if t.get("created_this_episode")]
|
| 13 |
+
|
| 14 |
+
expected_channels = set(expected.get("channels", []))
|
| 15 |
+
expected_teams = set(expected.get("teams_to_notify", []))
|
| 16 |
+
|
| 17 |
+
notified = {m.get("channel") for m in msgs}
|
| 18 |
+
correct_notified = notified & expected_channels
|
| 19 |
+
wrong_notified = notified - expected_channels
|
| 20 |
+
|
| 21 |
+
if correct_notified:
|
| 22 |
+
score += 0.25 * (len(correct_notified) / max(len(expected_channels), 1))
|
| 23 |
+
|
| 24 |
+
assigned_teams = {t.get("assigned_team") for t in new_tickets if t.get("assigned_team")}
|
| 25 |
+
correct_teams = assigned_teams & expected_teams
|
| 26 |
+
if correct_teams:
|
| 27 |
+
score += 0.25 * (len(correct_teams) / max(len(expected_teams), 1))
|
| 28 |
+
|
| 29 |
+
if new_tickets:
|
| 30 |
+
score += 0.25
|
| 31 |
+
|
| 32 |
+
policy = expected.get("escalation_policy", "post-channel")
|
| 33 |
+
if policy == "post-channel" and correct_notified:
|
| 34 |
+
score += 0.25
|
| 35 |
+
elif policy in ("dm-manager", "page-oncall") and msgs:
|
| 36 |
+
score += 0.25
|
| 37 |
+
|
| 38 |
+
score -= 0.05 * len(wrong_notified)
|
| 39 |
+
|
| 40 |
+
return max(0.0, min(1.0, score))
|
server/tasks/incident_routing_task.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict
|
| 2 |
+
from pm_ops.server.tasks.base_task import BaseTask
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class IncidentRoutingTask(BaseTask):
|
| 6 |
+
def grade(self, final_state: Dict[str, Any], org_config: Dict[str, Any], scenario: Dict[str, Any]) -> float:
|
| 7 |
+
score = 0.0
|
| 8 |
+
expected = scenario["expected"]
|
| 9 |
+
|
| 10 |
+
msgs = final_state["chat"]["messages_posted_this_episode"]
|
| 11 |
+
tickets = final_state["ticketing"]["tickets"]
|
| 12 |
+
new_tickets = [t for t in tickets if t.get("created_this_episode")]
|
| 13 |
+
|
| 14 |
+
correct_msgs = [m for m in msgs if m.get("channel") == expected.get("channel")]
|
| 15 |
+
wrong_msgs = [m for m in msgs if m.get("channel") != expected.get("channel")]
|
| 16 |
+
|
| 17 |
+
if correct_msgs:
|
| 18 |
+
score += 0.30
|
| 19 |
+
|
| 20 |
+
if new_tickets:
|
| 21 |
+
t = new_tickets[0]
|
| 22 |
+
if t.get("label") == expected.get("label"):
|
| 23 |
+
score += 0.20
|
| 24 |
+
if t.get("priority") == expected.get("priority"):
|
| 25 |
+
score += 0.20
|
| 26 |
+
if t.get("assigned_team") == expected.get("team"):
|
| 27 |
+
score += 0.15
|
| 28 |
+
if t.get("linked_pr"):
|
| 29 |
+
score += 0.05
|
| 30 |
+
|
| 31 |
+
score -= 0.15 * len(wrong_msgs)
|
| 32 |
+
|
| 33 |
+
return max(0.0, min(1.0, score))
|
server/tasks/release_notes_task.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict
|
| 2 |
+
from pm_ops.server.tasks.base_task import BaseTask
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class ReleaseNotesTask(BaseTask):
|
| 6 |
+
def grade(self, final_state: Dict[str, Any], org_config: Dict[str, Any], scenario: Dict[str, Any]) -> float:
|
| 7 |
+
score = 0.0
|
| 8 |
+
expected = scenario["expected"]
|
| 9 |
+
|
| 10 |
+
tickets = final_state["ticketing"]["tickets"]
|
| 11 |
+
closed = [t for t in tickets if t.get("status") == "closed"]
|
| 12 |
+
|
| 13 |
+
msgs = final_state["chat"]["messages_posted_this_episode"]
|
| 14 |
+
correct_msgs = [m for m in msgs if m.get("channel") == expected.get("channel")]
|
| 15 |
+
wrong_msgs = [m for m in msgs if m.get("channel") != expected.get("channel")]
|
| 16 |
+
|
| 17 |
+
if not correct_msgs:
|
| 18 |
+
score -= 0.10 * len(wrong_msgs)
|
| 19 |
+
return max(0.0, score)
|
| 20 |
+
|
| 21 |
+
msg_text = correct_msgs[0].get("text", "")
|
| 22 |
+
score += 0.30
|
| 23 |
+
|
| 24 |
+
style = expected.get("style", "terse")
|
| 25 |
+
if style == "bulleted" and ("-" in msg_text or "*" in msg_text):
|
| 26 |
+
score += 0.20
|
| 27 |
+
elif style == "terse" and len(msg_text) < 500:
|
| 28 |
+
score += 0.20
|
| 29 |
+
elif style == "verbose" and len(msg_text) >= 200:
|
| 30 |
+
score += 0.20
|
| 31 |
+
|
| 32 |
+
min_tickets = expected.get("min_tickets", 2)
|
| 33 |
+
referenced = sum(1 for t in closed if t["id"] in msg_text)
|
| 34 |
+
if referenced >= min_tickets:
|
| 35 |
+
score += 0.30
|
| 36 |
+
elif referenced > 0:
|
| 37 |
+
score += 0.15
|
| 38 |
+
|
| 39 |
+
score += 0.20 if not wrong_msgs else 0.0
|
| 40 |
+
score -= 0.10 * len(wrong_msgs)
|
| 41 |
+
|
| 42 |
+
return max(0.0, min(1.0, score))
|
server/tasks/triage_task.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict
|
| 2 |
+
from pm_ops.server.tasks.base_task import BaseTask
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class TriageTask(BaseTask):
|
| 6 |
+
def grade(self, final_state: Dict[str, Any], org_config: Dict[str, Any], scenario: Dict[str, Any]) -> float:
|
| 7 |
+
score = 0.0
|
| 8 |
+
expected = scenario["expected"]
|
| 9 |
+
|
| 10 |
+
tickets = final_state["ticketing"]["tickets"]
|
| 11 |
+
new_tickets = [t for t in tickets if t.get("created_this_episode")]
|
| 12 |
+
|
| 13 |
+
if not new_tickets:
|
| 14 |
+
return 0.0
|
| 15 |
+
|
| 16 |
+
score -= 0.10 * max(0, len(new_tickets) - 1) # duplicate penalty
|
| 17 |
+
|
| 18 |
+
t = new_tickets[0]
|
| 19 |
+
score += 0.25 # ticket exists
|
| 20 |
+
|
| 21 |
+
if t.get("label") == expected.get("label"):
|
| 22 |
+
score += 0.20
|
| 23 |
+
if t.get("priority") == expected.get("priority"):
|
| 24 |
+
score += 0.20
|
| 25 |
+
if t.get("assigned_team") == expected.get("team"):
|
| 26 |
+
score += 0.20
|
| 27 |
+
|
| 28 |
+
msgs = final_state["chat"]["messages_posted_this_episode"]
|
| 29 |
+
correct_msgs = [m for m in msgs if m.get("channel") == expected.get("channel")]
|
| 30 |
+
wrong_msgs = [m for m in msgs if m.get("channel") != expected.get("channel")]
|
| 31 |
+
|
| 32 |
+
if correct_msgs:
|
| 33 |
+
score += 0.10
|
| 34 |
+
if len(correct_msgs[0].get("text", "")) >= 20:
|
| 35 |
+
score += 0.05
|
| 36 |
+
|
| 37 |
+
score -= 0.05 * len(wrong_msgs)
|
| 38 |
+
|
| 39 |
+
return max(0.0, min(1.0, score))
|
server/verifiers/__init__.py
ADDED
|
File without changes
|
server/verifiers/state_verifier.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic state verifier — no LLM-as-judge."""
|
| 2 |
+
from typing import Any, Dict
|
| 3 |
+
|
| 4 |
+
from pm_ops.server.tasks.triage_task import TriageTask
|
| 5 |
+
from pm_ops.server.tasks.incident_routing_task import IncidentRoutingTask
|
| 6 |
+
from pm_ops.server.tasks.release_notes_task import ReleaseNotesTask
|
| 7 |
+
from pm_ops.server.tasks.dep_update_task import DepUpdateTask
|
| 8 |
+
|
| 9 |
+
_GRADERS = {
|
| 10 |
+
"triage": TriageTask(),
|
| 11 |
+
"incident_routing": IncidentRoutingTask(),
|
| 12 |
+
"release_notes": ReleaseNotesTask(),
|
| 13 |
+
"dep_update": DepUpdateTask(),
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def verify(final_state: Dict[str, Any], org_config: Dict[str, Any], scenario: Dict[str, Any]) -> Dict[str, Any]:
|
| 18 |
+
task_type = scenario.get("type", "")
|
| 19 |
+
grader = _GRADERS.get(task_type)
|
| 20 |
+
if not grader:
|
| 21 |
+
return {"score": 0.0, "error": f"Unknown task type: {task_type}"}
|
| 22 |
+
score = grader.grade(final_state, org_config, scenario)
|
| 23 |
+
return {"score": score, "task_type": task_type}
|
server/world/__init__.py
ADDED
|
File without changes
|
server/world/org_generator.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
from typing import Any, Dict, List
|
| 3 |
+
|
| 4 |
+
_LABEL_VARIANTS = {
|
| 5 |
+
"bug": ["bug", "defect", "issue", "fault"],
|
| 6 |
+
"feature": ["feature", "enhancement", "request", "improvement"],
|
| 7 |
+
"docs": ["docs", "documentation", "doc-update", "guide"],
|
| 8 |
+
"security": ["security", "vuln", "vulnerability", "sec-issue"],
|
| 9 |
+
"performance": ["perf", "performance", "slowness", "optimization"],
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
_PRIORITY_SCHEMES = [
|
| 13 |
+
["P0", "P1", "P2", "P3"],
|
| 14 |
+
["critical", "high", "medium", "low"],
|
| 15 |
+
["urgent", "high", "normal", "low"],
|
| 16 |
+
["blocker", "major", "minor", "trivial"],
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
_SERVICES = ["payments", "auth", "search", "notifications", "api-gateway", "checkout", "inventory"]
|
| 20 |
+
_TEAM_POOL = ["platform", "growth", "infra", "security", "backend", "frontend", "data"]
|
| 21 |
+
_CHANNEL_SUFFIXES = ["-alerts", "-oncall", "-ops", "-team", "-eng"]
|
| 22 |
+
_NOISE_CHANNELS = ["#random", "#general", "#water-cooler", "#announcements", "#off-topic", "#hiring"]
|
| 23 |
+
_ESCALATION_POLICIES = ["dm-manager", "page-oncall", "post-channel"]
|
| 24 |
+
_RELEASE_STYLES = ["terse", "verbose", "bulleted"]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def generate_org_config(seed: int, difficulty: str = "medium") -> Dict[str, Any]:
|
| 28 |
+
rng = random.Random(seed)
|
| 29 |
+
|
| 30 |
+
n_labels = {"easy": 3, "medium": 4, "hard": 5}.get(difficulty, 4)
|
| 31 |
+
label_keys = rng.sample(list(_LABEL_VARIANTS.keys()), n_labels)
|
| 32 |
+
label_taxonomy = {k: rng.choice(_LABEL_VARIANTS[k]) for k in label_keys}
|
| 33 |
+
|
| 34 |
+
priority_levels: List[str] = rng.choice(_PRIORITY_SCHEMES)
|
| 35 |
+
severity_to_priority = {
|
| 36 |
+
"critical": priority_levels[0],
|
| 37 |
+
"high": priority_levels[1] if len(priority_levels) > 1 else priority_levels[0],
|
| 38 |
+
"medium": priority_levels[2] if len(priority_levels) > 2 else priority_levels[-1],
|
| 39 |
+
"low": priority_levels[-1],
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
n_services = {"easy": 2, "medium": 3, "hard": 5}.get(difficulty, 3)
|
| 43 |
+
services = rng.sample(_SERVICES, min(n_services, len(_SERVICES)))
|
| 44 |
+
teams = rng.sample(_TEAM_POOL, min(len(services), len(_TEAM_POOL)))
|
| 45 |
+
|
| 46 |
+
team_map: Dict[str, str] = {svc: teams[i % len(teams)] for i, svc in enumerate(services)}
|
| 47 |
+
|
| 48 |
+
oncall_channels: Dict[str, str] = {}
|
| 49 |
+
for svc in services:
|
| 50 |
+
team = team_map[svc]
|
| 51 |
+
suffix = rng.choice(_CHANNEL_SUFFIXES)
|
| 52 |
+
oncall_channels[svc] = f"#{team}{suffix}"
|
| 53 |
+
|
| 54 |
+
escalation_policy: str = rng.choice(_ESCALATION_POLICIES)
|
| 55 |
+
release_notes_style: str = rng.choice(_RELEASE_STYLES)
|
| 56 |
+
|
| 57 |
+
all_fields = ["summary", "description", "priority", "label", "assignee"]
|
| 58 |
+
n_required = {"easy": 2, "medium": 3, "hard": 4}.get(difficulty, 3)
|
| 59 |
+
required_ticket_fields = ["summary"] + rng.sample(all_fields[1:], min(n_required - 1, len(all_fields) - 1))
|
| 60 |
+
|
| 61 |
+
n_noise = {"easy": 1, "medium": 2, "hard": 3}.get(difficulty, 2)
|
| 62 |
+
noise_channels = rng.sample(_NOISE_CHANNELS, min(n_noise, len(_NOISE_CHANNELS)))
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
"label_taxonomy": label_taxonomy,
|
| 66 |
+
"priority_levels": priority_levels,
|
| 67 |
+
"severity_to_priority": severity_to_priority,
|
| 68 |
+
"team_map": team_map,
|
| 69 |
+
"oncall_channels": oncall_channels,
|
| 70 |
+
"escalation_policy": escalation_policy,
|
| 71 |
+
"release_notes_style": release_notes_style,
|
| 72 |
+
"required_ticket_fields": required_ticket_fields,
|
| 73 |
+
"noise_channels": noise_channels,
|
| 74 |
+
"services": services,
|
| 75 |
+
"teams": teams,
|
| 76 |
+
}
|
server/world/scenario_gen.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
from typing import Any, Dict
|
| 3 |
+
|
| 4 |
+
_TRIAGE_BRIEFS = [
|
| 5 |
+
"A user reported that the {service} is returning 500 errors. The issue started 20 minutes ago. Triage this bug: file a ticket with the correct label and priority, assign it to the right team, and notify the correct oncall channel.",
|
| 6 |
+
"Support escalation: {service} is intermittently failing for EU users. Triage and route per org process.",
|
| 7 |
+
"Monitoring alert: elevated error rate on {service}. File a bug ticket and page the right team.",
|
| 8 |
+
]
|
| 9 |
+
|
| 10 |
+
_INCIDENT_BRIEFS = [
|
| 11 |
+
"ALERT: {service} is completely down. This is a {severity_name} incident. Identify the owning team from recent commits and route immediately.",
|
| 12 |
+
"Production incident: {service} spiking to 100% CPU. Escalate properly -- create a ticket and page the oncall channel.",
|
| 13 |
+
"Critical alert: {service} health check failing. Identify the right team via the codebase and notify them.",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
_RELEASE_NOTES_BRIEFS = [
|
| 17 |
+
"Prepare release notes for this sprint's closed tickets. Use the org's standard format and post them to the right channel.",
|
| 18 |
+
"Write and publish the weekly release notes for all resolved tickets. Follow the org's release_notes_style.",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
_DEP_UPDATE_BRIEFS = [
|
| 22 |
+
"Security advisory: the `requests` library has a CVE. Find all services that depend on it and notify their owning teams per the org's escalation policy.",
|
| 23 |
+
"Dependency update needed: `pydantic` v2 breaking changes affect multiple services. Coordinate the update across all owning teams.",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def generate_scenario(task_type: str, org_config: Dict[str, Any], seed: int) -> Dict[str, Any]:
|
| 28 |
+
rng = random.Random(seed + 42)
|
| 29 |
+
services = org_config["services"]
|
| 30 |
+
team_map = org_config["team_map"]
|
| 31 |
+
oncall_channels = org_config["oncall_channels"]
|
| 32 |
+
priority_levels = org_config["priority_levels"]
|
| 33 |
+
label_taxonomy = org_config["label_taxonomy"]
|
| 34 |
+
|
| 35 |
+
if task_type == "triage":
|
| 36 |
+
service = rng.choice(services)
|
| 37 |
+
label_key = "bug" if "bug" in label_taxonomy else list(label_taxonomy.keys())[0]
|
| 38 |
+
label = label_taxonomy[label_key]
|
| 39 |
+
priority = priority_levels[1] if len(priority_levels) > 1 else priority_levels[0]
|
| 40 |
+
brief = rng.choice(_TRIAGE_BRIEFS).format(service=service)
|
| 41 |
+
return {
|
| 42 |
+
"type": "triage",
|
| 43 |
+
"brief": brief,
|
| 44 |
+
"service": service,
|
| 45 |
+
"expected": {
|
| 46 |
+
"label": label,
|
| 47 |
+
"priority": priority,
|
| 48 |
+
"team": team_map[service],
|
| 49 |
+
"channel": oncall_channels[service],
|
| 50 |
+
},
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
elif task_type == "incident_routing":
|
| 54 |
+
service = rng.choice(services)
|
| 55 |
+
priority = priority_levels[0]
|
| 56 |
+
severity_name = org_config["severity_to_priority"].get("critical", priority)
|
| 57 |
+
brief = rng.choice(_INCIDENT_BRIEFS).format(service=service, severity_name=severity_name)
|
| 58 |
+
label_key = "bug" if "bug" in label_taxonomy else list(label_taxonomy.keys())[0]
|
| 59 |
+
return {
|
| 60 |
+
"type": "incident_routing",
|
| 61 |
+
"brief": brief,
|
| 62 |
+
"service": service,
|
| 63 |
+
"expected": {
|
| 64 |
+
"label": label_taxonomy[label_key],
|
| 65 |
+
"priority": priority,
|
| 66 |
+
"team": team_map[service],
|
| 67 |
+
"channel": oncall_channels[service],
|
| 68 |
+
},
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
elif task_type == "release_notes":
|
| 72 |
+
brief = rng.choice(_RELEASE_NOTES_BRIEFS)
|
| 73 |
+
channel = rng.choice(list(oncall_channels.values()))
|
| 74 |
+
return {
|
| 75 |
+
"type": "release_notes",
|
| 76 |
+
"brief": brief,
|
| 77 |
+
"expected": {
|
| 78 |
+
"style": org_config["release_notes_style"],
|
| 79 |
+
"channel": channel,
|
| 80 |
+
"min_tickets": 2,
|
| 81 |
+
},
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
elif task_type == "dep_update":
|
| 85 |
+
affected = rng.sample(services, min(2, len(services)))
|
| 86 |
+
brief = rng.choice(_DEP_UPDATE_BRIEFS)
|
| 87 |
+
return {
|
| 88 |
+
"type": "dep_update",
|
| 89 |
+
"brief": brief,
|
| 90 |
+
"affected_services": affected,
|
| 91 |
+
"expected": {
|
| 92 |
+
"teams_to_notify": [team_map[s] for s in affected],
|
| 93 |
+
"channels": [oncall_channels[s] for s in affected],
|
| 94 |
+
"escalation_policy": org_config["escalation_policy"],
|
| 95 |
+
},
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
raise ValueError(f"Unknown task type: {task_type}")
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|