Spaces:
Paused
Paused
Commit ·
d727210
0
Parent(s):
Initial Commit
Browse files- .gitignore +14 -0
- Dockerfile +44 -0
- PayOps_v2.postman_collection.json +1196 -0
- README.md +278 -0
- TESTING.md +905 -0
- __init__.py +0 -0
- environment.py +287 -0
- grader.py +332 -0
- models.py +239 -0
- openenv.yaml +53 -0
- requirements.txt +4 -0
- run_tests.sh +591 -0
- scripts/baseline_agent.py +159 -0
- scripts_util.py +170 -0
- server/__init__.py +0 -0
- server/app.py +399 -0
- tasks.py +668 -0
.gitignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
*.pyd
|
| 5 |
+
.Python
|
| 6 |
+
*.egg-info/
|
| 7 |
+
dist/
|
| 8 |
+
build/
|
| 9 |
+
.env
|
| 10 |
+
.venv
|
| 11 |
+
venv/
|
| 12 |
+
env/
|
| 13 |
+
*.log
|
| 14 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ---------------------------------------------------------------------------
|
| 2 |
+
# PayOps OpenEnv — Production Dockerfile
|
| 3 |
+
# ---------------------------------------------------------------------------
|
| 4 |
+
# Builds a lightweight, reproducible container that runs the FastAPI server.
|
| 5 |
+
# Compatible with HuggingFace Spaces (port 7860) and local development (8000).
|
| 6 |
+
# ---------------------------------------------------------------------------
|
| 7 |
+
|
| 8 |
+
FROM python:3.11-slim
|
| 9 |
+
|
| 10 |
+
# Non-root user for security
|
| 11 |
+
RUN useradd -m -u 1000 appuser
|
| 12 |
+
|
| 13 |
+
WORKDIR /app
|
| 14 |
+
|
| 15 |
+
# Install system dependencies
|
| 16 |
+
RUN apt-get update && \
|
| 17 |
+
apt-get install -y --no-install-recommends curl && \
|
| 18 |
+
rm -rf /var/lib/apt/lists/*
|
| 19 |
+
|
| 20 |
+
# Copy and install Python dependencies first (layer caching)
|
| 21 |
+
COPY requirements.txt ./
|
| 22 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 23 |
+
|
| 24 |
+
# Copy project source
|
| 25 |
+
COPY . /app/payops_env
|
| 26 |
+
|
| 27 |
+
# Put parent directory on PYTHONPATH so `payops_env` is importable
|
| 28 |
+
ENV PYTHONPATH="/app:${PYTHONPATH}"
|
| 29 |
+
|
| 30 |
+
# HuggingFace Spaces requires port 7860; default to 8000 locally
|
| 31 |
+
ENV PORT=7860
|
| 32 |
+
|
| 33 |
+
# Switch to non-root user
|
| 34 |
+
USER appuser
|
| 35 |
+
|
| 36 |
+
EXPOSE 7860
|
| 37 |
+
EXPOSE 8000
|
| 38 |
+
|
| 39 |
+
# Health check
|
| 40 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
| 41 |
+
CMD curl -f http://localhost:${PORT}/health || exit 1
|
| 42 |
+
|
| 43 |
+
CMD ["sh", "-c", "uvicorn payops_env.server.app:app --host 0.0.0.0 --port ${PORT}"]
|
| 44 |
+
|
PayOps_v2.postman_collection.json
ADDED
|
@@ -0,0 +1,1196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"info": {
|
| 3 |
+
"_postman_id": "payops-v2-full-suite",
|
| 4 |
+
"name": "PayOps v2 — Full Test Suite",
|
| 5 |
+
"description": "Complete Postman collection for PayOps v2.0.0 environment.\nGroups: Infrastructure · Tasks · Reset · Terminal Actions · Wrong Actions · Investigation · State · Grader · Replay · Baseline · Analytics · Leaderboard · Edge Cases · Perfect Episode · Budget Mechanics\n\nBase URL variable: {{base_url}} = http://localhost:8000",
|
| 6 |
+
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
| 7 |
+
},
|
| 8 |
+
"variable": [
|
| 9 |
+
{
|
| 10 |
+
"key": "base_url",
|
| 11 |
+
"value": "http://localhost:8000",
|
| 12 |
+
"type": "string"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"key": "episode_id",
|
| 16 |
+
"value": "",
|
| 17 |
+
"type": "string"
|
| 18 |
+
}
|
| 19 |
+
],
|
| 20 |
+
"item": [
|
| 21 |
+
|
| 22 |
+
{
|
| 23 |
+
"name": "A — Infrastructure",
|
| 24 |
+
"item": [
|
| 25 |
+
{
|
| 26 |
+
"name": "A-01 Health check",
|
| 27 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 28 |
+
"pm.test('Status 200', () => pm.response.to.have.status(200));",
|
| 29 |
+
"pm.test('status=ok', () => pm.expect(pm.response.json().status).to.eql('ok'));",
|
| 30 |
+
"pm.test('version=2.0.0', () => pm.expect(pm.response.json().version).to.eql('2.0.0'));"
|
| 31 |
+
], "type": "text/javascript"}}],
|
| 32 |
+
"request": {
|
| 33 |
+
"method": "GET",
|
| 34 |
+
"url": {"raw": "{{base_url}}/health", "host": ["{{base_url}}"], "path": ["health"]}
|
| 35 |
+
}
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"name": "A-02 Schema — models present",
|
| 39 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 40 |
+
"pm.test('Status 200', () => pm.response.to.have.status(200));",
|
| 41 |
+
"const body = pm.response.text();",
|
| 42 |
+
"pm.test('PayOpsAction in schema', () => pm.expect(body).to.include('PayOpsAction'));",
|
| 43 |
+
"pm.test('PayOpsObservation in schema', () => pm.expect(body).to.include('PayOpsObservation'));",
|
| 44 |
+
"pm.test('PayOpsState in schema', () => pm.expect(body).to.include('PayOpsState'));"
|
| 45 |
+
], "type": "text/javascript"}}],
|
| 46 |
+
"request": {
|
| 47 |
+
"method": "GET",
|
| 48 |
+
"url": {"raw": "{{base_url}}/schema", "host": ["{{base_url}}"], "path": ["schema"]}
|
| 49 |
+
}
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"name": "A-03 Schema — all 10 action types",
|
| 53 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 54 |
+
"const body = pm.response.text();",
|
| 55 |
+
"['approve','reject','flag','escalate','hold','inspect','request_docs','verify_kyc','contact_sender','file_sar'].forEach(a => {",
|
| 56 |
+
" pm.test(`action ${a} in schema`, () => pm.expect(body).to.include(a));",
|
| 57 |
+
"});"
|
| 58 |
+
], "type": "text/javascript"}}],
|
| 59 |
+
"request": {
|
| 60 |
+
"method": "GET",
|
| 61 |
+
"url": {"raw": "{{base_url}}/schema", "host": ["{{base_url}}"], "path": ["schema"]}
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
]
|
| 65 |
+
},
|
| 66 |
+
|
| 67 |
+
{
|
| 68 |
+
"name": "B — Tasks Endpoint",
|
| 69 |
+
"item": [
|
| 70 |
+
{
|
| 71 |
+
"name": "B-01 Tasks — count=20",
|
| 72 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 73 |
+
"pm.test('Status 200', () => pm.response.to.have.status(200));",
|
| 74 |
+
"const d = pm.response.json();",
|
| 75 |
+
"pm.test('count=20', () => pm.expect(d.count).to.eql(20));",
|
| 76 |
+
"pm.test('tasks array length=20', () => pm.expect(d.tasks.length).to.eql(20));"
|
| 77 |
+
], "type": "text/javascript"}}],
|
| 78 |
+
"request": {
|
| 79 |
+
"method": "GET",
|
| 80 |
+
"url": {"raw": "{{base_url}}/tasks", "host": ["{{base_url}}"], "path": ["tasks"]}
|
| 81 |
+
}
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"name": "B-02 Tasks — all 4 difficulty tiers",
|
| 85 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 86 |
+
"const tasks = pm.response.json().tasks;",
|
| 87 |
+
"const diffs = tasks.map(t => t.difficulty);",
|
| 88 |
+
"['easy','medium','hard','critical'].forEach(d => {",
|
| 89 |
+
" pm.test(`tier '${d}' present`, () => pm.expect(diffs).to.include(d));",
|
| 90 |
+
"});"
|
| 91 |
+
], "type": "text/javascript"}}],
|
| 92 |
+
"request": {
|
| 93 |
+
"method": "GET",
|
| 94 |
+
"url": {"raw": "{{base_url}}/tasks", "host": ["{{base_url}}"], "path": ["tasks"]}
|
| 95 |
+
}
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"name": "B-03 Tasks — task structure fields",
|
| 99 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 100 |
+
"const t = pm.response.json().tasks[0];",
|
| 101 |
+
"['task_id','difficulty','correct_action','requires_investigation','regulatory_action','chain_total'].forEach(f => {",
|
| 102 |
+
" pm.test(`field '${f}' present`, () => pm.expect(t).to.have.property(f));",
|
| 103 |
+
"});"
|
| 104 |
+
], "type": "text/javascript"}}],
|
| 105 |
+
"request": {
|
| 106 |
+
"method": "GET",
|
| 107 |
+
"url": {"raw": "{{base_url}}/tasks", "host": ["{{base_url}}"], "path": ["tasks"]}
|
| 108 |
+
}
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
"name": "B-04 Tasks — chain_total >= 1 on all",
|
| 112 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 113 |
+
"const tasks = pm.response.json().tasks;",
|
| 114 |
+
"pm.test('all tasks have chain_total >= 1', () => {",
|
| 115 |
+
" tasks.forEach(t => pm.expect(t.chain_total).to.be.at.least(1));",
|
| 116 |
+
"});"
|
| 117 |
+
], "type": "text/javascript"}}],
|
| 118 |
+
"request": {
|
| 119 |
+
"method": "GET",
|
| 120 |
+
"url": {"raw": "{{base_url}}/tasks", "host": ["{{base_url}}"], "path": ["tasks"]}
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
]
|
| 124 |
+
},
|
| 125 |
+
|
| 126 |
+
{
|
| 127 |
+
"name": "C — Reset",
|
| 128 |
+
"item": [
|
| 129 |
+
{
|
| 130 |
+
"name": "C-01 Reset — first observation",
|
| 131 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 132 |
+
"pm.test('Status 200', () => pm.response.to.have.status(200));",
|
| 133 |
+
"const d = pm.response.json();",
|
| 134 |
+
"pm.test('first task is EASY-001', () => pm.expect(d.task_id).to.eql('EASY-001'));",
|
| 135 |
+
"pm.test('done=false', () => pm.expect(d.done).to.eql(false));",
|
| 136 |
+
"pm.test('budget_remaining=5.0', () => pm.expect(d.budget_remaining).to.eql(5.0));",
|
| 137 |
+
"pm.test('risk_score present', () => pm.expect(d).to.have.property('risk_score'));",
|
| 138 |
+
"pm.test('ml_confidence present', () => pm.expect(d).to.have.property('ml_confidence'));",
|
| 139 |
+
"pm.test('chain_total present', () => pm.expect(d).to.have.property('chain_total'));",
|
| 140 |
+
"pm.test('steps_remaining present', () => pm.expect(d).to.have.property('steps_remaining'));"
|
| 141 |
+
], "type": "text/javascript"}}],
|
| 142 |
+
"request": {
|
| 143 |
+
"method": "POST",
|
| 144 |
+
"url": {"raw": "{{base_url}}/reset", "host": ["{{base_url}}"], "path": ["reset"]}
|
| 145 |
+
}
|
| 146 |
+
},
|
| 147 |
+
{
|
| 148 |
+
"name": "C-02 State after reset",
|
| 149 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 150 |
+
"const d = pm.response.json();",
|
| 151 |
+
"pm.test('episode_id set', () => pm.expect(d.episode_id).to.be.a('string').and.not.empty);",
|
| 152 |
+
"pm.collectionVariables.set('episode_id', d.episode_id);",
|
| 153 |
+
"pm.test('step_count=0', () => pm.expect(d.step_count).to.eql(0));",
|
| 154 |
+
"pm.test('budget_spent=0', () => pm.expect(d.budget_spent).to.eql(0));",
|
| 155 |
+
"pm.test('correct_decisions=0', () => pm.expect(d.correct_decisions).to.eql(0));",
|
| 156 |
+
"pm.test('investigation_actions_used is array', () => pm.expect(d.investigation_actions_used).to.be.an('array'));"
|
| 157 |
+
], "type": "text/javascript"}}],
|
| 158 |
+
"request": {
|
| 159 |
+
"method": "GET",
|
| 160 |
+
"url": {"raw": "{{base_url}}/state", "host": ["{{base_url}}"], "path": ["state"]}
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
]
|
| 164 |
+
},
|
| 165 |
+
|
| 166 |
+
{
|
| 167 |
+
"name": "D — Terminal Actions (Correct)",
|
| 168 |
+
"item": [
|
| 169 |
+
{
|
| 170 |
+
"name": "D-01 EASY-001 approve → reward=1.0",
|
| 171 |
+
"event": [
|
| 172 |
+
{"listen": "prerequest", "script": {"exec": ["// ensure fresh episode"], "type": "text/javascript"}},
|
| 173 |
+
{"listen": "test", "script": {"exec": [
|
| 174 |
+
"const d = pm.response.json();",
|
| 175 |
+
"pm.test('reward=1.0', () => pm.expect(d.reward).to.eql(1.0));",
|
| 176 |
+
"pm.test('advances to EASY-002', () => pm.expect(d.task_id).to.eql('EASY-002'));"
|
| 177 |
+
], "type": "text/javascript"}}
|
| 178 |
+
],
|
| 179 |
+
"request": {
|
| 180 |
+
"method": "POST",
|
| 181 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 182 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-E001\"}"},
|
| 183 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 184 |
+
}
|
| 185 |
+
},
|
| 186 |
+
{
|
| 187 |
+
"name": "D-02 EASY-002 reject → reward=1.0",
|
| 188 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 189 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 190 |
+
], "type": "text/javascript"}}],
|
| 191 |
+
"request": {
|
| 192 |
+
"method": "POST",
|
| 193 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 194 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"reject\", \"transaction_id\": \"TXN-E002\"}"},
|
| 195 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 196 |
+
}
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
"name": "D-03 EASY-003 approve → reward=1.0",
|
| 200 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 201 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 202 |
+
], "type": "text/javascript"}}],
|
| 203 |
+
"request": {
|
| 204 |
+
"method": "POST",
|
| 205 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 206 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-E003\"}"},
|
| 207 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 208 |
+
}
|
| 209 |
+
},
|
| 210 |
+
{
|
| 211 |
+
"name": "D-04 EASY-004 flag → reward=1.0",
|
| 212 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 213 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 214 |
+
], "type": "text/javascript"}}],
|
| 215 |
+
"request": {
|
| 216 |
+
"method": "POST",
|
| 217 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 218 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"flag\", \"transaction_id\": \"TXN-E004\"}"},
|
| 219 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 220 |
+
}
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"name": "D-05 MED-001 escalate → reward=1.0",
|
| 224 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 225 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 226 |
+
], "type": "text/javascript"}}],
|
| 227 |
+
"request": {
|
| 228 |
+
"method": "POST",
|
| 229 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 230 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"escalate\", \"transaction_id\": \"TXN-M001\"}"},
|
| 231 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 232 |
+
}
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"name": "D-06 MED-002 hold → reward=1.0",
|
| 236 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 237 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 238 |
+
], "type": "text/javascript"}}],
|
| 239 |
+
"request": {
|
| 240 |
+
"method": "POST",
|
| 241 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 242 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"hold\", \"transaction_id\": \"TXN-M002\"}"},
|
| 243 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 244 |
+
}
|
| 245 |
+
},
|
| 246 |
+
{
|
| 247 |
+
"name": "D-07 MED-003 flag → reward=1.0",
|
| 248 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 249 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 250 |
+
], "type": "text/javascript"}}],
|
| 251 |
+
"request": {
|
| 252 |
+
"method": "POST",
|
| 253 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 254 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"flag\", \"transaction_id\": \"TXN-M003\"}"},
|
| 255 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 256 |
+
}
|
| 257 |
+
},
|
| 258 |
+
{
|
| 259 |
+
"name": "D-08 MED-004 flag → reward=1.0",
|
| 260 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 261 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 262 |
+
], "type": "text/javascript"}}],
|
| 263 |
+
"request": {
|
| 264 |
+
"method": "POST",
|
| 265 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 266 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"flag\", \"transaction_id\": \"TXN-M004\"}"},
|
| 267 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 268 |
+
}
|
| 269 |
+
},
|
| 270 |
+
{
|
| 271 |
+
"name": "D-09 MED-005 hold → reward=1.0",
|
| 272 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 273 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 274 |
+
], "type": "text/javascript"}}],
|
| 275 |
+
"request": {
|
| 276 |
+
"method": "POST",
|
| 277 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 278 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"hold\", \"transaction_id\": \"TXN-M005\"}"},
|
| 279 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 280 |
+
}
|
| 281 |
+
},
|
| 282 |
+
{
|
| 283 |
+
"name": "D-10 MED-006 escalate → reward=1.0",
|
| 284 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 285 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 286 |
+
], "type": "text/javascript"}}],
|
| 287 |
+
"request": {
|
| 288 |
+
"method": "POST",
|
| 289 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 290 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"escalate\", \"transaction_id\": \"TXN-M006\"}"},
|
| 291 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 292 |
+
}
|
| 293 |
+
},
|
| 294 |
+
{
|
| 295 |
+
"name": "D-11 HARD-001 escalate → reward=1.0",
|
| 296 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 297 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 298 |
+
], "type": "text/javascript"}}],
|
| 299 |
+
"request": {
|
| 300 |
+
"method": "POST",
|
| 301 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 302 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"escalate\", \"transaction_id\": \"TXN-H001\"}"},
|
| 303 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 304 |
+
}
|
| 305 |
+
},
|
| 306 |
+
{
|
| 307 |
+
"name": "D-12 HARD-002 reject → reward=1.0",
|
| 308 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 309 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 310 |
+
], "type": "text/javascript"}}],
|
| 311 |
+
"request": {
|
| 312 |
+
"method": "POST",
|
| 313 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 314 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"reject\", \"transaction_id\": \"TXN-H002\"}"},
|
| 315 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 316 |
+
}
|
| 317 |
+
},
|
| 318 |
+
{
|
| 319 |
+
"name": "D-13 HARD-003 reject → reward=1.0",
|
| 320 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 321 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 322 |
+
], "type": "text/javascript"}}],
|
| 323 |
+
"request": {
|
| 324 |
+
"method": "POST",
|
| 325 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 326 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"reject\", \"transaction_id\": \"TXN-H003\"}"},
|
| 327 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 328 |
+
}
|
| 329 |
+
},
|
| 330 |
+
{
|
| 331 |
+
"name": "D-14 HARD-004 approve → reward=1.0",
|
| 332 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 333 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 334 |
+
], "type": "text/javascript"}}],
|
| 335 |
+
"request": {
|
| 336 |
+
"method": "POST",
|
| 337 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 338 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-H004\"}"},
|
| 339 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 340 |
+
}
|
| 341 |
+
},
|
| 342 |
+
{
|
| 343 |
+
"name": "D-15 HARD-005 escalate → reward=1.0",
|
| 344 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 345 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 346 |
+
], "type": "text/javascript"}}],
|
| 347 |
+
"request": {
|
| 348 |
+
"method": "POST",
|
| 349 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 350 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"escalate\", \"transaction_id\": \"TXN-H005\"}"},
|
| 351 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 352 |
+
}
|
| 353 |
+
},
|
| 354 |
+
{
|
| 355 |
+
"name": "D-16 HARD-006 flag → reward=1.0",
|
| 356 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 357 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 358 |
+
], "type": "text/javascript"}}],
|
| 359 |
+
"request": {
|
| 360 |
+
"method": "POST",
|
| 361 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 362 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"flag\", \"transaction_id\": \"TXN-H006\"}"},
|
| 363 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 364 |
+
}
|
| 365 |
+
},
|
| 366 |
+
{
|
| 367 |
+
"name": "D-17 CRIT-001 approve → reward=1.0",
|
| 368 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 369 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 370 |
+
], "type": "text/javascript"}}],
|
| 371 |
+
"request": {
|
| 372 |
+
"method": "POST",
|
| 373 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 374 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-C001\"}"},
|
| 375 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 376 |
+
}
|
| 377 |
+
},
|
| 378 |
+
{
|
| 379 |
+
"name": "D-18 CRIT-002 reject → reward=1.0",
|
| 380 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 381 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 382 |
+
], "type": "text/javascript"}}],
|
| 383 |
+
"request": {
|
| 384 |
+
"method": "POST",
|
| 385 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 386 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"reject\", \"transaction_id\": \"TXN-C002\"}"},
|
| 387 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 388 |
+
}
|
| 389 |
+
},
|
| 390 |
+
{
|
| 391 |
+
"name": "D-19 CRIT-003 escalate → reward=1.0",
|
| 392 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 393 |
+
"pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
|
| 394 |
+
], "type": "text/javascript"}}],
|
| 395 |
+
"request": {
|
| 396 |
+
"method": "POST",
|
| 397 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 398 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"escalate\", \"transaction_id\": \"TXN-C003\"}"},
|
| 399 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 400 |
+
}
|
| 401 |
+
},
|
| 402 |
+
{
|
| 403 |
+
"name": "D-20 CRIT-004 reject → reward=1.0 + done=true",
|
| 404 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 405 |
+
"const d = pm.response.json();",
|
| 406 |
+
"pm.test('reward=1.0', () => pm.expect(d.reward).to.eql(1.0));",
|
| 407 |
+
"pm.test('episode done', () => pm.expect(d.done).to.eql(true));"
|
| 408 |
+
], "type": "text/javascript"}}],
|
| 409 |
+
"request": {
|
| 410 |
+
"method": "POST",
|
| 411 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 412 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"reject\", \"transaction_id\": \"TXN-C004\"}"},
|
| 413 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
]
|
| 417 |
+
},
|
| 418 |
+
|
| 419 |
+
{
|
| 420 |
+
"name": "E — Wrong Actions & Partial Credit",
|
| 421 |
+
"item": [
|
| 422 |
+
{
|
| 423 |
+
"name": "E-00 Reset before wrong-action tests",
|
| 424 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 425 |
+
"pm.test('reset ok', () => pm.response.to.have.status(200));"
|
| 426 |
+
], "type": "text/javascript"}}],
|
| 427 |
+
"request": {
|
| 428 |
+
"method": "POST",
|
| 429 |
+
"url": {"raw": "{{base_url}}/reset", "host": ["{{base_url}}"], "path": ["reset"]}
|
| 430 |
+
}
|
| 431 |
+
},
|
| 432 |
+
{
|
| 433 |
+
"name": "E-01 Approve legit EASY-001 (correct, advance)",
|
| 434 |
+
"request": {
|
| 435 |
+
"method": "POST",
|
| 436 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 437 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-E001\"}"},
|
| 438 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 439 |
+
}
|
| 440 |
+
},
|
| 441 |
+
{
|
| 442 |
+
"name": "E-02 Approve fraud EASY-002 → reward=-1.0",
|
| 443 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 444 |
+
"pm.test('reward=-1.0 for fraud approval', () => pm.expect(pm.response.json().reward).to.eql(-1.0));"
|
| 445 |
+
], "type": "text/javascript"}}],
|
| 446 |
+
"request": {
|
| 447 |
+
"method": "POST",
|
| 448 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 449 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-E002\"}"},
|
| 450 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 451 |
+
}
|
| 452 |
+
},
|
| 453 |
+
{
|
| 454 |
+
"name": "E-03 Reject legit EASY-003 → reward=-0.5",
|
| 455 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 456 |
+
"pm.test('reward=-0.5 for wrong reject', () => pm.expect(pm.response.json().reward).to.eql(-0.5));"
|
| 457 |
+
], "type": "text/javascript"}}],
|
| 458 |
+
"request": {
|
| 459 |
+
"method": "POST",
|
| 460 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 461 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"reject\", \"transaction_id\": \"TXN-E003\"}"},
|
| 462 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 463 |
+
}
|
| 464 |
+
},
|
| 465 |
+
{
|
| 466 |
+
"name": "E-04 Flag EASY-004 correct → reward=1.0",
|
| 467 |
+
"request": {
|
| 468 |
+
"method": "POST",
|
| 469 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 470 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"flag\", \"transaction_id\": \"TXN-E004\"}"},
|
| 471 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 472 |
+
}
|
| 473 |
+
},
|
| 474 |
+
{
|
| 475 |
+
"name": "E-05 Partial credit — flag instead of escalate (MED-001)",
|
| 476 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 477 |
+
"const d = pm.response.json();",
|
| 478 |
+
"pm.test('partial credit reward > -1 and < 1', () => {",
|
| 479 |
+
" pm.expect(d.reward).to.be.greaterThan(-1.0);",
|
| 480 |
+
" pm.expect(d.reward).to.be.lessThan(1.0);",
|
| 481 |
+
"});"
|
| 482 |
+
], "type": "text/javascript"}}],
|
| 483 |
+
"request": {
|
| 484 |
+
"method": "POST",
|
| 485 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 486 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"flag\", \"transaction_id\": \"TXN-M001\"}"},
|
| 487 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 488 |
+
}
|
| 489 |
+
}
|
| 490 |
+
]
|
| 491 |
+
},
|
| 492 |
+
|
| 493 |
+
{
|
| 494 |
+
"name": "F — Investigation Sub-Actions",
|
| 495 |
+
"item": [
|
| 496 |
+
{
|
| 497 |
+
"name": "F-00 Reset before investigation tests",
|
| 498 |
+
"request": {
|
| 499 |
+
"method": "POST",
|
| 500 |
+
"url": {"raw": "{{base_url}}/reset", "host": ["{{base_url}}"], "path": ["reset"]}
|
| 501 |
+
}
|
| 502 |
+
},
|
| 503 |
+
{
|
| 504 |
+
"name": "F-01 inspect → reward=0.15, notes populated, budget=4.9",
|
| 505 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 506 |
+
"const d = pm.response.json();",
|
| 507 |
+
"pm.test('reward=0.15', () => pm.expect(d.reward).to.eql(0.15));",
|
| 508 |
+
"pm.test('task unchanged (EASY-001)', () => pm.expect(d.task_id).to.eql('EASY-001'));",
|
| 509 |
+
"pm.test('inspection_notes populated', () => pm.expect(d.inspection_notes).to.be.a('string').and.not.empty);",
|
| 510 |
+
"pm.test('budget_remaining=4.9', () => pm.expect(d.budget_remaining).to.eql(4.9));"
|
| 511 |
+
], "type": "text/javascript"}}],
|
| 512 |
+
"request": {
|
| 513 |
+
"method": "POST",
|
| 514 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 515 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"inspect\", \"transaction_id\": \"TXN-E001\"}"},
|
| 516 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 517 |
+
}
|
| 518 |
+
},
|
| 519 |
+
{
|
| 520 |
+
"name": "F-02 inspect again (duplicate) → reward=0.0",
|
| 521 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 522 |
+
"pm.test('duplicate inspect reward=0.0', () => pm.expect(pm.response.json().reward).to.eql(0.0));"
|
| 523 |
+
], "type": "text/javascript"}}],
|
| 524 |
+
"request": {
|
| 525 |
+
"method": "POST",
|
| 526 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 527 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"inspect\", \"transaction_id\": \"TXN-E001\"}"},
|
| 528 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 529 |
+
}
|
| 530 |
+
},
|
| 531 |
+
{
|
| 532 |
+
"name": "F-03 approve EASY-001 (advance to E-002)",
|
| 533 |
+
"request": {
|
| 534 |
+
"method": "POST",
|
| 535 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 536 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-E001\"}"},
|
| 537 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 538 |
+
}
|
| 539 |
+
},
|
| 540 |
+
{
|
| 541 |
+
"name": "F-04 request_docs → reward=0.15, docs_notes, budget-0.2",
|
| 542 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 543 |
+
"const d = pm.response.json();",
|
| 544 |
+
"pm.test('reward=0.15', () => pm.expect(d.reward).to.eql(0.15));",
|
| 545 |
+
"pm.test('docs_notes populated', () => pm.expect(d.docs_notes).to.be.a('string').and.not.empty);",
|
| 546 |
+
"pm.test('budget decreased by 0.2', () => pm.expect(d.budget_remaining).to.be.closeTo(4.6, 0.01));"
|
| 547 |
+
], "type": "text/javascript"}}],
|
| 548 |
+
"request": {
|
| 549 |
+
"method": "POST",
|
| 550 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 551 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"request_docs\", \"transaction_id\": \"TXN-E002\"}"},
|
| 552 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 553 |
+
}
|
| 554 |
+
},
|
| 555 |
+
{
|
| 556 |
+
"name": "F-05 request_docs again (duplicate) → reward=0.0",
|
| 557 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 558 |
+
"pm.test('duplicate request_docs reward=0.0', () => pm.expect(pm.response.json().reward).to.eql(0.0));"
|
| 559 |
+
], "type": "text/javascript"}}],
|
| 560 |
+
"request": {
|
| 561 |
+
"method": "POST",
|
| 562 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 563 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"request_docs\", \"transaction_id\": \"TXN-E002\"}"},
|
| 564 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 565 |
+
}
|
| 566 |
+
},
|
| 567 |
+
{
|
| 568 |
+
"name": "F-06 reject EASY-002 (advance to E-003)",
|
| 569 |
+
"request": {
|
| 570 |
+
"method": "POST",
|
| 571 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 572 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"reject\", \"transaction_id\": \"TXN-E002\"}"},
|
| 573 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 574 |
+
}
|
| 575 |
+
},
|
| 576 |
+
{
|
| 577 |
+
"name": "F-07 verify_kyc → reward=0.15, kyc_notes, budget-0.2",
|
| 578 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 579 |
+
"const d = pm.response.json();",
|
| 580 |
+
"pm.test('reward=0.15', () => pm.expect(d.reward).to.eql(0.15));",
|
| 581 |
+
"pm.test('kyc_notes populated', () => pm.expect(d.kyc_notes).to.be.a('string').and.not.empty);",
|
| 582 |
+
"pm.test('budget decreased by 0.2', () => pm.expect(d.budget_remaining).to.be.closeTo(4.2, 0.05));"
|
| 583 |
+
], "type": "text/javascript"}}],
|
| 584 |
+
"request": {
|
| 585 |
+
"method": "POST",
|
| 586 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 587 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"verify_kyc\", \"transaction_id\": \"TXN-E003\"}"},
|
| 588 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 589 |
+
}
|
| 590 |
+
},
|
| 591 |
+
{
|
| 592 |
+
"name": "F-08 approve EASY-003 (advance to E-004)",
|
| 593 |
+
"request": {
|
| 594 |
+
"method": "POST",
|
| 595 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 596 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-E003\"}"},
|
| 597 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 598 |
+
}
|
| 599 |
+
},
|
| 600 |
+
{
|
| 601 |
+
"name": "F-09 contact_sender → reward=0.15, contact_notes, budget-0.3",
|
| 602 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 603 |
+
"const d = pm.response.json();",
|
| 604 |
+
"pm.test('reward=0.15', () => pm.expect(d.reward).to.eql(0.15));",
|
| 605 |
+
"pm.test('contact_notes populated', () => pm.expect(d.contact_notes).to.be.a('string').and.not.empty);",
|
| 606 |
+
"pm.test('budget decreased by 0.3', () => pm.expect(d.budget_remaining).to.be.lessThan(4.2));"
|
| 607 |
+
], "type": "text/javascript"}}],
|
| 608 |
+
"request": {
|
| 609 |
+
"method": "POST",
|
| 610 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 611 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"contact_sender\", \"transaction_id\": \"TXN-E004\"}"},
|
| 612 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 613 |
+
}
|
| 614 |
+
},
|
| 615 |
+
{
|
| 616 |
+
"name": "F-10 flag EASY-004 (advance to M-001)",
|
| 617 |
+
"request": {
|
| 618 |
+
"method": "POST",
|
| 619 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 620 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"flag\", \"transaction_id\": \"TXN-E004\"}"},
|
| 621 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 622 |
+
}
|
| 623 |
+
},
|
| 624 |
+
{
|
| 625 |
+
"name": "F-11 file_sar → reward=0.15, docs_notes has SAR, budget-0.05",
|
| 626 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 627 |
+
"const d = pm.response.json();",
|
| 628 |
+
"pm.test('reward=0.15', () => pm.expect(d.reward).to.eql(0.15));",
|
| 629 |
+
"pm.test('docs_notes contains SAR', () => pm.expect(d.docs_notes).to.include('SAR'));",
|
| 630 |
+
"pm.test('budget_remaining updated', () => pm.expect(d.budget_remaining).to.be.lessThan(5.0));"
|
| 631 |
+
], "type": "text/javascript"}}],
|
| 632 |
+
"request": {
|
| 633 |
+
"method": "POST",
|
| 634 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 635 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"file_sar\", \"transaction_id\": \"TXN-M001\"}"},
|
| 636 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 637 |
+
}
|
| 638 |
+
}
|
| 639 |
+
]
|
| 640 |
+
},
|
| 641 |
+
|
| 642 |
+
{
|
| 643 |
+
"name": "G — State Endpoint",
|
| 644 |
+
"item": [
|
| 645 |
+
{
|
| 646 |
+
"name": "G-01 State — all required fields",
|
| 647 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 648 |
+
"const d = pm.response.json();",
|
| 649 |
+
"pm.test('status 200', () => pm.response.to.have.status(200));",
|
| 650 |
+
"['episode_id','step_count','budget_spent','budget_limit','investigation_actions_used','correct_decisions','wrong_high_cost','recent_decisions'].forEach(f => {",
|
| 651 |
+
" pm.test(`field '${f}' present`, () => pm.expect(d).to.have.property(f));",
|
| 652 |
+
"});"
|
| 653 |
+
], "type": "text/javascript"}}],
|
| 654 |
+
"request": {
|
| 655 |
+
"method": "GET",
|
| 656 |
+
"url": {"raw": "{{base_url}}/state", "host": ["{{base_url}}"], "path": ["state"]}
|
| 657 |
+
}
|
| 658 |
+
},
|
| 659 |
+
{
|
| 660 |
+
"name": "G-02 State — budget_spent reflects investigation costs",
|
| 661 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 662 |
+
"pm.test('budget_spent > 0', () => pm.expect(pm.response.json().budget_spent).to.be.greaterThan(0));"
|
| 663 |
+
], "type": "text/javascript"}}],
|
| 664 |
+
"request": {
|
| 665 |
+
"method": "GET",
|
| 666 |
+
"url": {"raw": "{{base_url}}/state", "host": ["{{base_url}}"], "path": ["state"]}
|
| 667 |
+
}
|
| 668 |
+
},
|
| 669 |
+
{
|
| 670 |
+
"name": "G-03 State — investigation_actions_used is array",
|
| 671 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 672 |
+
"pm.test('investigation_actions_used is array', () => pm.expect(pm.response.json().investigation_actions_used).to.be.an('array'));"
|
| 673 |
+
], "type": "text/javascript"}}],
|
| 674 |
+
"request": {
|
| 675 |
+
"method": "GET",
|
| 676 |
+
"url": {"raw": "{{base_url}}/state", "host": ["{{base_url}}"], "path": ["state"]}
|
| 677 |
+
}
|
| 678 |
+
}
|
| 679 |
+
]
|
| 680 |
+
},
|
| 681 |
+
|
| 682 |
+
{
|
| 683 |
+
"name": "H — Grader Endpoint",
|
| 684 |
+
"item": [
|
| 685 |
+
{
|
| 686 |
+
"name": "H-01 Grader — full result structure",
|
| 687 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 688 |
+
"const d = pm.response.json();",
|
| 689 |
+
"pm.test('status 200', () => pm.response.to.have.status(200));",
|
| 690 |
+
"['normalised_score','total_reward','max_possible_reward','budget_spent','budget_penalty','per_task','passed'].forEach(f => {",
|
| 691 |
+
" pm.test(`field '${f}' present`, () => pm.expect(d).to.have.property(f));",
|
| 692 |
+
"});"
|
| 693 |
+
], "type": "text/javascript"}}],
|
| 694 |
+
"request": {
|
| 695 |
+
"method": "GET",
|
| 696 |
+
"url": {"raw": "{{base_url}}/grader", "host": ["{{base_url}}"], "path": ["grader"]}
|
| 697 |
+
}
|
| 698 |
+
},
|
| 699 |
+
{
|
| 700 |
+
"name": "H-02 Grader — per_task has reward_breakdown",
|
| 701 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 702 |
+
"const d = pm.response.json();",
|
| 703 |
+
"pm.test('per_task is array', () => pm.expect(d.per_task).to.be.an('array'));",
|
| 704 |
+
"if (d.per_task.length > 0) {",
|
| 705 |
+
" pm.test('per_task[0] has reward_breakdown', () => pm.expect(d.per_task[0]).to.have.property('reward_breakdown'));",
|
| 706 |
+
" pm.test('per_task[0] has difficulty', () => pm.expect(d.per_task[0]).to.have.property('difficulty'));",
|
| 707 |
+
"}"
|
| 708 |
+
], "type": "text/javascript"}}],
|
| 709 |
+
"request": {
|
| 710 |
+
"method": "GET",
|
| 711 |
+
"url": {"raw": "{{base_url}}/grader", "host": ["{{base_url}}"], "path": ["grader"]}
|
| 712 |
+
}
|
| 713 |
+
},
|
| 714 |
+
{
|
| 715 |
+
"name": "H-03 Grader — normalised_score in [0,1]",
|
| 716 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 717 |
+
"const s = pm.response.json().normalised_score;",
|
| 718 |
+
"pm.test('score in [0,1]', () => { pm.expect(s).to.be.at.least(0); pm.expect(s).to.be.at.most(1); });"
|
| 719 |
+
], "type": "text/javascript"}}],
|
| 720 |
+
"request": {
|
| 721 |
+
"method": "GET",
|
| 722 |
+
"url": {"raw": "{{base_url}}/grader", "host": ["{{base_url}}"], "path": ["grader"]}
|
| 723 |
+
}
|
| 724 |
+
}
|
| 725 |
+
]
|
| 726 |
+
},
|
| 727 |
+
|
| 728 |
+
{
|
| 729 |
+
"name": "I — Replay Endpoint",
|
| 730 |
+
"item": [
|
| 731 |
+
{
|
| 732 |
+
"name": "I-01 Replay — 20 all-correct actions → score=1.0",
|
| 733 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 734 |
+
"const d = pm.response.json();",
|
| 735 |
+
"pm.test('normalised_score=1.0', () => pm.expect(d.normalised_score).to.eql(1.0));",
|
| 736 |
+
"pm.test('passed=true', () => pm.expect(d.passed).to.eql(true));",
|
| 737 |
+
"pm.test('budget_spent=0.0', () => pm.expect(d.budget_spent).to.eql(0.0));"
|
| 738 |
+
], "type": "text/javascript"}}],
|
| 739 |
+
"request": {
|
| 740 |
+
"method": "POST",
|
| 741 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 742 |
+
"body": {"mode": "raw", "raw": "{\n \"actions\": [\n \"approve\", \"reject\", \"approve\", \"flag\",\n \"escalate\", \"hold\", \"flag\", \"flag\", \"hold\", \"escalate\",\n \"escalate\", \"reject\", \"reject\", \"approve\", \"escalate\", \"flag\",\n \"approve\", \"reject\", \"escalate\", \"reject\"\n ]\n}"},
|
| 743 |
+
"url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
|
| 744 |
+
}
|
| 745 |
+
},
|
| 746 |
+
{
|
| 747 |
+
"name": "I-02 Replay — with inspect sub-actions → budget_spent=0.1",
|
| 748 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 749 |
+
"const d = pm.response.json();",
|
| 750 |
+
"pm.test('budget_spent=0.1', () => pm.expect(d.budget_spent).to.eql(0.1));",
|
| 751 |
+
"pm.test('per_task present', () => pm.expect(d.per_task).to.be.an('array'));"
|
| 752 |
+
], "type": "text/javascript"}}],
|
| 753 |
+
"request": {
|
| 754 |
+
"method": "POST",
|
| 755 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 756 |
+
"body": {"mode": "raw", "raw": "{\n \"actions\": [\n \"inspect\", \"approve\", \"reject\", \"approve\", \"flag\",\n \"escalate\", \"hold\", \"flag\", \"flag\", \"hold\", \"escalate\",\n \"escalate\", \"reject\", \"reject\", \"approve\", \"escalate\", \"flag\",\n \"approve\", \"reject\", \"escalate\", \"reject\"\n ]\n}"},
|
| 757 |
+
"url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
|
| 758 |
+
}
|
| 759 |
+
},
|
| 760 |
+
{
|
| 761 |
+
"name": "I-03 Replay — with confidences",
|
| 762 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 763 |
+
"pm.test('accepts confidences, returns score', () => pm.expect(pm.response.json()).to.have.property('normalised_score'));"
|
| 764 |
+
], "type": "text/javascript"}}],
|
| 765 |
+
"request": {
|
| 766 |
+
"method": "POST",
|
| 767 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 768 |
+
"body": {"mode": "raw", "raw": "{\n \"actions\": [\"approve\", \"reject\"],\n \"confidences\": [0.95, 0.88]\n}"},
|
| 769 |
+
"url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
|
| 770 |
+
}
|
| 771 |
+
},
|
| 772 |
+
{
|
| 773 |
+
"name": "I-04 Replay — all-wrong actions → score very low",
|
| 774 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 775 |
+
"const d = pm.response.json();",
|
| 776 |
+
"pm.test('all-wrong score < 0.3', () => pm.expect(d.normalised_score).to.be.lessThan(0.3));",
|
| 777 |
+
"pm.test('passed=false', () => pm.expect(d.passed).to.eql(false));"
|
| 778 |
+
], "type": "text/javascript"}}],
|
| 779 |
+
"request": {
|
| 780 |
+
"method": "POST",
|
| 781 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 782 |
+
"body": {"mode": "raw", "raw": "{\n \"actions\": [\n \"reject\", \"approve\", \"reject\", \"reject\",\n \"reject\", \"reject\", \"reject\", \"reject\", \"reject\", \"reject\",\n \"reject\", \"approve\", \"approve\", \"reject\", \"reject\", \"reject\",\n \"reject\", \"approve\", \"reject\", \"approve\"\n ]\n}"},
|
| 783 |
+
"url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
|
| 784 |
+
}
|
| 785 |
+
},
|
| 786 |
+
{
|
| 787 |
+
"name": "I-05 Replay — invalid action → 422",
|
| 788 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 789 |
+
"pm.test('422 or error body', () => {",
|
| 790 |
+
" const ok = pm.response.code === 422 || (pm.response.code === 400);",
|
| 791 |
+
" const hasError = pm.response.text().includes('Invalid') || pm.response.text().includes('detail');",
|
| 792 |
+
" pm.expect(ok || hasError).to.be.true;",
|
| 793 |
+
"});"
|
| 794 |
+
], "type": "text/javascript"}}],
|
| 795 |
+
"request": {
|
| 796 |
+
"method": "POST",
|
| 797 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 798 |
+
"body": {"mode": "raw", "raw": "{\"actions\": [\"delete\", \"approve\"]}"},
|
| 799 |
+
"url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
|
| 800 |
+
}
|
| 801 |
+
},
|
| 802 |
+
{
|
| 803 |
+
"name": "I-06 Replay — empty actions list",
|
| 804 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 805 |
+
"pm.test('handles empty list gracefully', () => pm.expect(pm.response.json()).to.have.property('normalised_score'));"
|
| 806 |
+
], "type": "text/javascript"}}],
|
| 807 |
+
"request": {
|
| 808 |
+
"method": "POST",
|
| 809 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 810 |
+
"body": {"mode": "raw", "raw": "{\"actions\": []}"},
|
| 811 |
+
"url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
|
| 812 |
+
}
|
| 813 |
+
},
|
| 814 |
+
{
|
| 815 |
+
"name": "I-07 Replay — does NOT alter live server state",
|
| 816 |
+
"event": [
|
| 817 |
+
{"listen": "prerequest", "script": {"exec": [
|
| 818 |
+
"// capture current state to compare after replay",
|
| 819 |
+
"pm.sendRequest(`${pm.collectionVariables.get('base_url') || 'http://localhost:8000'}/state`, (err, res) => {",
|
| 820 |
+
" if (!err) pm.collectionVariables.set('state_before', JSON.stringify(res.json().step_count));",
|
| 821 |
+
"});"
|
| 822 |
+
], "type": "text/javascript"}},
|
| 823 |
+
{"listen": "test", "script": {"exec": [
|
| 824 |
+
"pm.sendRequest(`${pm.collectionVariables.get('base_url') || 'http://localhost:8000'}/state`, (err, res) => {",
|
| 825 |
+
" const before = pm.collectionVariables.get('state_before');",
|
| 826 |
+
" const after = JSON.stringify(res.json().step_count);",
|
| 827 |
+
" pm.test('server state unchanged after replay', () => pm.expect(before).to.eql(after));",
|
| 828 |
+
"});"
|
| 829 |
+
], "type": "text/javascript"}}
|
| 830 |
+
],
|
| 831 |
+
"request": {
|
| 832 |
+
"method": "POST",
|
| 833 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 834 |
+
"body": {"mode": "raw", "raw": "{\"actions\": [\"approve\", \"reject\"]}"},
|
| 835 |
+
"url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
|
| 836 |
+
}
|
| 837 |
+
}
|
| 838 |
+
]
|
| 839 |
+
},
|
| 840 |
+
|
| 841 |
+
{
|
| 842 |
+
"name": "J — Baseline Endpoint",
|
| 843 |
+
"item": [
|
| 844 |
+
{
|
| 845 |
+
"name": "J-01 Baseline — full result",
|
| 846 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 847 |
+
"const d = pm.response.json();",
|
| 848 |
+
"pm.test('status 200', () => pm.response.to.have.status(200));",
|
| 849 |
+
"['normalised_score','total_reward','steps','scores'].forEach(f => {",
|
| 850 |
+
" pm.test(`field '${f}' present`, () => pm.expect(d).to.have.property(f));",
|
| 851 |
+
"});",
|
| 852 |
+
"pm.test('score > 0.5', () => pm.expect(d.normalised_score).to.be.greaterThan(0.5));",
|
| 853 |
+
"pm.test('steps = 20', () => pm.expect(d.steps).to.eql(20));",
|
| 854 |
+
"pm.test('scores length = 20', () => pm.expect(d.scores.length).to.eql(20));"
|
| 855 |
+
], "type": "text/javascript"}}],
|
| 856 |
+
"request": {
|
| 857 |
+
"method": "POST",
|
| 858 |
+
"url": {"raw": "{{base_url}}/baseline", "host": ["{{base_url}}"], "path": ["baseline"]}
|
| 859 |
+
}
|
| 860 |
+
},
|
| 861 |
+
{
|
| 862 |
+
"name": "J-02 Baseline — passed=true",
|
| 863 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 864 |
+
"pm.test('passed=true', () => pm.expect(pm.response.json().passed).to.eql(true));"
|
| 865 |
+
], "type": "text/javascript"}}],
|
| 866 |
+
"request": {
|
| 867 |
+
"method": "POST",
|
| 868 |
+
"url": {"raw": "{{base_url}}/baseline", "host": ["{{base_url}}"], "path": ["baseline"]}
|
| 869 |
+
}
|
| 870 |
+
}
|
| 871 |
+
]
|
| 872 |
+
},
|
| 873 |
+
|
| 874 |
+
{
|
| 875 |
+
"name": "K — Analytics Endpoint",
|
| 876 |
+
"item": [
|
| 877 |
+
{
|
| 878 |
+
"name": "K-01 Analytics — structure",
|
| 879 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 880 |
+
"pm.test('status 200', () => pm.response.to.have.status(200));",
|
| 881 |
+
"const d = pm.response.json();",
|
| 882 |
+
"const hasMsg = d.message !== undefined;",
|
| 883 |
+
"const hasData = d.episodes_completed !== undefined;",
|
| 884 |
+
"pm.test('returns message or episode data', () => pm.expect(hasMsg || hasData).to.be.true);"
|
| 885 |
+
], "type": "text/javascript"}}],
|
| 886 |
+
"request": {
|
| 887 |
+
"method": "GET",
|
| 888 |
+
"url": {"raw": "{{base_url}}/analytics", "host": ["{{base_url}}"], "path": ["analytics"]}
|
| 889 |
+
}
|
| 890 |
+
},
|
| 891 |
+
{
|
| 892 |
+
"name": "K-02 Analytics — after completed episode",
|
| 893 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 894 |
+
"const d = pm.response.json();",
|
| 895 |
+
"pm.test('episodes_completed >= 1', () => pm.expect(d.episodes_completed).to.be.at.least(1));",
|
| 896 |
+
"pm.test('best_score present', () => pm.expect(d).to.have.property('best_score'));",
|
| 897 |
+
"pm.test('avg_score present', () => pm.expect(d).to.have.property('avg_score'));",
|
| 898 |
+
"pm.test('by_difficulty present', () => pm.expect(d).to.have.property('by_difficulty'));",
|
| 899 |
+
"pm.test('easy accuracy present', () => pm.expect(d.by_difficulty).to.have.property('easy'));",
|
| 900 |
+
"pm.test('critical accuracy present', () => pm.expect(d.by_difficulty).to.have.property('critical'));"
|
| 901 |
+
], "type": "text/javascript"}},
|
| 902 |
+
{"listen": "prerequest", "script": {"exec": [
|
| 903 |
+
"// Run a complete episode first so analytics has data",
|
| 904 |
+
"const base = pm.collectionVariables.get('base_url') || 'http://localhost:8000';",
|
| 905 |
+
"const actions = [",
|
| 906 |
+
" ['approve','TXN-E001'],['reject','TXN-E002'],['approve','TXN-E003'],['flag','TXN-E004'],",
|
| 907 |
+
" ['escalate','TXN-M001'],['hold','TXN-M002'],['flag','TXN-M003'],['flag','TXN-M004'],",
|
| 908 |
+
" ['hold','TXN-M005'],['escalate','TXN-M006'],",
|
| 909 |
+
" ['escalate','TXN-H001'],['reject','TXN-H002'],['reject','TXN-H003'],['approve','TXN-H004'],",
|
| 910 |
+
" ['escalate','TXN-H005'],['flag','TXN-H006'],",
|
| 911 |
+
" ['approve','TXN-C001'],['reject','TXN-C002'],['escalate','TXN-C003'],['reject','TXN-C004']",
|
| 912 |
+
"];",
|
| 913 |
+
"// Note: triggering via test runner means sequence matters — this prerequest",
|
| 914 |
+
"// runs before the actual GET /analytics is sent."
|
| 915 |
+
], "type": "text/javascript"}}],
|
| 916 |
+
"request": {
|
| 917 |
+
"method": "GET",
|
| 918 |
+
"url": {"raw": "{{base_url}}/analytics", "host": ["{{base_url}}"], "path": ["analytics"]}
|
| 919 |
+
}
|
| 920 |
+
},
|
| 921 |
+
{
|
| 922 |
+
"name": "K-03 Analytics — current_episode present",
|
| 923 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 924 |
+
"pm.test('current_episode field present', () => pm.expect(pm.response.json()).to.have.property('current_episode'));"
|
| 925 |
+
], "type": "text/javascript"}}],
|
| 926 |
+
"request": {
|
| 927 |
+
"method": "GET",
|
| 928 |
+
"url": {"raw": "{{base_url}}/analytics", "host": ["{{base_url}}"], "path": ["analytics"]}
|
| 929 |
+
}
|
| 930 |
+
}
|
| 931 |
+
]
|
| 932 |
+
},
|
| 933 |
+
|
| 934 |
+
{
|
| 935 |
+
"name": "L — Leaderboard Endpoint",
|
| 936 |
+
"item": [
|
| 937 |
+
{
|
| 938 |
+
"name": "L-01 Leaderboard — structure",
|
| 939 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 940 |
+
"const d = pm.response.json();",
|
| 941 |
+
"pm.test('status 200', () => pm.response.to.have.status(200));",
|
| 942 |
+
"pm.test('count field present', () => pm.expect(d).to.have.property('count'));",
|
| 943 |
+
"pm.test('entries field present', () => pm.expect(d).to.have.property('entries'));",
|
| 944 |
+
"pm.test('entries is array', () => pm.expect(d.entries).to.be.an('array'));"
|
| 945 |
+
], "type": "text/javascript"}}],
|
| 946 |
+
"request": {
|
| 947 |
+
"method": "GET",
|
| 948 |
+
"url": {"raw": "{{base_url}}/leaderboard", "host": ["{{base_url}}"], "path": ["leaderboard"]}
|
| 949 |
+
}
|
| 950 |
+
},
|
| 951 |
+
{
|
| 952 |
+
"name": "L-02 Leaderboard — entry structure",
|
| 953 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 954 |
+
"const d = pm.response.json();",
|
| 955 |
+
"if (d.entries.length > 0) {",
|
| 956 |
+
" const e = d.entries[0];",
|
| 957 |
+
" ['episode_id','normalised_score','timestamp','budget_spent'].forEach(f => {",
|
| 958 |
+
" pm.test(`entry has field '${f}'`, () => pm.expect(e).to.have.property(f));",
|
| 959 |
+
" });",
|
| 960 |
+
"} else {",
|
| 961 |
+
" pm.test('no entries yet (ok before completing episode)', () => true);",
|
| 962 |
+
"}"
|
| 963 |
+
], "type": "text/javascript"}}],
|
| 964 |
+
"request": {
|
| 965 |
+
"method": "GET",
|
| 966 |
+
"url": {"raw": "{{base_url}}/leaderboard", "host": ["{{base_url}}"], "path": ["leaderboard"]}
|
| 967 |
+
}
|
| 968 |
+
},
|
| 969 |
+
{
|
| 970 |
+
"name": "L-03 Leaderboard — after baseline run, count >= 1",
|
| 971 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 972 |
+
"pm.test('at least one entry after baseline', () => pm.expect(pm.response.json().count).to.be.at.least(1));"
|
| 973 |
+
], "type": "text/javascript"}}],
|
| 974 |
+
"request": {
|
| 975 |
+
"method": "GET",
|
| 976 |
+
"url": {"raw": "{{base_url}}/leaderboard", "host": ["{{base_url}}"], "path": ["leaderboard"]}
|
| 977 |
+
}
|
| 978 |
+
}
|
| 979 |
+
]
|
| 980 |
+
},
|
| 981 |
+
|
| 982 |
+
{
|
| 983 |
+
"name": "M — Budget Mechanics",
|
| 984 |
+
"item": [
|
| 985 |
+
{
|
| 986 |
+
"name": "M-00 Reset for budget test",
|
| 987 |
+
"request": {
|
| 988 |
+
"method": "POST",
|
| 989 |
+
"url": {"raw": "{{base_url}}/reset", "host": ["{{base_url}}"], "path": ["reset"]}
|
| 990 |
+
}
|
| 991 |
+
},
|
| 992 |
+
{
|
| 993 |
+
"name": "M-01 contact_sender (cost=0.3) → budget=4.7",
|
| 994 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 995 |
+
"pm.test('budget_remaining=4.7', () => pm.expect(pm.response.json().budget_remaining).to.be.closeTo(4.7, 0.01));"
|
| 996 |
+
], "type": "text/javascript"}}],
|
| 997 |
+
"request": {
|
| 998 |
+
"method": "POST",
|
| 999 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 1000 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"contact_sender\", \"transaction_id\": \"TXN-E001\"}"},
|
| 1001 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 1002 |
+
}
|
| 1003 |
+
},
|
| 1004 |
+
{
|
| 1005 |
+
"name": "M-02 request_docs (cost=0.2) → budget=4.5",
|
| 1006 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 1007 |
+
"pm.test('budget_remaining=4.5', () => pm.expect(pm.response.json().budget_remaining).to.be.closeTo(4.5, 0.01));"
|
| 1008 |
+
], "type": "text/javascript"}}],
|
| 1009 |
+
"request": {
|
| 1010 |
+
"method": "POST",
|
| 1011 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 1012 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"request_docs\", \"transaction_id\": \"TXN-E001\"}"},
|
| 1013 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 1014 |
+
}
|
| 1015 |
+
},
|
| 1016 |
+
{
|
| 1017 |
+
"name": "M-03 verify_kyc (cost=0.2) → budget=4.3",
|
| 1018 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 1019 |
+
"pm.test('budget_remaining=4.3', () => pm.expect(pm.response.json().budget_remaining).to.be.closeTo(4.3, 0.01));"
|
| 1020 |
+
], "type": "text/javascript"}}],
|
| 1021 |
+
"request": {
|
| 1022 |
+
"method": "POST",
|
| 1023 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 1024 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"verify_kyc\", \"transaction_id\": \"TXN-E001\"}"},
|
| 1025 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 1026 |
+
}
|
| 1027 |
+
},
|
| 1028 |
+
{
|
| 1029 |
+
"name": "M-04 file_sar (cost=0.05) → budget=4.25",
|
| 1030 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 1031 |
+
"pm.test('budget_remaining=4.25', () => pm.expect(pm.response.json().budget_remaining).to.be.closeTo(4.25, 0.01));"
|
| 1032 |
+
], "type": "text/javascript"}}],
|
| 1033 |
+
"request": {
|
| 1034 |
+
"method": "POST",
|
| 1035 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 1036 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"file_sar\", \"transaction_id\": \"TXN-E001\"}"},
|
| 1037 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 1038 |
+
}
|
| 1039 |
+
},
|
| 1040 |
+
{
|
| 1041 |
+
"name": "M-05 Grader shows non-zero budget_spent",
|
| 1042 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 1043 |
+
"pm.test('budget_spent > 0', () => pm.expect(pm.response.json().budget_spent).to.be.greaterThan(0));"
|
| 1044 |
+
], "type": "text/javascript"}}],
|
| 1045 |
+
"request": {
|
| 1046 |
+
"method": "GET",
|
| 1047 |
+
"url": {"raw": "{{base_url}}/grader", "host": ["{{base_url}}"], "path": ["grader"]}
|
| 1048 |
+
}
|
| 1049 |
+
},
|
| 1050 |
+
{
|
| 1051 |
+
"name": "M-06 Replay — heavy investigation causes budget_penalty",
|
| 1052 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 1053 |
+
"const d = pm.response.json();",
|
| 1054 |
+
"pm.test('budget_overspend > 0', () => pm.expect(d.budget_overspend).to.be.greaterThan(0));",
|
| 1055 |
+
"pm.test('budget_penalty > 0', () => pm.expect(d.budget_penalty).to.be.greaterThan(0));"
|
| 1056 |
+
], "type": "text/javascript"}}],
|
| 1057 |
+
"request": {
|
| 1058 |
+
"method": "POST",
|
| 1059 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 1060 |
+
"body": {"mode": "raw", "raw": "{\n \"actions\": [\n \"contact_sender\",\"contact_sender\",\"contact_sender\",\"contact_sender\",\n \"contact_sender\",\"contact_sender\",\"contact_sender\",\"contact_sender\",\n \"contact_sender\",\"contact_sender\",\"contact_sender\",\"contact_sender\",\n \"contact_sender\",\"contact_sender\",\"contact_sender\",\"contact_sender\",\n \"contact_sender\",\"contact_sender\",\n \"approve\",\"reject\",\"approve\",\"flag\",\n \"escalate\",\"hold\",\"flag\",\"flag\",\"hold\",\"escalate\",\n \"escalate\",\"reject\",\"reject\",\"approve\",\"escalate\",\"flag\",\n \"approve\",\"reject\",\"escalate\",\"reject\"\n ]\n}"},
|
| 1061 |
+
"url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
|
| 1062 |
+
}
|
| 1063 |
+
}
|
| 1064 |
+
]
|
| 1065 |
+
},
|
| 1066 |
+
|
| 1067 |
+
{
|
| 1068 |
+
"name": "N — Edge Cases & Errors",
|
| 1069 |
+
"item": [
|
| 1070 |
+
{
|
| 1071 |
+
"name": "N-01 Unknown action_type → 422",
|
| 1072 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 1073 |
+
"pm.test('error response', () => {",
|
| 1074 |
+
" const code = pm.response.code;",
|
| 1075 |
+
" pm.expect([400, 422]).to.include(code);",
|
| 1076 |
+
"});"
|
| 1077 |
+
], "type": "text/javascript"}}],
|
| 1078 |
+
"request": {
|
| 1079 |
+
"method": "POST",
|
| 1080 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 1081 |
+
"body": {"mode": "raw", "raw": "{\"action_type\": \"nuke\", \"transaction_id\": \"TXN-E001\"}"},
|
| 1082 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 1083 |
+
}
|
| 1084 |
+
},
|
| 1085 |
+
{
|
| 1086 |
+
"name": "N-02 Missing action_type → 422",
|
| 1087 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 1088 |
+
"pm.test('422 for missing field', () => pm.expect([400, 422]).to.include(pm.response.code));"
|
| 1089 |
+
], "type": "text/javascript"}}],
|
| 1090 |
+
"request": {
|
| 1091 |
+
"method": "POST",
|
| 1092 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 1093 |
+
"body": {"mode": "raw", "raw": "{\"transaction_id\": \"TXN-E001\"}"},
|
| 1094 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 1095 |
+
}
|
| 1096 |
+
},
|
| 1097 |
+
{
|
| 1098 |
+
"name": "N-03 Empty POST body → 422",
|
| 1099 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 1100 |
+
"pm.test('422 for empty body', () => pm.expect([400, 422]).to.include(pm.response.code));"
|
| 1101 |
+
], "type": "text/javascript"}}],
|
| 1102 |
+
"request": {
|
| 1103 |
+
"method": "POST",
|
| 1104 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 1105 |
+
"body": {"mode": "raw", "raw": "{}"},
|
| 1106 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 1107 |
+
}
|
| 1108 |
+
},
|
| 1109 |
+
{
|
| 1110 |
+
"name": "N-04 Replay with partial confidences (shorter than actions)",
|
| 1111 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 1112 |
+
"pm.test('handles partial confidences gracefully', () => pm.expect(pm.response.json()).to.have.property('normalised_score'));"
|
| 1113 |
+
], "type": "text/javascript"}}],
|
| 1114 |
+
"request": {
|
| 1115 |
+
"method": "POST",
|
| 1116 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 1117 |
+
"body": {"mode": "raw", "raw": "{\"actions\": [\"approve\", \"reject\", \"approve\"], \"confidences\": [0.9]}"},
|
| 1118 |
+
"url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
|
| 1119 |
+
}
|
| 1120 |
+
},
|
| 1121 |
+
{
|
| 1122 |
+
"name": "N-05 Replay with invalid JSON → 422",
|
| 1123 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 1124 |
+
"pm.test('error on invalid JSON', () => pm.expect([400, 422]).to.include(pm.response.code));"
|
| 1125 |
+
], "type": "text/javascript"}}],
|
| 1126 |
+
"request": {
|
| 1127 |
+
"method": "POST",
|
| 1128 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 1129 |
+
"body": {"mode": "raw", "raw": "{broken json}"},
|
| 1130 |
+
"url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
|
| 1131 |
+
}
|
| 1132 |
+
}
|
| 1133 |
+
]
|
| 1134 |
+
},
|
| 1135 |
+
|
| 1136 |
+
{
|
| 1137 |
+
"name": "O — Perfect Episode (Full Run)",
|
| 1138 |
+
"item": [
|
| 1139 |
+
{
|
| 1140 |
+
"name": "O-00 Reset (start fresh)",
|
| 1141 |
+
"request": {
|
| 1142 |
+
"method": "POST",
|
| 1143 |
+
"url": {"raw": "{{base_url}}/reset", "host": ["{{base_url}}"], "path": ["reset"]}
|
| 1144 |
+
}
|
| 1145 |
+
},
|
| 1146 |
+
{"name": "O-01 EASY-001 approve", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"approve\",\"transaction_id\":\"TXN-E001\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1147 |
+
{"name": "O-02 EASY-002 reject", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"reject\",\"transaction_id\":\"TXN-E002\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1148 |
+
{"name": "O-03 EASY-003 approve", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"approve\",\"transaction_id\":\"TXN-E003\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1149 |
+
{"name": "O-04 EASY-004 flag", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"flag\",\"transaction_id\":\"TXN-E004\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1150 |
+
{"name": "O-05 MED-001 escalate", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"escalate\",\"transaction_id\":\"TXN-M001\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1151 |
+
{"name": "O-06 MED-002 hold", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"hold\",\"transaction_id\":\"TXN-M002\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1152 |
+
{"name": "O-07 MED-003 flag", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"flag\",\"transaction_id\":\"TXN-M003\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1153 |
+
{"name": "O-08 MED-004 flag", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"flag\",\"transaction_id\":\"TXN-M004\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1154 |
+
{"name": "O-09 MED-005 hold", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"hold\",\"transaction_id\":\"TXN-M005\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1155 |
+
{"name": "O-10 MED-006 escalate", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"escalate\",\"transaction_id\":\"TXN-M006\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1156 |
+
{"name": "O-11 HARD-001 escalate", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"escalate\",\"transaction_id\":\"TXN-H001\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1157 |
+
{"name": "O-12 HARD-002 reject", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"reject\",\"transaction_id\":\"TXN-H002\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1158 |
+
{"name": "O-13 HARD-003 reject", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"reject\",\"transaction_id\":\"TXN-H003\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1159 |
+
{"name": "O-14 HARD-004 approve", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"approve\",\"transaction_id\":\"TXN-H004\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1160 |
+
{"name": "O-15 HARD-005 escalate", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"escalate\",\"transaction_id\":\"TXN-H005\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1161 |
+
{"name": "O-16 HARD-006 flag", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"flag\",\"transaction_id\":\"TXN-H006\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1162 |
+
{"name": "O-17 CRIT-001 approve", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"approve\",\"transaction_id\":\"TXN-C001\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1163 |
+
{"name": "O-18 CRIT-002 reject", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"reject\",\"transaction_id\":\"TXN-C002\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1164 |
+
{"name": "O-19 CRIT-003 escalate", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"escalate\",\"transaction_id\":\"TXN-C003\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
|
| 1165 |
+
{
|
| 1166 |
+
"name": "O-20 CRIT-004 reject → done=true",
|
| 1167 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 1168 |
+
"const d = pm.response.json();",
|
| 1169 |
+
"pm.test('reward=1.0', () => pm.expect(d.reward).to.eql(1.0));",
|
| 1170 |
+
"pm.test('done=true', () => pm.expect(d.done).to.eql(true));"
|
| 1171 |
+
], "type": "text/javascript"}}],
|
| 1172 |
+
"request": {
|
| 1173 |
+
"method": "POST",
|
| 1174 |
+
"header": [{"key": "Content-Type", "value": "application/json"}],
|
| 1175 |
+
"body": {"mode": "raw", "raw": "{\"action_type\":\"reject\",\"transaction_id\":\"TXN-C004\"}"},
|
| 1176 |
+
"url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
|
| 1177 |
+
}
|
| 1178 |
+
},
|
| 1179 |
+
{
|
| 1180 |
+
"name": "O-21 Grader — score=1.0 after perfect episode",
|
| 1181 |
+
"event": [{"listen": "test", "script": {"exec": [
|
| 1182 |
+
"const d = pm.response.json();",
|
| 1183 |
+
"pm.test('normalised_score=1.0', () => pm.expect(d.normalised_score).to.eql(1.0));",
|
| 1184 |
+
"pm.test('passed=true', () => pm.expect(d.passed).to.eql(true));",
|
| 1185 |
+
"pm.test('budget_penalty=0', () => pm.expect(d.budget_penalty).to.eql(0.0));"
|
| 1186 |
+
], "type": "text/javascript"}}],
|
| 1187 |
+
"request": {
|
| 1188 |
+
"method": "GET",
|
| 1189 |
+
"url": {"raw": "{{base_url}}/grader", "host": ["{{base_url}}"], "path": ["grader"]}
|
| 1190 |
+
}
|
| 1191 |
+
}
|
| 1192 |
+
]
|
| 1193 |
+
}
|
| 1194 |
+
|
| 1195 |
+
]
|
| 1196 |
+
}
|
README.md
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PayOps — Payment Operations Incident Response
|
| 2 |
+
|
| 3 |
+
An **OpenEnv-compatible** reinforcement-learning environment where an AI agent
|
| 4 |
+
acts as a Payment Operations analyst. The agent reviews financial transactions
|
| 5 |
+
one by one and must decide the correct compliance action for each.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Motivation
|
| 10 |
+
|
| 11 |
+
Payment operations teams process thousands of transactions every day. A
|
| 12 |
+
skilled analyst uses dozens of signals — risk scores, velocity, KYC status,
|
| 13 |
+
flag patterns — to make fast, accurate decisions. This environment lets an AI
|
| 14 |
+
agent learn and be evaluated on exactly this task, spanning clear-cut cases all
|
| 15 |
+
the way to subtle adversarial patterns like model-score poisoning and
|
| 16 |
+
Authorised Push Payment (APP) scams.
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## Environment Description
|
| 21 |
+
|
| 22 |
+
Each **episode** steps through 12 transactions (4 easy, 4 medium, 4 hard).
|
| 23 |
+
For each transaction the agent observes a rich set of signals and submits one
|
| 24 |
+
of six possible actions. A reward is returned immediately, and the next
|
| 25 |
+
transaction is presented until the episode is complete.
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## Action Space
|
| 30 |
+
|
| 31 |
+
| Action | Description |
|
| 32 |
+
|-------------|-------------|
|
| 33 |
+
| `approve` | Mark transaction as legitimate; allow it through |
|
| 34 |
+
| `reject` | Block the transaction outright |
|
| 35 |
+
| `flag` | Soft hold; mark for manual review |
|
| 36 |
+
| `escalate` | Route to senior compliance officer / fraud team |
|
| 37 |
+
| `inspect` | Request additional signals (logs, KYC, velocity) — yields reveal notes and a small reward; agent then acts again on the same transaction |
|
| 38 |
+
| `hold` | Temporary hold pending more information |
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
## Observation Space
|
| 43 |
+
|
| 44 |
+
| Field | Type | Description |
|
| 45 |
+
|----------------------|----------------|-------------|
|
| 46 |
+
| `transaction_id` | `str` | Unique transaction identifier |
|
| 47 |
+
| `amount` | `float` | Transaction amount |
|
| 48 |
+
| `currency` | `str` | ISO-4217 currency code |
|
| 49 |
+
| `sender` | `str` | Sender identifier |
|
| 50 |
+
| `receiver` | `str` | Receiver identifier |
|
| 51 |
+
| `transaction_type` | `str` | transfer \| payment \| withdrawal \| refund \| internal |
|
| 52 |
+
| `status` | `str` | pending \| approved \| rejected \| flagged \| escalated \| held \| inspected |
|
| 53 |
+
| `risk_score` | `float [0,1]` | Composite ML risk score |
|
| 54 |
+
| `flags` | `List[str]` | Active risk flags |
|
| 55 |
+
| `velocity_1h` | `int?` | Transactions from sender in the past hour |
|
| 56 |
+
| `country_risk` | `str?` | low \| medium \| high \| sanctioned |
|
| 57 |
+
| `kyc_status` | `str?` | verified \| pending \| failed \| none |
|
| 58 |
+
| `previous_violations`| `int?` | Prior compliance violations |
|
| 59 |
+
| `inspection_notes` | `str?` | Extra details revealed after an `inspect` action |
|
| 60 |
+
| `task_id` | `str` | Identifier of the active task |
|
| 61 |
+
| `task_difficulty` | `str` | easy \| medium \| hard |
|
| 62 |
+
| `step_in_episode` | `int` | Steps elapsed in this episode |
|
| 63 |
+
| `reward` | `float` | Reward from the last action |
|
| 64 |
+
| `cumulative_reward` | `float` | Total reward so far this episode |
|
| 65 |
+
| `done` | `bool` | Whether the episode has ended |
|
| 66 |
+
| `info` | `dict` | Diagnostic info (event, correct action, etc.) |
|
| 67 |
+
|
| 68 |
+
---
|
| 69 |
+
|
| 70 |
+
## Task Descriptions
|
| 71 |
+
|
| 72 |
+
### Easy (4 tasks — clear signals)
|
| 73 |
+
|
| 74 |
+
| ID | Description | Correct Action |
|
| 75 |
+
|----------|-------------|----------------|
|
| 76 |
+
| EASY-001 | Low-value domestic transfer between verified users; no flags | `approve` |
|
| 77 |
+
| EASY-002 | Textbook fraud: unknown sender, offshore, sanctioned country, risk=0.97 | `reject` |
|
| 78 |
+
| EASY-003 | Standard refund to verified customer; tiny amount, no flags | `approve` |
|
| 79 |
+
| EASY-004 | ATM withdrawal burst — 15 withdrawals in 58 minutes | `flag` |
|
| 80 |
+
|
| 81 |
+
### Medium (4 tasks — ambiguous, multi-signal reasoning required)
|
| 82 |
+
|
| 83 |
+
| ID | Description | Correct Action |
|
| 84 |
+
|---------|-------------|----------------|
|
| 85 |
+
| MED-001 | Large B2B wire, verified CFO, cross-border to medium-risk jurisdiction | `escalate` |
|
| 86 |
+
| MED-002 | Internal treasury transfer; large amount, KYC pending renewal | `hold` |
|
| 87 |
+
| MED-003 | Recurring subscription 3× higher than historical average | `flag` |
|
| 88 |
+
| MED-004 | Payment to licensed crypto exchange from verified personal account | `flag` |
|
| 89 |
+
|
| 90 |
+
### Hard (4 tasks — adversarial / edge-case)
|
| 91 |
+
|
| 92 |
+
| ID | Description | Correct Action |
|
| 93 |
+
|----------|-------------|----------------|
|
| 94 |
+
| HARD-001 | Fraud model poisoning: risk_score=0.18 but manual signals scream escalate | `escalate` |
|
| 95 |
+
| HARD-002 | APP (Authorised Push Payment) scam: victim sending willingly to mule account | `reject` |
|
| 96 |
+
| HARD-003 | Structuring / smurfing: three just-below-CTR-threshold payments, same UBO | `reject` |
|
| 97 |
+
| HARD-004 | Legitimate FX correspondent banking settlement — looks alarming, is not | `approve` |
|
| 98 |
+
|
| 99 |
+
---
|
| 100 |
+
|
| 101 |
+
## Reward Design
|
| 102 |
+
|
| 103 |
+
| Outcome | Reward |
|
| 104 |
+
|---------|--------|
|
| 105 |
+
| Correct action | **+1.0** |
|
| 106 |
+
| Partial-credit adjacent action (per-task) | **+0.2 – +0.6** |
|
| 107 |
+
| `inspect` (information seeking, first time) | **+0.15** |
|
| 108 |
+
| `approve` when correct is `reject` / `escalate` | **−1.0** |
|
| 109 |
+
| `approve` when correct is `flag` / `hold` | **−0.5** |
|
| 110 |
+
| `reject` when correct is `approve` | **−0.5** |
|
| 111 |
+
| Any other wrong action | **−0.25** |
|
| 112 |
+
|
| 113 |
+
The **episode score** (0–1) is: `max(0, total_reward) / max_possible_reward`.
|
| 114 |
+
A score ≥ 0.5 is considered a passing episode.
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
|
| 118 |
+
## API Endpoints
|
| 119 |
+
|
| 120 |
+
| Method | Path | Description |
|
| 121 |
+
|--------|------|-------------|
|
| 122 |
+
| `POST` | `/reset` | Reset environment, return first observation |
|
| 123 |
+
| `POST` | `/step` | Execute an action |
|
| 124 |
+
| `GET` | `/state` | Current internal environment state |
|
| 125 |
+
| `GET` | `/schema` | JSON schemas for action / observation / state |
|
| 126 |
+
| `GET` | `/tasks` | Full task list with metadata |
|
| 127 |
+
| `GET` | `/grader` | Grade the current episode |
|
| 128 |
+
| `POST` | `/baseline` | Run rule-based baseline and return scores |
|
| 129 |
+
| `GET` | `/health` | Health check |
|
| 130 |
+
| `WS` | `/ws` | WebSocket persistent session |
|
| 131 |
+
|
| 132 |
+
Interactive API docs: `http://localhost:8000/docs`
|
| 133 |
+
|
| 134 |
+
---
|
| 135 |
+
|
| 136 |
+
## Setup & Running
|
| 137 |
+
|
| 138 |
+
### Local (Python)
|
| 139 |
+
|
| 140 |
+
```bash
|
| 141 |
+
# 1. Install dependencies
|
| 142 |
+
pip install -r requirements.txt
|
| 143 |
+
|
| 144 |
+
# 2. Start the server (from the parent directory of payops_env)
|
| 145 |
+
PYTHONPATH=$(pwd) uvicorn payops_env.server.app:app --host 0.0.0.0 --port 8000
|
| 146 |
+
|
| 147 |
+
# 3. Verify
|
| 148 |
+
curl http://localhost:8000/health
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
### Run the baseline agent
|
| 152 |
+
|
| 153 |
+
```bash
|
| 154 |
+
# From the project root
|
| 155 |
+
PYTHONPATH=$(pwd) python payops_env/scripts/baseline_agent.py
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
### Docker
|
| 159 |
+
|
| 160 |
+
```bash
|
| 161 |
+
# Build
|
| 162 |
+
docker build -t payops-env .
|
| 163 |
+
|
| 164 |
+
# Run locally on port 8000
|
| 165 |
+
docker run -p 8000:7860 -e PORT=7860 payops-env
|
| 166 |
+
|
| 167 |
+
# Verify
|
| 168 |
+
curl http://localhost:8000/health
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
### HuggingFace Space
|
| 172 |
+
|
| 173 |
+
The `Dockerfile` exposes port **7860** (HF Spaces default). Push the repo to
|
| 174 |
+
a HF Space with Docker runtime — no additional configuration required.
|
| 175 |
+
|
| 176 |
+
---
|
| 177 |
+
|
| 178 |
+
## Example Agent Interaction
|
| 179 |
+
|
| 180 |
+
```python
|
| 181 |
+
import httpx
|
| 182 |
+
|
| 183 |
+
base = "http://localhost:8000"
|
| 184 |
+
|
| 185 |
+
# Reset
|
| 186 |
+
obs = httpx.post(f"{base}/reset").json()
|
| 187 |
+
print(obs["transaction_id"], obs["risk_score"], obs["flags"])
|
| 188 |
+
|
| 189 |
+
# Step
|
| 190 |
+
while not obs["done"]:
|
| 191 |
+
# ... agent decides action_type ...
|
| 192 |
+
obs = httpx.post(f"{base}/step", json={
|
| 193 |
+
"action_type": "approve",
|
| 194 |
+
"transaction_id": obs["transaction_id"],
|
| 195 |
+
}).json()
|
| 196 |
+
print(f"reward={obs['reward']:+.2f} done={obs['done']}")
|
| 197 |
+
|
| 198 |
+
# Grade
|
| 199 |
+
score = httpx.get(f"{base}/grader").json()
|
| 200 |
+
print(f"Episode score: {score['normalised_score']:.4f}")
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
---
|
| 204 |
+
|
| 205 |
+
## Baseline Results
|
| 206 |
+
|
| 207 |
+
The rule-based baseline agent uses a deterministic priority-ordered policy.
|
| 208 |
+
|
| 209 |
+
| Metric | Baseline |
|
| 210 |
+
|--------|----------|
|
| 211 |
+
| Normalised score | ~0.67 |
|
| 212 |
+
| Total reward | ~8.0 / 12.0 |
|
| 213 |
+
| Passed | Yes |
|
| 214 |
+
| Strong at | Easy tasks, clear velocity/flag patterns |
|
| 215 |
+
| Weak at | Hard adversarial tasks (e.g. FX settlement over-rejected) |
|
| 216 |
+
|
| 217 |
+
Run `POST /baseline` to reproduce these results programmatically.
|
| 218 |
+
|
| 219 |
+
---
|
| 220 |
+
|
| 221 |
+
## Project Structure
|
| 222 |
+
|
| 223 |
+
```
|
| 224 |
+
payops_env/
|
| 225 |
+
├── models.py # PayOpsAction, PayOpsObservation, PayOpsState (Pydantic)
|
| 226 |
+
├── environment.py # PayOpsEnvironment — reset_async / step_async / state
|
| 227 |
+
├── tasks.py # 12 tasks (easy / medium / hard) with ground-truth labels
|
| 228 |
+
├── grader.py # Partial-credit reward function + episode grader
|
| 229 |
+
├── scripts_util.py # Baseline runner helper (used by /baseline endpoint)
|
| 230 |
+
├── scripts/
|
| 231 |
+
│ └── baseline_agent.py # Standalone rule-based baseline agent
|
| 232 |
+
├── server/
|
| 233 |
+
│ └── app.py # FastAPI server with all required endpoints
|
| 234 |
+
├── openenv.yaml # OpenEnv manifest
|
| 235 |
+
├── Dockerfile # Docker / HuggingFace Space container
|
| 236 |
+
├── requirements.txt # Python dependencies
|
| 237 |
+
└── README.md # This file
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
---
|
| 241 |
+
|
| 242 |
+
## Evaluation Criteria Alignment
|
| 243 |
+
|
| 244 |
+
| Criterion | Implementation |
|
| 245 |
+
|-----------|---------------|
|
| 246 |
+
| Real-world utility | Payment fraud and compliance triage — a genuine operational domain |
|
| 247 |
+
| Task & grader quality | 12 tasks across 3 difficulty tiers; partial-credit grader; clear pass/fail |
|
| 248 |
+
| Environment design | Rich observation space; 6-action space; inspect mechanic; episode state tracking |
|
| 249 |
+
| Code quality & spec compliance | Pydantic v2 models; async API; all required endpoints; openenv.yaml |
|
| 250 |
+
| Creativity & novelty | Adversarial model-poisoning task; APP scam; FX settlement edge-case |
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
| Outcome | Reward |
|
| 254 |
+
|----------------------------------|--------|
|
| 255 |
+
| Correct action | +1.0 |
|
| 256 |
+
| Approve a dangerous transaction | -2.0 |
|
| 257 |
+
| Wrong reject | -0.5 |
|
| 258 |
+
| Wrong flag / escalate | -0.25 |
|
| 259 |
+
|
| 260 |
+
## Quick Start
|
| 261 |
+
|
| 262 |
+
```bash
|
| 263 |
+
# Install dependencies
|
| 264 |
+
pip install -r requirements.txt
|
| 265 |
+
|
| 266 |
+
# Run the server
|
| 267 |
+
uvicorn server.app:app --reload
|
| 268 |
+
|
| 269 |
+
# Run the baseline agent (no server needed)
|
| 270 |
+
python scripts/baseline_agent.py
|
| 271 |
+
```
|
| 272 |
+
|
| 273 |
+
## Docker
|
| 274 |
+
|
| 275 |
+
```bash
|
| 276 |
+
docker build -t payops-env:latest .
|
| 277 |
+
docker run -p 8000:8000 payops-env:latest
|
| 278 |
+
```
|
TESTING.md
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PayOps Environment — Test Cases & Testing Guide
|
| 2 |
+
|
| 3 |
+
This document covers every testable behaviour of the PayOps OpenEnv, organised
|
| 4 |
+
by endpoint and scenario. Each test shows the exact command to run, the
|
| 5 |
+
expected response, and what a failure looks like.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Prerequisites
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
# 1. Start the server (run from /Users/padmapriya)
|
| 13 |
+
PYTHONPATH=/Users/padmapriya uvicorn payops_env.server.app:app --host 0.0.0.0 --port 8000
|
| 14 |
+
|
| 15 |
+
# 2. Confirm it is up (should return {"status":"ok",...})
|
| 16 |
+
curl -s http://localhost:8000/health
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
All `curl` commands below assume the server is running on `localhost:8000`.
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## T-01 Health Check
|
| 24 |
+
|
| 25 |
+
**Goal:** Confirm the server is alive and returns version metadata.
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
curl -s http://localhost:8000/health
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
**Expected output**
|
| 32 |
+
```json
|
| 33 |
+
{"status": "ok", "environment": "payops_env", "version": "1.0.0"}
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
**Failure indicator:** Connection refused, or any field missing / wrong value.
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## T-02 Schema Endpoint
|
| 41 |
+
|
| 42 |
+
**Goal:** Verify that action, observation, and state JSON schemas are served correctly.
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
curl -s http://localhost:8000/schema | python3 -m json.tool
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
**Expected output (condensed)**
|
| 49 |
+
```json
|
| 50 |
+
{
|
| 51 |
+
"action": { "title": "PayOpsAction", "type": "object", ... },
|
| 52 |
+
"observation": { "title": "PayOpsObservation", "type": "object", ... },
|
| 53 |
+
"state": { "title": "PayOpsState", "type": "object", ... }
|
| 54 |
+
}
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
**Checks to verify manually:**
|
| 58 |
+
- `action.properties` includes `action_type`, `transaction_id`, `reason`, `confidence`
|
| 59 |
+
- `observation.properties` includes `risk_score`, `flags`, `kyc_status`, `velocity_1h`
|
| 60 |
+
- HTTP status code is `200`
|
| 61 |
+
|
| 62 |
+
---
|
| 63 |
+
|
| 64 |
+
## T-03 Tasks Endpoint
|
| 65 |
+
|
| 66 |
+
**Goal:** Confirm all 12 tasks are returned with the correct difficulty distribution.
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
curl -s http://localhost:8000/tasks | python3 -c "
|
| 70 |
+
import sys, json
|
| 71 |
+
d = json.load(sys.stdin)
|
| 72 |
+
print('Total tasks:', d['count'])
|
| 73 |
+
from collections import Counter
|
| 74 |
+
c = Counter(t['difficulty'] for t in d['tasks'])
|
| 75 |
+
print('By difficulty:', dict(c))
|
| 76 |
+
print()
|
| 77 |
+
for t in d['tasks']:
|
| 78 |
+
print(f\" {t['task_id']:12} [{t['difficulty']:6}] correct={t['correct_action']}\")
|
| 79 |
+
"
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
**Expected output**
|
| 83 |
+
```
|
| 84 |
+
Total tasks: 12
|
| 85 |
+
By difficulty: {'easy': 4, 'medium': 4, 'hard': 4}
|
| 86 |
+
|
| 87 |
+
EASY-001 [easy ] correct=approve
|
| 88 |
+
EASY-002 [easy ] correct=reject
|
| 89 |
+
EASY-003 [easy ] correct=approve
|
| 90 |
+
EASY-004 [easy ] correct=flag
|
| 91 |
+
MED-001 [medium] correct=escalate
|
| 92 |
+
MED-002 [medium] correct=hold
|
| 93 |
+
MED-003 [medium] correct=flag
|
| 94 |
+
MED-004 [medium] correct=flag
|
| 95 |
+
HARD-001 [hard ] correct=escalate
|
| 96 |
+
HARD-002 [hard ] correct=reject
|
| 97 |
+
HARD-003 [hard ] correct=reject
|
| 98 |
+
HARD-004 [hard ] correct=approve
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
**Failure indicator:** count != 12, missing difficulty tier, wrong correct_action.
|
| 102 |
+
|
| 103 |
+
---
|
| 104 |
+
|
| 105 |
+
## T-04 Reset
|
| 106 |
+
|
| 107 |
+
**Goal:** Reset the environment and confirm the first task is EASY-001.
|
| 108 |
+
|
| 109 |
+
```bash
|
| 110 |
+
curl -s -X POST http://localhost:8000/reset | python3 -c "
|
| 111 |
+
import sys, json
|
| 112 |
+
d = json.load(sys.stdin)
|
| 113 |
+
print('task_id :', d['task_id'])
|
| 114 |
+
print('transaction_id :', d['transaction_id'])
|
| 115 |
+
print('difficulty :', d['task_difficulty'])
|
| 116 |
+
print('status :', d['status'])
|
| 117 |
+
print('done :', d['done'])
|
| 118 |
+
print('reward :', d['reward'])
|
| 119 |
+
print('cumulative_reward :', d['cumulative_reward'])
|
| 120 |
+
print('risk_score :', d['risk_score'])
|
| 121 |
+
"
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
**Expected output**
|
| 125 |
+
```
|
| 126 |
+
task_id : EASY-001
|
| 127 |
+
transaction_id : TXN-E001
|
| 128 |
+
difficulty : easy
|
| 129 |
+
status : pending
|
| 130 |
+
done : false
|
| 131 |
+
reward : 0.0
|
| 132 |
+
cumulative_reward : 0.0
|
| 133 |
+
risk_score : 0.05
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
**Failure indicator:** `done=true`, `reward != 0`, wrong `task_id`.
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
## T-05 Correct Action — Full Credit (+1.0)
|
| 141 |
+
|
| 142 |
+
**Goal:** Submit the correct action for EASY-001 (`approve`) and receive reward +1.0.
|
| 143 |
+
|
| 144 |
+
```bash
|
| 145 |
+
curl -s -X POST http://localhost:8000/reset > /dev/null # fresh start
|
| 146 |
+
|
| 147 |
+
curl -s -X POST http://localhost:8000/step \
|
| 148 |
+
-H "Content-Type: application/json" \
|
| 149 |
+
-d '{"action_type":"approve","transaction_id":"TXN-E001"}' \
|
| 150 |
+
| python3 -c "
|
| 151 |
+
import sys, json
|
| 152 |
+
d = json.load(sys.stdin)
|
| 153 |
+
print('reward :', d['reward'])
|
| 154 |
+
print('correct info :', d['info'].get('correct_action'))
|
| 155 |
+
print('action taken :', d['info'].get('action_taken'))
|
| 156 |
+
"
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
**Expected output**
|
| 160 |
+
```
|
| 161 |
+
reward : 1.0
|
| 162 |
+
correct info : approve
|
| 163 |
+
action taken : approve
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
---
|
| 167 |
+
|
| 168 |
+
## T-06 Wrong Action — Penalty (approve on fraud = -1.0)
|
| 169 |
+
|
| 170 |
+
**Goal:** Skip to EASY-002 (textbook fraud) and approve it. Expect -1.0 penalty.
|
| 171 |
+
|
| 172 |
+
```bash
|
| 173 |
+
curl -s -X POST http://localhost:8000/reset > /dev/null
|
| 174 |
+
# Step past EASY-001
|
| 175 |
+
curl -s -X POST http://localhost:8000/step \
|
| 176 |
+
-H "Content-Type: application/json" \
|
| 177 |
+
-d '{"action_type":"approve","transaction_id":"TXN-E001"}' > /dev/null
|
| 178 |
+
|
| 179 |
+
# Now on EASY-002 (correct=reject). Try approving it.
|
| 180 |
+
curl -s -X POST http://localhost:8000/step \
|
| 181 |
+
-H "Content-Type: application/json" \
|
| 182 |
+
-d '{"action_type":"approve","transaction_id":"TXN-E002"}' \
|
| 183 |
+
| python3 -c "
|
| 184 |
+
import sys, json
|
| 185 |
+
d = json.load(sys.stdin)
|
| 186 |
+
print('reward :', d['reward'])
|
| 187 |
+
print('correct was :', d['info'].get('correct_action'))
|
| 188 |
+
"
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
**Expected output**
|
| 192 |
+
```
|
| 193 |
+
reward : -1.0
|
| 194 |
+
correct was : reject
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
---
|
| 198 |
+
|
| 199 |
+
## T-07 Partial Credit Action
|
| 200 |
+
|
| 201 |
+
**Goal:** On MED-001 (correct=`escalate`), submit `flag` — should earn +0.5 partial credit.
|
| 202 |
+
|
| 203 |
+
```bash
|
| 204 |
+
curl -s -X POST http://localhost:8000/reset > /dev/null
|
| 205 |
+
# Step through EASY tasks 1-4 with any actions
|
| 206 |
+
for ACTION in approve reject approve reject; do
|
| 207 |
+
curl -s -X POST http://localhost:8000/step \
|
| 208 |
+
-H "Content-Type: application/json" \
|
| 209 |
+
-d "{\"action_type\":\"$ACTION\",\"transaction_id\":\"dummy\"}" > /dev/null
|
| 210 |
+
done
|
| 211 |
+
|
| 212 |
+
# Now on MED-001 (correct=escalate). Submit flag.
|
| 213 |
+
curl -s -X POST http://localhost:8000/step \
|
| 214 |
+
-H "Content-Type: application/json" \
|
| 215 |
+
-d '{"action_type":"flag","transaction_id":"TXN-M001"}' \
|
| 216 |
+
| python3 -c "
|
| 217 |
+
import sys, json
|
| 218 |
+
d = json.load(sys.stdin)
|
| 219 |
+
print('reward :', d['reward'])
|
| 220 |
+
print('correct was :', d['info'].get('correct_action'))
|
| 221 |
+
print('partial? :', 0 < d['reward'] < 1.0)
|
| 222 |
+
"
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
**Expected output**
|
| 226 |
+
```
|
| 227 |
+
reward : 0.5
|
| 228 |
+
correct was : escalate
|
| 229 |
+
partial? : True
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
---
|
| 233 |
+
|
| 234 |
+
## T-08 Inspect Action — Information Reveal
|
| 235 |
+
|
| 236 |
+
**Goal:** Use `inspect` on EASY-001 to receive investigation notes and a small reward (+0.15). The episode should NOT advance (still on same transaction).
|
| 237 |
+
|
| 238 |
+
```bash
|
| 239 |
+
curl -s -X POST http://localhost:8000/reset > /dev/null
|
| 240 |
+
|
| 241 |
+
curl -s -X POST http://localhost:8000/step \
|
| 242 |
+
-H "Content-Type: application/json" \
|
| 243 |
+
-d '{"action_type":"inspect","transaction_id":"TXN-E001"}' \
|
| 244 |
+
| python3 -c "
|
| 245 |
+
import sys, json
|
| 246 |
+
d = json.load(sys.stdin)
|
| 247 |
+
print('reward :', d['reward'])
|
| 248 |
+
print('status :', d['status'])
|
| 249 |
+
print('task_id :', d['task_id']) # should still be EASY-001
|
| 250 |
+
print('inspection_notes :', d['inspection_notes'])
|
| 251 |
+
"
|
| 252 |
+
```
|
| 253 |
+
|
| 254 |
+
**Expected output**
|
| 255 |
+
```
|
| 256 |
+
reward : 0.15
|
| 257 |
+
status : inspected
|
| 258 |
+
task_id : EASY-001
|
| 259 |
+
inspection_notes : Sender account opened 3 years ago. Consistent transaction history. KYC fully verified.
|
| 260 |
+
```
|
| 261 |
+
|
| 262 |
+
---
|
| 263 |
+
|
| 264 |
+
## T-09 Double Inspect — No Double-Dipping
|
| 265 |
+
|
| 266 |
+
**Goal:** Inspect the same transaction twice. Second inspect should return reward 0.0 (already inspected).
|
| 267 |
+
|
| 268 |
+
```bash
|
| 269 |
+
curl -s -X POST http://localhost:8000/reset > /dev/null
|
| 270 |
+
|
| 271 |
+
# First inspect — reward 0.15
|
| 272 |
+
curl -s -X POST http://localhost:8000/step \
|
| 273 |
+
-H "Content-Type: application/json" \
|
| 274 |
+
-d '{"action_type":"inspect","transaction_id":"TXN-E001"}' \
|
| 275 |
+
| python3 -c "import sys,json; d=json.load(sys.stdin); print('First inspect reward:', d['reward'])"
|
| 276 |
+
|
| 277 |
+
# Second inspect — reward 0.0
|
| 278 |
+
curl -s -X POST http://localhost:8000/step \
|
| 279 |
+
-H "Content-Type: application/json" \
|
| 280 |
+
-d '{"action_type":"inspect","transaction_id":"TXN-E001"}' \
|
| 281 |
+
| python3 -c "import sys,json; d=json.load(sys.stdin); print('Second inspect reward:', d['reward'])"
|
| 282 |
+
```
|
| 283 |
+
|
| 284 |
+
**Expected output**
|
| 285 |
+
```
|
| 286 |
+
First inspect reward: 0.15
|
| 287 |
+
Second inspect reward: 0.0
|
| 288 |
+
```
|
| 289 |
+
|
| 290 |
+
---
|
| 291 |
+
|
| 292 |
+
## T-10 Invalid Action Type
|
| 293 |
+
|
| 294 |
+
**Goal:** Send an unsupported action type and receive a 422 validation error.
|
| 295 |
+
|
| 296 |
+
```bash
|
| 297 |
+
curl -s -X POST http://localhost:8000/step \
|
| 298 |
+
-H "Content-Type: application/json" \
|
| 299 |
+
-d '{"action_type":"delete","transaction_id":"TXN-E001"}' \
|
| 300 |
+
| python3 -c "import sys,json; d=json.load(sys.stdin); print('status_code:', d.get('detail','')[:60])"
|
| 301 |
+
```
|
| 302 |
+
|
| 303 |
+
**Expected output**
|
| 304 |
+
```
|
| 305 |
+
status_code: Invalid action_type 'delete'. Valid values: ['approve', 'escal
|
| 306 |
+
```
|
| 307 |
+
|
| 308 |
+
HTTP status code should be `422`.
|
| 309 |
+
|
| 310 |
+
---
|
| 311 |
+
|
| 312 |
+
## T-11 Step Without Reset
|
| 313 |
+
|
| 314 |
+
**Goal:** Call `/step` without calling `/reset` first. Should return a `400` error.
|
| 315 |
+
|
| 316 |
+
```bash
|
| 317 |
+
# Kill and restart server to guarantee clean state
|
| 318 |
+
# Then immediately step without reset:
|
| 319 |
+
curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:8000/step \
|
| 320 |
+
-H "Content-Type: application/json" \
|
| 321 |
+
-d '{"action_type":"approve","transaction_id":"TXN-E001"}'
|
| 322 |
+
```
|
| 323 |
+
|
| 324 |
+
**Expected output**
|
| 325 |
+
```
|
| 326 |
+
400
|
| 327 |
+
```
|
| 328 |
+
|
| 329 |
+
---
|
| 330 |
+
|
| 331 |
+
## T-12 State Endpoint Tracking
|
| 332 |
+
|
| 333 |
+
**Goal:** Confirm `/state` reflects the episode progress correctly.
|
| 334 |
+
|
| 335 |
+
```bash
|
| 336 |
+
curl -s -X POST http://localhost:8000/reset > /dev/null
|
| 337 |
+
|
| 338 |
+
curl -s http://localhost:8000/state | python3 -c "
|
| 339 |
+
import sys, json
|
| 340 |
+
d = json.load(sys.stdin)
|
| 341 |
+
print('step_count :', d['step_count'])
|
| 342 |
+
print('transactions_processed:', d['transactions_processed'])
|
| 343 |
+
print('total_tasks :', d['total_tasks'])
|
| 344 |
+
print('done :', d['done'])
|
| 345 |
+
"
|
| 346 |
+
|
| 347 |
+
# Take one step
|
| 348 |
+
curl -s -X POST http://localhost:8000/step \
|
| 349 |
+
-H "Content-Type: application/json" \
|
| 350 |
+
-d '{"action_type":"approve","transaction_id":"TXN-E001"}' > /dev/null
|
| 351 |
+
|
| 352 |
+
curl -s http://localhost:8000/state | python3 -c "
|
| 353 |
+
import sys, json
|
| 354 |
+
d = json.load(sys.stdin)
|
| 355 |
+
print('step_count :', d['step_count'])
|
| 356 |
+
print('transactions_processed:', d['transactions_processed'])
|
| 357 |
+
print('last_action :', d['last_action'])
|
| 358 |
+
print('cumulative_reward :', d['cumulative_reward'])
|
| 359 |
+
"
|
| 360 |
+
```
|
| 361 |
+
|
| 362 |
+
**Expected output (before step)**
|
| 363 |
+
```
|
| 364 |
+
step_count : 0
|
| 365 |
+
transactions_processed: 0
|
| 366 |
+
total_tasks : 12
|
| 367 |
+
done : false
|
| 368 |
+
```
|
| 369 |
+
|
| 370 |
+
**Expected output (after step)**
|
| 371 |
+
```
|
| 372 |
+
step_count : 1
|
| 373 |
+
transactions_processed: 1
|
| 374 |
+
last_action : approve
|
| 375 |
+
cumulative_reward : 1.0
|
| 376 |
+
```
|
| 377 |
+
|
| 378 |
+
---
|
| 379 |
+
|
| 380 |
+
## T-13 Complete Episode — Done Flag
|
| 381 |
+
|
| 382 |
+
**Goal:** Step through all 12 tasks and confirm `done=true` on the last step.
|
| 383 |
+
|
| 384 |
+
```bash
|
| 385 |
+
curl -s -X POST http://localhost:8000/reset > /dev/null
|
| 386 |
+
|
| 387 |
+
python3 - <<'EOF'
|
| 388 |
+
import httpx, asyncio
|
| 389 |
+
|
| 390 |
+
BASE = "http://localhost:8000"
|
| 391 |
+
ACTIONS = [
|
| 392 |
+
"approve","reject","approve","flag", # easy
|
| 393 |
+
"escalate","hold","flag","flag", # medium
|
| 394 |
+
"escalate","reject","reject","approve" # hard (perfect sequence)
|
| 395 |
+
]
|
| 396 |
+
|
| 397 |
+
client = httpx.Client()
|
| 398 |
+
txn_ids = [t["transaction_id"] for t in client.get(f"{BASE}/tasks").json()["tasks"]]
|
| 399 |
+
|
| 400 |
+
for i, (action, txn) in enumerate(zip(ACTIONS, txn_ids)):
|
| 401 |
+
resp = client.post(f"{BASE}/step", json={"action_type": action, "transaction_id": txn}).json()
|
| 402 |
+
print(f"Step {i+1:2d} {txn:12} action={action:10} reward={resp['reward']:+.2f} done={resp['done']}")
|
| 403 |
+
|
| 404 |
+
client.close()
|
| 405 |
+
EOF
|
| 406 |
+
```
|
| 407 |
+
|
| 408 |
+
**Expected output (last line)**
|
| 409 |
+
```
|
| 410 |
+
Step 12 TXN-H004 action=approve reward=+1.00 done=True
|
| 411 |
+
```
|
| 412 |
+
|
| 413 |
+
All other steps should show `done=False`.
|
| 414 |
+
|
| 415 |
+
---
|
| 416 |
+
|
| 417 |
+
## T-14 Grader Endpoint
|
| 418 |
+
|
| 419 |
+
**Goal:** Grade and score the episode immediately after completing all steps.
|
| 420 |
+
|
| 421 |
+
```bash
|
| 422 |
+
# Run the perfect sequence first (T-13 above), then:
|
| 423 |
+
curl -s http://localhost:8000/grader | python3 -c "
|
| 424 |
+
import sys, json
|
| 425 |
+
d = json.load(sys.stdin)
|
| 426 |
+
print('total_reward :', d['total_reward'])
|
| 427 |
+
print('max_possible :', d['max_possible_reward'])
|
| 428 |
+
print('normalised_score :', d['normalised_score'])
|
| 429 |
+
print('passed :', d['passed'])
|
| 430 |
+
print()
|
| 431 |
+
for t in d['per_task']:
|
| 432 |
+
mark = '✓' if t['correct'] else '✗'
|
| 433 |
+
print(f\" {mark} {t['task_id']:12} action={t['action_taken']:10} correct={t['correct_action']:10} reward={t['reward']:+.2f}\")
|
| 434 |
+
"
|
| 435 |
+
```
|
| 436 |
+
|
| 437 |
+
**Expected output (perfect run)**
|
| 438 |
+
```
|
| 439 |
+
total_reward : 12.0
|
| 440 |
+
max_possible : 12.0
|
| 441 |
+
normalised_score : 1.0
|
| 442 |
+
passed : True
|
| 443 |
+
|
| 444 |
+
✓ EASY-001 action=approve correct=approve reward=+1.00
|
| 445 |
+
✓ EASY-002 action=reject correct=reject reward=+1.00
|
| 446 |
+
...
|
| 447 |
+
✓ HARD-004 action=approve correct=approve reward=+1.00
|
| 448 |
+
```
|
| 449 |
+
|
| 450 |
+
---
|
| 451 |
+
|
| 452 |
+
## T-15 Grader Without Episode
|
| 453 |
+
|
| 454 |
+
**Goal:** Call `/grader` before any steps — should return a 400 error.
|
| 455 |
+
|
| 456 |
+
```bash
|
| 457 |
+
curl -s -X POST http://localhost:8000/reset > /dev/null
|
| 458 |
+
curl -s http://localhost:8000/grader
|
| 459 |
+
```
|
| 460 |
+
|
| 461 |
+
**Expected output**
|
| 462 |
+
```json
|
| 463 |
+
{"error": "No actions recorded. Run /reset then /step first."}
|
| 464 |
+
```
|
| 465 |
+
|
| 466 |
+
---
|
| 467 |
+
|
| 468 |
+
## T-16 Baseline Endpoint
|
| 469 |
+
|
| 470 |
+
**Goal:** Confirm `/baseline` runs the rule-based agent and returns a normalised score ≥ 0.5.
|
| 471 |
+
|
| 472 |
+
```bash
|
| 473 |
+
curl -s -X POST http://localhost:8000/baseline | python3 -c "
|
| 474 |
+
import sys, json
|
| 475 |
+
d = json.load(sys.stdin)
|
| 476 |
+
print('normalised_score :', d['normalised_score'])
|
| 477 |
+
print('total_reward :', d['total_reward'])
|
| 478 |
+
print('steps :', d['steps'])
|
| 479 |
+
print('passed (>=0.5) :', d['normalised_score'] >= 0.5)
|
| 480 |
+
print()
|
| 481 |
+
for t in d['scores']:
|
| 482 |
+
mark = '✓' if t['correct'] else '✗'
|
| 483 |
+
print(f\" {mark} {t['task_id']:12} [{t['difficulty']:6}] action={t['action_taken']:10} reward={t['reward']:+.2f}\")
|
| 484 |
+
"
|
| 485 |
+
```
|
| 486 |
+
|
| 487 |
+
**Expected output**
|
| 488 |
+
```
|
| 489 |
+
normalised_score : 0.7292
|
| 490 |
+
total_reward : 8.75
|
| 491 |
+
steps : 12
|
| 492 |
+
passed (>=0.5) : True
|
| 493 |
+
|
| 494 |
+
✓ EASY-001 [easy ] action=approve reward=+1.00
|
| 495 |
+
✓ EASY-002 [easy ] action=reject reward=+1.00
|
| 496 |
+
...
|
| 497 |
+
```
|
| 498 |
+
|
| 499 |
+
---
|
| 500 |
+
|
| 501 |
+
## T-17 Optional Reason and Confidence Fields
|
| 502 |
+
|
| 503 |
+
**Goal:** Submit an action with optional `reason` and `confidence`. Should be accepted without error.
|
| 504 |
+
|
| 505 |
+
```bash
|
| 506 |
+
curl -s -X POST http://localhost:8000/reset > /dev/null
|
| 507 |
+
|
| 508 |
+
curl -s -X POST http://localhost:8000/step \
|
| 509 |
+
-H "Content-Type: application/json" \
|
| 510 |
+
-d '{
|
| 511 |
+
"action_type": "flag",
|
| 512 |
+
"transaction_id": "TXN-E001",
|
| 513 |
+
"reason": "Low risk but flagging for audit trail verification",
|
| 514 |
+
"confidence": 0.82
|
| 515 |
+
}' \
|
| 516 |
+
| python3 -c "import sys,json; d=json.load(sys.stdin); print('HTTP OK, reward:', d['reward'])"
|
| 517 |
+
```
|
| 518 |
+
|
| 519 |
+
**Expected output**
|
| 520 |
+
```
|
| 521 |
+
HTTP OK, reward: 0.3
|
| 522 |
+
```
|
| 523 |
+
|
| 524 |
+
(0.3 = partial credit for flagging when correct action is approve)
|
| 525 |
+
|
| 526 |
+
---
|
| 527 |
+
|
| 528 |
+
## T-18 Hard Task — Model Poisoning (HARD-001)
|
| 529 |
+
|
| 530 |
+
**Goal:** Demonstrate that the low risk_score (0.18) is a trap. The correct action is `escalate`.
|
| 531 |
+
|
| 532 |
+
```bash
|
| 533 |
+
curl -s -X POST http://localhost:8000/reset > /dev/null
|
| 534 |
+
# Advance to HARD-001 (task 9) — step through easy + medium with dummy actions
|
| 535 |
+
python3 - <<'EOF'
|
| 536 |
+
import httpx
|
| 537 |
+
BASE = "http://localhost:8000"
|
| 538 |
+
c = httpx.Client()
|
| 539 |
+
tasks = c.get(f"{BASE}/tasks").json()["tasks"]
|
| 540 |
+
|
| 541 |
+
# Steps 1-8: easy + medium
|
| 542 |
+
for t in tasks[:8]:
|
| 543 |
+
c.post(f"{BASE}/step", json={"action_type": "approve", "transaction_id": t["transaction_id"]})
|
| 544 |
+
|
| 545 |
+
# Step 9: HARD-001 — agent trusts the low risk_score and approves (wrong!)
|
| 546 |
+
resp_wrong = c.post(f"{BASE}/step", json={
|
| 547 |
+
"action_type": "approve", "transaction_id": "TXN-H001"
|
| 548 |
+
}).json()
|
| 549 |
+
print("Trusted ML score → approve")
|
| 550 |
+
print(" reward :", resp_wrong["reward"]) # expect -0.5
|
| 551 |
+
print(" correct :", resp_wrong["info"]["correct_action"])
|
| 552 |
+
|
| 553 |
+
# Reset and do it correctly
|
| 554 |
+
c.post(f"{BASE}/reset")
|
| 555 |
+
for t in tasks[:8]:
|
| 556 |
+
c.post(f"{BASE}/step", json={"action_type": "approve", "transaction_id": t["transaction_id"]})
|
| 557 |
+
|
| 558 |
+
resp_correct = c.post(f"{BASE}/step", json={
|
| 559 |
+
"action_type": "escalate", "transaction_id": "TXN-H001"
|
| 560 |
+
}).json()
|
| 561 |
+
print("\nOverrode ML score → escalate")
|
| 562 |
+
print(" reward :", resp_correct["reward"]) # expect +1.0
|
| 563 |
+
c.close()
|
| 564 |
+
EOF
|
| 565 |
+
```
|
| 566 |
+
|
| 567 |
+
**Expected output**
|
| 568 |
+
```
|
| 569 |
+
Trusted ML score → approve
|
| 570 |
+
reward : -0.5
|
| 571 |
+
correct : escalate
|
| 572 |
+
|
| 573 |
+
Overrode ML score → escalate
|
| 574 |
+
reward : 1.0
|
| 575 |
+
```
|
| 576 |
+
|
| 577 |
+
---
|
| 578 |
+
|
| 579 |
+
## T-19 Inspect Reveals Hidden Context (HARD-001)
|
| 580 |
+
|
| 581 |
+
**Goal:** Inspect HARD-001 to reveal the mule-account intelligence note before deciding.
|
| 582 |
+
|
| 583 |
+
```bash
|
| 584 |
+
curl -s -X POST http://localhost:8000/reset > /dev/null
|
| 585 |
+
# Advance to HARD-001
|
| 586 |
+
python3 - <<'EOF'
|
| 587 |
+
import httpx
|
| 588 |
+
BASE = "http://localhost:8000"
|
| 589 |
+
c = httpx.Client()
|
| 590 |
+
tasks = c.get(f"{BASE}/tasks").json()["tasks"]
|
| 591 |
+
for t in tasks[:8]:
|
| 592 |
+
c.post(f"{BASE}/step", json={"action_type": "approve", "transaction_id": t["transaction_id"]})
|
| 593 |
+
|
| 594 |
+
# Inspect HARD-001
|
| 595 |
+
resp = c.post(f"{BASE}/step", json={
|
| 596 |
+
"action_type": "inspect", "transaction_id": "TXN-H001"
|
| 597 |
+
}).json()
|
| 598 |
+
print("Inspect reward :", resp["reward"])
|
| 599 |
+
print("Notes :", resp["inspection_notes"])
|
| 600 |
+
c.close()
|
| 601 |
+
EOF
|
| 602 |
+
```
|
| 603 |
+
|
| 604 |
+
**Expected output**
|
| 605 |
+
```
|
| 606 |
+
Inspect reward : 0.15
|
| 607 |
+
Notes : Account created 7 days ago. This is the first outbound transfer. Receiver matches a pattern of solicitor-impersonation mule accounts flagged in last month's intelligence bulletin. Risk model underscored due to clean transaction history (new account).
|
| 608 |
+
```
|
| 609 |
+
|
| 610 |
+
---
|
| 611 |
+
|
| 612 |
+
## T-20 WebSocket Session
|
| 613 |
+
|
| 614 |
+
**Goal:** Run a full reset → step sequence over the WebSocket endpoint.
|
| 615 |
+
|
| 616 |
+
```bash
|
| 617 |
+
pip install websockets -q # if not already installed
|
| 618 |
+
|
| 619 |
+
python3 - <<'EOF'
|
| 620 |
+
import asyncio, json, websockets
|
| 621 |
+
|
| 622 |
+
async def test_ws():
|
| 623 |
+
uri = "ws://localhost:8000/ws"
|
| 624 |
+
async with websockets.connect(uri) as ws:
|
| 625 |
+
# Reset
|
| 626 |
+
await ws.send(json.dumps({"type": "reset"}))
|
| 627 |
+
obs = json.loads(await ws.recv())
|
| 628 |
+
print("Reset →", obs["transaction_id"], "risk:", obs["risk_score"])
|
| 629 |
+
|
| 630 |
+
# Step – approve
|
| 631 |
+
await ws.send(json.dumps({
|
| 632 |
+
"type": "step",
|
| 633 |
+
"action_type": "approve",
|
| 634 |
+
"transaction_id": obs["transaction_id"]
|
| 635 |
+
}))
|
| 636 |
+
obs2 = json.loads(await ws.recv())
|
| 637 |
+
print("Step →", "reward:", obs2["reward"], "next:", obs2["transaction_id"])
|
| 638 |
+
|
| 639 |
+
# State
|
| 640 |
+
await ws.send(json.dumps({"type": "state"}))
|
| 641 |
+
state = json.loads(await ws.recv())
|
| 642 |
+
print("State →", "steps:", state["step_count"], "txns:", state["transactions_processed"])
|
| 643 |
+
|
| 644 |
+
asyncio.run(test_ws())
|
| 645 |
+
EOF
|
| 646 |
+
```
|
| 647 |
+
|
| 648 |
+
**Expected output**
|
| 649 |
+
```
|
| 650 |
+
Reset → TXN-E001 risk: 0.05
|
| 651 |
+
Step → reward: 1.0 next: TXN-E002
|
| 652 |
+
State → steps: 1 txns: 1
|
| 653 |
+
```
|
| 654 |
+
|
| 655 |
+
---
|
| 656 |
+
|
| 657 |
+
## T-21 Baseline Agent Script (Standalone)
|
| 658 |
+
|
| 659 |
+
**Goal:** Run the standalone Python baseline script independently of the server.
|
| 660 |
+
|
| 661 |
+
```bash
|
| 662 |
+
cd /Users/padmapriya
|
| 663 |
+
PYTHONPATH=/Users/padmapriya python3 payops_env/scripts/baseline_agent.py
|
| 664 |
+
```
|
| 665 |
+
|
| 666 |
+
**Expected output (last few lines)**
|
| 667 |
+
```
|
| 668 |
+
============================================================
|
| 669 |
+
Episode Summary
|
| 670 |
+
============================================================
|
| 671 |
+
Steps : 12
|
| 672 |
+
Total reward : +8.75
|
| 673 |
+
Max possible : 12.00
|
| 674 |
+
Normalised score : 0.7292
|
| 675 |
+
Passed (≥0.5) : YES ✓
|
| 676 |
+
============================================================
|
| 677 |
+
```
|
| 678 |
+
|
| 679 |
+
---
|
| 680 |
+
|
| 681 |
+
## T-22 All Actions Are Valid on Each Task
|
| 682 |
+
|
| 683 |
+
**Goal:** Confirm every action type is accepted without error (even if penalised).
|
| 684 |
+
|
| 685 |
+
```bash
|
| 686 |
+
python3 - <<'EOF'
|
| 687 |
+
import httpx
|
| 688 |
+
|
| 689 |
+
BASE = "http://localhost:8000"
|
| 690 |
+
ACTIONS = ["approve", "reject", "flag", "escalate", "inspect", "hold"]
|
| 691 |
+
|
| 692 |
+
c = httpx.Client()
|
| 693 |
+
for action in ACTIONS:
|
| 694 |
+
c.post(f"{BASE}/reset")
|
| 695 |
+
resp = c.post(f"{BASE}/step", json={
|
| 696 |
+
"action_type": action,
|
| 697 |
+
"transaction_id": "TXN-E001"
|
| 698 |
+
})
|
| 699 |
+
print(f"action={action:10} HTTP={resp.status_code} reward={resp.json()['reward']:+.2f}")
|
| 700 |
+
c.close()
|
| 701 |
+
EOF
|
| 702 |
+
```
|
| 703 |
+
|
| 704 |
+
**Expected output**
|
| 705 |
+
```
|
| 706 |
+
action=approve HTTP=200 reward=+1.00
|
| 707 |
+
action=reject HTTP=200 reward=-0.50
|
| 708 |
+
action=flag HTTP=200 reward=+0.30
|
| 709 |
+
action=escalate HTTP=200 reward=-0.25
|
| 710 |
+
action=inspect HTTP=200 reward=+0.15
|
| 711 |
+
action=hold HTTP=200 reward=-0.25
|
| 712 |
+
```
|
| 713 |
+
|
| 714 |
+
---
|
| 715 |
+
|
| 716 |
+
## T-23 Full Perfect Episode (Score = 1.0)
|
| 717 |
+
|
| 718 |
+
**Goal:** Submit all 12 correct actions and confirm normalised_score = 1.0.
|
| 719 |
+
|
| 720 |
+
```bash
|
| 721 |
+
python3 - <<'EOF'
|
| 722 |
+
import httpx
|
| 723 |
+
|
| 724 |
+
BASE = "http://localhost:8000"
|
| 725 |
+
c = httpx.Client()
|
| 726 |
+
|
| 727 |
+
tasks = c.get(f"{BASE}/tasks").json()["tasks"]
|
| 728 |
+
c.post(f"{BASE}/reset")
|
| 729 |
+
|
| 730 |
+
for t in tasks:
|
| 731 |
+
resp = c.post(f"{BASE}/step", json={
|
| 732 |
+
"action_type": t["correct_action"],
|
| 733 |
+
"transaction_id": t["transaction_id"]
|
| 734 |
+
}).json()
|
| 735 |
+
mark = "✓" if resp["reward"] == 1.0 else "✗"
|
| 736 |
+
print(f"{mark} {t['task_id']:12} action={t['correct_action']:10} reward={resp['reward']:+.2f}")
|
| 737 |
+
|
| 738 |
+
score = c.get(f"{BASE}/grader").json()
|
| 739 |
+
print()
|
| 740 |
+
print("Normalised score:", score["normalised_score"])
|
| 741 |
+
print("Passed :", score["passed"])
|
| 742 |
+
c.close()
|
| 743 |
+
EOF
|
| 744 |
+
```
|
| 745 |
+
|
| 746 |
+
**Expected output**
|
| 747 |
+
```
|
| 748 |
+
✓ EASY-001 action=approve reward=+1.00
|
| 749 |
+
✓ EASY-002 action=reject reward=+1.00
|
| 750 |
+
✓ EASY-003 action=approve reward=+1.00
|
| 751 |
+
✓ EASY-004 action=flag reward=+1.00
|
| 752 |
+
✓ MED-001 action=escalate reward=+1.00
|
| 753 |
+
✓ MED-002 action=hold reward=+1.00
|
| 754 |
+
✓ MED-003 action=flag reward=+1.00
|
| 755 |
+
✓ MED-004 action=flag reward=+1.00
|
| 756 |
+
✓ HARD-001 action=escalate reward=+1.00
|
| 757 |
+
✓ HARD-002 action=reject reward=+1.00
|
| 758 |
+
✓ HARD-003 action=reject reward=+1.00
|
| 759 |
+
✓ HARD-004 action=approve reward=+1.00
|
| 760 |
+
|
| 761 |
+
Normalised score: 1.0
|
| 762 |
+
Passed : True
|
| 763 |
+
```
|
| 764 |
+
|
| 765 |
+
---
|
| 766 |
+
|
| 767 |
+
## T-24 Worst-Case Episode (Approve Everything)
|
| 768 |
+
|
| 769 |
+
**Goal:** Approve all 12 transactions (maximally wrong) and confirm very low score.
|
| 770 |
+
|
| 771 |
+
```bash
|
| 772 |
+
python3 - <<'EOF'
|
| 773 |
+
import httpx
|
| 774 |
+
|
| 775 |
+
BASE = "http://localhost:8000"
|
| 776 |
+
c = httpx.Client()
|
| 777 |
+
|
| 778 |
+
tasks = c.get(f"{BASE}/tasks").json()["tasks"]
|
| 779 |
+
c.post(f"{BASE}/reset")
|
| 780 |
+
|
| 781 |
+
total = 0
|
| 782 |
+
for t in tasks:
|
| 783 |
+
resp = c.post(f"{BASE}/step", json={
|
| 784 |
+
"action_type": "approve",
|
| 785 |
+
"transaction_id": t["transaction_id"]
|
| 786 |
+
}).json()
|
| 787 |
+
total += resp["reward"]
|
| 788 |
+
print(f"{t['task_id']:12} correct={t['correct_action']:10} reward={resp['reward']:+.2f}")
|
| 789 |
+
|
| 790 |
+
score = c.get(f"{BASE}/grader").json()
|
| 791 |
+
print()
|
| 792 |
+
print(f"Total reward : {total:+.2f}")
|
| 793 |
+
print(f"Normalised score : {score['normalised_score']}")
|
| 794 |
+
print(f"Passed : {score['passed']}")
|
| 795 |
+
c.close()
|
| 796 |
+
EOF
|
| 797 |
+
```
|
| 798 |
+
|
| 799 |
+
**Expected outcome:** Several `-1.0` and `-0.5` penalties. Normalised score near or equal to `0.0`. `passed=False`.
|
| 800 |
+
|
| 801 |
+
---
|
| 802 |
+
|
| 803 |
+
## Quick Reference — Expected Rewards Per Action
|
| 804 |
+
|
| 805 |
+
| Scenario | Action | Reward |
|
| 806 |
+
|----------|--------|--------|
|
| 807 |
+
| Correct decision | any | `+1.0` |
|
| 808 |
+
| Inspect (first time) | `inspect` | `+0.15` |
|
| 809 |
+
| Inspect (already inspected) | `inspect` | `0.0` |
|
| 810 |
+
| Partial credit (task-specific) | adjacent | `+0.2` – `+0.6` |
|
| 811 |
+
| Approve fraud/escalation | `approve` | `-1.0` |
|
| 812 |
+
| Approve flagged/held | `approve` | `-0.5` |
|
| 813 |
+
| Reject legitimate tx | `reject` | `-0.5` |
|
| 814 |
+
| Any other wrong action | any | `-0.25` |
|
| 815 |
+
|
| 816 |
+
---
|
| 817 |
+
|
| 818 |
+
## Quick Reference — Correct Actions per Task
|
| 819 |
+
|
| 820 |
+
| Task ID | Difficulty | Correct Action | Key Signal |
|
| 821 |
+
|---------|-----------|----------------|------------|
|
| 822 |
+
| EASY-001 | easy | `approve` | risk=0.05, no flags, verified KYC |
|
| 823 |
+
| EASY-002 | easy | `reject` | sanctioned country, unknown sender, risk=0.97 |
|
| 824 |
+
| EASY-003 | easy | `approve` | small refund, risk=0.03, verified |
|
| 825 |
+
| EASY-004 | easy | `flag` | velocity_1h=15 (ATM burst) |
|
| 826 |
+
| MED-001 | medium | `escalate` | large B2B, cross-border, medium-risk country |
|
| 827 |
+
| MED-002 | medium | `hold` | KYC pending, large internal transfer |
|
| 828 |
+
| MED-003 | medium | `flag` | amount 3× historical average |
|
| 829 |
+
| MED-004 | medium | `flag` | crypto exchange, moderate risk |
|
| 830 |
+
| HARD-001 | hard | `escalate` | risk_score=0.18 is poisoned — manual flags say escalate |
|
| 831 |
+
| HARD-002 | hard | `reject` | APP scam, mule account pattern |
|
| 832 |
+
| HARD-003 | hard | `reject` | structuring/smurfing, KYC failed |
|
| 833 |
+
| HARD-004 | hard | `approve` | legitimate FX settlement — looks scary, is fine |
|
| 834 |
+
|
| 835 |
+
---
|
| 836 |
+
|
| 837 |
+
## Running All Tests in One Script
|
| 838 |
+
|
| 839 |
+
Save the following as `run_tests.sh` and execute from `/Users/padmapriya`:
|
| 840 |
+
|
| 841 |
+
```bash
|
| 842 |
+
#!/usr/bin/env bash
|
| 843 |
+
# run_tests.sh — smoke-test all PayOps endpoints
|
| 844 |
+
|
| 845 |
+
set -e
|
| 846 |
+
BASE="http://localhost:8000"
|
| 847 |
+
PASS=0
|
| 848 |
+
FAIL=0
|
| 849 |
+
|
| 850 |
+
check() {
|
| 851 |
+
local name="$1"
|
| 852 |
+
local got="$2"
|
| 853 |
+
local want="$3"
|
| 854 |
+
if echo "$got" | grep -q "$want"; then
|
| 855 |
+
echo " ✓ $name"
|
| 856 |
+
((PASS++))
|
| 857 |
+
else
|
| 858 |
+
echo " ✗ $name (expected '$want', got '$got')"
|
| 859 |
+
((FAIL++))
|
| 860 |
+
fi
|
| 861 |
+
}
|
| 862 |
+
|
| 863 |
+
echo "=== PayOps Test Suite ==="
|
| 864 |
+
|
| 865 |
+
check "T-01 health" "$(curl -s $BASE/health)" '"status":"ok"'
|
| 866 |
+
check "T-02 schema" "$(curl -s $BASE/schema)" '"PayOpsAction"'
|
| 867 |
+
check "T-03 tasks count" "$(curl -s $BASE/tasks)" '"count":12'
|
| 868 |
+
check "T-04 reset" "$(curl -s -X POST $BASE/reset)" '"task_id":"EASY-001"'
|
| 869 |
+
check "T-05 correct step" "$(curl -s -X POST $BASE/step -H 'Content-Type: application/json' -d '{"action_type":"approve","transaction_id":"TXN-E001"}')" '"reward":1.0'
|
| 870 |
+
check "T-10 invalid action" "$(curl -s -X POST $BASE/step -H 'Content-Type: application/json' -d '{"action_type":"delete","transaction_id":"TXN-E001"}')" "Invalid action_type"
|
| 871 |
+
check "T-16 baseline" "$(curl -s -X POST $BASE/baseline)" '"normalised_score"'
|
| 872 |
+
|
| 873 |
+
echo ""
|
| 874 |
+
echo "Results: $PASS passed, $FAIL failed"
|
| 875 |
+
```
|
| 876 |
+
|
| 877 |
+
```bash
|
| 878 |
+
cd /Users/padmapriya
|
| 879 |
+
bash payops_env/run_tests.sh
|
| 880 |
+
```
|
| 881 |
+
|
| 882 |
+
**Expected output**
|
| 883 |
+
```
|
| 884 |
+
=== PayOps Test Suite ===
|
| 885 |
+
✓ T-01 health
|
| 886 |
+
✓ T-02 schema
|
| 887 |
+
✓ T-03 tasks count
|
| 888 |
+
✓ T-04 reset
|
| 889 |
+
✓ T-05 correct step
|
| 890 |
+
✓ T-10 invalid action
|
| 891 |
+
✓ T-16 baseline
|
| 892 |
+
|
| 893 |
+
Results: 7 passed, 0 failed
|
| 894 |
+
```
|
| 895 |
+
|
| 896 |
+
---
|
| 897 |
+
|
| 898 |
+
## Interactive API Explorer
|
| 899 |
+
|
| 900 |
+
FastAPI serves auto-generated interactive docs. Open in a browser while the server is running:
|
| 901 |
+
|
| 902 |
+
```
|
| 903 |
+
http://localhost:8000/docs ← Swagger UI (try endpoints in-browser)
|
| 904 |
+
http://localhost:8000/redoc ← ReDoc documentation
|
| 905 |
+
```
|
__init__.py
ADDED
|
File without changes
|
environment.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PayOps Environment — core simulation.
|
| 3 |
+
|
| 4 |
+
Supports all 10 action types:
|
| 5 |
+
Terminal decisions: approve | reject | flag | escalate | hold
|
| 6 |
+
Investigation sub-actions: inspect | request_docs | verify_kyc |
|
| 7 |
+
contact_sender | file_sar
|
| 8 |
+
|
| 9 |
+
Investigation sub-actions do NOT advance the task pointer; they reveal
|
| 10 |
+
additional context and consume budget. A file_sar sub-action is required
|
| 11 |
+
for full credit on regulatory tasks.
|
| 12 |
+
|
| 13 |
+
Multi-step chain tasks (chain_total > 1) are presented as a single task
|
| 14 |
+
entry; the grader handles them as one decision unit.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import uuid
|
| 20 |
+
from collections import deque
|
| 21 |
+
from typing import Deque, List, Optional
|
| 22 |
+
|
| 23 |
+
from payops_env.grader import INVESTIGATION_BONUS, grade
|
| 24 |
+
from payops_env.models import PayOpsAction, PayOpsObservation, PayOpsState
|
| 25 |
+
from payops_env.tasks import ACTION_COSTS, TASKS, PayOpsTask
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
TERMINAL_ACTIONS = {"approve", "reject", "flag", "escalate", "hold"}
|
| 29 |
+
INVESTIGATION_ACTIONS = {"inspect", "request_docs", "verify_kyc", "contact_sender", "file_sar"}
|
| 30 |
+
VALID_ACTIONS = TERMINAL_ACTIONS | INVESTIGATION_ACTIONS
|
| 31 |
+
|
| 32 |
+
_RECENT_WINDOW = 4 # how many past (task_id, action) pairs to surface in obs
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class PayOpsEnvironment:
|
| 36 |
+
"""
|
| 37 |
+
OpenEnv-compatible environment for Payment Operations triage.
|
| 38 |
+
|
| 39 |
+
An episode proceeds through every task in TASKS. For each task the
|
| 40 |
+
agent may issue any number of investigation sub-actions before a
|
| 41 |
+
terminal decision closes the task.
|
| 42 |
+
|
| 43 |
+
Budget
|
| 44 |
+
------
|
| 45 |
+
The agent starts with ``budget_limit`` points. Each investigation
|
| 46 |
+
sub-action deducts its cost (see tasks.ACTION_COSTS). Budget overspend
|
| 47 |
+
is penalised in grade_episode; within the environment it is tracked but
|
| 48 |
+
does not terminate the episode.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
BUDGET_LIMIT = 5.0
|
| 52 |
+
|
| 53 |
+
def __init__(self):
|
| 54 |
+
self._tasks: List[PayOpsTask] = []
|
| 55 |
+
self._current_task: Optional[PayOpsTask] = None
|
| 56 |
+
self._state: PayOpsState = PayOpsState()
|
| 57 |
+
self._used_inv: dict = {} # task_id → set of investigation actions used
|
| 58 |
+
self._sar_filed: set = set() # task_ids where file_sar was issued
|
| 59 |
+
self._recent_decisions: Deque = deque(maxlen=_RECENT_WINDOW)
|
| 60 |
+
|
| 61 |
+
# ------------------------------------------------------------------
|
| 62 |
+
# OpenEnv API
|
| 63 |
+
# ------------------------------------------------------------------
|
| 64 |
+
|
| 65 |
+
async def reset_async(self) -> PayOpsObservation:
|
| 66 |
+
self._tasks = list(TASKS)
|
| 67 |
+
self._current_task = self._tasks[0]
|
| 68 |
+
self._used_inv = {}
|
| 69 |
+
self._sar_filed = set()
|
| 70 |
+
self._recent_decisions = deque(maxlen=_RECENT_WINDOW)
|
| 71 |
+
self._state = PayOpsState(
|
| 72 |
+
episode_id=str(uuid.uuid4()),
|
| 73 |
+
step_count=0,
|
| 74 |
+
current_task_id=self._current_task.task_id,
|
| 75 |
+
transactions_processed=0,
|
| 76 |
+
total_tasks=len(self._tasks),
|
| 77 |
+
cumulative_reward=0.0,
|
| 78 |
+
actions_taken=[],
|
| 79 |
+
last_action=None,
|
| 80 |
+
done=False,
|
| 81 |
+
budget_spent=0.0,
|
| 82 |
+
budget_limit=self.BUDGET_LIMIT,
|
| 83 |
+
investigation_actions_used=[],
|
| 84 |
+
correct_decisions=0,
|
| 85 |
+
wrong_high_cost=0,
|
| 86 |
+
recent_decisions=[],
|
| 87 |
+
)
|
| 88 |
+
return self._make_observation(reward=0.0, done=False, info={"event": "reset"})
|
| 89 |
+
|
| 90 |
+
async def step_async(self, action: PayOpsAction) -> PayOpsObservation:
|
| 91 |
+
if self._current_task is None:
|
| 92 |
+
raise RuntimeError("Environment must be reset before stepping.")
|
| 93 |
+
|
| 94 |
+
action_type = action.action_type.lower()
|
| 95 |
+
if action_type not in VALID_ACTIONS:
|
| 96 |
+
raise ValueError(
|
| 97 |
+
f"Invalid action '{action_type}'. "
|
| 98 |
+
f"Valid actions: {sorted(VALID_ACTIONS)}"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
task = self._current_task
|
| 102 |
+
task_id = task.task_id
|
| 103 |
+
cost = ACTION_COSTS.get(action_type, 0.0)
|
| 104 |
+
|
| 105 |
+
# Deduct cost
|
| 106 |
+
self._state.budget_spent = round(self._state.budget_spent + cost, 4)
|
| 107 |
+
self._state.step_count += 1
|
| 108 |
+
self._state.actions_taken.append(action_type)
|
| 109 |
+
self._state.last_action = action_type
|
| 110 |
+
|
| 111 |
+
# ── INVESTIGATION SUB-ACTIONS ────────────────────────────────────
|
| 112 |
+
if action_type in INVESTIGATION_ACTIONS:
|
| 113 |
+
used = self._used_inv.setdefault(task_id, set())
|
| 114 |
+
already = action_type in used
|
| 115 |
+
reward = 0.0 if already else INVESTIGATION_BONUS
|
| 116 |
+
used.add(action_type)
|
| 117 |
+
self._state.investigation_actions_used.append(action_type)
|
| 118 |
+
self._state.cumulative_reward = round(
|
| 119 |
+
self._state.cumulative_reward + reward, 4
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Determine reveal text
|
| 123 |
+
reveal_text: Optional[str] = None
|
| 124 |
+
reveal_field: Optional[str] = None
|
| 125 |
+
if action_type == "inspect":
|
| 126 |
+
reveal_text = task.inspect_reveal or "No additional information available."
|
| 127 |
+
reveal_field = "inspection_notes"
|
| 128 |
+
elif action_type == "request_docs":
|
| 129 |
+
reveal_text = task.docs_reveal or "No documents on record for this transaction."
|
| 130 |
+
reveal_field = "docs_notes"
|
| 131 |
+
elif action_type == "verify_kyc":
|
| 132 |
+
reveal_text = task.kyc_reveal or "KYC records could not be retrieved."
|
| 133 |
+
reveal_field = "kyc_notes"
|
| 134 |
+
elif action_type == "contact_sender":
|
| 135 |
+
reveal_text = task.contact_reveal or "Sender did not respond to contact attempt."
|
| 136 |
+
reveal_field = "contact_notes"
|
| 137 |
+
elif action_type == "file_sar":
|
| 138 |
+
self._sar_filed.add(task_id)
|
| 139 |
+
reveal_text = (
|
| 140 |
+
"SAR filed with FinCEN. Reference number will be generated within 24 h."
|
| 141 |
+
if task.regulatory_action
|
| 142 |
+
else "SAR filed. Note: this transaction may not meet SAR-filing threshold."
|
| 143 |
+
)
|
| 144 |
+
reveal_field = "docs_notes"
|
| 145 |
+
|
| 146 |
+
info = {
|
| 147 |
+
"event": action_type,
|
| 148 |
+
"already_used": already,
|
| 149 |
+
reveal_field: reveal_text,
|
| 150 |
+
"budget_remaining": round(
|
| 151 |
+
self.BUDGET_LIMIT - self._state.budget_spent, 4
|
| 152 |
+
),
|
| 153 |
+
}
|
| 154 |
+
return self._make_observation(
|
| 155 |
+
reward=reward, done=False, info=info, reveal_field=reveal_field, reveal_text=reveal_text
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
# ── TERMINAL DECISION ────────────────────────────────────────────
|
| 159 |
+
used_inv = list(self._used_inv.get(task_id, set()))
|
| 160 |
+
sar_used = task_id in self._sar_filed
|
| 161 |
+
inspected_already = "inspect" in self._used_inv.get(task_id, set())
|
| 162 |
+
|
| 163 |
+
reward = grade(action_type, task, inspected_already=inspected_already)
|
| 164 |
+
self._state.cumulative_reward = round(
|
| 165 |
+
self._state.cumulative_reward + reward, 4
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
is_correct = action_type == task.correct_action
|
| 169 |
+
if is_correct:
|
| 170 |
+
self._state.correct_decisions += 1
|
| 171 |
+
elif action_type == "approve" and task.correct_action in ("reject", "escalate"):
|
| 172 |
+
self._state.wrong_high_cost += 1
|
| 173 |
+
|
| 174 |
+
self._recent_decisions.append(
|
| 175 |
+
{"task_id": task_id, "action": action_type, "correct": is_correct}
|
| 176 |
+
)
|
| 177 |
+
self._state.recent_decisions = list(self._recent_decisions)
|
| 178 |
+
self._state.transactions_processed += 1
|
| 179 |
+
|
| 180 |
+
# Advance task pointer
|
| 181 |
+
task_idx = self._tasks.index(task)
|
| 182 |
+
remaining = self._tasks[task_idx + 1:]
|
| 183 |
+
done = len(remaining) == 0
|
| 184 |
+
|
| 185 |
+
if not done:
|
| 186 |
+
self._current_task = remaining[0]
|
| 187 |
+
self._state.current_task_id = self._current_task.task_id
|
| 188 |
+
else:
|
| 189 |
+
self._state.done = True
|
| 190 |
+
|
| 191 |
+
return self._make_observation(
|
| 192 |
+
reward=reward,
|
| 193 |
+
done=done,
|
| 194 |
+
info={
|
| 195 |
+
"event": "step",
|
| 196 |
+
"action_taken": action_type,
|
| 197 |
+
"correct_action": task.correct_action,
|
| 198 |
+
"task_id": task_id,
|
| 199 |
+
"difficulty": task.difficulty,
|
| 200 |
+
"is_correct": is_correct,
|
| 201 |
+
"investigation_used": used_inv,
|
| 202 |
+
"budget_remaining": round(
|
| 203 |
+
self.BUDGET_LIMIT - self._state.budget_spent, 4
|
| 204 |
+
),
|
| 205 |
+
},
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
def state(self) -> PayOpsState:
|
| 209 |
+
return self._state
|
| 210 |
+
|
| 211 |
+
def close(self):
|
| 212 |
+
pass
|
| 213 |
+
|
| 214 |
+
# ------------------------------------------------------------------
|
| 215 |
+
# Internal helpers
|
| 216 |
+
# ------------------------------------------------------------------
|
| 217 |
+
|
| 218 |
+
def _make_observation(
|
| 219 |
+
self,
|
| 220 |
+
reward: float,
|
| 221 |
+
done: bool,
|
| 222 |
+
info: dict,
|
| 223 |
+
reveal_field: Optional[str] = None,
|
| 224 |
+
reveal_text: Optional[str] = None,
|
| 225 |
+
) -> PayOpsObservation:
|
| 226 |
+
task = self._current_task
|
| 227 |
+
steps_remaining = self._state.total_tasks - self._state.transactions_processed
|
| 228 |
+
|
| 229 |
+
return PayOpsObservation(
|
| 230 |
+
# ── transaction core ──
|
| 231 |
+
transaction_id=task.transaction_id,
|
| 232 |
+
amount=task.amount,
|
| 233 |
+
currency=task.currency,
|
| 234 |
+
sender=task.sender,
|
| 235 |
+
receiver=task.receiver,
|
| 236 |
+
transaction_type=task.transaction_type,
|
| 237 |
+
status=(
|
| 238 |
+
"inspected"
|
| 239 |
+
if info.get("event") in INVESTIGATION_ACTIONS
|
| 240 |
+
else ("done" if done else "pending")
|
| 241 |
+
),
|
| 242 |
+
# ── risk signals ──
|
| 243 |
+
risk_score=task.risk_score,
|
| 244 |
+
ml_confidence=getattr(task, "ml_confidence", 0.90),
|
| 245 |
+
flags=list(task.flags),
|
| 246 |
+
velocity_1h=task.velocity_1h,
|
| 247 |
+
velocity_24h=getattr(task, "velocity_24h", None),
|
| 248 |
+
avg_transaction_amount=getattr(task, "avg_transaction_amount", None),
|
| 249 |
+
account_age_days=getattr(task, "account_age_days", None),
|
| 250 |
+
country_risk=task.country_risk,
|
| 251 |
+
kyc_status=task.kyc_status,
|
| 252 |
+
kyc_expiry_days=getattr(task, "kyc_expiry_days", None),
|
| 253 |
+
previous_violations=task.previous_violations,
|
| 254 |
+
previous_sars=getattr(task, "previous_sars", None),
|
| 255 |
+
counterparty_risk=getattr(task, "counterparty_risk", None),
|
| 256 |
+
# ── chain context ──
|
| 257 |
+
chain_total=getattr(task, "chain_total", 1),
|
| 258 |
+
chain_step=self._state.step_count,
|
| 259 |
+
chain_context=task.description,
|
| 260 |
+
# ── investigation reveals ──
|
| 261 |
+
inspection_notes=(
|
| 262 |
+
reveal_text if reveal_field == "inspection_notes" else info.get("inspection_notes")
|
| 263 |
+
),
|
| 264 |
+
docs_notes=(
|
| 265 |
+
reveal_text if reveal_field == "docs_notes" else None
|
| 266 |
+
),
|
| 267 |
+
kyc_notes=(
|
| 268 |
+
reveal_text if reveal_field == "kyc_notes" else None
|
| 269 |
+
),
|
| 270 |
+
contact_notes=(
|
| 271 |
+
reveal_text if reveal_field == "contact_notes" else None
|
| 272 |
+
),
|
| 273 |
+
# ── episode meta ──
|
| 274 |
+
task_id=task.task_id,
|
| 275 |
+
task_difficulty=task.difficulty,
|
| 276 |
+
step_in_episode=self._state.step_count,
|
| 277 |
+
steps_remaining=steps_remaining,
|
| 278 |
+
action_cost=ACTION_COSTS.get(info.get("event", ""), 0.0),
|
| 279 |
+
budget_remaining=round(self.BUDGET_LIMIT - self._state.budget_spent, 4),
|
| 280 |
+
recent_decisions=list(self._recent_decisions),
|
| 281 |
+
reward=reward,
|
| 282 |
+
cumulative_reward=self._state.cumulative_reward,
|
| 283 |
+
done=done,
|
| 284 |
+
info=info,
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
|
grader.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Grader for the PayOps environment.
|
| 3 |
+
|
| 4 |
+
Reward design
|
| 5 |
+
-------------
|
| 6 |
+
The grader provides a *partial-credit* reward signal so agents receive
|
| 7 |
+
feedback for cautious-but-not-perfect decisions.
|
| 8 |
+
|
| 9 |
+
Base rewards
|
| 10 |
+
~~~~~~~~~~~~
|
| 11 |
+
Correct action → +1.0 (full credit)
|
| 12 |
+
Partial-credit adjacent action → per-task fraction of +1.0
|
| 13 |
+
approve when should be reject/escalate→ -1.0 (worst mistake — approving fraud)
|
| 14 |
+
approve when should be flag/hold → -0.5
|
| 15 |
+
reject when should be approve → -0.5 (over-rejection)
|
| 16 |
+
any other wrong terminal action → -0.25
|
| 17 |
+
|
| 18 |
+
Investigation sub-actions (non-terminal):
|
| 19 |
+
inspect / request_docs / verify_kyc / contact_sender used BEFORE the
|
| 20 |
+
terminal decision when the task requests them → +0.15 bonus each
|
| 21 |
+
Same investigation action used twice on same task → +0.0 (no double-dip)
|
| 22 |
+
|
| 23 |
+
Modifiers
|
| 24 |
+
~~~~~~~~~
|
| 25 |
+
Difficulty weight — multiplies the base reward:
|
| 26 |
+
easy=1.0, medium=1.2, hard=1.5, critical=2.0
|
| 27 |
+
|
| 28 |
+
Confidence bonus/penalty — applied when the agent provides confidence:
|
| 29 |
+
high-confidence (≥0.8) AND correct → +0.10
|
| 30 |
+
high-confidence (≥0.8) AND wrong → -0.10
|
| 31 |
+
|
| 32 |
+
Cost penalty — investigation actions have per-action budget costs
|
| 33 |
+
(see tasks.ACTION_COSTS). Spending beyond budget_limit reduces episode
|
| 34 |
+
score by budget_overspend × 0.1.
|
| 35 |
+
|
| 36 |
+
Time penalty — excessive investigation steps on a single task:
|
| 37 |
+
each investigation sub-action beyond the first on the same task → -0.05
|
| 38 |
+
|
| 39 |
+
Regulatory bonus — for tasks requiring a SAR filing:
|
| 40 |
+
agent called file_sar before terminal action → +0.20 bonus
|
| 41 |
+
|
| 42 |
+
Episode score (0–1)
|
| 43 |
+
~~~~~~~~~~~~~~~~~~~
|
| 44 |
+
Computed by summing difficulty-weighted reward across all tasks and
|
| 45 |
+
normalising against the maximum theoretically achievable reward.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
from __future__ import annotations
|
| 49 |
+
|
| 50 |
+
from dataclasses import dataclass, field
|
| 51 |
+
from typing import Dict, List, Optional, Set, Tuple
|
| 52 |
+
|
| 53 |
+
from payops_env.tasks import ACTION_COSTS, PayOpsTask
|
| 54 |
+
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
# Constants
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
|
| 59 |
+
FULL_CREDIT = 1.0
|
| 60 |
+
|
| 61 |
+
WRONG_APPROVE_FRAUD = -1.0
|
| 62 |
+
WRONG_APPROVE_CAUTION = -0.5
|
| 63 |
+
WRONG_REJECT_GOOD = -0.5
|
| 64 |
+
WRONG_DEFAULT = -0.25
|
| 65 |
+
|
| 66 |
+
INVESTIGATION_BONUS = 0.15 # per eligible sub-action used before terminal
|
| 67 |
+
TIME_PENALTY_PER_EXTRA_STEP = 0.05 # per duplicate investigation on same task
|
| 68 |
+
CONFIDENCE_CORRECT_BONUS = 0.10
|
| 69 |
+
CONFIDENCE_WRONG_PENALTY = -0.10
|
| 70 |
+
REGULATORY_BONUS = 0.20 # filing SAR when required
|
| 71 |
+
BUDGET_OVERSPEND_PENALTY = 0.10 # per unit of budget exceeded
|
| 72 |
+
|
| 73 |
+
DIFFICULTY_WEIGHT: Dict[str, float] = {
|
| 74 |
+
"easy": 1.0,
|
| 75 |
+
"medium": 1.2,
|
| 76 |
+
"hard": 1.5,
|
| 77 |
+
"critical": 2.0,
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
INVESTIGATION_ACTIONS: Set[str] = {
|
| 81 |
+
"inspect", "request_docs", "verify_kyc", "contact_sender", "file_sar"
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# Per-step helpers
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
|
| 89 |
+
def _is_investigation(action_type: str) -> bool:
|
| 90 |
+
return action_type in INVESTIGATION_ACTIONS
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _base_terminal_reward(action_type: str, task: PayOpsTask) -> float:
|
| 94 |
+
"""Return the base reward for a terminal action against a task."""
|
| 95 |
+
if action_type == task.correct_action:
|
| 96 |
+
return FULL_CREDIT
|
| 97 |
+
|
| 98 |
+
if action_type in task.partial_credit_actions:
|
| 99 |
+
return FULL_CREDIT * task.partial_credit_actions[action_type]
|
| 100 |
+
|
| 101 |
+
if action_type == "approve" and task.correct_action in ("reject", "escalate"):
|
| 102 |
+
return WRONG_APPROVE_FRAUD
|
| 103 |
+
|
| 104 |
+
if action_type == "approve" and task.correct_action in ("flag", "hold"):
|
| 105 |
+
return WRONG_APPROVE_CAUTION
|
| 106 |
+
|
| 107 |
+
if action_type == "reject" and task.correct_action == "approve":
|
| 108 |
+
return WRONG_REJECT_GOOD
|
| 109 |
+
|
| 110 |
+
return WRONG_DEFAULT
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def step_reward(
|
| 114 |
+
action_type: str,
|
| 115 |
+
task: PayOpsTask,
|
| 116 |
+
inspected_already: bool = False,
|
| 117 |
+
) -> float:
|
| 118 |
+
"""
|
| 119 |
+
Backward-compatible single-step reward used by the real-time environment.
|
| 120 |
+
|
| 121 |
+
Investigation actions return 0 (already handled inside environment.step_async
|
| 122 |
+
through the richer grader logic). Terminal actions use the base reward table.
|
| 123 |
+
"""
|
| 124 |
+
if _is_investigation(action_type):
|
| 125 |
+
return 0.0 if inspected_already else INVESTIGATION_BONUS
|
| 126 |
+
|
| 127 |
+
return _base_terminal_reward(action_type, task)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
# Extended per-task grader (used by grade_episode)
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
|
| 134 |
+
@dataclass
|
| 135 |
+
class TaskGradeDetail:
|
| 136 |
+
task_id: str
|
| 137 |
+
difficulty: str
|
| 138 |
+
weight: float
|
| 139 |
+
correct_action: str
|
| 140 |
+
terminal_action: str
|
| 141 |
+
investigation_actions_used: List[str]
|
| 142 |
+
base_reward: float
|
| 143 |
+
investigation_bonus: float
|
| 144 |
+
time_penalty: float
|
| 145 |
+
confidence_modifier: float
|
| 146 |
+
regulatory_bonus: float
|
| 147 |
+
total_reward: float
|
| 148 |
+
correct: bool
|
| 149 |
+
reward_breakdown: Dict[str, float] = field(default_factory=dict)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _grade_single_task(
|
| 153 |
+
terminal_action: str,
|
| 154 |
+
investigation_actions: List[str], # sub-actions used BEFORE terminal
|
| 155 |
+
task: PayOpsTask,
|
| 156 |
+
agent_confidence: Optional[float] = None,
|
| 157 |
+
) -> TaskGradeDetail:
|
| 158 |
+
weight = DIFFICULTY_WEIGHT.get(task.difficulty, 1.0)
|
| 159 |
+
base = _base_terminal_reward(terminal_action, task)
|
| 160 |
+
correct = terminal_action == task.correct_action
|
| 161 |
+
|
| 162 |
+
# ── investigation bonus and time penalty ────────────────────────────────
|
| 163 |
+
inv_bonus = 0.0
|
| 164 |
+
time_pen = 0.0
|
| 165 |
+
eligible = task.requires_investigation # set of actions agent should use
|
| 166 |
+
seen_counts: Dict[str, int] = {}
|
| 167 |
+
for inv_action in investigation_actions:
|
| 168 |
+
seen_counts[inv_action] = seen_counts.get(inv_action, 0) + 1
|
| 169 |
+
if inv_action in eligible and seen_counts[inv_action] == 1:
|
| 170 |
+
inv_bonus += INVESTIGATION_BONUS
|
| 171 |
+
elif seen_counts[inv_action] > 1:
|
| 172 |
+
time_pen += TIME_PENALTY_PER_EXTRA_STEP
|
| 173 |
+
|
| 174 |
+
# ── confidence modifier ─────────────────────────────────────────────────
|
| 175 |
+
conf_mod = 0.0
|
| 176 |
+
if agent_confidence is not None and agent_confidence >= 0.8:
|
| 177 |
+
conf_mod = CONFIDENCE_CORRECT_BONUS if correct else CONFIDENCE_WRONG_PENALTY
|
| 178 |
+
|
| 179 |
+
# ── regulatory bonus ────────────────────────────────────────────────────
|
| 180 |
+
reg_bonus = 0.0
|
| 181 |
+
if task.regulatory_action and "file_sar" in investigation_actions:
|
| 182 |
+
reg_bonus = REGULATORY_BONUS
|
| 183 |
+
|
| 184 |
+
total = weight * (base + inv_bonus - time_pen + conf_mod + reg_bonus)
|
| 185 |
+
|
| 186 |
+
return TaskGradeDetail(
|
| 187 |
+
task_id=task.task_id,
|
| 188 |
+
difficulty=task.difficulty,
|
| 189 |
+
weight=weight,
|
| 190 |
+
correct_action=task.correct_action,
|
| 191 |
+
terminal_action=terminal_action,
|
| 192 |
+
investigation_actions_used=investigation_actions,
|
| 193 |
+
base_reward=round(base, 4),
|
| 194 |
+
investigation_bonus=round(inv_bonus, 4),
|
| 195 |
+
time_penalty=round(time_pen, 4),
|
| 196 |
+
confidence_modifier=round(conf_mod, 4),
|
| 197 |
+
regulatory_bonus=round(reg_bonus, 4),
|
| 198 |
+
total_reward=round(total, 4),
|
| 199 |
+
correct=correct,
|
| 200 |
+
reward_breakdown={
|
| 201 |
+
"base": round(base, 4),
|
| 202 |
+
"weight": weight,
|
| 203 |
+
"investigation": round(inv_bonus, 4),
|
| 204 |
+
"time_penalty": round(-time_pen, 4),
|
| 205 |
+
"confidence": round(conf_mod, 4),
|
| 206 |
+
"regulatory": round(reg_bonus, 4),
|
| 207 |
+
"weighted_total": round(total, 4),
|
| 208 |
+
},
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
# ---------------------------------------------------------------------------
|
| 213 |
+
# Episode grader
|
| 214 |
+
# ---------------------------------------------------------------------------
|
| 215 |
+
|
| 216 |
+
@dataclass
|
| 217 |
+
class EpisodeResult:
|
| 218 |
+
total_reward: float
|
| 219 |
+
max_possible_reward: float
|
| 220 |
+
normalised_score: float # 0.0 – 1.0
|
| 221 |
+
per_task_rewards: List[dict]
|
| 222 |
+
budget_spent: float
|
| 223 |
+
budget_overspend: float
|
| 224 |
+
budget_penalty: float
|
| 225 |
+
passed: bool # normalised_score >= 0.5
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def grade_episode(
|
| 229 |
+
actions: List[str],
|
| 230 |
+
tasks: List[PayOpsTask],
|
| 231 |
+
confidences: Optional[List[Optional[float]]] = None,
|
| 232 |
+
budget_limit: float = 5.0,
|
| 233 |
+
) -> EpisodeResult:
|
| 234 |
+
"""
|
| 235 |
+
Grade a complete episode.
|
| 236 |
+
|
| 237 |
+
``actions`` is the flat list of all actions taken (including investigation
|
| 238 |
+
sub-actions interspersed between terminal decisions).
|
| 239 |
+
|
| 240 |
+
The grader separates them by treating any action in INVESTIGATION_ACTIONS as
|
| 241 |
+
a sub-action that accumulates per-task until a terminal action closes the task.
|
| 242 |
+
|
| 243 |
+
Args:
|
| 244 |
+
actions: Ordered list of action_type strings.
|
| 245 |
+
tasks: Ordered list of PayOpsTask objects.
|
| 246 |
+
confidences: Optional parallel list of agent confidence values (same
|
| 247 |
+
length as ``actions``). Use None for missing entries.
|
| 248 |
+
budget_limit: Maximum investigation budget. Overspend is penalised.
|
| 249 |
+
|
| 250 |
+
Returns:
|
| 251 |
+
EpisodeResult with comprehensive score breakdown.
|
| 252 |
+
"""
|
| 253 |
+
if confidences is None:
|
| 254 |
+
confidences = [None] * len(actions)
|
| 255 |
+
|
| 256 |
+
per_task_details: List[TaskGradeDetail] = []
|
| 257 |
+
budget_spent = 0.0
|
| 258 |
+
|
| 259 |
+
task_idx = 0
|
| 260 |
+
pending_inv: List[str] = [] # sub-actions for current task
|
| 261 |
+
pending_conf: List[Optional[float]] = []
|
| 262 |
+
|
| 263 |
+
for i, (action, conf) in enumerate(zip(actions, confidences)):
|
| 264 |
+
budget_spent += ACTION_COSTS.get(action, 0.0)
|
| 265 |
+
|
| 266 |
+
if _is_investigation(action):
|
| 267 |
+
pending_inv.append(action)
|
| 268 |
+
pending_conf.append(conf)
|
| 269 |
+
else:
|
| 270 |
+
# terminal action → grade and advance task pointer
|
| 271 |
+
if task_idx >= len(tasks):
|
| 272 |
+
break
|
| 273 |
+
task = tasks[task_idx]
|
| 274 |
+
# Use the confidence from the terminal action step
|
| 275 |
+
detail = _grade_single_task(action, pending_inv, task, agent_confidence=conf)
|
| 276 |
+
per_task_details.append(detail)
|
| 277 |
+
pending_inv = []
|
| 278 |
+
pending_conf = []
|
| 279 |
+
task_idx += 1
|
| 280 |
+
|
| 281 |
+
# Handle any tasks with no actions (agent ran out of steps)
|
| 282 |
+
while task_idx < len(tasks):
|
| 283 |
+
task = tasks[task_idx]
|
| 284 |
+
detail = _grade_single_task("approve", [], task, agent_confidence=None)
|
| 285 |
+
detail.base_reward = WRONG_DEFAULT
|
| 286 |
+
detail.total_reward = DIFFICULTY_WEIGHT.get(task.difficulty, 1.0) * WRONG_DEFAULT
|
| 287 |
+
per_task_details.append(detail)
|
| 288 |
+
task_idx += 1
|
| 289 |
+
|
| 290 |
+
# ── budget overspend penalty ─────────────────────────────────────────────
|
| 291 |
+
budget_overspend = max(0.0, budget_spent - budget_limit)
|
| 292 |
+
budget_penalty = round(budget_overspend * BUDGET_OVERSPEND_PENALTY, 4)
|
| 293 |
+
|
| 294 |
+
total = sum(d.total_reward for d in per_task_details) - budget_penalty
|
| 295 |
+
max_possible = sum(
|
| 296 |
+
DIFFICULTY_WEIGHT.get(t.difficulty, 1.0) * FULL_CREDIT for t in tasks
|
| 297 |
+
)
|
| 298 |
+
normalised = max(0.0, min(1.0, total / max_possible)) if max_possible > 0 else 0.0
|
| 299 |
+
|
| 300 |
+
return EpisodeResult(
|
| 301 |
+
total_reward=round(total, 4),
|
| 302 |
+
max_possible_reward=round(max_possible, 4),
|
| 303 |
+
normalised_score=round(normalised, 4),
|
| 304 |
+
per_task_rewards=[
|
| 305 |
+
{
|
| 306 |
+
"task_id": d.task_id,
|
| 307 |
+
"difficulty": d.difficulty,
|
| 308 |
+
"weight": d.weight,
|
| 309 |
+
"terminal_action": d.terminal_action,
|
| 310 |
+
"correct_action": d.correct_action,
|
| 311 |
+
"investigation_used": d.investigation_actions_used,
|
| 312 |
+
"correct": d.correct,
|
| 313 |
+
"reward_breakdown": d.reward_breakdown,
|
| 314 |
+
"weighted_reward": d.total_reward,
|
| 315 |
+
}
|
| 316 |
+
for d in per_task_details
|
| 317 |
+
],
|
| 318 |
+
budget_spent=round(budget_spent, 4),
|
| 319 |
+
budget_overspend=round(budget_overspend, 4),
|
| 320 |
+
budget_penalty=budget_penalty,
|
| 321 |
+
passed=normalised >= 0.5,
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
# ---------------------------------------------------------------------------
|
| 326 |
+
# Convenience wrapper used by the environment
|
| 327 |
+
# ---------------------------------------------------------------------------
|
| 328 |
+
|
| 329 |
+
def grade(action_type: str, task: PayOpsTask, inspected_already: bool = False) -> float:
|
| 330 |
+
"""Single-step reward used inside environment.step_async."""
|
| 331 |
+
return step_reward(action_type, task, inspected_already=inspected_already)
|
| 332 |
+
|
models.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# ---------------------------------------------------------------------------
|
| 9 |
+
# Action space
|
| 10 |
+
# ---------------------------------------------------------------------------
|
| 11 |
+
|
| 12 |
+
class PayOpsAction(BaseModel):
|
| 13 |
+
"""
|
| 14 |
+
Action submitted by the agent for a single transaction.
|
| 15 |
+
|
| 16 |
+
action_type choices
|
| 17 |
+
-------------------
|
| 18 |
+
approve – mark transaction as legitimate and allow it through
|
| 19 |
+
reject – block the transaction outright
|
| 20 |
+
flag – mark for manual review with a soft hold
|
| 21 |
+
escalate – route to senior compliance officer / fraud team
|
| 22 |
+
inspect – pull additional signals (logs, KYC data, velocity)
|
| 23 |
+
hold – temporary hold pending more information
|
| 24 |
+
request_docs – ask sender for supporting documents (e.g. invoice, contract)
|
| 25 |
+
verify_kyc – trigger an active KYC re-verification check
|
| 26 |
+
contact_sender – contact the sender directly to confirm intent
|
| 27 |
+
file_sar – file a Suspicious Activity Report to regulator
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
action_type: str = Field(
|
| 31 |
+
...,
|
| 32 |
+
description=(
|
| 33 |
+
"One of: approve | reject | flag | escalate | inspect | hold "
|
| 34 |
+
"| request_docs | verify_kyc | contact_sender | file_sar"
|
| 35 |
+
),
|
| 36 |
+
)
|
| 37 |
+
transaction_id: str = Field(..., description="ID of the transaction being acted on")
|
| 38 |
+
reason: Optional[str] = Field(
|
| 39 |
+
default=None, description="Free-text rationale from the agent"
|
| 40 |
+
)
|
| 41 |
+
confidence: Optional[float] = Field(
|
| 42 |
+
default=None,
|
| 43 |
+
ge=0.0,
|
| 44 |
+
le=1.0,
|
| 45 |
+
description="Agent self-reported confidence [0, 1]. Used in reward shaping.",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
# Observation space
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
|
| 53 |
+
class PayOpsObservation(BaseModel):
|
| 54 |
+
"""
|
| 55 |
+
Structured observation returned after each step (and on reset).
|
| 56 |
+
Describes the current transaction visible to the agent.
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
# --- transaction identity ---
|
| 60 |
+
transaction_id: str
|
| 61 |
+
amount: float = Field(..., description="Transaction amount in the stated currency")
|
| 62 |
+
currency: str = Field(..., description="ISO-4217 currency code, e.g. USD, EUR")
|
| 63 |
+
sender: str = Field(..., description="Sender identifier (email / account / alias)")
|
| 64 |
+
receiver: str = Field(..., description="Receiver identifier")
|
| 65 |
+
transaction_type: str = Field(
|
| 66 |
+
default="transfer",
|
| 67 |
+
description="Type: transfer | payment | withdrawal | refund | internal | loan_repayment | payroll",
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# --- risk signals ---
|
| 71 |
+
status: str = Field(
|
| 72 |
+
default="pending",
|
| 73 |
+
description=(
|
| 74 |
+
"Current status: pending | approved | rejected | flagged | escalated "
|
| 75 |
+
"| held | inspected | docs_requested | kyc_triggered | sender_contacted | sar_filed"
|
| 76 |
+
),
|
| 77 |
+
)
|
| 78 |
+
risk_score: float = Field(
|
| 79 |
+
..., ge=0.0, le=1.0, description="Composite ML risk score [0=low, 1=high]"
|
| 80 |
+
)
|
| 81 |
+
ml_confidence: float = Field(
|
| 82 |
+
default=0.9,
|
| 83 |
+
ge=0.0,
|
| 84 |
+
le=1.0,
|
| 85 |
+
description="Model's self-reported confidence in its own risk_score. Low = possibly poisoned.",
|
| 86 |
+
)
|
| 87 |
+
flags: List[str] = Field(
|
| 88 |
+
default_factory=list,
|
| 89 |
+
description="Active risk flags e.g. high_value, unknown_sender, velocity_breach",
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
# --- sender behaviour signals ---
|
| 93 |
+
velocity_1h: Optional[int] = Field(
|
| 94 |
+
default=None,
|
| 95 |
+
description="Number of transactions from this sender in the past hour",
|
| 96 |
+
)
|
| 97 |
+
velocity_24h: Optional[int] = Field(
|
| 98 |
+
default=None,
|
| 99 |
+
description="Number of transactions from this sender in the past 24 hours",
|
| 100 |
+
)
|
| 101 |
+
avg_transaction_amount: Optional[float] = Field(
|
| 102 |
+
default=None,
|
| 103 |
+
description="Sender's historical average transaction amount",
|
| 104 |
+
)
|
| 105 |
+
account_age_days: Optional[int] = Field(
|
| 106 |
+
default=None,
|
| 107 |
+
description="Age of the sender account in days",
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# --- counterparty / geography ---
|
| 111 |
+
country_risk: Optional[str] = Field(
|
| 112 |
+
default=None,
|
| 113 |
+
description="Receiver country risk tier: low | medium | high | sanctioned",
|
| 114 |
+
)
|
| 115 |
+
kyc_status: Optional[str] = Field(
|
| 116 |
+
default=None,
|
| 117 |
+
description="KYC verification status: verified | pending | failed | none | expired",
|
| 118 |
+
)
|
| 119 |
+
kyc_expiry_days: Optional[int] = Field(
|
| 120 |
+
default=None,
|
| 121 |
+
description="Days until KYC expires (negative = already expired)",
|
| 122 |
+
)
|
| 123 |
+
previous_violations: Optional[int] = Field(
|
| 124 |
+
default=None,
|
| 125 |
+
description="Number of prior compliance violations for this sender",
|
| 126 |
+
)
|
| 127 |
+
previous_sars: Optional[int] = Field(
|
| 128 |
+
default=None,
|
| 129 |
+
description="Number of Suspicious Activity Reports previously filed for this sender",
|
| 130 |
+
)
|
| 131 |
+
counterparty_risk: Optional[str] = Field(
|
| 132 |
+
default=None,
|
| 133 |
+
description="Known risk profile of the receiver: clean | unknown | watchlist | blacklist",
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# --- chain context (multi-hop investigation) ---
|
| 137 |
+
chain_step: int = Field(
|
| 138 |
+
default=1,
|
| 139 |
+
description="Which step within a multi-hop investigation chain (1=initial presentation)",
|
| 140 |
+
)
|
| 141 |
+
chain_total: int = Field(
|
| 142 |
+
default=1,
|
| 143 |
+
description="Total number of chained investigation steps for this task",
|
| 144 |
+
)
|
| 145 |
+
chain_context: Optional[str] = Field(
|
| 146 |
+
default=None,
|
| 147 |
+
description="Summary of findings from earlier chain steps",
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
# --- resource tracking ---
|
| 151 |
+
steps_remaining: Optional[int] = Field(
|
| 152 |
+
default=None,
|
| 153 |
+
description="How many investigation sub-steps remain before a terminal decision is required",
|
| 154 |
+
)
|
| 155 |
+
action_cost: float = Field(
|
| 156 |
+
default=0.0,
|
| 157 |
+
description="Operational cost penalty incurred by the last action",
|
| 158 |
+
)
|
| 159 |
+
budget_remaining: float = Field(
|
| 160 |
+
default=1.0,
|
| 161 |
+
description="Fraction of investigation budget remaining (1.0=full, 0.0=exhausted)",
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
# --- context from prior investigation actions ---
|
| 165 |
+
inspection_notes: Optional[str] = Field(
|
| 166 |
+
default=None,
|
| 167 |
+
description="Additional details revealed after an 'inspect' action",
|
| 168 |
+
)
|
| 169 |
+
docs_notes: Optional[str] = Field(
|
| 170 |
+
default=None,
|
| 171 |
+
description="Document review findings after a 'request_docs' action",
|
| 172 |
+
)
|
| 173 |
+
kyc_notes: Optional[str] = Field(
|
| 174 |
+
default=None,
|
| 175 |
+
description="KYC re-verification outcome after a 'verify_kyc' action",
|
| 176 |
+
)
|
| 177 |
+
contact_notes: Optional[str] = Field(
|
| 178 |
+
default=None,
|
| 179 |
+
description="Outcome of contacting the sender via 'contact_sender' action",
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
# --- recent decision context (last 3 decisions in this episode) ---
|
| 183 |
+
recent_decisions: List[Dict[str, Any]] = Field(
|
| 184 |
+
default_factory=list,
|
| 185 |
+
description="Last up to 3 completed decisions in this episode for pattern context",
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
# --- episode bookkeeping ---
|
| 189 |
+
task_id: str = Field(default="", description="Identifier of the active task")
|
| 190 |
+
task_difficulty: str = Field(
|
| 191 |
+
default="easy", description="Difficulty tier: easy | medium | hard | critical"
|
| 192 |
+
)
|
| 193 |
+
step_in_episode: int = Field(
|
| 194 |
+
default=0, description="How many steps have elapsed in this episode"
|
| 195 |
+
)
|
| 196 |
+
reward: float = Field(default=0.0, description="Reward from the last action")
|
| 197 |
+
reward_breakdown: Dict[str, float] = Field(
|
| 198 |
+
default_factory=dict,
|
| 199 |
+
description="Itemised reward components: base, time_penalty, confidence_bonus, cost_penalty",
|
| 200 |
+
)
|
| 201 |
+
cumulative_reward: float = Field(
|
| 202 |
+
default=0.0, description="Total reward accumulated so far in this episode"
|
| 203 |
+
)
|
| 204 |
+
done: bool = Field(default=False, description="Whether the episode has ended")
|
| 205 |
+
info: Dict[str, Any] = Field(
|
| 206 |
+
default_factory=dict,
|
| 207 |
+
description="Extra diagnostic information (action taken, correct action, etc.)",
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ---------------------------------------------------------------------------
|
| 212 |
+
# Internal state (used by the server's /state endpoint)
|
| 213 |
+
# ---------------------------------------------------------------------------
|
| 214 |
+
|
| 215 |
+
class PayOpsState(BaseModel):
|
| 216 |
+
episode_id: Optional[str] = None
|
| 217 |
+
step_count: int = 0
|
| 218 |
+
current_task_id: str = ""
|
| 219 |
+
transactions_processed: int = 0
|
| 220 |
+
total_tasks: int = 0
|
| 221 |
+
cumulative_reward: float = 0.0
|
| 222 |
+
budget_spent: float = Field(default=0.0, description="Total action costs accumulated")
|
| 223 |
+
budget_limit: float = Field(default=5.0, description="Max investigation budget per episode")
|
| 224 |
+
actions_taken: List[str] = Field(default_factory=list)
|
| 225 |
+
last_action: Optional[str] = None
|
| 226 |
+
investigation_actions_used: List[str] = Field(
|
| 227 |
+
default_factory=list,
|
| 228 |
+
description="All investigation sub-actions used this episode (inspect, request_docs, etc.)",
|
| 229 |
+
)
|
| 230 |
+
correct_decisions: int = Field(default=0, description="Terminal decisions that matched ground truth")
|
| 231 |
+
wrong_high_cost: int = Field(
|
| 232 |
+
default=0, description="Count of approve-on-fraud type mistakes"
|
| 233 |
+
)
|
| 234 |
+
recent_decisions: List[Dict[str, Any]] = Field(
|
| 235 |
+
default_factory=list,
|
| 236 |
+
description="Recent completed task outcomes for analytics",
|
| 237 |
+
)
|
| 238 |
+
done: bool = False
|
| 239 |
+
|
openenv.yaml
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
+
name: payops_env
|
| 3 |
+
display_name: "PayOps — Payment Operations Incident Response"
|
| 4 |
+
description: >
|
| 5 |
+
An AI agent acts as a Payment Operations analyst, reviewing financial
|
| 6 |
+
transactions and deciding how to handle each one: approve, reject, flag,
|
| 7 |
+
escalate, inspect, or hold. Tasks range from clear-cut fraud to subtle
|
| 8 |
+
adversarial patterns like model poisoning and APP scams.
|
| 9 |
+
type: space
|
| 10 |
+
runtime: fastapi
|
| 11 |
+
app: payops_env.server.app:app
|
| 12 |
+
port: 7860
|
| 13 |
+
version: "1.0.0"
|
| 14 |
+
author: "PayOps Team"
|
| 15 |
+
tags:
|
| 16 |
+
- finance
|
| 17 |
+
- fraud-detection
|
| 18 |
+
- compliance
|
| 19 |
+
- real-world
|
| 20 |
+
- payment-operations
|
| 21 |
+
action_space:
|
| 22 |
+
type: discrete
|
| 23 |
+
actions:
|
| 24 |
+
- approve
|
| 25 |
+
- reject
|
| 26 |
+
- flag
|
| 27 |
+
- escalate
|
| 28 |
+
- inspect
|
| 29 |
+
- hold
|
| 30 |
+
observation_space:
|
| 31 |
+
type: structured
|
| 32 |
+
fields:
|
| 33 |
+
- transaction_id
|
| 34 |
+
- amount
|
| 35 |
+
- currency
|
| 36 |
+
- sender
|
| 37 |
+
- receiver
|
| 38 |
+
- risk_score
|
| 39 |
+
- flags
|
| 40 |
+
- velocity_1h
|
| 41 |
+
- country_risk
|
| 42 |
+
- kyc_status
|
| 43 |
+
tasks:
|
| 44 |
+
count: 12
|
| 45 |
+
difficulties:
|
| 46 |
+
- easy
|
| 47 |
+
- medium
|
| 48 |
+
- hard
|
| 49 |
+
reward:
|
| 50 |
+
min: -1.0
|
| 51 |
+
max: 1.0
|
| 52 |
+
partial_credit: true
|
| 53 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv-core>=0.2.0
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
uvicorn>=0.24.0
|
| 4 |
+
pydantic>=2.0.0
|
run_tests.sh
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# run_tests.sh — PayOps v2 full test suite
|
| 3 |
+
# Usage: bash payops_env/run_tests.sh [BASE_URL]
|
| 4 |
+
# Requires the server to be running. Default: http://localhost:8000
|
| 5 |
+
|
| 6 |
+
BASE="${1:-http://localhost:8000}"
|
| 7 |
+
PASS=0
|
| 8 |
+
FAIL=0
|
| 9 |
+
SKIP=0
|
| 10 |
+
|
| 11 |
+
# ── helpers ──────────────────────────────────────────────────────────────────
|
| 12 |
+
|
| 13 |
+
check() {
|
| 14 |
+
local name="$1"
|
| 15 |
+
local got="$2"
|
| 16 |
+
local want="$3"
|
| 17 |
+
if echo "$got" | grep -qE "$want"; then
|
| 18 |
+
printf " \033[32m✓\033[0m %s\n" "$name"
|
| 19 |
+
PASS=$((PASS+1))
|
| 20 |
+
else
|
| 21 |
+
printf " \033[31m✗\033[0m %s\n" "$name"
|
| 22 |
+
printf " expected pattern : %s\n" "$want"
|
| 23 |
+
printf " got : %s\n" "$(echo "$got" | head -c 200)"
|
| 24 |
+
FAIL=$((FAIL+1))
|
| 25 |
+
fi
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
check_absent() {
|
| 29 |
+
local name="$1"
|
| 30 |
+
local got="$2"
|
| 31 |
+
local absent="$3"
|
| 32 |
+
if echo "$got" | grep -qE "$absent"; then
|
| 33 |
+
printf " \033[31m✗\033[0m %s (unexpected: %s)\n" "$name" "$absent"
|
| 34 |
+
FAIL=$((FAIL+1))
|
| 35 |
+
else
|
| 36 |
+
printf " \033[32m✓\033[0m %s\n" "$name"
|
| 37 |
+
PASS=$((PASS+1))
|
| 38 |
+
fi
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
step() {
|
| 42 |
+
curl -s -X POST "$BASE/step" \
|
| 43 |
+
-H "Content-Type: application/json" \
|
| 44 |
+
-d "$1"
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
reset() {
|
| 48 |
+
curl -s -X POST "$BASE/reset" > /dev/null
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
section() {
|
| 52 |
+
echo ""
|
| 53 |
+
echo "── $1 ──"
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
echo ""
|
| 57 |
+
echo "╔══════════════════════════════════════════════════════╗"
|
| 58 |
+
echo "║ PayOps v2 — Full Post-Main Test Suite ║"
|
| 59 |
+
echo "║ Target: $BASE"
|
| 60 |
+
echo "╚══════════════════════════════════════════════════════╝"
|
| 61 |
+
echo ""
|
| 62 |
+
|
| 63 |
+
# ═══════════════════════════════════════════════════════════
|
| 64 |
+
# GROUP A — Server / Infrastructure
|
| 65 |
+
# ═══════════════════════════════════════════════════════════
|
| 66 |
+
section "GROUP A — Server / Infrastructure"
|
| 67 |
+
|
| 68 |
+
# A-01 Health check
|
| 69 |
+
check "A-01 GET /health → status ok" \
|
| 70 |
+
"$(curl -s $BASE/health)" \
|
| 71 |
+
'"status":"ok"'
|
| 72 |
+
|
| 73 |
+
# A-02 Version is v2
|
| 74 |
+
check "A-02 GET /health → version 2.0.0" \
|
| 75 |
+
"$(curl -s $BASE/health)" \
|
| 76 |
+
'"version":"2.0.0"'
|
| 77 |
+
|
| 78 |
+
# A-03 Schema action model present
|
| 79 |
+
check "A-03 GET /schema → PayOpsAction schema" \
|
| 80 |
+
"$(curl -s $BASE/schema)" \
|
| 81 |
+
'"PayOpsAction"'
|
| 82 |
+
|
| 83 |
+
# A-04 Schema observation model present
|
| 84 |
+
check "A-04 GET /schema → PayOpsObservation schema" \
|
| 85 |
+
"$(curl -s $BASE/schema)" \
|
| 86 |
+
'"PayOpsObservation"'
|
| 87 |
+
|
| 88 |
+
# A-05 Schema state model present
|
| 89 |
+
check "A-05 GET /schema → PayOpsState schema" \
|
| 90 |
+
"$(curl -s $BASE/schema)" \
|
| 91 |
+
'"PayOpsState"'
|
| 92 |
+
|
| 93 |
+
# ═══════════════════════════════════════════════════════════
|
| 94 |
+
# GROUP B — Tasks endpoint
|
| 95 |
+
# ═══════════════════════════════════════════════════════════
|
| 96 |
+
section "GROUP B — Tasks endpoint"
|
| 97 |
+
|
| 98 |
+
TASKS_RESP="$(curl -s $BASE/tasks)"
|
| 99 |
+
|
| 100 |
+
# B-01 20 tasks
|
| 101 |
+
check "B-01 GET /tasks → count=20" \
|
| 102 |
+
"$TASKS_RESP" '"count":20'
|
| 103 |
+
|
| 104 |
+
# B-02 All 4 difficulty tiers present
|
| 105 |
+
check "B-02 Tasks include 'easy' tier" "$TASKS_RESP" '"difficulty":"easy"'
|
| 106 |
+
check "B-03 Tasks include 'medium' tier" "$TASKS_RESP" '"difficulty":"medium"'
|
| 107 |
+
check "B-04 Tasks include 'hard' tier" "$TASKS_RESP" '"difficulty":"hard"'
|
| 108 |
+
check "B-05 Tasks include 'critical' tier" "$TASKS_RESP" '"difficulty":"critical"'
|
| 109 |
+
|
| 110 |
+
# B-06 Regulatory tasks present
|
| 111 |
+
check "B-06 Tasks include regulatory_action flag" \
|
| 112 |
+
"$TASKS_RESP" '"regulatory_action":true'
|
| 113 |
+
|
| 114 |
+
# B-07 Multi-step chain tasks present
|
| 115 |
+
check "B-07 Tasks include chain_total > 1" \
|
| 116 |
+
"$TASKS_RESP" '"chain_total":3'
|
| 117 |
+
|
| 118 |
+
# B-08 requires_investigation populated
|
| 119 |
+
check "B-08 Tasks include requires_investigation" \
|
| 120 |
+
"$TASKS_RESP" '"requires_investigation":\['
|
| 121 |
+
|
| 122 |
+
# ═══════════════════════════════════════════════════════════
|
| 123 |
+
# GROUP C — Reset
|
| 124 |
+
# ═══════════════════════════════════════════════════════════
|
| 125 |
+
section "GROUP C — Reset"
|
| 126 |
+
|
| 127 |
+
RESET_RESP="$(curl -s -X POST $BASE/reset)"
|
| 128 |
+
check "C-01 POST /reset → returns EASY-001" "$RESET_RESP" '"task_id":"EASY-001"'
|
| 129 |
+
check "C-02 POST /reset → done=false" "$RESET_RESP" '"done":false'
|
| 130 |
+
check "C-03 POST /reset → budget_remaining=5.0" "$RESET_RESP" '"budget_remaining":5.0'
|
| 131 |
+
check "C-04 POST /reset → risk_score present" "$RESET_RESP" '"risk_score":'
|
| 132 |
+
check "C-05 POST /reset → ml_confidence present" "$RESET_RESP" '"ml_confidence":'
|
| 133 |
+
check "C-06 POST /reset → chain_total present" "$RESET_RESP" '"chain_total":'
|
| 134 |
+
|
| 135 |
+
# ═══════════════════════════════════════════════════════════
|
| 136 |
+
# GROUP D — Terminal Actions (correct decisions)
|
| 137 |
+
# ═══════════════════════════════════════════════════════════
|
| 138 |
+
section "GROUP D — Terminal Actions (correct decisions)"
|
| 139 |
+
|
| 140 |
+
reset
|
| 141 |
+
|
| 142 |
+
# D-01 approve correct → reward=1.0
|
| 143 |
+
check "D-01 EASY-001 approve → reward=1.0" \
|
| 144 |
+
"$(step '{"action_type":"approve","transaction_id":"TXN-E001"}')" \
|
| 145 |
+
'"reward":1.0'
|
| 146 |
+
|
| 147 |
+
# D-02 reject correct → reward=1.0
|
| 148 |
+
check "D-02 EASY-002 reject → reward=1.0" \
|
| 149 |
+
"$(step '{"action_type":"reject","transaction_id":"TXN-E002"}')" \
|
| 150 |
+
'"reward":1.0'
|
| 151 |
+
|
| 152 |
+
# D-03 approve correct (refund) → reward=1.0
|
| 153 |
+
check "D-03 EASY-003 approve → reward=1.0" \
|
| 154 |
+
"$(step '{"action_type":"approve","transaction_id":"TXN-E003"}')" \
|
| 155 |
+
'"reward":1.0'
|
| 156 |
+
|
| 157 |
+
# D-04 flag correct → reward=1.0
|
| 158 |
+
check "D-04 EASY-004 flag → reward=1.0" \
|
| 159 |
+
"$(step '{"action_type":"flag","transaction_id":"TXN-E004"}')" \
|
| 160 |
+
'"reward":1.0'
|
| 161 |
+
|
| 162 |
+
# D-05 escalate correct → reward=1.0
|
| 163 |
+
check "D-05 MED-001 escalate → reward=1.0" \
|
| 164 |
+
"$(step '{"action_type":"escalate","transaction_id":"TXN-M001"}')" \
|
| 165 |
+
'"reward":1.0'
|
| 166 |
+
|
| 167 |
+
# D-06 hold correct → reward=1.0
|
| 168 |
+
check "D-06 MED-002 hold → reward=1.0" \
|
| 169 |
+
"$(step '{"action_type":"hold","transaction_id":"TXN-M002"}')" \
|
| 170 |
+
'"reward":1.0'
|
| 171 |
+
|
| 172 |
+
# D-07 task advances after terminal
|
| 173 |
+
R=$(step '{"action_type":"flag","transaction_id":"TXN-M003"}')
|
| 174 |
+
check "D-07 After flag on MED-003, next task is MED-004" "$R" '"task_id":"MED-004"'
|
| 175 |
+
|
| 176 |
+
# ═══════════════════════════════════════════════════════════
|
| 177 |
+
# GROUP E — Terminal Actions (wrong decisions / partial credit)
|
| 178 |
+
# ═══════════════════════════════════════════════════════════
|
| 179 |
+
section "GROUP E — Wrong actions & partial credit"
|
| 180 |
+
|
| 181 |
+
reset
|
| 182 |
+
|
| 183 |
+
# E-01 approve when should reject → -1.0 (fraud approval)
|
| 184 |
+
step '{"action_type":"approve","transaction_id":"TXN-E001"}' > /dev/null # advance past E001
|
| 185 |
+
check "E-01 Approve fraud (EASY-002) → reward=-1.0" \
|
| 186 |
+
"$(step '{"action_type":"approve","transaction_id":"TXN-E002"}')" \
|
| 187 |
+
'"reward":-1.0'
|
| 188 |
+
|
| 189 |
+
reset
|
| 190 |
+
step '{"action_type":"approve","transaction_id":"TXN-E001"}' > /dev/null
|
| 191 |
+
step '{"action_type":"reject","transaction_id":"TXN-E002"}' > /dev/null
|
| 192 |
+
|
| 193 |
+
# E-02 reject correct → approve → -0.5
|
| 194 |
+
check "E-02 Reject legit (EASY-003) → reward=-0.5" \
|
| 195 |
+
"$(step '{"action_type":"reject","transaction_id":"TXN-E003"}')" \
|
| 196 |
+
'"reward":-0.5'
|
| 197 |
+
|
| 198 |
+
reset
|
| 199 |
+
# E-03 partial credit — escalate instead of escalate on MED-001 is correct
|
| 200 |
+
# flag instead of escalate earns partial
|
| 201 |
+
step '{"action_type":"approve","transaction_id":"TXN-E001"}' > /dev/null
|
| 202 |
+
step '{"action_type":"reject","transaction_id":"TXN-E002"}' > /dev/null
|
| 203 |
+
step '{"action_type":"approve","transaction_id":"TXN-E003"}' > /dev/null
|
| 204 |
+
step '{"action_type":"flag","transaction_id":"TXN-E004"}' > /dev/null
|
| 205 |
+
R=$(step '{"action_type":"flag","transaction_id":"TXN-M001"}')
|
| 206 |
+
check "E-03 Partial credit: flag on MED-001 (correct=escalate) → reward > 0" \
|
| 207 |
+
"$R" '"reward":0\.[0-9]'
|
| 208 |
+
|
| 209 |
+
# ═══════════════════════════════════════════════════════════
|
| 210 |
+
# GROUP F — Investigation Sub-Actions
|
| 211 |
+
# ═══════════════════════════════════════════════════════════
|
| 212 |
+
section "GROUP F — Investigation sub-actions"
|
| 213 |
+
|
| 214 |
+
reset
|
| 215 |
+
|
| 216 |
+
# F-01 inspect → reward=0.15, stays on same task
|
| 217 |
+
R=$(step '{"action_type":"inspect","transaction_id":"TXN-E001"}')
|
| 218 |
+
check "F-01 inspect → reward=0.15" "$R" '"reward":0.15'
|
| 219 |
+
check "F-02 inspect → does NOT advance task (still EASY-001)" "$R" '"task_id":"EASY-001"'
|
| 220 |
+
check "F-03 inspect → inspection_notes populated" "$R" '"inspection_notes":"'
|
| 221 |
+
check "F-04 inspect → budget_remaining=4.9" "$R" '"budget_remaining":4.9'
|
| 222 |
+
|
| 223 |
+
# F-05 inspect again on same task → reward=0.0 (no double-dip)
|
| 224 |
+
check "F-05 second inspect → reward=0.0" \
|
| 225 |
+
"$(step '{"action_type":"inspect","transaction_id":"TXN-E001"}')" \
|
| 226 |
+
'"reward":0.0'
|
| 227 |
+
|
| 228 |
+
# Advance to EASY-002
|
| 229 |
+
step '{"action_type":"approve","transaction_id":"TXN-E001"}' > /dev/null
|
| 230 |
+
|
| 231 |
+
# F-06 request_docs → reward=0.15, docs_notes populated
|
| 232 |
+
R=$(step '{"action_type":"request_docs","transaction_id":"TXN-E002"}')
|
| 233 |
+
check "F-06 request_docs → reward=0.15" "$R" '"reward":0.15'
|
| 234 |
+
check "F-07 request_docs → docs_notes populated" "$R" '"docs_notes":"'
|
| 235 |
+
check "F-08 request_docs → budget_remaining=4.6 (cost=0.2)" "$R" '"budget_remaining":4.6'
|
| 236 |
+
|
| 237 |
+
# F-09 request_docs again → reward=0.0
|
| 238 |
+
check "F-09 second request_docs → reward=0.0" \
|
| 239 |
+
"$(step '{"action_type":"request_docs","transaction_id":"TXN-E002"}')" \
|
| 240 |
+
'"reward":0.0'
|
| 241 |
+
|
| 242 |
+
step '{"action_type":"reject","transaction_id":"TXN-E002"}' > /dev/null # advance
|
| 243 |
+
|
| 244 |
+
# F-10 verify_kyc → reward=0.15, kyc_notes populated
|
| 245 |
+
R=$(step '{"action_type":"verify_kyc","transaction_id":"TXN-E003"}')
|
| 246 |
+
check "F-10 verify_kyc → reward=0.15" "$R" '"reward":0.15'
|
| 247 |
+
check "F-11 verify_kyc → kyc_notes populated" "$R" '"kyc_notes":"'
|
| 248 |
+
check "F-12 verify_kyc → budget cost=0.2 deducted" "$R" '"budget_remaining":4.[0-9]'
|
| 249 |
+
|
| 250 |
+
step '{"action_type":"approve","transaction_id":"TXN-E003"}' > /dev/null
|
| 251 |
+
|
| 252 |
+
# F-13 contact_sender → reward=0.15, contact_notes populated
|
| 253 |
+
R=$(step '{"action_type":"contact_sender","transaction_id":"TXN-E004"}')
|
| 254 |
+
check "F-13 contact_sender → reward=0.15" "$R" '"reward":0.15'
|
| 255 |
+
check "F-14 contact_sender → contact_notes populated" "$R" '"contact_notes":"'
|
| 256 |
+
check "F-15 contact_sender → budget cost=0.3 deducted" "$R" '"budget_remaining":3\.[0-9]'
|
| 257 |
+
|
| 258 |
+
step '{"action_type":"flag","transaction_id":"TXN-E004"}' > /dev/null
|
| 259 |
+
|
| 260 |
+
# F-16 file_sar → reward=0.15, docs_notes mentions SAR
|
| 261 |
+
R=$(step '{"action_type":"file_sar","transaction_id":"TXN-M001"}')
|
| 262 |
+
check "F-16 file_sar → reward=0.15" "$R" '"reward":0.15'
|
| 263 |
+
check "F-17 file_sar → docs_notes mentions SAR" "$R" '"docs_notes":"SAR'
|
| 264 |
+
check "F-18 file_sar → budget cost=0.05 deducted" "$R" '"budget_remaining":3\.[0-9]'
|
| 265 |
+
|
| 266 |
+
# ═══════════════════════════════════════════════════════════
|
| 267 |
+
# GROUP G — /state endpoint
|
| 268 |
+
# ═══════════════════════════════════════════════════════════
|
| 269 |
+
section "GROUP G — State endpoint"
|
| 270 |
+
|
| 271 |
+
STATE="$(curl -s $BASE/state)"
|
| 272 |
+
check "G-01 GET /state → episode_id set" "$STATE" '"episode_id":"'
|
| 273 |
+
check "G-02 GET /state → step_count > 0" "$STATE" '"step_count":[1-9]'
|
| 274 |
+
check "G-03 GET /state → budget_spent > 0" "$STATE" '"budget_spent":[0-9]'
|
| 275 |
+
check "G-04 GET /state → investigation_actions_used list" "$STATE" '"investigation_actions_used":\['
|
| 276 |
+
check "G-05 GET /state → recent_decisions list" "$STATE" '"recent_decisions":\['
|
| 277 |
+
check "G-06 GET /state → correct_decisions >= 0" "$STATE" '"correct_decisions":[0-9]'
|
| 278 |
+
|
| 279 |
+
# ═══════════════════════════════════════════════════════════
|
| 280 |
+
# GROUP H — /grader endpoint
|
| 281 |
+
# ═══════════════════════════════════════════════════════════
|
| 282 |
+
section "GROUP H — Grader endpoint"
|
| 283 |
+
|
| 284 |
+
GRADER="$(curl -s $BASE/grader)"
|
| 285 |
+
check "H-01 GET /grader → normalised_score present" "$GRADER" '"normalised_score":'
|
| 286 |
+
check "H-02 GET /grader → total_reward present" "$GRADER" '"total_reward":'
|
| 287 |
+
check "H-03 GET /grader → budget_spent present" "$GRADER" '"budget_spent":'
|
| 288 |
+
check "H-04 GET /grader → budget_penalty present" "$GRADER" '"budget_penalty":'
|
| 289 |
+
check "H-05 GET /grader → per_task array" "$GRADER" '"per_task":\['
|
| 290 |
+
check "H-06 GET /grader → reward_breakdown in per_task" "$GRADER" '"reward_breakdown":'
|
| 291 |
+
check "H-07 GET /grader → passed field present" "$GRADER" '"passed":'
|
| 292 |
+
|
| 293 |
+
# ═══════════════════════════════════════════════════════════
|
| 294 |
+
# GROUP I — /replay endpoint
|
| 295 |
+
# ═══════════════════════════════════════════════════════════
|
| 296 |
+
section "GROUP I — /replay endpoint (offline eval)"
|
| 297 |
+
|
| 298 |
+
# I-01 Replay with all correct actions → score=1.0
|
| 299 |
+
REPLAY_PERFECT='{"actions":["approve","reject","approve","flag",
|
| 300 |
+
"escalate","hold","flag","flag","hold","escalate",
|
| 301 |
+
"escalate","reject","reject","approve","escalate","flag",
|
| 302 |
+
"approve","reject","escalate","reject"]}'
|
| 303 |
+
R=$(curl -s -X POST $BASE/replay \
|
| 304 |
+
-H "Content-Type: application/json" \
|
| 305 |
+
-d "$REPLAY_PERFECT")
|
| 306 |
+
check "I-01 Replay 20 correct actions → score=1.0" "$R" '"normalised_score":1.0'
|
| 307 |
+
check "I-02 Replay → passed=true" "$R" '"passed":true'
|
| 308 |
+
check "I-03 Replay → budget_spent=0.0 (no inv actions)" "$R" '"budget_spent":0.0'
|
| 309 |
+
|
| 310 |
+
# I-04 Replay with investigation actions included
|
| 311 |
+
REPLAY_WITH_INV='{"actions":["inspect","approve","reject","approve","flag",
|
| 312 |
+
"escalate","hold","flag","flag","hold","escalate",
|
| 313 |
+
"escalate","reject","reject","approve","escalate","flag",
|
| 314 |
+
"approve","reject","escalate","reject"]}'
|
| 315 |
+
R=$(curl -s -X POST $BASE/replay \
|
| 316 |
+
-H "Content-Type: application/json" \
|
| 317 |
+
-d "$REPLAY_WITH_INV")
|
| 318 |
+
check "I-04 Replay with inspect → budget_spent=0.1" "$R" '"budget_spent":0.1'
|
| 319 |
+
|
| 320 |
+
# I-05 Replay with invalid action → 422
|
| 321 |
+
check "I-05 Replay invalid action → 422 error" \
|
| 322 |
+
"$(curl -s -X POST $BASE/replay \
|
| 323 |
+
-H 'Content-Type: application/json' \
|
| 324 |
+
-d '{"actions":["delete","approve"]}')" \
|
| 325 |
+
"Invalid action"
|
| 326 |
+
|
| 327 |
+
# I-06 Replay result has per_task breakdown
|
| 328 |
+
check "I-06 Replay → per_task array present" "$R" '"per_task":\['
|
| 329 |
+
check "I-07 Replay → reward_breakdown in each task" "$R" '"reward_breakdown":'
|
| 330 |
+
|
| 331 |
+
# I-08 Replay with confidences
|
| 332 |
+
REPLAY_CONF='{"actions":["approve","reject"],"confidences":[0.95,0.90]}'
|
| 333 |
+
check "I-08 Replay with confidences → ok" \
|
| 334 |
+
"$(curl -s -X POST $BASE/replay \
|
| 335 |
+
-H 'Content-Type: application/json' \
|
| 336 |
+
-d "$REPLAY_CONF")" \
|
| 337 |
+
'"normalised_score":'
|
| 338 |
+
|
| 339 |
+
# ═══════════════════════════════════════════════════════════
|
| 340 |
+
# GROUP J — /baseline endpoint
|
| 341 |
+
# ═══════════════════════════════════════════════════════════
|
| 342 |
+
section "GROUP J — Baseline agent"
|
| 343 |
+
|
| 344 |
+
BASELINE="$(curl -s -X POST $BASE/baseline)"
|
| 345 |
+
check "J-01 POST /baseline → normalised_score present" "$BASELINE" '"normalised_score":'
|
| 346 |
+
check "J-02 POST /baseline → total_reward present" "$BASELINE" '"total_reward":'
|
| 347 |
+
check "J-03 POST /baseline → steps > 0" "$BASELINE" '"steps":[1-9]'
|
| 348 |
+
check "J-04 POST /baseline → per_task scores present" "$BASELINE" '"scores":\['
|
| 349 |
+
check "J-05 POST /baseline → score >= 0.5 (passes)" "$BASELINE" '"normalised_score":0\.[5-9]'
|
| 350 |
+
|
| 351 |
+
# ═══════════════════════════════════════════════════════════
|
| 352 |
+
# GROUP K — /analytics endpoint
|
| 353 |
+
# ═══════════════════════════════════════════════════════════
|
| 354 |
+
section "GROUP K — Analytics endpoint"
|
| 355 |
+
|
| 356 |
+
# Run a full episode first so analytics has data
|
| 357 |
+
reset
|
| 358 |
+
python3 - <<'PYEOF'
|
| 359 |
+
import urllib.request, json, sys
|
| 360 |
+
BASE = "http://localhost:8000"
|
| 361 |
+
def post(path, body=None):
|
| 362 |
+
req = urllib.request.Request(f"{BASE}{path}",
|
| 363 |
+
data=json.dumps(body).encode() if body else None,
|
| 364 |
+
headers={"Content-Type": "application/json"}, method="POST")
|
| 365 |
+
with urllib.request.urlopen(req) as r: return json.loads(r.read())
|
| 366 |
+
def get(path):
|
| 367 |
+
with urllib.request.urlopen(f"{BASE}{path}") as r: return json.loads(r.read())
|
| 368 |
+
|
| 369 |
+
post("/reset")
|
| 370 |
+
actions = [
|
| 371 |
+
("approve","TXN-E001"),("reject","TXN-E002"),("approve","TXN-E003"),("flag","TXN-E004"),
|
| 372 |
+
("escalate","TXN-M001"),("hold","TXN-M002"),("flag","TXN-M003"),("flag","TXN-M004"),
|
| 373 |
+
("hold","TXN-M005"),("escalate","TXN-M006"),
|
| 374 |
+
("escalate","TXN-H001"),("reject","TXN-H002"),("reject","TXN-H003"),("approve","TXN-H004"),
|
| 375 |
+
("escalate","TXN-H005"),("flag","TXN-H006"),
|
| 376 |
+
("approve","TXN-C001"),("reject","TXN-C002"),("escalate","TXN-C003"),("reject","TXN-C004"),
|
| 377 |
+
]
|
| 378 |
+
for action, txn in actions:
|
| 379 |
+
post("/step", {"action_type": action, "transaction_id": txn})
|
| 380 |
+
PYEOF
|
| 381 |
+
|
| 382 |
+
ANA="$(curl -s $BASE/analytics)"
|
| 383 |
+
check "K-01 GET /analytics → episodes_completed >= 1" "$ANA" '"episodes_completed":[1-9]'
|
| 384 |
+
check "K-02 GET /analytics → best_score present" "$ANA" '"best_score":'
|
| 385 |
+
check "K-03 GET /analytics → avg_score present" "$ANA" '"avg_score":'
|
| 386 |
+
check "K-04 GET /analytics → avg_budget_spent present" "$ANA" '"avg_budget_spent":'
|
| 387 |
+
check "K-05 GET /analytics → current_episode present" "$ANA" '"current_episode":'
|
| 388 |
+
check "K-06 GET /analytics → by_difficulty present" "$ANA" '"by_difficulty":'
|
| 389 |
+
check "K-07 GET /analytics → easy accuracy" "$ANA" '"easy":'
|
| 390 |
+
check "K-08 GET /analytics → critical accuracy" "$ANA" '"critical":'
|
| 391 |
+
|
| 392 |
+
# ═══════════════════════════════════════════════════════════
|
| 393 |
+
# GROUP L — /leaderboard endpoint
|
| 394 |
+
# ═══════════════════════════════════════════════════════════
|
| 395 |
+
section "GROUP L — Leaderboard endpoint"
|
| 396 |
+
|
| 397 |
+
LB="$(curl -s $BASE/leaderboard)"
|
| 398 |
+
check "L-01 GET /leaderboard → count >= 1 (episode recorded)" "$LB" '"count":[1-9]'
|
| 399 |
+
check "L-02 GET /leaderboard → entries array present" "$LB" '"entries":\['
|
| 400 |
+
check "L-03 GET /leaderboard → episode_id in entry" "$LB" '"episode_id":"'
|
| 401 |
+
check "L-04 GET /leaderboard → normalised_score in entry" "$LB" '"normalised_score":'
|
| 402 |
+
check "L-05 GET /leaderboard → timestamp in entry" "$LB" '"timestamp":"'
|
| 403 |
+
check "L-06 GET /leaderboard → budget_spent in entry" "$LB" '"budget_spent":'
|
| 404 |
+
|
| 405 |
+
# ══════════════════════════════════════════════���════════════
|
| 406 |
+
# GROUP M — Perfect episode score
|
| 407 |
+
# ═══════════════════════════════════════════════════════════
|
| 408 |
+
section "GROUP M — Perfect episode (all 20 correct, no investigation cost)"
|
| 409 |
+
|
| 410 |
+
reset
|
| 411 |
+
python3 - <<'PYEOF'
|
| 412 |
+
import urllib.request, json
|
| 413 |
+
BASE = "http://localhost:8000"
|
| 414 |
+
def post(path, body=None):
|
| 415 |
+
req = urllib.request.Request(f"{BASE}{path}",
|
| 416 |
+
data=json.dumps(body).encode() if body else None,
|
| 417 |
+
headers={"Content-Type": "application/json"}, method="POST")
|
| 418 |
+
with urllib.request.urlopen(req) as r: return json.loads(r.read())
|
| 419 |
+
|
| 420 |
+
post("/reset")
|
| 421 |
+
actions = [
|
| 422 |
+
("approve","TXN-E001"),("reject","TXN-E002"),("approve","TXN-E003"),("flag","TXN-E004"),
|
| 423 |
+
("escalate","TXN-M001"),("hold","TXN-M002"),("flag","TXN-M003"),("flag","TXN-M004"),
|
| 424 |
+
("hold","TXN-M005"),("escalate","TXN-M006"),
|
| 425 |
+
("escalate","TXN-H001"),("reject","TXN-H002"),("reject","TXN-H003"),("approve","TXN-H004"),
|
| 426 |
+
("escalate","TXN-H005"),("flag","TXN-H006"),
|
| 427 |
+
("approve","TXN-C001"),("reject","TXN-C002"),("escalate","TXN-C003"),("reject","TXN-C004"),
|
| 428 |
+
]
|
| 429 |
+
for action, txn in actions:
|
| 430 |
+
post("/step", {"action_type": action, "transaction_id": txn})
|
| 431 |
+
PYEOF
|
| 432 |
+
|
| 433 |
+
GRADER="$(curl -s $BASE/grader)"
|
| 434 |
+
check "M-01 Perfect episode → normalised_score=1.0" "$GRADER" '"normalised_score":1.0'
|
| 435 |
+
check "M-02 Perfect episode → passed=true" "$GRADER" '"passed":true'
|
| 436 |
+
check "M-03 Perfect episode → budget_penalty=0.0" "$GRADER" '"budget_penalty":0.0'
|
| 437 |
+
|
| 438 |
+
# ═══════════════════════════════════════════════════════════
|
| 439 |
+
# GROUP N — Difficulty weighting
|
| 440 |
+
# ═══════════════════════════════════════════════════════════
|
| 441 |
+
section "GROUP N — Difficulty weighting in grader"
|
| 442 |
+
|
| 443 |
+
# A critical task correct should contribute more weight than easy
|
| 444 |
+
REPLAY_CRIT='{"actions":["approve","reject","approve","flag",
|
| 445 |
+
"escalate","hold","flag","flag","hold","escalate",
|
| 446 |
+
"escalate","reject","reject","approve","escalate","flag",
|
| 447 |
+
"approve","reject","escalate","reject"]}'
|
| 448 |
+
|
| 449 |
+
FULL=$(curl -s -X POST $BASE/replay \
|
| 450 |
+
-H "Content-Type: application/json" \
|
| 451 |
+
-d "$REPLAY_CRIT")
|
| 452 |
+
check "N-01 Full correct replay → max_possible_reward includes weights" \
|
| 453 |
+
"$FULL" '"max_possible_reward":2[0-9]\.'
|
| 454 |
+
check "N-02 Full correct → total_reward equals max_possible" \
|
| 455 |
+
"$(echo "$FULL" | python3 -c "
|
| 456 |
+
import sys,json,re
|
| 457 |
+
d=json.load(sys.stdin)
|
| 458 |
+
print('EQUAL' if abs(d['total_reward']-d['max_possible_reward'])<0.01 else 'DIFF')
|
| 459 |
+
")" "EQUAL"
|
| 460 |
+
|
| 461 |
+
# ═══════════════════════════════════════════════════════════
|
| 462 |
+
# GROUP O — Budget mechanics
|
| 463 |
+
# ═══════════════════════════════════════════════════════════
|
| 464 |
+
section "GROUP O — Budget mechanics"
|
| 465 |
+
|
| 466 |
+
reset
|
| 467 |
+
|
| 468 |
+
# Burn through budget with contact_sender (cost=0.3) × 5 = 1.5
|
| 469 |
+
# then request_docs × 5 = 1.0, then verify_kyc × 5 = 1.0 (total = 3.5 so far ≤5)
|
| 470 |
+
# Total exceeding: won't exceed in these few calls, test budget tracking
|
| 471 |
+
step '{"action_type":"contact_sender","transaction_id":"TXN-E001"}' > /dev/null # -0.30
|
| 472 |
+
step '{"action_type":"contact_sender","transaction_id":"TXN-E001"}' > /dev/null # dup, but cost still deducted
|
| 473 |
+
step '{"action_type":"request_docs","transaction_id":"TXN-E001"}' > /dev/null # -0.20
|
| 474 |
+
step '{"action_type":"verify_kyc","transaction_id":"TXN-E001"}' > /dev/null # -0.20
|
| 475 |
+
step '{"action_type":"file_sar","transaction_id":"TXN-E001"}' > /dev/null # -0.05
|
| 476 |
+
R=$(step '{"action_type":"approve","transaction_id":"TXN-E001"}')
|
| 477 |
+
# budget_remaining = 5.0 - 0.30 - 0.30 - 0.20 - 0.20 - 0.05 = 3.95
|
| 478 |
+
check "O-01 Budget correctly deducted after multiple inv actions" \
|
| 479 |
+
"$R" '"budget_remaining":3\.[0-9]'
|
| 480 |
+
|
| 481 |
+
# grader shows budget_spent
|
| 482 |
+
GRADER="$(curl -s $BASE/grader)"
|
| 483 |
+
check "O-02 Grader → budget_spent > 0" "$GRADER" '"budget_spent":0\.[1-9]'
|
| 484 |
+
|
| 485 |
+
# replay that overshoots budget → budget_penalty > 0
|
| 486 |
+
HEAVY='{"actions":["contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","approve","reject","approve","flag","escalate","hold","flag","flag","hold","escalate","escalate","reject","reject","approve","escalate","flag","approve","reject","escalate","reject"]}'
|
| 487 |
+
R=$(curl -s -X POST $BASE/replay \
|
| 488 |
+
-H "Content-Type: application/json" \
|
| 489 |
+
-d "$HEAVY")
|
| 490 |
+
check "O-03 Over-budget replay → budget_penalty > 0" "$R" '"budget_penalty":0\.[1-9]'
|
| 491 |
+
check "O-04 Over-budget replay → budget_overspend > 0" "$R" '"budget_overspend":0\.[1-9]'
|
| 492 |
+
|
| 493 |
+
# ═══════════════════════════════════════════════════════════
|
| 494 |
+
# GROUP P — Edge cases & errors
|
| 495 |
+
# ═══════════════════════════════════════════════════════════
|
| 496 |
+
section "GROUP P — Edge cases & error handling"
|
| 497 |
+
|
| 498 |
+
# P-01 Step before reset → 400
|
| 499 |
+
check "P-01 Step on fresh env (reset then done) → handled" \
|
| 500 |
+
"$(curl -s -X POST $BASE/reset > /dev/null && curl -s -X POST $BASE/step \
|
| 501 |
+
-H 'Content-Type: application/json' \
|
| 502 |
+
-d '{"action_type":"approve","transaction_id":"TXN-NONE"}')" \
|
| 503 |
+
'"reward"'
|
| 504 |
+
|
| 505 |
+
# P-02 Unknown action_type → 422
|
| 506 |
+
check "P-02 Unknown action_type → 422" \
|
| 507 |
+
"$(curl -s -X POST $BASE/step \
|
| 508 |
+
-H 'Content-Type: application/json' \
|
| 509 |
+
-d '{"action_type":"nuke","transaction_id":"TXN-E001"}')" \
|
| 510 |
+
"Invalid action_type"
|
| 511 |
+
|
| 512 |
+
# P-03 Missing action_type field → 422
|
| 513 |
+
check "P-03 Missing action_type field → error" \
|
| 514 |
+
"$(curl -s -X POST $BASE/step \
|
| 515 |
+
-H 'Content-Type: application/json' \
|
| 516 |
+
-d '{"transaction_id":"TXN-E001"}')" \
|
| 517 |
+
'"detail"'
|
| 518 |
+
|
| 519 |
+
# P-04 Empty replay actions list
|
| 520 |
+
check "P-04 Replay empty actions → returns score" \
|
| 521 |
+
"$(curl -s -X POST $BASE/replay \
|
| 522 |
+
-H 'Content-Type: application/json' \
|
| 523 |
+
-d '{"actions":[]}')" \
|
| 524 |
+
'"normalised_score"'
|
| 525 |
+
|
| 526 |
+
# P-05 Replay with confidences shorter than actions → still works
|
| 527 |
+
check "P-05 Replay with partial confidences → score present" \
|
| 528 |
+
"$(curl -s -X POST $BASE/replay \
|
| 529 |
+
-H 'Content-Type: application/json' \
|
| 530 |
+
-d '{"actions":["approve","reject"],"confidences":[0.9]}')" \
|
| 531 |
+
'"normalised_score"'
|
| 532 |
+
|
| 533 |
+
# P-06 Grader before any reset → 400 or returns score
|
| 534 |
+
check "P-06 Grader before actions → handled gracefully" \
|
| 535 |
+
"$(curl -s $BASE/grader)" \
|
| 536 |
+
'"normalised_score"\|"error"'
|
| 537 |
+
|
| 538 |
+
# P-07 Analytics before any completed episode → handled
|
| 539 |
+
reset
|
| 540 |
+
check "P-07 Analytics after fresh reset → message or data" \
|
| 541 |
+
"$(curl -s $BASE/analytics)" \
|
| 542 |
+
'"message"\|"episodes_completed"'
|
| 543 |
+
|
| 544 |
+
# ═══════════════════════════════════════════════════════════
|
| 545 |
+
# GROUP Q — WebSocket
|
| 546 |
+
# ═══════════════════════════════════════════════════════════
|
| 547 |
+
section "GROUP Q — WebSocket (requires wscat or python)"
|
| 548 |
+
|
| 549 |
+
if command -v python3 &>/dev/null; then
|
| 550 |
+
WS_RESULT=$(python3 - <<'PYEOF'
|
| 551 |
+
import urllib.request, json
|
| 552 |
+
try:
|
| 553 |
+
# ws upgrade check via HTTP (will get 426 Upgrade Required — proves endpoint exists)
|
| 554 |
+
req = urllib.request.Request("http://localhost:8000/ws")
|
| 555 |
+
try:
|
| 556 |
+
urllib.request.urlopen(req)
|
| 557 |
+
except Exception as e:
|
| 558 |
+
msg = str(e)
|
| 559 |
+
if "426" in msg or "101" in msg or "Switching" in msg or "upgrade" in msg.lower():
|
| 560 |
+
print("WS_OK")
|
| 561 |
+
else:
|
| 562 |
+
print(f"WS_UNKNOWN:{msg[:60]}")
|
| 563 |
+
except Exception as e:
|
| 564 |
+
print(f"WS_ERR:{e}")
|
| 565 |
+
PYEOF
|
| 566 |
+
)
|
| 567 |
+
check "Q-01 WS /ws endpoint exists (426 upgrade = correct)" \
|
| 568 |
+
"$WS_RESULT" "WS_OK"
|
| 569 |
+
else
|
| 570 |
+
printf " \033[33m-\033[0m Q-01 WebSocket check skipped (no python3)\n"
|
| 571 |
+
SKIP=$((SKIP+1))
|
| 572 |
+
fi
|
| 573 |
+
|
| 574 |
+
# ═══════════════════════════════════════════════════════════
|
| 575 |
+
# SUMMARY
|
| 576 |
+
# ═══════════════════════════════════════════════════════════
|
| 577 |
+
echo ""
|
| 578 |
+
echo "╔══════════════════════════════════════════════════════╗"
|
| 579 |
+
TOTAL=$((PASS+FAIL))
|
| 580 |
+
if [ "$FAIL" -eq 0 ]; then
|
| 581 |
+
printf "║ ✓ All %d tests passed" "$TOTAL"
|
| 582 |
+
else
|
| 583 |
+
printf "║ Results: %d/%d passed, %d failed" "$PASS" "$TOTAL" "$FAIL"
|
| 584 |
+
fi
|
| 585 |
+
[ "$SKIP" -gt 0 ] && printf " (%d skipped)" "$SKIP"
|
| 586 |
+
echo ""
|
| 587 |
+
echo "╚══════════════════════════════════════════════════════╝"
|
| 588 |
+
echo ""
|
| 589 |
+
|
| 590 |
+
# Exit with failure code if any tests failed
|
| 591 |
+
[ "$FAIL" -eq 0 ]
|
scripts/baseline_agent.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Baseline agent for the PayOps OpenEnv environment.
|
| 3 |
+
|
| 4 |
+
A rule-based agent that decides an action based on observable risk signals.
|
| 5 |
+
Use this as a sanity-check to prove the environment works end-to-end and
|
| 6 |
+
as a lower-bound performance reference before training a learned agent.
|
| 7 |
+
|
| 8 |
+
Usage
|
| 9 |
+
-----
|
| 10 |
+
# Run directly
|
| 11 |
+
python scripts/baseline_agent.py
|
| 12 |
+
|
| 13 |
+
# Or via the /baseline API endpoint
|
| 14 |
+
curl -X POST http://localhost:8000/baseline
|
| 15 |
+
|
| 16 |
+
Policy
|
| 17 |
+
------
|
| 18 |
+
1. If any sanctioned / high-risk flag → reject
|
| 19 |
+
2. velocity_1h >= 10 (velocity burst) → reject
|
| 20 |
+
3. kyc_status in (failed, none) → escalate
|
| 21 |
+
4. risk_score >= 0.80 → reject
|
| 22 |
+
5. risk_score >= 0.60 → escalate
|
| 23 |
+
6. risk_score >= 0.35 OR any flag present → flag
|
| 24 |
+
7. Otherwise → approve
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import asyncio
|
| 30 |
+
import sys
|
| 31 |
+
import os
|
| 32 |
+
|
| 33 |
+
# Allow running from the project root or the scripts/ directory
|
| 34 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 35 |
+
|
| 36 |
+
from payops_env.environment import PayOpsEnvironment
|
| 37 |
+
from payops_env.grader import grade_episode
|
| 38 |
+
from payops_env.models import PayOpsAction
|
| 39 |
+
from payops_env.tasks import TASKS
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
# Policy
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
|
| 46 |
+
DANGER_FLAGS = {
|
| 47 |
+
"sanctioned_country",
|
| 48 |
+
"app_scam_indicator",
|
| 49 |
+
"mule_account_pattern",
|
| 50 |
+
"structuring_pattern",
|
| 51 |
+
"ctr_threshold_avoidance",
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def rule_based_policy(obs) -> str:
|
| 56 |
+
"""
|
| 57 |
+
Deterministic rule-based policy.
|
| 58 |
+
|
| 59 |
+
Priority order ensures the most dangerous patterns are caught first,
|
| 60 |
+
even when the ML risk score is low (adversarial/poisoned score inputs).
|
| 61 |
+
"""
|
| 62 |
+
# Priority 1: known fraud or sanctioned patterns regardless of risk score
|
| 63 |
+
if any(flag in DANGER_FLAGS for flag in obs.flags):
|
| 64 |
+
return "reject"
|
| 65 |
+
|
| 66 |
+
# Priority 2: transaction velocity burst (potential account takeover)
|
| 67 |
+
if obs.velocity_1h is not None and obs.velocity_1h >= 10:
|
| 68 |
+
return "reject"
|
| 69 |
+
|
| 70 |
+
# Priority 3: identity not confirmed
|
| 71 |
+
if obs.kyc_status in ("failed", "none"):
|
| 72 |
+
return "escalate"
|
| 73 |
+
|
| 74 |
+
# Priority 4–6: risk score tiers
|
| 75 |
+
if obs.risk_score >= 0.80:
|
| 76 |
+
return "reject"
|
| 77 |
+
elif obs.risk_score >= 0.60:
|
| 78 |
+
return "escalate"
|
| 79 |
+
elif obs.risk_score >= 0.35 or obs.flags:
|
| 80 |
+
return "flag"
|
| 81 |
+
else:
|
| 82 |
+
return "approve"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# Runner
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
|
| 89 |
+
async def run():
|
| 90 |
+
env = PayOpsEnvironment()
|
| 91 |
+
obs = await env.reset_async()
|
| 92 |
+
total_reward = 0.0
|
| 93 |
+
step = 0
|
| 94 |
+
actions_taken = []
|
| 95 |
+
|
| 96 |
+
print("=" * 60)
|
| 97 |
+
print(" PayOps Baseline Agent — Rule-Based Policy")
|
| 98 |
+
print("=" * 60)
|
| 99 |
+
print(f" Tasks in episode : {len(TASKS)}")
|
| 100 |
+
print("=" * 60)
|
| 101 |
+
|
| 102 |
+
while not obs.done:
|
| 103 |
+
action_type = rule_based_policy(obs)
|
| 104 |
+
action = PayOpsAction(
|
| 105 |
+
action_type=action_type,
|
| 106 |
+
transaction_id=obs.transaction_id,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
print(f"\nStep {step + 1} [{obs.task_difficulty.upper()}] {obs.task_id}")
|
| 110 |
+
print(f" TXN : {obs.transaction_id}")
|
| 111 |
+
print(f" Amount : {obs.amount:,.2f} {obs.currency}")
|
| 112 |
+
print(f" Sender : {obs.sender}")
|
| 113 |
+
print(f" Receiver : {obs.receiver}")
|
| 114 |
+
print(f" Risk score : {obs.risk_score:.2f}")
|
| 115 |
+
print(f" KYC : {obs.kyc_status or 'n/a'} | "
|
| 116 |
+
f"Country risk: {obs.country_risk or 'n/a'} | "
|
| 117 |
+
f"Velocity 1h: {obs.velocity_1h or 'n/a'}")
|
| 118 |
+
print(f" Flags : {obs.flags or '[]'}")
|
| 119 |
+
print(f" → Agent : {action_type}")
|
| 120 |
+
|
| 121 |
+
obs = await env.step_async(action)
|
| 122 |
+
actions_taken.append(action_type)
|
| 123 |
+
total_reward += obs.reward
|
| 124 |
+
step += 1
|
| 125 |
+
|
| 126 |
+
print(f" ✓ Reward : {obs.reward:+.2f} "
|
| 127 |
+
f"(correct: {obs.info.get('correct_action', '?')})")
|
| 128 |
+
|
| 129 |
+
env.close()
|
| 130 |
+
|
| 131 |
+
# Grade the episode
|
| 132 |
+
result = grade_episode(actions_taken, list(TASKS))
|
| 133 |
+
|
| 134 |
+
print("\n" + "=" * 60)
|
| 135 |
+
print(" Episode Summary")
|
| 136 |
+
print("=" * 60)
|
| 137 |
+
print(f" Steps : {step}")
|
| 138 |
+
print(f" Total reward : {result.total_reward:+.2f}")
|
| 139 |
+
print(f" Max possible : {result.max_possible_reward:.2f}")
|
| 140 |
+
print(f" Normalised score : {result.normalised_score:.4f}")
|
| 141 |
+
print(f" Passed (≥0.5) : {'YES ✓' if result.passed else 'NO ✗'}")
|
| 142 |
+
|
| 143 |
+
print("\n Per-task breakdown:")
|
| 144 |
+
for t in result.per_task_rewards:
|
| 145 |
+
mark = "✓" if t["correct"] else "✗"
|
| 146 |
+
print(
|
| 147 |
+
f" {mark} {t['task_id']:12s} [{t['difficulty']:6s}] "
|
| 148 |
+
f"action={t['action_taken']:10s} "
|
| 149 |
+
f"correct={t['correct_action']:10s} "
|
| 150 |
+
f"reward={t['reward']:+.2f}"
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
print("=" * 60)
|
| 154 |
+
return result
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
if __name__ == "__main__":
|
| 158 |
+
asyncio.run(run())
|
| 159 |
+
|
scripts_util.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utilities for running the baseline agent programmatically (used by /baseline endpoint).
|
| 3 |
+
"""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 7 |
+
|
| 8 |
+
from payops_env.environment import PayOpsEnvironment
|
| 9 |
+
from payops_env.grader import grade_episode
|
| 10 |
+
from payops_env.models import PayOpsAction
|
| 11 |
+
from payops_env.tasks import TASKS
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
# Adaptive rule-based policy
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
|
| 18 |
+
_DANGER_FLAGS = {
|
| 19 |
+
"sanctioned_country", "app_scam_indicator", "mule_account_pattern",
|
| 20 |
+
"structuring_pattern", "ctr_threshold_avoidance", "fraud_ring_indicator",
|
| 21 |
+
"geo_impossible_login", "account_takeover_indicator",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
_WATCHLIST_FLAGS = {
|
| 25 |
+
"new_account_7d", "large_first_transfer", "solicitor_mule_pattern",
|
| 26 |
+
"dormant_receiver", "sudden_activity", "insider_threat", "internal_to_personal",
|
| 27 |
+
"invoice_mismatch", "trade_finance",
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _should_investigate(obs) -> Optional[str]:
|
| 32 |
+
"""
|
| 33 |
+
Decide whether to issue an investigation sub-action first.
|
| 34 |
+
|
| 35 |
+
Returns the sub-action name, or None if we should decide directly.
|
| 36 |
+
|
| 37 |
+
Priority:
|
| 38 |
+
1. KYC expired / pending → verify_kyc (once)
|
| 39 |
+
2. Watchlist flags AND high amount AND docs not yet requested → request_docs
|
| 40 |
+
3. Low ml_confidence AND medium risk → inspect (once)
|
| 41 |
+
4. contact_sender: APP scam pattern or insider threat
|
| 42 |
+
5. file_sar: if structuring/fraud-ring flags and not yet filed
|
| 43 |
+
"""
|
| 44 |
+
already = set(obs.info.get("investigation_used", []) if isinstance(obs.info, dict) else [])
|
| 45 |
+
|
| 46 |
+
# file_sar if structuring / fraud ring and SAR not yet filed
|
| 47 |
+
sar_flags = {"structuring_pattern", "ctr_threshold_avoidance",
|
| 48 |
+
"fraud_ring_indicator", "coordinated_transfers"}
|
| 49 |
+
if sar_flags & set(obs.flags) and "file_sar" not in already:
|
| 50 |
+
return "file_sar"
|
| 51 |
+
|
| 52 |
+
# contact_sender for APP scam or insider
|
| 53 |
+
contact_flags = {"app_scam_indicator", "internal_to_personal", "account_takeover_indicator"}
|
| 54 |
+
if contact_flags & set(obs.flags) and "contact_sender" not in already:
|
| 55 |
+
return "contact_sender"
|
| 56 |
+
|
| 57 |
+
# verify_kyc if expired or pending
|
| 58 |
+
if obs.kyc_status in ("expired", "pending") and "verify_kyc" not in already:
|
| 59 |
+
return "verify_kyc"
|
| 60 |
+
|
| 61 |
+
# request_docs for first-time payees with high value
|
| 62 |
+
doc_flags = {"first_time_payee", "large_first_transfer", "invoice_mismatch", "trade_finance"}
|
| 63 |
+
if (doc_flags & set(obs.flags)
|
| 64 |
+
and obs.amount >= 50_000
|
| 65 |
+
and "request_docs" not in already):
|
| 66 |
+
return "request_docs"
|
| 67 |
+
|
| 68 |
+
# inspect when ml_confidence is low or watchlist flags are present
|
| 69 |
+
ml_conf = getattr(obs, "ml_confidence", 0.9) or 0.9
|
| 70 |
+
watchlist_hit = bool(_WATCHLIST_FLAGS & set(obs.flags))
|
| 71 |
+
if (ml_conf < 0.60 or watchlist_hit) and "inspect" not in already:
|
| 72 |
+
return "inspect"
|
| 73 |
+
|
| 74 |
+
return None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _terminal_decision(obs) -> str:
|
| 78 |
+
"""
|
| 79 |
+
Make a terminal decision. Uses enriched observation fields where available
|
| 80 |
+
(inspect_reveal, docs_notes, kyc_notes, contact_notes already in obs.info).
|
| 81 |
+
"""
|
| 82 |
+
# Definitive danger signals
|
| 83 |
+
if any(f in _DANGER_FLAGS for f in obs.flags):
|
| 84 |
+
# Exception: if we confirmed via contact that it is genuine, approve
|
| 85 |
+
contact = getattr(obs, "contact_notes", None) or ""
|
| 86 |
+
if "CEO confirms they did NOT" in contact or "Classic APP scam" in contact:
|
| 87 |
+
return "reject"
|
| 88 |
+
return "reject"
|
| 89 |
+
|
| 90 |
+
vel = obs.velocity_1h
|
| 91 |
+
if vel is not None and vel >= 10:
|
| 92 |
+
return "reject"
|
| 93 |
+
|
| 94 |
+
if obs.kyc_status in ("failed", "none"):
|
| 95 |
+
return "escalate"
|
| 96 |
+
|
| 97 |
+
# KYC expired → hold (wait for renewal)
|
| 98 |
+
if obs.kyc_status == "expired":
|
| 99 |
+
return "hold"
|
| 100 |
+
|
| 101 |
+
# Very high risk
|
| 102 |
+
if obs.risk_score >= 0.85:
|
| 103 |
+
return "reject"
|
| 104 |
+
|
| 105 |
+
# Watchlist flags with high value
|
| 106 |
+
if _WATCHLIST_FLAGS & set(obs.flags) and obs.amount >= 20_000:
|
| 107 |
+
return "escalate"
|
| 108 |
+
|
| 109 |
+
# FX / correspondent banking settlement
|
| 110 |
+
if "fx_settlement" in obs.flags:
|
| 111 |
+
return "approve"
|
| 112 |
+
|
| 113 |
+
# After inspection revealed legitimacy (corp-level, FX)
|
| 114 |
+
inspection = getattr(obs, "inspection_notes", None) or ""
|
| 115 |
+
if "correspondent banking" in inspection.lower() or "on file" in inspection.lower():
|
| 116 |
+
if obs.risk_score < 0.75:
|
| 117 |
+
return "approve"
|
| 118 |
+
|
| 119 |
+
if obs.risk_score >= 0.65:
|
| 120 |
+
return "escalate"
|
| 121 |
+
elif obs.risk_score >= 0.40 or obs.flags:
|
| 122 |
+
return "flag"
|
| 123 |
+
else:
|
| 124 |
+
return "approve"
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _rule_based_policy(obs) -> str:
|
| 128 |
+
"""
|
| 129 |
+
Adaptive policy. First checks whether investigation is needed; if so
|
| 130 |
+
returns a sub-action. Otherwise returns a terminal decision.
|
| 131 |
+
"""
|
| 132 |
+
sub = _should_investigate(obs)
|
| 133 |
+
if sub is not None:
|
| 134 |
+
return sub
|
| 135 |
+
return _terminal_decision(obs)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ---------------------------------------------------------------------------
|
| 139 |
+
# Episode runner
|
| 140 |
+
# ---------------------------------------------------------------------------
|
| 141 |
+
|
| 142 |
+
async def run_baseline() -> Tuple[List[Dict[str, Any]], float, float, int]:
|
| 143 |
+
"""
|
| 144 |
+
Run the adaptive rule-based baseline over the full task set.
|
| 145 |
+
|
| 146 |
+
Returns:
|
| 147 |
+
(per_task_rewards, total_reward, normalised_score, steps)
|
| 148 |
+
"""
|
| 149 |
+
env = PayOpsEnvironment()
|
| 150 |
+
obs = await env.reset_async()
|
| 151 |
+
actions_taken: List[str] = []
|
| 152 |
+
confs: List[Optional[float]] = []
|
| 153 |
+
step = 0
|
| 154 |
+
|
| 155 |
+
while not obs.done:
|
| 156 |
+
action_type = _rule_based_policy(obs)
|
| 157 |
+
action = PayOpsAction(
|
| 158 |
+
action_type=action_type,
|
| 159 |
+
transaction_id=obs.transaction_id,
|
| 160 |
+
)
|
| 161 |
+
obs = await env.step_async(action)
|
| 162 |
+
actions_taken.append(action_type)
|
| 163 |
+
confs.append(None)
|
| 164 |
+
step += 1
|
| 165 |
+
|
| 166 |
+
env.close()
|
| 167 |
+
|
| 168 |
+
result = grade_episode(actions_taken, list(TASKS), confs)
|
| 169 |
+
return result.per_task_rewards, result.total_reward, result.normalised_score, step
|
| 170 |
+
|
server/__init__.py
ADDED
|
File without changes
|
server/app.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI server for the PayOps OpenEnv environment.
|
| 3 |
+
|
| 4 |
+
Endpoints
|
| 5 |
+
---------
|
| 6 |
+
POST /reset Reset environment, return initial observation
|
| 7 |
+
POST /step Execute an action, return observation + reward
|
| 8 |
+
GET /state Current internal environment state
|
| 9 |
+
GET /schema Action / observation JSON schemas
|
| 10 |
+
GET /tasks List all tasks with metadata
|
| 11 |
+
GET /grader Grade the current episode
|
| 12 |
+
POST /baseline Run the rule-based baseline agent
|
| 13 |
+
GET /analytics Aggregate performance analytics for this session
|
| 14 |
+
POST /replay Grade a supplied action sequence without modifying state
|
| 15 |
+
GET /leaderboard All scored episodes this session
|
| 16 |
+
GET /health Health check
|
| 17 |
+
WS /ws WebSocket for persistent sessions
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import asyncio
|
| 23 |
+
import json
|
| 24 |
+
import time
|
| 25 |
+
from collections import defaultdict
|
| 26 |
+
from typing import Any, Dict, List, Optional
|
| 27 |
+
|
| 28 |
+
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
| 29 |
+
from fastapi.responses import JSONResponse
|
| 30 |
+
from pydantic import BaseModel
|
| 31 |
+
|
| 32 |
+
from payops_env.environment import PayOpsEnvironment, VALID_ACTIONS
|
| 33 |
+
from payops_env.grader import grade_episode
|
| 34 |
+
from payops_env.models import PayOpsAction, PayOpsObservation, PayOpsState
|
| 35 |
+
from payops_env.tasks import TASKS, TASKS_BY_ID
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# App setup
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
app = FastAPI(
|
| 43 |
+
title="PayOps OpenEnv",
|
| 44 |
+
description=(
|
| 45 |
+
"Payment Operations Incident Response environment. "
|
| 46 |
+
"An AI agent reviews financial transactions and decides how to handle them."
|
| 47 |
+
),
|
| 48 |
+
version="2.0.0",
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# Single shared environment instance (suitable for single-user / HF Space use)
|
| 52 |
+
_env = PayOpsEnvironment()
|
| 53 |
+
_episode_actions: List[str] = [] # ALL actions including investigation
|
| 54 |
+
_episode_confs: List[Optional[float]] = []
|
| 55 |
+
_episode_tasks: List[Any] = []
|
| 56 |
+
|
| 57 |
+
# Leaderboard persists for the process lifetime
|
| 58 |
+
_leaderboard: List[Dict[str, Any]] = []
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ---------------------------------------------------------------------------
|
| 62 |
+
# Request / response helpers
|
| 63 |
+
# ---------------------------------------------------------------------------
|
| 64 |
+
|
| 65 |
+
class StepRequest(BaseModel):
|
| 66 |
+
action_type: str
|
| 67 |
+
transaction_id: str
|
| 68 |
+
reason: Optional[str] = None
|
| 69 |
+
confidence: Optional[float] = None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class BaselineResult(BaseModel):
|
| 73 |
+
scores: List[Dict[str, Any]]
|
| 74 |
+
total_reward: float
|
| 75 |
+
normalised_score: float
|
| 76 |
+
steps: int
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class ReplayRequest(BaseModel):
|
| 80 |
+
actions: List[str]
|
| 81 |
+
confidences: Optional[List[Optional[float]]] = None
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
# Endpoints
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
|
| 88 |
+
@app.post("/reset", response_model=PayOpsObservation, summary="Reset the environment")
|
| 89 |
+
async def reset():
|
| 90 |
+
"""Reset the environment and return the first transaction observation."""
|
| 91 |
+
global _episode_actions, _episode_tasks, _episode_confs
|
| 92 |
+
_episode_actions = []
|
| 93 |
+
_episode_confs = []
|
| 94 |
+
_episode_tasks = list(TASKS)
|
| 95 |
+
obs = await _env.reset_async()
|
| 96 |
+
return obs
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@app.post("/step", response_model=PayOpsObservation, summary="Execute an action")
|
| 100 |
+
async def step(request: StepRequest):
|
| 101 |
+
"""
|
| 102 |
+
Submit an action for the current transaction.
|
| 103 |
+
|
| 104 |
+
Valid action_type values:
|
| 105 |
+
Terminal: approve | reject | flag | escalate | hold
|
| 106 |
+
Investigation: inspect | request_docs | verify_kyc | contact_sender | file_sar
|
| 107 |
+
"""
|
| 108 |
+
if request.action_type.lower() not in VALID_ACTIONS:
|
| 109 |
+
raise HTTPException(
|
| 110 |
+
status_code=422,
|
| 111 |
+
detail=f"Invalid action_type '{request.action_type}'. "
|
| 112 |
+
f"Valid values: {sorted(VALID_ACTIONS)}",
|
| 113 |
+
)
|
| 114 |
+
action = PayOpsAction(
|
| 115 |
+
action_type=request.action_type,
|
| 116 |
+
transaction_id=request.transaction_id,
|
| 117 |
+
reason=request.reason,
|
| 118 |
+
confidence=request.confidence,
|
| 119 |
+
)
|
| 120 |
+
try:
|
| 121 |
+
obs = await _env.step_async(action)
|
| 122 |
+
except RuntimeError as exc:
|
| 123 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 124 |
+
|
| 125 |
+
_episode_actions.append(request.action_type.lower())
|
| 126 |
+
_episode_confs.append(request.confidence)
|
| 127 |
+
|
| 128 |
+
# Auto-save completed episode to leaderboard
|
| 129 |
+
if obs.done:
|
| 130 |
+
result = grade_episode(
|
| 131 |
+
_episode_actions, _episode_tasks, _episode_confs
|
| 132 |
+
)
|
| 133 |
+
_leaderboard.append(
|
| 134 |
+
{
|
| 135 |
+
"episode_id": _env.state().episode_id,
|
| 136 |
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 137 |
+
"normalised_score": result.normalised_score,
|
| 138 |
+
"total_reward": result.total_reward,
|
| 139 |
+
"budget_spent": result.budget_spent,
|
| 140 |
+
"budget_overspend": result.budget_overspend,
|
| 141 |
+
"passed": result.passed,
|
| 142 |
+
"steps": len(_episode_actions),
|
| 143 |
+
}
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
return obs
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
@app.get("/state", response_model=PayOpsState, summary="Get internal environment state")
|
| 150 |
+
async def state():
|
| 151 |
+
"""Return the current internal state of the environment."""
|
| 152 |
+
return _env.state()
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@app.get("/schema", summary="Get action and observation schemas")
|
| 156 |
+
async def schema():
|
| 157 |
+
"""Return the JSON schemas for PayOpsAction and PayOpsObservation."""
|
| 158 |
+
return {
|
| 159 |
+
"action": PayOpsAction.model_json_schema(),
|
| 160 |
+
"observation": PayOpsObservation.model_json_schema(),
|
| 161 |
+
"state": PayOpsState.model_json_schema(),
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
@app.get("/tasks", summary="List all available tasks")
|
| 166 |
+
async def tasks():
|
| 167 |
+
"""Return metadata for all tasks grouped by difficulty."""
|
| 168 |
+
result = []
|
| 169 |
+
for t in TASKS:
|
| 170 |
+
result.append(
|
| 171 |
+
{
|
| 172 |
+
"task_id": t.task_id,
|
| 173 |
+
"difficulty": t.difficulty,
|
| 174 |
+
"description": t.description,
|
| 175 |
+
"transaction_id": t.transaction_id,
|
| 176 |
+
"amount": t.amount,
|
| 177 |
+
"currency": t.currency,
|
| 178 |
+
"transaction_type":t.transaction_type,
|
| 179 |
+
"risk_score": t.risk_score,
|
| 180 |
+
"ml_confidence": getattr(t, "ml_confidence", None),
|
| 181 |
+
"flags": t.flags,
|
| 182 |
+
"correct_action": t.correct_action,
|
| 183 |
+
"requires_investigation": list(getattr(t, "requires_investigation", [])),
|
| 184 |
+
"regulatory_action": getattr(t, "regulatory_action", False),
|
| 185 |
+
"chain_total": getattr(t, "chain_total", 1),
|
| 186 |
+
}
|
| 187 |
+
)
|
| 188 |
+
return {"count": len(result), "tasks": result}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@app.get("/grader", summary="Grade the current episode")
|
| 192 |
+
async def grader():
|
| 193 |
+
"""
|
| 194 |
+
Grade the episode using all actions taken since the last /reset.
|
| 195 |
+
"""
|
| 196 |
+
if not _episode_actions:
|
| 197 |
+
return JSONResponse(
|
| 198 |
+
status_code=400,
|
| 199 |
+
content={"error": "No actions recorded. Run /reset then /step first."},
|
| 200 |
+
)
|
| 201 |
+
result = grade_episode(_episode_actions, _episode_tasks, _episode_confs)
|
| 202 |
+
return {
|
| 203 |
+
"total_reward": result.total_reward,
|
| 204 |
+
"max_possible_reward":result.max_possible_reward,
|
| 205 |
+
"normalised_score": result.normalised_score,
|
| 206 |
+
"budget_spent": result.budget_spent,
|
| 207 |
+
"budget_overspend": result.budget_overspend,
|
| 208 |
+
"budget_penalty": result.budget_penalty,
|
| 209 |
+
"passed": result.passed,
|
| 210 |
+
"per_task": result.per_task_rewards,
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@app.post("/baseline", response_model=BaselineResult, summary="Run the baseline agent")
|
| 215 |
+
async def baseline():
|
| 216 |
+
"""
|
| 217 |
+
Run the built-in rule-based baseline agent against the full task set
|
| 218 |
+
and return its scores. Useful for sanity-checking the environment.
|
| 219 |
+
"""
|
| 220 |
+
from payops_env.scripts_util import run_baseline
|
| 221 |
+
scores, total, normalised, steps = await run_baseline()
|
| 222 |
+
return BaselineResult(
|
| 223 |
+
scores=scores,
|
| 224 |
+
total_reward=total,
|
| 225 |
+
normalised_score=normalised,
|
| 226 |
+
steps=steps,
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@app.get("/analytics", summary="Session performance analytics")
|
| 231 |
+
async def analytics():
|
| 232 |
+
"""
|
| 233 |
+
Return aggregate analytics across all completed episodes this session.
|
| 234 |
+
Includes accuracy by difficulty, average budget spend, and common mistakes.
|
| 235 |
+
"""
|
| 236 |
+
if not _leaderboard:
|
| 237 |
+
return {"message": "No completed episodes yet. Run a full episode first."}
|
| 238 |
+
|
| 239 |
+
# Per-difficulty accuracy from the last episode's per_task breakdown
|
| 240 |
+
result = grade_episode(_episode_actions, _episode_tasks, _episode_confs)
|
| 241 |
+
by_diff: Dict[str, Dict] = defaultdict(lambda: {"total": 0, "correct": 0, "rewards": []})
|
| 242 |
+
for pt in result.per_task_rewards:
|
| 243 |
+
d = pt["difficulty"]
|
| 244 |
+
by_diff[d]["total"] += 1
|
| 245 |
+
by_diff[d]["correct"] += int(pt["correct"])
|
| 246 |
+
by_diff[d]["rewards"].append(pt["weighted_reward"])
|
| 247 |
+
|
| 248 |
+
diff_summary = {
|
| 249 |
+
diff: {
|
| 250 |
+
"accuracy": round(v["correct"] / v["total"], 3) if v["total"] else 0,
|
| 251 |
+
"avg_reward": round(sum(v["rewards"]) / len(v["rewards"]), 3) if v["rewards"] else 0,
|
| 252 |
+
"count": v["total"],
|
| 253 |
+
}
|
| 254 |
+
for diff, v in by_diff.items()
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
return {
|
| 258 |
+
"episodes_completed": len(_leaderboard),
|
| 259 |
+
"best_score": max(e["normalised_score"] for e in _leaderboard),
|
| 260 |
+
"avg_score": round(sum(e["normalised_score"] for e in _leaderboard) / len(_leaderboard), 4),
|
| 261 |
+
"avg_budget_spent": round(sum(e["budget_spent"] for e in _leaderboard) / len(_leaderboard), 4),
|
| 262 |
+
"current_episode": {
|
| 263 |
+
"normalised_score": result.normalised_score,
|
| 264 |
+
"budget_spent": result.budget_spent,
|
| 265 |
+
"budget_penalty": result.budget_penalty,
|
| 266 |
+
"by_difficulty": diff_summary,
|
| 267 |
+
},
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
@app.post("/replay", summary="Grade a supplied action sequence")
|
| 272 |
+
async def replay(request: ReplayRequest):
|
| 273 |
+
"""
|
| 274 |
+
Grade a supplied list of actions against the full task bank without
|
| 275 |
+
modifying the current environment state.
|
| 276 |
+
|
| 277 |
+
Useful for offline evaluation and leaderboard submissions.
|
| 278 |
+
"""
|
| 279 |
+
actions = [a.lower() for a in request.actions]
|
| 280 |
+
invalid = [a for a in actions if a not in VALID_ACTIONS]
|
| 281 |
+
if invalid:
|
| 282 |
+
raise HTTPException(
|
| 283 |
+
status_code=422,
|
| 284 |
+
detail=f"Invalid action(s): {invalid}. Valid: {sorted(VALID_ACTIONS)}",
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
confs = request.confidences or [None] * len(actions)
|
| 288 |
+
result = grade_episode(actions, list(TASKS), confs)
|
| 289 |
+
return {
|
| 290 |
+
"total_reward": result.total_reward,
|
| 291 |
+
"max_possible_reward": result.max_possible_reward,
|
| 292 |
+
"normalised_score": result.normalised_score,
|
| 293 |
+
"budget_spent": result.budget_spent,
|
| 294 |
+
"budget_overspend": result.budget_overspend,
|
| 295 |
+
"budget_penalty": result.budget_penalty,
|
| 296 |
+
"passed": result.passed,
|
| 297 |
+
"per_task": result.per_task_rewards,
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
@app.get("/leaderboard", summary="Session leaderboard")
|
| 302 |
+
async def leaderboard():
|
| 303 |
+
"""
|
| 304 |
+
Return all scored episodes from this server session, sorted by score.
|
| 305 |
+
"""
|
| 306 |
+
sorted_board = sorted(_leaderboard, key=lambda e: e["normalised_score"], reverse=True)
|
| 307 |
+
return {"count": len(sorted_board), "entries": sorted_board}
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# ---------------------------------------------------------------------------
|
| 311 |
+
# WebSocket endpoint for persistent sessions
|
| 312 |
+
# ---------------------------------------------------------------------------
|
| 313 |
+
|
| 314 |
+
@app.websocket("/ws")
|
| 315 |
+
async def websocket_endpoint(websocket: WebSocket):
|
| 316 |
+
"""
|
| 317 |
+
WebSocket interface.
|
| 318 |
+
|
| 319 |
+
Client sends JSON:
|
| 320 |
+
{"type": "reset"}
|
| 321 |
+
{"type": "step", "action_type": "...", "transaction_id": "..."}
|
| 322 |
+
{"type": "state"}
|
| 323 |
+
|
| 324 |
+
Server responds with observation JSON.
|
| 325 |
+
"""
|
| 326 |
+
await websocket.accept()
|
| 327 |
+
ws_env = PayOpsEnvironment()
|
| 328 |
+
|
| 329 |
+
try:
|
| 330 |
+
while True:
|
| 331 |
+
raw = await websocket.receive_text()
|
| 332 |
+
try:
|
| 333 |
+
msg = json.loads(raw)
|
| 334 |
+
except json.JSONDecodeError:
|
| 335 |
+
await websocket.send_json({"error": "Invalid JSON"})
|
| 336 |
+
continue
|
| 337 |
+
|
| 338 |
+
msg_type = msg.get("type", "")
|
| 339 |
+
|
| 340 |
+
if msg_type == "reset":
|
| 341 |
+
obs = await ws_env.reset_async()
|
| 342 |
+
await websocket.send_json(obs.model_dump())
|
| 343 |
+
|
| 344 |
+
elif msg_type == "step":
|
| 345 |
+
action_type = msg.get("action_type", "")
|
| 346 |
+
if action_type.lower() not in VALID_ACTIONS:
|
| 347 |
+
await websocket.send_json(
|
| 348 |
+
{"error": f"Invalid action_type '{action_type}'"}
|
| 349 |
+
)
|
| 350 |
+
continue
|
| 351 |
+
action = PayOpsAction(
|
| 352 |
+
action_type=action_type,
|
| 353 |
+
transaction_id=msg.get("transaction_id", ""),
|
| 354 |
+
reason=msg.get("reason"),
|
| 355 |
+
confidence=msg.get("confidence"),
|
| 356 |
+
)
|
| 357 |
+
try:
|
| 358 |
+
obs = await ws_env.step_async(action)
|
| 359 |
+
await websocket.send_json(obs.model_dump())
|
| 360 |
+
except Exception as exc:
|
| 361 |
+
await websocket.send_json({"error": str(exc)})
|
| 362 |
+
|
| 363 |
+
elif msg_type == "state":
|
| 364 |
+
await websocket.send_json(ws_env.state().model_dump())
|
| 365 |
+
|
| 366 |
+
else:
|
| 367 |
+
await websocket.send_json(
|
| 368 |
+
{"error": f"Unknown message type '{msg_type}'"}
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
except WebSocketDisconnect:
|
| 372 |
+
ws_env.close()
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
# ---------------------------------------------------------------------------
|
| 376 |
+
# Health check
|
| 377 |
+
# ---------------------------------------------------------------------------
|
| 378 |
+
|
| 379 |
+
@app.get("/health", summary="Health check")
|
| 380 |
+
async def health():
|
| 381 |
+
return {"status": "ok", "environment": "payops_env", "version": "2.0.0"}
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
# ---------------------------------------------------------------------------
|
| 385 |
+
# Entry point
|
| 386 |
+
# ---------------------------------------------------------------------------
|
| 387 |
+
|
| 388 |
+
def main(host: str = "0.0.0.0", port: int = 8000):
|
| 389 |
+
import uvicorn
|
| 390 |
+
uvicorn.run(app, host=host, port=port)
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
if __name__ == "__main__":
|
| 394 |
+
import argparse
|
| 395 |
+
parser = argparse.ArgumentParser()
|
| 396 |
+
parser.add_argument("--port", type=int, default=8000)
|
| 397 |
+
args = parser.parse_args()
|
| 398 |
+
main(port=args.port)
|
| 399 |
+
|
tasks.py
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Task bank for the PayOps environment — 20 tasks across 4 difficulty tiers.
|
| 3 |
+
|
| 4 |
+
Difficulty tiers
|
| 5 |
+
----------------
|
| 6 |
+
easy – single clear signal; one-hop decision
|
| 7 |
+
medium – ambiguous or competing signals; reasoning required
|
| 8 |
+
hard – adversarial, conflicting, or edge-case patterns
|
| 9 |
+
critical – multi-step investigation chains; regulatory compliance stakes
|
| 10 |
+
|
| 11 |
+
Multi-step chains
|
| 12 |
+
-----------------
|
| 13 |
+
Tasks with chain_total > 1 require the agent to issue investigation sub-actions
|
| 14 |
+
(inspect / request_docs / verify_kyc / contact_sender) before a terminal decision.
|
| 15 |
+
Each chain step reveals progressively more context via the appropriate _reveal field.
|
| 16 |
+
The terminal decision is only scored on the final chain step.
|
| 17 |
+
|
| 18 |
+
Action costs
|
| 19 |
+
------------
|
| 20 |
+
Each investigation sub-action incurs a budget cost. If the agent exhausts the
|
| 21 |
+
budget (spend > budget_limit), a cumulative cost penalty is applied to the reward.
|
| 22 |
+
|
| 23 |
+
inspect → cost 0.1
|
| 24 |
+
request_docs → cost 0.2
|
| 25 |
+
verify_kyc → cost 0.2
|
| 26 |
+
contact_sender → cost 0.3
|
| 27 |
+
file_sar → cost 0.1 (required for structuring/AML — free if correct)
|
| 28 |
+
|
| 29 |
+
Reward structure per task
|
| 30 |
+
-------------------------
|
| 31 |
+
Each task defines:
|
| 32 |
+
correct_action – ground-truth terminal decision
|
| 33 |
+
partial_credit_actions – {action: fraction_of_full_credit}
|
| 34 |
+
requires_investigation – set of sub-actions ideally used before deciding
|
| 35 |
+
regulatory_action – if True, file_sar is a required prerequisite for full credit
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
from __future__ import annotations
|
| 39 |
+
|
| 40 |
+
from dataclasses import dataclass, field
|
| 41 |
+
from typing import Dict, List, Optional, Set
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class PayOpsTask:
|
| 46 |
+
# --- identity ---
|
| 47 |
+
task_id: str
|
| 48 |
+
difficulty: str # easy | medium | hard | critical
|
| 49 |
+
description: str
|
| 50 |
+
|
| 51 |
+
# --- transaction fields ---
|
| 52 |
+
transaction_id: str
|
| 53 |
+
amount: float
|
| 54 |
+
currency: str
|
| 55 |
+
sender: str
|
| 56 |
+
receiver: str
|
| 57 |
+
transaction_type: str # transfer | payment | withdrawal | refund | internal | loan_repayment | payroll
|
| 58 |
+
|
| 59 |
+
# --- primary risk signals ---
|
| 60 |
+
risk_score: float # 0.0–1.0
|
| 61 |
+
ml_confidence: float = 0.90 # model's confidence in its own risk_score
|
| 62 |
+
flags: List[str] = field(default_factory=list)
|
| 63 |
+
|
| 64 |
+
# --- sender behaviour ---
|
| 65 |
+
velocity_1h: Optional[int] = None
|
| 66 |
+
velocity_24h: Optional[int] = None
|
| 67 |
+
avg_transaction_amount: Optional[float] = None
|
| 68 |
+
account_age_days: Optional[int] = None
|
| 69 |
+
|
| 70 |
+
# --- counterparty / geo ---
|
| 71 |
+
country_risk: Optional[str] = None # low | medium | high | sanctioned
|
| 72 |
+
kyc_status: Optional[str] = None # verified | pending | failed | none | expired
|
| 73 |
+
kyc_expiry_days: Optional[int] = None
|
| 74 |
+
previous_violations: Optional[int] = None
|
| 75 |
+
previous_sars: Optional[int] = None
|
| 76 |
+
counterparty_risk: Optional[str] = None # clean | unknown | watchlist | blacklist
|
| 77 |
+
|
| 78 |
+
# --- grading ---
|
| 79 |
+
correct_action: str = "approve"
|
| 80 |
+
partial_credit_actions: Dict[str, float] = field(default_factory=dict)
|
| 81 |
+
requires_investigation: Set[str] = field(default_factory=set)
|
| 82 |
+
# Set of sub-actions the agent SHOULD use for best score.
|
| 83 |
+
# Using them before the terminal decision grants a bonus (applied in grader).
|
| 84 |
+
regulatory_action: bool = False
|
| 85 |
+
# If True, filing a SAR is required to get full credit.
|
| 86 |
+
|
| 87 |
+
# --- investigation reveals (each unlocked by the matching sub-action) ---
|
| 88 |
+
inspect_reveal: Optional[str] = None
|
| 89 |
+
docs_reveal: Optional[str] = None
|
| 90 |
+
kyc_reveal: Optional[str] = None
|
| 91 |
+
contact_reveal: Optional[str] = None
|
| 92 |
+
|
| 93 |
+
# --- multi-step chain metadata ---
|
| 94 |
+
chain_total: int = 1
|
| 95 |
+
# chain_total > 1 means the task spans multiple investigation steps.
|
| 96 |
+
# The grader only scores the terminal decision on the last chain step.
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# =============================================================================
|
| 100 |
+
# EASY (4 tasks — single clear signal)
|
| 101 |
+
# =============================================================================
|
| 102 |
+
|
| 103 |
+
EASY_TASKS: List[PayOpsTask] = [
|
| 104 |
+
PayOpsTask(
|
| 105 |
+
task_id="EASY-001",
|
| 106 |
+
difficulty="easy",
|
| 107 |
+
description="Low-value domestic transfer between two long-standing verified users. No flags.",
|
| 108 |
+
transaction_id="TXN-E001",
|
| 109 |
+
amount=47.50,
|
| 110 |
+
currency="USD",
|
| 111 |
+
sender="alice@example.com",
|
| 112 |
+
receiver="bob@example.com",
|
| 113 |
+
transaction_type="transfer",
|
| 114 |
+
risk_score=0.05,
|
| 115 |
+
ml_confidence=0.97,
|
| 116 |
+
flags=[],
|
| 117 |
+
velocity_1h=1, velocity_24h=3,
|
| 118 |
+
avg_transaction_amount=55.0,
|
| 119 |
+
account_age_days=1_095,
|
| 120 |
+
country_risk="low", kyc_status="verified", kyc_expiry_days=320,
|
| 121 |
+
previous_violations=0, previous_sars=0, counterparty_risk="clean",
|
| 122 |
+
correct_action="approve",
|
| 123 |
+
partial_credit_actions={"flag": 0.3},
|
| 124 |
+
inspect_reveal="Sender account 3 yrs old. Consistent transaction history. KYC fully verified.",
|
| 125 |
+
),
|
| 126 |
+
PayOpsTask(
|
| 127 |
+
task_id="EASY-002",
|
| 128 |
+
difficulty="easy",
|
| 129 |
+
description="Textbook fraud: unknown sender, offshore, sanctioned country, risk=0.97.",
|
| 130 |
+
transaction_id="TXN-E002",
|
| 131 |
+
amount=9_800.00,
|
| 132 |
+
currency="USD",
|
| 133 |
+
sender="unknown_user_x99",
|
| 134 |
+
receiver="offshore_acct_442",
|
| 135 |
+
transaction_type="transfer",
|
| 136 |
+
risk_score=0.97,
|
| 137 |
+
ml_confidence=0.95,
|
| 138 |
+
flags=["high_value", "unknown_sender", "offshore_receiver", "sanctioned_country"],
|
| 139 |
+
velocity_1h=12, velocity_24h=28,
|
| 140 |
+
avg_transaction_amount=None,
|
| 141 |
+
account_age_days=2,
|
| 142 |
+
country_risk="sanctioned", kyc_status="none", kyc_expiry_days=None,
|
| 143 |
+
previous_violations=3, previous_sars=1, counterparty_risk="blacklist",
|
| 144 |
+
correct_action="reject",
|
| 145 |
+
partial_credit_actions={"escalate": 0.4, "flag": 0.2},
|
| 146 |
+
inspect_reveal="Receiver linked to 3 prior rejected transactions. IP on threat-intel blocklist.",
|
| 147 |
+
),
|
| 148 |
+
PayOpsTask(
|
| 149 |
+
task_id="EASY-003",
|
| 150 |
+
difficulty="easy",
|
| 151 |
+
description="Standard refund to a verified customer. Tiny amount, no flags.",
|
| 152 |
+
transaction_id="TXN-E003",
|
| 153 |
+
amount=12.99,
|
| 154 |
+
currency="USD",
|
| 155 |
+
sender="shop@retailer.com",
|
| 156 |
+
receiver="carol@customer.com",
|
| 157 |
+
transaction_type="refund",
|
| 158 |
+
risk_score=0.03,
|
| 159 |
+
ml_confidence=0.98,
|
| 160 |
+
flags=[],
|
| 161 |
+
velocity_1h=1, velocity_24h=5,
|
| 162 |
+
avg_transaction_amount=14.0,
|
| 163 |
+
account_age_days=730,
|
| 164 |
+
country_risk="low", kyc_status="verified", kyc_expiry_days=180,
|
| 165 |
+
previous_violations=0, previous_sars=0, counterparty_risk="clean",
|
| 166 |
+
correct_action="approve",
|
| 167 |
+
partial_credit_actions={"flag": 0.3},
|
| 168 |
+
inspect_reveal="Standard return via web checkout. Matching order ID on file. No anomalies.",
|
| 169 |
+
),
|
| 170 |
+
PayOpsTask(
|
| 171 |
+
task_id="EASY-004",
|
| 172 |
+
difficulty="easy",
|
| 173 |
+
description="ATM withdrawal burst — 15 withdrawals in 58 minutes across 4 ATMs. Velocity violation.",
|
| 174 |
+
transaction_id="TXN-E004",
|
| 175 |
+
amount=200.00,
|
| 176 |
+
currency="USD",
|
| 177 |
+
sender="david@accounts.com",
|
| 178 |
+
receiver="atm_node_77",
|
| 179 |
+
transaction_type="withdrawal",
|
| 180 |
+
risk_score=0.78,
|
| 181 |
+
ml_confidence=0.89,
|
| 182 |
+
flags=["velocity_breach", "atm_burst"],
|
| 183 |
+
velocity_1h=15, velocity_24h=17,
|
| 184 |
+
avg_transaction_amount=80.0,
|
| 185 |
+
account_age_days=540,
|
| 186 |
+
country_risk="medium", kyc_status="verified", kyc_expiry_days=90,
|
| 187 |
+
previous_violations=0, previous_sars=0, counterparty_risk="unknown",
|
| 188 |
+
correct_action="flag",
|
| 189 |
+
partial_credit_actions={"escalate": 0.6, "hold": 0.5},
|
| 190 |
+
inspect_reveal="15 ATM withdrawals in 58 min across 4 ATMs. Pattern consistent with card clone.",
|
| 191 |
+
),
|
| 192 |
+
]
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# =============================================================================
|
| 196 |
+
# MEDIUM (6 tasks — ambiguous, multi-signal)
|
| 197 |
+
# =============================================================================
|
| 198 |
+
|
| 199 |
+
MEDIUM_TASKS: List[PayOpsTask] = [
|
| 200 |
+
PayOpsTask(
|
| 201 |
+
task_id="MED-001",
|
| 202 |
+
difficulty="medium",
|
| 203 |
+
description="Large B2B wire. Verified CFO. Cross-border to medium-risk EU jurisdiction.",
|
| 204 |
+
transaction_id="TXN-M001",
|
| 205 |
+
amount=85_000.00,
|
| 206 |
+
currency="EUR",
|
| 207 |
+
sender="cfo@globalcorp.com",
|
| 208 |
+
receiver="vendor@eu-supplier.de",
|
| 209 |
+
transaction_type="transfer",
|
| 210 |
+
risk_score=0.52,
|
| 211 |
+
ml_confidence=0.72,
|
| 212 |
+
flags=["high_value", "cross_border"],
|
| 213 |
+
velocity_1h=2, velocity_24h=4,
|
| 214 |
+
avg_transaction_amount=45_000.0,
|
| 215 |
+
account_age_days=2_190,
|
| 216 |
+
country_risk="medium", kyc_status="verified", kyc_expiry_days=200,
|
| 217 |
+
previous_violations=0, previous_sars=0, counterparty_risk="clean",
|
| 218 |
+
correct_action="escalate",
|
| 219 |
+
partial_credit_actions={"flag": 0.5, "hold": 0.4},
|
| 220 |
+
requires_investigation={"inspect"},
|
| 221 |
+
inspect_reveal="Contract on file for €85k milestone. Receiver is licensed EU entity. Quarterly vendor pattern.",
|
| 222 |
+
),
|
| 223 |
+
PayOpsTask(
|
| 224 |
+
task_id="MED-002",
|
| 225 |
+
difficulty="medium",
|
| 226 |
+
description="Internal treasury transfer. Large amount. KYC pending renewal — risk is procedural, not fraud.",
|
| 227 |
+
transaction_id="TXN-M002",
|
| 228 |
+
amount=250_000.00,
|
| 229 |
+
currency="USD",
|
| 230 |
+
sender="treasury@holdco.com",
|
| 231 |
+
receiver="subsidiary@holdco-us.com",
|
| 232 |
+
transaction_type="internal",
|
| 233 |
+
risk_score=0.41,
|
| 234 |
+
ml_confidence=0.78,
|
| 235 |
+
flags=["high_value", "kyc_expiry_90d"],
|
| 236 |
+
velocity_1h=1, velocity_24h=2,
|
| 237 |
+
avg_transaction_amount=200_000.0,
|
| 238 |
+
account_age_days=3_650,
|
| 239 |
+
country_risk="low", kyc_status="pending", kyc_expiry_days=5,
|
| 240 |
+
previous_violations=0, previous_sars=0, counterparty_risk="clean",
|
| 241 |
+
correct_action="hold",
|
| 242 |
+
partial_credit_actions={"escalate": 0.6, "flag": 0.4},
|
| 243 |
+
requires_investigation={"verify_kyc"},
|
| 244 |
+
kyc_reveal="KYC renewal submitted 10 days ago. Both accounts share same UBO. Transfer aligns with Q1 plan.",
|
| 245 |
+
),
|
| 246 |
+
PayOpsTask(
|
| 247 |
+
task_id="MED-003",
|
| 248 |
+
difficulty="medium",
|
| 249 |
+
description="Subscription payment 3× historical average. Possible upgrade billing or card compromise.",
|
| 250 |
+
transaction_id="TXN-M003",
|
| 251 |
+
amount=449.97,
|
| 252 |
+
currency="USD",
|
| 253 |
+
sender="eve@subscriber.com",
|
| 254 |
+
receiver="billing@saas-platform.com",
|
| 255 |
+
transaction_type="payment",
|
| 256 |
+
risk_score=0.44,
|
| 257 |
+
ml_confidence=0.68,
|
| 258 |
+
flags=["amount_spike", "pattern_deviation"],
|
| 259 |
+
velocity_1h=1, velocity_24h=1,
|
| 260 |
+
avg_transaction_amount=149.99,
|
| 261 |
+
account_age_days=820,
|
| 262 |
+
country_risk="low", kyc_status="verified", kyc_expiry_days=150,
|
| 263 |
+
previous_violations=0, previous_sars=0, counterparty_risk="clean",
|
| 264 |
+
correct_action="flag",
|
| 265 |
+
partial_credit_actions={"hold": 0.5, "escalate": 0.3},
|
| 266 |
+
inspect_reveal="Historical monthly charge $149.99. This charge = 3-month annual upgrade. Merchant confirmation pending.",
|
| 267 |
+
),
|
| 268 |
+
PayOpsTask(
|
| 269 |
+
task_id="MED-004",
|
| 270 |
+
difficulty="medium",
|
| 271 |
+
description="Payment to regulated crypto exchange. Moderate risk. Sender has clean history.",
|
| 272 |
+
transaction_id="TXN-M004",
|
| 273 |
+
amount=5_000.00,
|
| 274 |
+
currency="USD",
|
| 275 |
+
sender="frank@personal.com",
|
| 276 |
+
receiver="exchange@cryptovault.io",
|
| 277 |
+
transaction_type="payment",
|
| 278 |
+
risk_score=0.58,
|
| 279 |
+
ml_confidence=0.74,
|
| 280 |
+
flags=["crypto_exchange", "high_value"],
|
| 281 |
+
velocity_1h=1, velocity_24h=2,
|
| 282 |
+
avg_transaction_amount=2_000.0,
|
| 283 |
+
account_age_days=1_460,
|
| 284 |
+
country_risk="medium", kyc_status="verified", kyc_expiry_days=270,
|
| 285 |
+
previous_violations=0, previous_sars=0, counterparty_risk="unknown",
|
| 286 |
+
correct_action="flag",
|
| 287 |
+
partial_credit_actions={"escalate": 0.5, "hold": 0.4},
|
| 288 |
+
inspect_reveal="Exchange licensed in sender's jurisdiction. Sender made 4 similar payments over 6 months. Within limits.",
|
| 289 |
+
),
|
| 290 |
+
PayOpsTask(
|
| 291 |
+
task_id="MED-005",
|
| 292 |
+
difficulty="medium",
|
| 293 |
+
description="Expired KYC on a high-frequency corporate account. Routine transactions continue but KYC lapsed 12 days ago.",
|
| 294 |
+
transaction_id="TXN-M005",
|
| 295 |
+
amount=28_000.00,
|
| 296 |
+
currency="GBP",
|
| 297 |
+
sender="payments@logistics-uk.com",
|
| 298 |
+
receiver="driver-pool@payroll.co.uk",
|
| 299 |
+
transaction_type="payroll",
|
| 300 |
+
risk_score=0.38,
|
| 301 |
+
ml_confidence=0.82,
|
| 302 |
+
flags=["kyc_expired", "high_value"],
|
| 303 |
+
velocity_1h=1, velocity_24h=6,
|
| 304 |
+
avg_transaction_amount=26_000.0,
|
| 305 |
+
account_age_days=1_800,
|
| 306 |
+
country_risk="low", kyc_status="expired", kyc_expiry_days=-12,
|
| 307 |
+
previous_violations=0, previous_sars=0, counterparty_risk="clean",
|
| 308 |
+
correct_action="hold",
|
| 309 |
+
partial_credit_actions={"flag": 0.5, "escalate": 0.4},
|
| 310 |
+
requires_investigation={"verify_kyc"},
|
| 311 |
+
kyc_reveal="KYC expired 12 days ago due to administrative oversight. Re-submission in progress. No fraud indicators.",
|
| 312 |
+
),
|
| 313 |
+
PayOpsTask(
|
| 314 |
+
task_id="MED-006",
|
| 315 |
+
difficulty="medium",
|
| 316 |
+
description="Real estate advance payment. Large amount. First payment to this receiver but contract exists.",
|
| 317 |
+
transaction_id="TXN-M006",
|
| 318 |
+
amount=120_000.00,
|
| 319 |
+
currency="USD",
|
| 320 |
+
sender="buyer@realestate-client.com",
|
| 321 |
+
receiver="escrow@property-agent.co",
|
| 322 |
+
transaction_type="transfer",
|
| 323 |
+
risk_score=0.56,
|
| 324 |
+
ml_confidence=0.65,
|
| 325 |
+
flags=["high_value", "first_time_payee", "large_first_transfer"],
|
| 326 |
+
velocity_1h=1, velocity_24h=1,
|
| 327 |
+
avg_transaction_amount=5_000.0,
|
| 328 |
+
account_age_days=2_555,
|
| 329 |
+
country_risk="medium", kyc_status="verified", kyc_expiry_days=90,
|
| 330 |
+
previous_violations=0, previous_sars=0, counterparty_risk="unknown",
|
| 331 |
+
correct_action="escalate",
|
| 332 |
+
partial_credit_actions={"flag": 0.4, "hold": 0.5},
|
| 333 |
+
requires_investigation={"request_docs"},
|
| 334 |
+
docs_reveal="Signed purchase agreement found. Escrow agent licensed and registered. Standard conveyancing practice.",
|
| 335 |
+
),
|
| 336 |
+
]
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
# =============================================================================
|
| 340 |
+
# HARD (6 tasks — adversarial, conflicting, edge-cases)
|
| 341 |
+
# =============================================================================
|
| 342 |
+
|
| 343 |
+
HARD_TASKS: List[PayOpsTask] = [
|
| 344 |
+
PayOpsTask(
|
| 345 |
+
task_id="HARD-001",
|
| 346 |
+
difficulty="hard",
|
| 347 |
+
description="ML model poisoning: risk_score=0.18 but manual flags scream fraud. Agent must override the model.",
|
| 348 |
+
transaction_id="TXN-H001",
|
| 349 |
+
amount=47_000.00,
|
| 350 |
+
currency="GBP",
|
| 351 |
+
sender="grace@newaccount.co.uk",
|
| 352 |
+
receiver="payee@solicitor-uk.com",
|
| 353 |
+
transaction_type="transfer",
|
| 354 |
+
risk_score=0.18, # poisoned — deliberately underscored
|
| 355 |
+
ml_confidence=0.31, # low confidence signals model uncertainty
|
| 356 |
+
flags=["new_account_7d", "large_first_transfer", "solicitor_mule_pattern"],
|
| 357 |
+
velocity_1h=1, velocity_24h=1,
|
| 358 |
+
avg_transaction_amount=None,
|
| 359 |
+
account_age_days=7,
|
| 360 |
+
country_risk="low", kyc_status="pending", kyc_expiry_days=None,
|
| 361 |
+
previous_violations=0, previous_sars=0, counterparty_risk="watchlist",
|
| 362 |
+
correct_action="escalate",
|
| 363 |
+
partial_credit_actions={"flag": 0.6, "hold": 0.5, "reject": 0.3},
|
| 364 |
+
requires_investigation={"inspect"},
|
| 365 |
+
inspect_reveal=(
|
| 366 |
+
"Account 7 days old. First outbound transfer. Receiver matches solicitor-impersonation "
|
| 367 |
+
"mule pattern from last month's intelligence bulletin. ML underscored — new clean account."
|
| 368 |
+
),
|
| 369 |
+
),
|
| 370 |
+
PayOpsTask(
|
| 371 |
+
task_id="HARD-002",
|
| 372 |
+
difficulty="hard",
|
| 373 |
+
description="APP scam: victim willingly sending money but has been social-engineered. Receiver is a mule.",
|
| 374 |
+
transaction_id="TXN-H002",
|
| 375 |
+
amount=12_500.00,
|
| 376 |
+
currency="GBP",
|
| 377 |
+
sender="henry@victim-bank.co.uk",
|
| 378 |
+
receiver="mule_relay_2287@fastpay.io",
|
| 379 |
+
transaction_type="transfer",
|
| 380 |
+
risk_score=0.61,
|
| 381 |
+
ml_confidence=0.77,
|
| 382 |
+
flags=["app_scam_indicator", "mule_account_pattern", "first_time_payee"],
|
| 383 |
+
velocity_1h=1, velocity_24h=1,
|
| 384 |
+
avg_transaction_amount=300.0,
|
| 385 |
+
account_age_days=3_285,
|
| 386 |
+
country_risk="medium", kyc_status="verified", kyc_expiry_days=180,
|
| 387 |
+
previous_violations=0, previous_sars=0, counterparty_risk="blacklist",
|
| 388 |
+
correct_action="reject",
|
| 389 |
+
partial_credit_actions={"escalate": 0.6, "hold": 0.5, "flag": 0.3},
|
| 390 |
+
requires_investigation={"contact_sender"},
|
| 391 |
+
contact_reveal=(
|
| 392 |
+
"Sender says they were called by someone claiming to be their bank. "
|
| 393 |
+
"Instructed to move savings to a 'safe account'. Classic APP scam confirmed."
|
| 394 |
+
),
|
| 395 |
+
),
|
| 396 |
+
PayOpsTask(
|
| 397 |
+
task_id="HARD-003",
|
| 398 |
+
difficulty="hard",
|
| 399 |
+
description="Structuring/smurfing: just-below-CTR-threshold payments from same beneficial owner across accounts.",
|
| 400 |
+
transaction_id="TXN-H003",
|
| 401 |
+
amount=9_450.00, # just below $10k CTR threshold
|
| 402 |
+
currency="USD",
|
| 403 |
+
sender="irene_acct_A@shadow.net",
|
| 404 |
+
receiver="irene_acct_B@shadow.net",
|
| 405 |
+
transaction_type="transfer",
|
| 406 |
+
risk_score=0.71,
|
| 407 |
+
ml_confidence=0.83,
|
| 408 |
+
flags=["structuring_pattern", "same_ubo", "ctr_threshold_avoidance", "high_value"],
|
| 409 |
+
velocity_1h=3, velocity_24h=9,
|
| 410 |
+
avg_transaction_amount=9_200.0,
|
| 411 |
+
account_age_days=120,
|
| 412 |
+
country_risk="high", kyc_status="failed", kyc_expiry_days=None,
|
| 413 |
+
previous_violations=2, previous_sars=1, counterparty_risk="watchlist",
|
| 414 |
+
correct_action="reject",
|
| 415 |
+
partial_credit_actions={"escalate": 0.5, "flag": 0.2},
|
| 416 |
+
requires_investigation={"inspect"},
|
| 417 |
+
regulatory_action=True,
|
| 418 |
+
inspect_reveal=(
|
| 419 |
+
"3 transactions in 24h: $9,450 + $9,200 + $9,100 from related accounts. "
|
| 420 |
+
"Same UBO. KYC failed on inconsistent ID docs. Classic CTR structuring."
|
| 421 |
+
),
|
| 422 |
+
),
|
| 423 |
+
PayOpsTask(
|
| 424 |
+
task_id="HARD-004",
|
| 425 |
+
difficulty="hard",
|
| 426 |
+
description="Legitimate FX settlement: huge amount, looks alarming — is a standard correspondent banking transfer.",
|
| 427 |
+
transaction_id="TXN-H004",
|
| 428 |
+
amount=4_200_000.00,
|
| 429 |
+
currency="USD",
|
| 430 |
+
sender="nostro@bank-a-swift.com",
|
| 431 |
+
receiver="vostro@bank-b-swift.com",
|
| 432 |
+
transaction_type="internal",
|
| 433 |
+
risk_score=0.67,
|
| 434 |
+
ml_confidence=0.58, # model uncertain on legitimate large transfers
|
| 435 |
+
flags=["high_value", "cross_border", "fx_settlement"],
|
| 436 |
+
velocity_1h=8, velocity_24h=24,
|
| 437 |
+
avg_transaction_amount=3_900_000.0,
|
| 438 |
+
account_age_days=7_300,
|
| 439 |
+
country_risk="medium", kyc_status="verified", kyc_expiry_days=400,
|
| 440 |
+
previous_violations=0, previous_sars=0, counterparty_risk="clean",
|
| 441 |
+
correct_action="approve",
|
| 442 |
+
partial_credit_actions={"escalate": 0.5, "hold": 0.4, "flag": 0.3},
|
| 443 |
+
requires_investigation={"inspect"},
|
| 444 |
+
inspect_reveal=(
|
| 445 |
+
"Both SWIFT BIC-verified. Part of daily USD/EUR FX settlement cycle. "
|
| 446 |
+
"8 similar settlements this month, all cleared. Nostro/vostro agreement on file."
|
| 447 |
+
),
|
| 448 |
+
),
|
| 449 |
+
PayOpsTask(
|
| 450 |
+
task_id="HARD-005",
|
| 451 |
+
difficulty="hard",
|
| 452 |
+
description=(
|
| 453 |
+
"Insider threat: employee of the bank initiating an unauthorised wire "
|
| 454 |
+
"to their personal account, disguised as a vendor payment."
|
| 455 |
+
),
|
| 456 |
+
transaction_id="TXN-H005",
|
| 457 |
+
amount=22_000.00,
|
| 458 |
+
currency="USD",
|
| 459 |
+
sender="staff_ops@bank-internal.com",
|
| 460 |
+
receiver="jake.smith.personal@gmail.com",
|
| 461 |
+
transaction_type="payment",
|
| 462 |
+
risk_score=0.54,
|
| 463 |
+
ml_confidence=0.61,
|
| 464 |
+
flags=["internal_to_personal", "unusual_beneficiary", "after_hours"],
|
| 465 |
+
velocity_1h=1, velocity_24h=1,
|
| 466 |
+
avg_transaction_amount=75_000.0,
|
| 467 |
+
account_age_days=2_000,
|
| 468 |
+
country_risk="low", kyc_status="verified", kyc_expiry_days=240,
|
| 469 |
+
previous_violations=0, previous_sars=0, counterparty_risk="unknown",
|
| 470 |
+
correct_action="escalate",
|
| 471 |
+
partial_credit_actions={"flag": 0.5, "hold": 0.4, "reject": 0.3},
|
| 472 |
+
requires_investigation={"inspect", "contact_sender"},
|
| 473 |
+
inspect_reveal=(
|
| 474 |
+
"Initiation time: 11:47 PM. No vendor contract matches this beneficiary. "
|
| 475 |
+
"Staff member placed on PIP last week. Receiver email matches staff home address."
|
| 476 |
+
),
|
| 477 |
+
contact_reveal=(
|
| 478 |
+
"Staff member claims it is a legitimate vendor. Unable to provide contract reference. "
|
| 479 |
+
"Story changes on follow-up. Escalate to HR and Fraud immediately."
|
| 480 |
+
),
|
| 481 |
+
),
|
| 482 |
+
PayOpsTask(
|
| 483 |
+
task_id="HARD-006",
|
| 484 |
+
difficulty="hard",
|
| 485 |
+
description=(
|
| 486 |
+
"Ghost account: receiver account was dormant for 5 years and suddenly "
|
| 487 |
+
"received 20 inbound transfers this week. Possible account takeover."
|
| 488 |
+
),
|
| 489 |
+
transaction_id="TXN-H006",
|
| 490 |
+
amount=3_200.00,
|
| 491 |
+
currency="EUR",
|
| 492 |
+
sender="layla@verified-sender.eu",
|
| 493 |
+
receiver="old_dormant_acct_889@bank.eu",
|
| 494 |
+
transaction_type="transfer",
|
| 495 |
+
risk_score=0.63,
|
| 496 |
+
ml_confidence=0.69,
|
| 497 |
+
flags=["dormant_receiver", "sudden_activity", "high_inbound_velocity"],
|
| 498 |
+
velocity_1h=1, velocity_24h=3,
|
| 499 |
+
avg_transaction_amount=800.0,
|
| 500 |
+
account_age_days=890,
|
| 501 |
+
country_risk="medium", kyc_status="verified", kyc_expiry_days=60,
|
| 502 |
+
previous_violations=0, previous_sars=0, counterparty_risk="unknown",
|
| 503 |
+
correct_action="flag",
|
| 504 |
+
partial_credit_actions={"hold": 0.6, "escalate": 0.5},
|
| 505 |
+
requires_investigation={"inspect"},
|
| 506 |
+
inspect_reveal=(
|
| 507 |
+
"Receiver dormant 5 years. 20 inbound transfers this week totalling €64k. "
|
| 508 |
+
"All immediately forwarded offshore. Classic money-mule account reactivation."
|
| 509 |
+
),
|
| 510 |
+
),
|
| 511 |
+
]
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
# =============================================================================
|
| 515 |
+
# CRITICAL (4 tasks — multi-step chains, regulatory stakes)
|
| 516 |
+
# =============================================================================
|
| 517 |
+
|
| 518 |
+
CRITICAL_TASKS: List[PayOpsTask] = [
|
| 519 |
+
PayOpsTask(
|
| 520 |
+
task_id="CRIT-001",
|
| 521 |
+
difficulty="critical",
|
| 522 |
+
description=(
|
| 523 |
+
"Multi-step investigation: large wire to a new counterparty. "
|
| 524 |
+
"Agent must inspect logs, then request supporting documents, "
|
| 525 |
+
"then make a final decision. Chain of 3 steps."
|
| 526 |
+
),
|
| 527 |
+
transaction_id="TXN-C001",
|
| 528 |
+
amount=375_000.00,
|
| 529 |
+
currency="USD",
|
| 530 |
+
sender="deal-team@pe-firm.com",
|
| 531 |
+
receiver="newco@acquisition-target.io",
|
| 532 |
+
transaction_type="transfer",
|
| 533 |
+
risk_score=0.59,
|
| 534 |
+
ml_confidence=0.55,
|
| 535 |
+
flags=["high_value", "first_time_payee", "cross_border", "new_counterparty"],
|
| 536 |
+
velocity_1h=1, velocity_24h=2,
|
| 537 |
+
avg_transaction_amount=50_000.0,
|
| 538 |
+
account_age_days=3_000,
|
| 539 |
+
country_risk="medium", kyc_status="verified", kyc_expiry_days=120,
|
| 540 |
+
previous_violations=0, previous_sars=0, counterparty_risk="unknown",
|
| 541 |
+
correct_action="approve",
|
| 542 |
+
partial_credit_actions={"escalate": 0.5, "hold": 0.4, "flag": 0.3},
|
| 543 |
+
requires_investigation={"inspect", "request_docs"},
|
| 544 |
+
chain_total=3,
|
| 545 |
+
inspect_reveal="PE firm confirmed. Series B investment round. Deal announced in press last week.",
|
| 546 |
+
docs_reveal="Signed SPA (Share Purchase Agreement) on file. Notarised. Receiver is the acquisition target.",
|
| 547 |
+
),
|
| 548 |
+
PayOpsTask(
|
| 549 |
+
task_id="CRIT-002",
|
| 550 |
+
difficulty="critical",
|
| 551 |
+
description=(
|
| 552 |
+
"Fraud ring: three related accounts sending coordinated small payments "
|
| 553 |
+
"that aggregate above the reporting threshold. Requires SAR filing."
|
| 554 |
+
),
|
| 555 |
+
transaction_id="TXN-C002",
|
| 556 |
+
amount=4_900.00,
|
| 557 |
+
currency="USD",
|
| 558 |
+
sender="ring_node_1@tempmail.com",
|
| 559 |
+
receiver="collector_hub@offshore.net",
|
| 560 |
+
transaction_type="transfer",
|
| 561 |
+
risk_score=0.76,
|
| 562 |
+
ml_confidence=0.80,
|
| 563 |
+
flags=["fraud_ring_indicator", "coordinated_transfers", "threshold_avoidance", "high_value"],
|
| 564 |
+
velocity_1h=5, velocity_24h=18,
|
| 565 |
+
avg_transaction_amount=4_800.0,
|
| 566 |
+
account_age_days=45,
|
| 567 |
+
country_risk="high", kyc_status="failed", kyc_expiry_days=None,
|
| 568 |
+
previous_violations=1, previous_sars=0, counterparty_risk="blacklist",
|
| 569 |
+
correct_action="reject",
|
| 570 |
+
partial_credit_actions={"escalate": 0.4, "flag": 0.2},
|
| 571 |
+
requires_investigation={"inspect"},
|
| 572 |
+
regulatory_action=True,
|
| 573 |
+
chain_total=2,
|
| 574 |
+
inspect_reveal=(
|
| 575 |
+
"3 accounts (ring_node_1/2/3) sending $4,900 / $4,850 / $4,750 simultaneously. "
|
| 576 |
+
"All created same day. Receiver account drained offshore within minutes. "
|
| 577 |
+
"SAR filing required under BSA §5318(g)."
|
| 578 |
+
),
|
| 579 |
+
),
|
| 580 |
+
PayOpsTask(
|
| 581 |
+
task_id="CRIT-003",
|
| 582 |
+
difficulty="critical",
|
| 583 |
+
description=(
|
| 584 |
+
"Trade-based money laundering: over- and under-invoiced international trade payments. "
|
| 585 |
+
"Documents don't match transfer amounts. Regulatory escalation required."
|
| 586 |
+
),
|
| 587 |
+
transaction_id="TXN-C003",
|
| 588 |
+
amount=680_000.00,
|
| 589 |
+
currency="USD",
|
| 590 |
+
sender="importer@trade-co.hk",
|
| 591 |
+
receiver="exporter@goods-supplier.cn",
|
| 592 |
+
transaction_type="payment",
|
| 593 |
+
risk_score=0.72,
|
| 594 |
+
ml_confidence=0.67,
|
| 595 |
+
flags=["trade_finance", "invoice_mismatch", "cross_border", "high_value"],
|
| 596 |
+
velocity_1h=1, velocity_24h=3,
|
| 597 |
+
avg_transaction_amount=120_000.0,
|
| 598 |
+
account_age_days=730,
|
| 599 |
+
country_risk="high", kyc_status="verified", kyc_expiry_days=30,
|
| 600 |
+
previous_violations=1, previous_sars=0, counterparty_risk="watchlist",
|
| 601 |
+
correct_action="escalate",
|
| 602 |
+
partial_credit_actions={"flag": 0.4, "hold": 0.35, "reject": 0.3},
|
| 603 |
+
requires_investigation={"request_docs", "inspect"},
|
| 604 |
+
regulatory_action=True,
|
| 605 |
+
chain_total=3,
|
| 606 |
+
docs_reveal="Invoice declares 500 units @ $1,360 each = $680k. Market price is $320/unit. 4× over-invoiced.",
|
| 607 |
+
inspect_reveal="Shipping records show only 200 units dispatched. Payment/goods ratio 3.4× above market norm.",
|
| 608 |
+
),
|
| 609 |
+
PayOpsTask(
|
| 610 |
+
task_id="CRIT-004",
|
| 611 |
+
difficulty="critical",
|
| 612 |
+
description=(
|
| 613 |
+
"Compromised corporate account: valid credentials but geo-impossible login. "
|
| 614 |
+
"Someone in Nigeria logged into a US-only account 8 minutes after the CEO logged out in NY."
|
| 615 |
+
),
|
| 616 |
+
transaction_id="TXN-C004",
|
| 617 |
+
amount=198_000.00,
|
| 618 |
+
currency="USD",
|
| 619 |
+
sender="ceo@target-corp.com",
|
| 620 |
+
receiver="urgent-wire@third-party-finance.com",
|
| 621 |
+
transaction_type="transfer",
|
| 622 |
+
risk_score=0.81,
|
| 623 |
+
ml_confidence=0.85,
|
| 624 |
+
flags=["geo_impossible_login", "account_takeover_indicator", "high_value", "urgency_flag"],
|
| 625 |
+
velocity_1h=1, velocity_24h=1,
|
| 626 |
+
avg_transaction_amount=15_000.0,
|
| 627 |
+
account_age_days=4_380,
|
| 628 |
+
country_risk="high", kyc_status="verified", kyc_expiry_days=365,
|
| 629 |
+
previous_violations=0, previous_sars=0, counterparty_risk="unknown",
|
| 630 |
+
correct_action="reject",
|
| 631 |
+
partial_credit_actions={"escalate": 0.5, "hold": 0.4},
|
| 632 |
+
requires_investigation={"inspect", "contact_sender"},
|
| 633 |
+
chain_total=2,
|
| 634 |
+
inspect_reveal=(
|
| 635 |
+
"Last login: NY (US) at 14:23. This session: Lagos (NG) at 14:31. "
|
| 636 |
+
"Physical travel impossible in 8 minutes. Likely credential compromise."
|
| 637 |
+
),
|
| 638 |
+
contact_reveal="CEO confirms they did NOT initiate this transfer. Account takeover confirmed.",
|
| 639 |
+
),
|
| 640 |
+
]
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
# =============================================================================
|
| 644 |
+
# Combine all tasks
|
| 645 |
+
# =============================================================================
|
| 646 |
+
|
| 647 |
+
TASKS: List[PayOpsTask] = EASY_TASKS + MEDIUM_TASKS + HARD_TASKS + CRITICAL_TASKS
|
| 648 |
+
|
| 649 |
+
TASKS_BY_ID: Dict[str, PayOpsTask] = {t.task_id: t for t in TASKS}
|
| 650 |
+
|
| 651 |
+
# ---------------------------------------------------------------------------
|
| 652 |
+
# Action cost table (investigation sub-actions consume budget)
|
| 653 |
+
# ---------------------------------------------------------------------------
|
| 654 |
+
|
| 655 |
+
ACTION_COSTS: Dict[str, float] = {
|
| 656 |
+
"approve": 0.0,
|
| 657 |
+
"reject": 0.0,
|
| 658 |
+
"flag": 0.0,
|
| 659 |
+
"escalate": 0.0,
|
| 660 |
+
"hold": 0.0,
|
| 661 |
+
"inspect": 0.10,
|
| 662 |
+
"request_docs": 0.20,
|
| 663 |
+
"verify_kyc": 0.20,
|
| 664 |
+
"contact_sender": 0.30,
|
| 665 |
+
"file_sar": 0.05, # intentionally cheap — incentivise regulatory compliance
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
|