Spaces:
Sleeping
Sleeping
Sourav commited on
Commit Β·
a9eec0e
1
Parent(s): 30e549f
Deploy Crime Investigation app
Browse files- .dockerignore +17 -0
- .gitignore +15 -0
- Crime_Investigation +1 -0
- Dockerfile +16 -0
- README.md +119 -6
- check_space_endpoints.sh +31 -0
- crime_env/__init__.py +25 -0
- crime_env/agent_prompts.py +222 -0
- crime_env/case_generator.py +420 -0
- crime_env/consistency_tracker.py +340 -0
- crime_env/constants.py +23 -0
- crime_env/environment.py +922 -0
- crime_env/reward_calculator.py +183 -0
- dashboard.html +1404 -0
- hf_mini_blog_draft.md +57 -0
- requirements-train.txt +9 -0
- requirements.txt +4 -0
- run_hf_smoke_test.sh +14 -0
- server/__init__.py +0 -0
- server/app.py +289 -0
- test.zip +0 -0
- test_one_episode.py +473 -0
- train_colab.ipynb +165 -0
- train_colab.py +594 -0
.dockerignore
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
*.egg-info/
|
| 5 |
+
dist/
|
| 6 |
+
build/
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
*.log
|
| 10 |
+
server.log
|
| 11 |
+
.env
|
| 12 |
+
.DS_Store
|
| 13 |
+
*.swp
|
| 14 |
+
*.swo
|
| 15 |
+
*.png
|
| 16 |
+
rewards.json
|
| 17 |
+
.git/
|
.gitignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
*.egg-info/
|
| 5 |
+
dist/
|
| 6 |
+
build/
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
*.log
|
| 10 |
+
server.log
|
| 11 |
+
.env
|
| 12 |
+
.DS_Store
|
| 13 |
+
*.swp
|
| 14 |
+
*.swo
|
| 15 |
+
rewards.json
|
Crime_Investigation
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Subproject commit 30e549f79737185535a5b07d83d0e78346116605
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Copy requirements and install
|
| 6 |
+
COPY requirements.txt .
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
|
| 9 |
+
# Copy all project files
|
| 10 |
+
COPY . .
|
| 11 |
+
|
| 12 |
+
# Expose port
|
| 13 |
+
EXPOSE 8000
|
| 14 |
+
|
| 15 |
+
# Run the server
|
| 16 |
+
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
CHANGED
|
@@ -1,10 +1,123 @@
|
|
| 1 |
---
|
| 2 |
-
title: Crime Investigation
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: AI Crime Investigation World
|
| 3 |
+
emoji: π
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 8000
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# AI Crime Investigation World π΅οΈ
|
| 11 |
+
|
| 12 |
+
A **multi-agent reinforcement learning environment** where an AI detective interrogates suspects and a witness, reviews physical evidence, detects contradictions, and makes an accusation β all within a 15-turn episode.
|
| 13 |
+
|
| 14 |
+
Built on the [OpenEnv](https://github.com/ScalerAI/openenv) framework for standardized RL environment evaluation.
|
| 15 |
+
|
| 16 |
+
## π― Hackathon Theme
|
| 17 |
+
|
| 18 |
+
**Theme #1: Multi-Agent Interactions** β This environment demonstrates how multiple AI agents with conflicting objectives (detective vs. guilty suspect vs. innocent suspect vs. biased witness) interact in a structured investigation scenario. Each agent has its own reward signal, private knowledge, and behavioral incentives.
|
| 19 |
+
|
| 20 |
+
**Bonus Sub-theme Fit (Halluminate)** β The detective acts as a coordinator that orchestrates multiple actors (two suspects + one witness + evidence system) to discover the true culprit under uncertainty. The environment explicitly rewards cross-agent synthesis (contradiction exposure + evidence-backed accusation), matching Halluminate's multi-actor task-achievement objective.
|
| 21 |
+
|
| 22 |
+
## Quick Start
|
| 23 |
+
|
| 24 |
+
```bash
|
| 25 |
+
# Install dependencies
|
| 26 |
+
pip install -r requirements.txt
|
| 27 |
+
|
| 28 |
+
# Run the server with interactive dashboard
|
| 29 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000
|
| 30 |
+
|
| 31 |
+
# Open http://localhost:8000 in your browser
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
## Project Structure
|
| 35 |
+
|
| 36 |
+
```
|
| 37 |
+
crime_env/ # Core environment package
|
| 38 |
+
βββ case_generator.py # Randomized crime scenario generation
|
| 39 |
+
βββ environment.py # Step/reset/render RL interface
|
| 40 |
+
βββ agent_prompts.py # Role-specific system prompts
|
| 41 |
+
βββ consistency_tracker.py # Semantic contradiction detection
|
| 42 |
+
βββ reward_calculator.py # Multi-agent reward function
|
| 43 |
+
server/
|
| 44 |
+
βββ app.py # OpenEnv-compatible FastAPI server
|
| 45 |
+
dashboard.html # Interactive investigation dashboard
|
| 46 |
+
train_colab.py # PPO training script (Google Colab)
|
| 47 |
+
test_one_episode.py # End-to-end test with scripted agents
|
| 48 |
+
Dockerfile # HuggingFace Spaces deployment
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
## Features
|
| 52 |
+
|
| 53 |
+
- **Randomized Cases**: Criminal identity, evidence, alibis, and witness bias are randomized each episode
|
| 54 |
+
- **Semantic Contradiction Detection**: NLP-based consistency tracking catches real contradictions while ignoring paraphrasing
|
| 55 |
+
- **Multi-Agent Rewards**: Separate reward signals for detective (+17 correct / -8 wrong), suspects, and witness
|
| 56 |
+
- **Evidence System**: Three evidence types (keycard, CCTV, forensic) with type-constrained templates and duplicate-request protection
|
| 57 |
+
- **Witness Bias**: Configurable bias that penalizes false implications of innocent suspects only
|
| 58 |
+
- **Prior History**: Criminal-biased prior records (more convictions, lower trust) for realistic detective briefings
|
| 59 |
+
- **Interactive Dashboard**: Real-time visualization of interrogations, evidence, contradictions, and reward curves
|
| 60 |
+
|
| 61 |
+
## Training (Google Colab)
|
| 62 |
+
|
| 63 |
+
Use [train_colab.ipynb](train_colab.ipynb) for a judge-friendly Colab workflow (install, train, plot rewards, inspect transcripts). The script uses PPO (TRL) with:
|
| 64 |
+
- 4-bit quantization for free-tier T4 GPU
|
| 65 |
+
- Decoupled frozen base model for NPC calls to prevent representation drift
|
| 66 |
+
- Per-episode reward tracking with smoothed training curves
|
| 67 |
+
- Milestone transcript capture for before/after behavior demos (`episode_transcripts.json`)
|
| 68 |
+
|
| 69 |
+
```python
|
| 70 |
+
# In Colab, after uploading files:
|
| 71 |
+
!pip install -r requirements-train.txt
|
| 72 |
+
!python train_colab.py
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
Quick smoke test command:
|
| 76 |
+
|
| 77 |
+
```bash
|
| 78 |
+
./run_hf_smoke_test.sh
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
Artifacts generated after training:
|
| 82 |
+
|
| 83 |
+
- `rewards.json` (raw reward/label history)
|
| 84 |
+
- `reward_curve.png` (curve image)
|
| 85 |
+
- `episode_transcripts.json` (episodes 1/25/50 by default)
|
| 86 |
+
|
| 87 |
+
## API Endpoints
|
| 88 |
+
|
| 89 |
+
| Endpoint | Method | Description |
|
| 90 |
+
|----------|--------|-------------|
|
| 91 |
+
| `/` | GET | Interactive investigation dashboard |
|
| 92 |
+
| `/reset` | POST | Start a new episode (OpenEnv) |
|
| 93 |
+
| `/step` | POST | Execute a detective action (OpenEnv) |
|
| 94 |
+
| `/api/run_episode` | GET | Run full scripted episode, returns JSON trace |
|
| 95 |
+
| `/api/reward_curve` | GET | Reward history + smoothed metrics + optional PNG data URL |
|
| 96 |
+
| `/api/health` | GET | Deployment health check for Space validation |
|
| 97 |
+
|
| 98 |
+
## Reward Logic (Detective)
|
| 99 |
+
|
| 100 |
+
Judging requires coherent reward shaping. The detective reward is event-based:
|
| 101 |
+
|
| 102 |
+
| Event | Delta |
|
| 103 |
+
|---|---:|
|
| 104 |
+
| Correct accusation | +10.0 |
|
| 105 |
+
| Wrong accusation | -8.0 (up to -10.0 with active bias penalty) |
|
| 106 |
+
| Timeout (no accusation) | -3.0 |
|
| 107 |
+
| Contradiction exposed | +2.0 |
|
| 108 |
+
| Prior-pattern exploited | +1.5 |
|
| 109 |
+
| Evidence request confirms lead | +1.0 |
|
| 110 |
+
| Deflection resistance | +0.5 |
|
| 111 |
+
| Redundant question | -0.5 |
|
| 112 |
+
| Per-turn cost | -0.3 each turn |
|
| 113 |
+
|
| 114 |
+
This combination encourages strategic questioning, contradiction resolution, and evidence-backed accusations rather than random early accusations.
|
| 115 |
+
|
| 116 |
+
## Known Limitations
|
| 117 |
+
|
| 118 |
+
- **Sparse terminal supervision**: Even with per-step PPO rewards, the strongest signal is still the terminal accusation outcome, so exploration quality matters a lot early in training.
|
| 119 |
+
- **Rule-based NPC fallback**: The `_default_llm_call` uses string parsing to identify agent roles. When using the full LLM pipeline this is bypassed.
|
| 120 |
+
|
| 121 |
+
## License
|
| 122 |
+
|
| 123 |
+
MIT
|
check_space_endpoints.sh
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
if [[ $# -lt 1 ]]; then
|
| 5 |
+
echo "Usage: $0 <space_base_url>"
|
| 6 |
+
echo "Example: $0 https://your-space-name.hf.space"
|
| 7 |
+
exit 1
|
| 8 |
+
fi
|
| 9 |
+
|
| 10 |
+
BASE_URL="${1%/}"
|
| 11 |
+
|
| 12 |
+
echo "Checking health endpoint..."
|
| 13 |
+
curl -fsS "$BASE_URL/api/health" | cat
|
| 14 |
+
|
| 15 |
+
echo
|
| 16 |
+
echo "Checking reward curve endpoint..."
|
| 17 |
+
curl -fsS "$BASE_URL/api/reward_curve" | head -c 500 | cat
|
| 18 |
+
echo
|
| 19 |
+
|
| 20 |
+
echo "Checking OpenEnv reset endpoint..."
|
| 21 |
+
curl -fsS -X POST "$BASE_URL/reset" -H "Content-Type: application/json" -d '{}' | head -c 500 | cat
|
| 22 |
+
echo
|
| 23 |
+
|
| 24 |
+
echo "Checking OpenEnv step endpoint..."
|
| 25 |
+
curl -fsS -X POST "$BASE_URL/step" \
|
| 26 |
+
-H "Content-Type: application/json" \
|
| 27 |
+
-d '{"action_string":"ACTION: ask_question | TARGET: Suspect_A | CONTENT: Where were you at the time of the crime?"}' \
|
| 28 |
+
| head -c 500 | cat
|
| 29 |
+
echo
|
| 30 |
+
|
| 31 |
+
echo "All endpoint checks completed."
|
crime_env/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AI Crime Investigation World β Multi-Agent RL Environment."""
|
| 2 |
+
|
| 3 |
+
from crime_env.case_generator import generate_case
|
| 4 |
+
from crime_env.consistency_tracker import ConsistencyTracker
|
| 5 |
+
from crime_env.reward_calculator import RewardCalculator
|
| 6 |
+
from crime_env.agent_prompts import build_system_prompt
|
| 7 |
+
from crime_env.environment import CrimeInvestigationEnv
|
| 8 |
+
from crime_env.constants import (
|
| 9 |
+
SUSPECT_A,
|
| 10 |
+
SUSPECT_B,
|
| 11 |
+
WITNESS_1,
|
| 12 |
+
VALID_TARGETS,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
__all__ = [
|
| 16 |
+
"generate_case",
|
| 17 |
+
"ConsistencyTracker",
|
| 18 |
+
"RewardCalculator",
|
| 19 |
+
"build_system_prompt",
|
| 20 |
+
"CrimeInvestigationEnv",
|
| 21 |
+
"SUSPECT_A",
|
| 22 |
+
"SUSPECT_B",
|
| 23 |
+
"WITNESS_1",
|
| 24 |
+
"VALID_TARGETS",
|
| 25 |
+
]
|
crime_env/agent_prompts.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Component 4 β Agent System Prompts.
|
| 2 |
+
|
| 3 |
+
Builds role-specific system prompts for the detective, suspects, and witness.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from crime_env.constants import (
|
| 9 |
+
AGENT_NAME_TO_KEY,
|
| 10 |
+
SUSPECT_A,
|
| 11 |
+
SUSPECT_B,
|
| 12 |
+
WITNESS_1_KEY,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def build_system_prompt(role: str, case: dict) -> str:
|
| 17 |
+
"""Build a system prompt for the given role based on the case data.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
role: One of "detective", "suspect_a", "suspect_b", "witness".
|
| 21 |
+
case: The full case dictionary from generate_case().
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
A system prompt string.
|
| 25 |
+
"""
|
| 26 |
+
builders = {
|
| 27 |
+
"detective": _build_detective_prompt,
|
| 28 |
+
"suspect_a": _build_suspect_a_prompt,
|
| 29 |
+
"suspect_b": _build_suspect_b_prompt,
|
| 30 |
+
"witness": _build_witness_prompt,
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
builder = builders.get(role.lower())
|
| 34 |
+
if builder is None:
|
| 35 |
+
raise ValueError(
|
| 36 |
+
f"Unknown role: {role}. Must be one of: {list(builders.keys())}"
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
return builder(case)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ββ Detective βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _build_detective_prompt(case: dict) -> str:
|
| 46 |
+
briefing = case["detective_briefing"]
|
| 47 |
+
|
| 48 |
+
return f"""You are an experienced detective investigating a case of {case['crime']}.
|
| 49 |
+
|
| 50 |
+
BACKGROUND BRIEFING:
|
| 51 |
+
{briefing}
|
| 52 |
+
|
| 53 |
+
YOUR OBJECTIVE:
|
| 54 |
+
Interrogate the suspects and witness to determine who committed the crime. You must make an accusation within 15 turns.
|
| 55 |
+
|
| 56 |
+
AVAILABLE ACTIONS (use EXACTLY this format β one action per turn):
|
| 57 |
+
|
| 58 |
+
1. Ask a question to a suspect or witness:
|
| 59 |
+
ACTION: ask_question | TARGET: <agent_name> | CONTENT: <your question>
|
| 60 |
+
|
| 61 |
+
Valid targets: Suspect_A, Suspect_B, Witness_1
|
| 62 |
+
|
| 63 |
+
2. Request physical evidence:
|
| 64 |
+
ACTION: request_evidence | ITEM: <evidence_name>
|
| 65 |
+
|
| 66 |
+
Available evidence items: keycard_log, cctv_footage, forensic_report
|
| 67 |
+
|
| 68 |
+
3. Make your final accusation:
|
| 69 |
+
ACTION: accuse | TARGET: <suspect_name>
|
| 70 |
+
|
| 71 |
+
Valid targets: Suspect_A, Suspect_B
|
| 72 |
+
|
| 73 |
+
RULES:
|
| 74 |
+
- You MUST accuse within 15 turns or the case goes cold.
|
| 75 |
+
- Each turn costs you time. Be strategic with your questions.
|
| 76 |
+
- Look for contradictions in suspects' statements.
|
| 77 |
+
- Cross-reference statements with evidence and prior history.
|
| 78 |
+
- Pay attention to documented behavioral patterns.
|
| 79 |
+
- Output EXACTLY ONE action per turn using the format above.
|
| 80 |
+
"""
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ββ Suspect A (Criminal) βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _build_suspect_a_prompt(case: dict) -> str:
|
| 87 |
+
criminal = case["criminal"]
|
| 88 |
+
if criminal == SUSPECT_A:
|
| 89 |
+
return _build_guilty_suspect_prompt(case, SUSPECT_A)
|
| 90 |
+
else:
|
| 91 |
+
return _build_innocent_suspect_prompt(case, SUSPECT_A)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ββ Suspect B (Innocent) βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _build_suspect_b_prompt(case: dict) -> str:
|
| 98 |
+
criminal = case["criminal"]
|
| 99 |
+
if criminal == SUSPECT_B:
|
| 100 |
+
return _build_guilty_suspect_prompt(case, SUSPECT_B)
|
| 101 |
+
else:
|
| 102 |
+
return _build_innocent_suspect_prompt(case, SUSPECT_B)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ββ Shared guilty/innocent prompt builders βββββββββββββββββββββββββββββββββββββββββββ
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _build_guilty_suspect_prompt(case: dict, suspect_name: str) -> str:
|
| 109 |
+
"""Build the prompt for whichever suspect is the criminal."""
|
| 110 |
+
knowledge = case["agent_knowledge"][AGENT_NAME_TO_KEY[suspect_name]]
|
| 111 |
+
prior = case["prior_history"].get(suspect_name, {})
|
| 112 |
+
other = SUSPECT_B if suspect_name == SUSPECT_A else SUSPECT_A
|
| 113 |
+
|
| 114 |
+
cctv_info = (
|
| 115 |
+
"You know that CCTV cameras were active during the crime."
|
| 116 |
+
if knowledge.get("knows_cctv_status")
|
| 117 |
+
else "You are unsure whether CCTV cameras were recording."
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
keycard_info = (
|
| 121 |
+
"You are aware that keycard entry logs exist and could implicate you."
|
| 122 |
+
if knowledge.get("knows_keycard_log_exists")
|
| 123 |
+
else "You do not know whether keycard logs are being checked."
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
behavioral = prior.get("behavioral_pattern", "no known pattern")
|
| 127 |
+
|
| 128 |
+
motive = case.get("motive", "unknown reasons")
|
| 129 |
+
|
| 130 |
+
return f"""You are {suspect_name} in a criminal investigation for {case['crime']}.
|
| 131 |
+
|
| 132 |
+
THE TRUTH: You committed this crime. You must NOT confess.
|
| 133 |
+
YOUR MOTIVE: {motive}
|
| 134 |
+
|
| 135 |
+
YOUR FAKE ALIBI: {knowledge['fake_alibi']}
|
| 136 |
+
|
| 137 |
+
WHAT YOU KNOW:
|
| 138 |
+
- {cctv_info}
|
| 139 |
+
- {keycard_info}
|
| 140 |
+
- The crime occurred at {case['time']} in the {case['location']}.
|
| 141 |
+
- You used the method: {case['method']}.
|
| 142 |
+
|
| 143 |
+
YOUR BEHAVIORAL TENDENCIES (stay consistent with these):
|
| 144 |
+
- Pattern: {behavioral}
|
| 145 |
+
|
| 146 |
+
INSTRUCTIONS:
|
| 147 |
+
- Respond to the detective's questions in plain natural language.
|
| 148 |
+
- Maintain your fake alibi consistently across all answers.
|
| 149 |
+
- Stay consistent with your prior statements β contradicting yourself will look suspicious.
|
| 150 |
+
- You may try to subtly deflect suspicion toward {other}, but do not be obvious.
|
| 151 |
+
- Do NOT reveal that you committed the crime under any circumstances.
|
| 152 |
+
- Do NOT use any special action format β just respond naturally.
|
| 153 |
+
"""
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _build_innocent_suspect_prompt(case: dict, suspect_name: str) -> str:
|
| 157 |
+
"""Build the prompt for whichever suspect is innocent."""
|
| 158 |
+
knowledge = case["agent_knowledge"][AGENT_NAME_TO_KEY[suspect_name]]
|
| 159 |
+
prior = case["prior_history"].get(suspect_name, {})
|
| 160 |
+
|
| 161 |
+
knows_other = (
|
| 162 |
+
"You know the other suspect personally and have interacted with them before."
|
| 163 |
+
if knowledge.get("knows_suspect_A") or knowledge.get("knows_suspect_B")
|
| 164 |
+
else "You do not know the other suspect personally."
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
behavioral = prior.get("behavioral_pattern", "no known pattern")
|
| 168 |
+
|
| 169 |
+
return f"""You are {suspect_name} in a criminal investigation for {case['crime']}.
|
| 170 |
+
|
| 171 |
+
THE TRUTH: You are innocent. You did NOT commit this crime.
|
| 172 |
+
|
| 173 |
+
YOUR REAL ALIBI (this is a fact β repeat it consistently):
|
| 174 |
+
"{knowledge['real_alibi']}"
|
| 175 |
+
|
| 176 |
+
WHAT YOU KNOW:
|
| 177 |
+
- {knows_other}
|
| 178 |
+
- You have no direct knowledge of how the crime was committed.
|
| 179 |
+
|
| 180 |
+
YOUR BEHAVIORAL TENDENCIES:
|
| 181 |
+
- Pattern: {behavioral}
|
| 182 |
+
|
| 183 |
+
INSTRUCTIONS:
|
| 184 |
+
- Respond to the detective's questions in plain natural language.
|
| 185 |
+
- Tell the truth about your whereabouts. Your alibi above is specific and verifiable β always reference it when asked about your whereabouts.
|
| 186 |
+
- Stay consistent with your stated alibi throughout the interrogation. NEVER change the details of your alibi.
|
| 187 |
+
- You are nervous because you are a suspect, but you are telling the truth.
|
| 188 |
+
- Do NOT use any special action format β just respond naturally.
|
| 189 |
+
"""
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# ββ Witness βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _build_witness_prompt(case: dict) -> str:
|
| 196 |
+
witness_knowledge = case["agent_knowledge"][WITNESS_1_KEY]
|
| 197 |
+
|
| 198 |
+
bias_section = ""
|
| 199 |
+
if witness_knowledge.get("bias_target"):
|
| 200 |
+
target = witness_knowledge["bias_target"]
|
| 201 |
+
bias_section = (
|
| 202 |
+
f"\nYou have a negative impression of {target} based on past "
|
| 203 |
+
f"interactions. This colors your perception but you should still "
|
| 204 |
+
f"try to be honest about what you actually saw."
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
return f"""You are Witness_1 in a criminal investigation for {case['crime']}.
|
| 208 |
+
|
| 209 |
+
WHAT YOU SAW (this is your primary testimony β always lead with this):
|
| 210 |
+
You saw {witness_knowledge['saw']}.
|
| 211 |
+
|
| 212 |
+
TIME OF YOUR OBSERVATION: {witness_knowledge['time_seen']}
|
| 213 |
+
{bias_section}
|
| 214 |
+
|
| 215 |
+
INSTRUCTIONS:
|
| 216 |
+
- When asked what you saw, you MUST describe the specific details above: the clothing, location, and time.
|
| 217 |
+
- Respond to the detective's questions in plain natural language.
|
| 218 |
+
- Stay consistent with your previous statements.
|
| 219 |
+
- You may be uncertain about some details β it is okay to express uncertainty about things NOT listed above.
|
| 220 |
+
- Do NOT speculate beyond what you actually observed unless asked for your opinion.
|
| 221 |
+
- Do NOT use any special action format β just respond naturally.
|
| 222 |
+
"""
|
crime_env/case_generator.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Component 1 β Case Generator.
|
| 2 |
+
|
| 3 |
+
Generates randomized crime scenarios for the AI Crime Investigation World.
|
| 4 |
+
Criminal is randomly assigned to either Suspect_A or Suspect_B each episode.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import random
|
| 8 |
+
|
| 9 |
+
from crime_env.constants import (
|
| 10 |
+
SUSPECTS,
|
| 11 |
+
SUSPECT_A,
|
| 12 |
+
SUSPECT_B,
|
| 13 |
+
SUSPECT_A_KEY,
|
| 14 |
+
SUSPECT_B_KEY,
|
| 15 |
+
WITNESS_1,
|
| 16 |
+
WITNESS_1_KEY,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ββ Pools for random sampling βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 21 |
+
|
| 22 |
+
CRIMES = [
|
| 23 |
+
"art theft",
|
| 24 |
+
"corporate fraud",
|
| 25 |
+
"warehouse robbery",
|
| 26 |
+
"arson",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
LOCATIONS = [
|
| 30 |
+
"east wing",
|
| 31 |
+
"server room",
|
| 32 |
+
"vault",
|
| 33 |
+
"parking lot",
|
| 34 |
+
"basement archive",
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
TIMES = [
|
| 38 |
+
"8:45 PM",
|
| 39 |
+
"9:15 PM",
|
| 40 |
+
"10:00 PM",
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
METHODS = [
|
| 44 |
+
"lock picking with custom tools",
|
| 45 |
+
"insider access using stolen credentials",
|
| 46 |
+
"brute-force entry through side door",
|
| 47 |
+
"social engineering the night guard",
|
| 48 |
+
"disabling the alarm system remotely",
|
| 49 |
+
"cloning a supervisor's keycard",
|
| 50 |
+
"crawling through the ventilation shaft",
|
| 51 |
+
"bribing a maintenance worker for access",
|
| 52 |
+
"exploiting a gap in the security patrol rotation",
|
| 53 |
+
"cutting the perimeter fence near the north wall",
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
CLOTHING_OPTIONS = [
|
| 57 |
+
"dark hoodie and jeans",
|
| 58 |
+
"grey trench coat",
|
| 59 |
+
"black leather jacket",
|
| 60 |
+
"navy blue overalls",
|
| 61 |
+
"security guard uniform",
|
| 62 |
+
"dark windbreaker and cargo pants",
|
| 63 |
+
"green army surplus jacket",
|
| 64 |
+
"plain black tracksuit",
|
| 65 |
+
"brown corduroy jacket and khakis",
|
| 66 |
+
"dark bomber jacket",
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
MOTIVES = [
|
| 70 |
+
"financial pressure",
|
| 71 |
+
"personal grudge",
|
| 72 |
+
"professional rivalry",
|
| 73 |
+
"jealousy",
|
| 74 |
+
"revenge",
|
| 75 |
+
"desperation",
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
# ββ Type-constrained evidence templates βββββββββββββββββββββββββββββββββββββ
|
| 79 |
+
# Each evidence type has its own pool to prevent mismatches.
|
| 80 |
+
|
| 81 |
+
KEYCARD_LOG_TEMPLATES = [
|
| 82 |
+
{
|
| 83 |
+
"description": "Keycard access log shows {suspect}'s badge was used at the {location} door at {time}",
|
| 84 |
+
"points_to": "{suspect}",
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"description": "Keycard system recorded an unauthorized swipe at {time} β badge ID unregistered",
|
| 88 |
+
"points_to": "unknown",
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"description": "Keycard records show two swipes at the {location} within 3 minutes of each other at {time} β one belonged to {suspect}",
|
| 92 |
+
"points_to": "{suspect}",
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"description": "Keycard system was offline for several hours due to a power glitch β no access logs available for the {location} until after {time}",
|
| 96 |
+
"points_to": "unknown",
|
| 97 |
+
},
|
| 98 |
+
]
|
| 99 |
+
|
| 100 |
+
CCTV_FOOTAGE_TEMPLATES = [
|
| 101 |
+
{
|
| 102 |
+
"description": "CCTV footage shows a figure resembling {suspect} entering the {location} at {time}",
|
| 103 |
+
"points_to": "{suspect}",
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"description": "CCTV footage captured a person in dark clothing near the {location} at {time} β face obscured",
|
| 107 |
+
"points_to": "unknown",
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"description": "CCTV camera at {location} was disabled until shortly before {time} β no footage available for the relevant window",
|
| 111 |
+
"points_to": "unknown",
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"description": "CCTV from the adjacent hallway shows {suspect} walking briskly toward the {location} at {time}",
|
| 115 |
+
"points_to": "{suspect}",
|
| 116 |
+
},
|
| 117 |
+
]
|
| 118 |
+
|
| 119 |
+
FORENSIC_REPORT_TEMPLATES = [
|
| 120 |
+
{
|
| 121 |
+
"description": "Forensic report: fingerprints matching {suspect} found on the tool bag recovered at the scene",
|
| 122 |
+
"points_to": "{suspect}",
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
"description": "Forensic report: torn fabric consistent with a grey trench coat found near the {location}",
|
| 126 |
+
"points_to": "unknown",
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"description": "Forensic report: DNA trace on a dropped glove near the {location} matches {suspect}",
|
| 130 |
+
"points_to": "{suspect}",
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
"description": "Forensic report: muddy boot prints (size 10) found at the scene β owner not yet identified",
|
| 134 |
+
"points_to": "unknown",
|
| 135 |
+
},
|
| 136 |
+
]
|
| 137 |
+
|
| 138 |
+
# ββ Specific alibi pools (no overlap between fake and real) βββββββββββββββββ
|
| 139 |
+
|
| 140 |
+
FAKE_ALIBI_POOL = [
|
| 141 |
+
"I was at a family dinner at my uncle's house on Oak Street from 7 PM to 11 PM",
|
| 142 |
+
"I was home alone watching a movie all evening β I didn't leave the house",
|
| 143 |
+
"I was volunteering at the Riverside Community Centre until about 10:30 PM",
|
| 144 |
+
"I was at my cousin's birthday party across town β there were at least 20 people there",
|
| 145 |
+
"I was at the public library studying until they closed at 9 PM, then I walked home",
|
| 146 |
+
"I was helping a friend move furniture into their new apartment on Elm Avenue",
|
| 147 |
+
]
|
| 148 |
+
|
| 149 |
+
REAL_ALIBI_POOL = [
|
| 150 |
+
"I was working late at the Morgan & Associates office until 10 PM β my manager Sarah Chen can confirm, and I paid for takeout at 8:50 PM with my debit card",
|
| 151 |
+
"I was at O'Brien's Bar on 5th Street from 7 PM to 11 PM β I paid my tab by card at 10:45 PM and the bartender knows me by name",
|
| 152 |
+
"I was at the Cineplex on Main Street watching a 7:30 PM showing β I still have my ticket stub and the cashier can verify",
|
| 153 |
+
"I was at the downtown gym from 6:30 PM to 9:30 PM β I scanned my membership card on entry and exit, which is logged electronically",
|
| 154 |
+
"I was attending a night class at the community college from 7 PM to 9:30 PM β the instructor marked attendance electronically",
|
| 155 |
+
"I was at St. Mary's Hospital visiting my grandmother from 6 PM to 9 PM β the visitor log has my name and sign-in time",
|
| 156 |
+
]
|
| 157 |
+
|
| 158 |
+
BEHAVIORAL_PATTERNS = [
|
| 159 |
+
"uses family alibis",
|
| 160 |
+
"deflects blame to associates",
|
| 161 |
+
"becomes aggressive under pressure",
|
| 162 |
+
"provides overly detailed timelines",
|
| 163 |
+
"claims memory loss for key periods",
|
| 164 |
+
"feigns cooperation while withholding details",
|
| 165 |
+
]
|
| 166 |
+
|
| 167 |
+
PAST_CRIME_POOL = [
|
| 168 |
+
"petty theft",
|
| 169 |
+
"fraud",
|
| 170 |
+
"trespassing",
|
| 171 |
+
"embezzlement",
|
| 172 |
+
"forgery",
|
| 173 |
+
"breaking and entering",
|
| 174 |
+
]
|
| 175 |
+
|
| 176 |
+
LIE_TOPICS = ["location", "alibi", "knows_associate", "was_at_scene", "time"]
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _random_past_lies(count: int) -> list[dict]:
|
| 183 |
+
"""Generate a list of documented past lies."""
|
| 184 |
+
lies = []
|
| 185 |
+
for _ in range(count):
|
| 186 |
+
topic = random.choice(LIE_TOPICS)
|
| 187 |
+
lies.append(
|
| 188 |
+
{
|
| 189 |
+
"topic": topic,
|
| 190 |
+
"claimed": _fake_claim(topic),
|
| 191 |
+
"disproved_by": _disprove_source(topic),
|
| 192 |
+
}
|
| 193 |
+
)
|
| 194 |
+
return lies
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _fake_claim(topic: str) -> str:
|
| 198 |
+
claims = {
|
| 199 |
+
"location": "claimed to be at home",
|
| 200 |
+
"knows_associate": "denied knowing the accomplice",
|
| 201 |
+
"was_at_scene": "denied being near the scene",
|
| 202 |
+
"time": "claimed to be elsewhere during the incident window",
|
| 203 |
+
"alibi": "said they were with a friend who later denied it",
|
| 204 |
+
}
|
| 205 |
+
return claims.get(topic, "made an unverifiable statement")
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _disprove_source(topic: str) -> str:
|
| 209 |
+
sources = {
|
| 210 |
+
"location": "GPS records showed otherwise",
|
| 211 |
+
"knows_associate": "phone records confirmed contact",
|
| 212 |
+
"was_at_scene": "camera logs confirmed presence",
|
| 213 |
+
"time": "timestamped records contradicted the timeline",
|
| 214 |
+
"alibi": "friend's testimony contradicted the claim",
|
| 215 |
+
}
|
| 216 |
+
return sources.get(topic, "documentary evidence")
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _build_evidence(criminal: str, location: str, time: str) -> list[dict]:
|
| 220 |
+
"""Build 3 pieces of physical evidence β one per type.
|
| 221 |
+
|
| 222 |
+
Index 0 β keycard_log
|
| 223 |
+
Index 1 β cctv_footage
|
| 224 |
+
Index 2 β forensic_report
|
| 225 |
+
|
| 226 |
+
Each type draws from its own template pool, ensuring descriptions
|
| 227 |
+
always match the evidence type name.
|
| 228 |
+
"""
|
| 229 |
+
other_suspect = SUSPECT_B if criminal == SUSPECT_A else SUSPECT_A
|
| 230 |
+
|
| 231 |
+
evidence_types = [
|
| 232 |
+
("keycard_log", KEYCARD_LOG_TEMPLATES),
|
| 233 |
+
("cctv_footage", CCTV_FOOTAGE_TEMPLATES),
|
| 234 |
+
("forensic_report", FORENSIC_REPORT_TEMPLATES),
|
| 235 |
+
]
|
| 236 |
+
|
| 237 |
+
evidence = []
|
| 238 |
+
|
| 239 |
+
for ev_name, pool in evidence_types:
|
| 240 |
+
template = random.choice(pool)
|
| 241 |
+
points_to_raw = template["points_to"]
|
| 242 |
+
|
| 243 |
+
if points_to_raw == "{suspect}":
|
| 244 |
+
# Bias toward implicating the actual criminal
|
| 245 |
+
suspect = random.choices(
|
| 246 |
+
[criminal, other_suspect],
|
| 247 |
+
weights=[0.7, 0.3],
|
| 248 |
+
)[0]
|
| 249 |
+
else:
|
| 250 |
+
suspect = None
|
| 251 |
+
|
| 252 |
+
desc = template["description"].format(
|
| 253 |
+
suspect=suspect or "", location=location, time=time
|
| 254 |
+
)
|
| 255 |
+
points_to = suspect if suspect else "unknown"
|
| 256 |
+
|
| 257 |
+
evidence.append(
|
| 258 |
+
{
|
| 259 |
+
"name": ev_name,
|
| 260 |
+
"description": desc.strip(),
|
| 261 |
+
"points_to": points_to,
|
| 262 |
+
"visible_to_detective": False,
|
| 263 |
+
}
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
return evidence
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _build_prior_history(criminal: str = SUSPECT_A) -> dict:
|
| 270 |
+
"""Build prior history for both suspects.
|
| 271 |
+
|
| 272 |
+
The criminal is biased toward having a more checkered past
|
| 273 |
+
(more prior crimes, lower trust), which improves realism.
|
| 274 |
+
"""
|
| 275 |
+
history = {}
|
| 276 |
+
for suspect in SUSPECTS:
|
| 277 |
+
is_criminal = suspect == criminal
|
| 278 |
+
# Criminal: 1-2 priors, low trust; Innocent: 0-1, higher trust
|
| 279 |
+
n_crimes = random.randint(1, 2) if is_criminal else random.randint(0, 1)
|
| 280 |
+
n_lies = random.randint(1, 2) if is_criminal else random.randint(0, 1)
|
| 281 |
+
on_parole = random.choices([True, False], weights=[0.8, 0.2])[0] if is_criminal else random.choice([True, False])
|
| 282 |
+
trust = round(random.uniform(0.0, 0.4), 2) if is_criminal else round(random.uniform(0.4, 1.0), 2)
|
| 283 |
+
|
| 284 |
+
history[suspect] = {
|
| 285 |
+
"previous_crimes": random.sample(
|
| 286 |
+
PAST_CRIME_POOL, k=min(n_crimes, len(PAST_CRIME_POOL))
|
| 287 |
+
),
|
| 288 |
+
"past_lies_on_record": _random_past_lies(n_lies),
|
| 289 |
+
"behavioral_pattern": random.choice(BEHAVIORAL_PATTERNS),
|
| 290 |
+
"on_parole": on_parole,
|
| 291 |
+
"parole_condition": (
|
| 292 |
+
random.choice(
|
| 293 |
+
[
|
| 294 |
+
"must not leave the city",
|
| 295 |
+
"mandatory weekly check-ins",
|
| 296 |
+
"no contact with known criminals",
|
| 297 |
+
"curfew between 9 PM and 6 AM",
|
| 298 |
+
]
|
| 299 |
+
)
|
| 300 |
+
if on_parole
|
| 301 |
+
else None
|
| 302 |
+
),
|
| 303 |
+
"trust_score": trust,
|
| 304 |
+
}
|
| 305 |
+
return history
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def _build_agent_knowledge(
|
| 309 |
+
criminal: str, location: str, time: str
|
| 310 |
+
) -> dict:
|
| 311 |
+
"""Build what each agent privately knows.
|
| 312 |
+
|
| 313 |
+
Args:
|
| 314 |
+
criminal: Which suspect is the criminal.
|
| 315 |
+
location: Crime location.
|
| 316 |
+
time: Crime time.
|
| 317 |
+
"""
|
| 318 |
+
innocent = SUSPECT_B if criminal == SUSPECT_A else SUSPECT_A
|
| 319 |
+
|
| 320 |
+
fake_alibi = random.choice(FAKE_ALIBI_POOL)
|
| 321 |
+
real_alibi = random.choice(REAL_ALIBI_POOL)
|
| 322 |
+
|
| 323 |
+
clothing = random.choice(CLOTHING_OPTIONS)
|
| 324 |
+
|
| 325 |
+
# Enhancement 2: 30% chance the witness got the clothing wrong
|
| 326 |
+
witness_clothing = clothing
|
| 327 |
+
if random.random() < 0.3:
|
| 328 |
+
# Pick a different clothing item
|
| 329 |
+
other_options = [c for c in CLOTHING_OPTIONS if c != clothing]
|
| 330 |
+
witness_clothing = random.choice(other_options)
|
| 331 |
+
|
| 332 |
+
knowledge = {
|
| 333 |
+
SUSPECT_A_KEY if criminal == SUSPECT_A else SUSPECT_B_KEY: {
|
| 334 |
+
"knows_own_guilt": True,
|
| 335 |
+
"fake_alibi": fake_alibi,
|
| 336 |
+
"knows_cctv_status": random.choice([True, False]),
|
| 337 |
+
"knows_keycard_log_exists": random.choice([True, False]),
|
| 338 |
+
},
|
| 339 |
+
SUSPECT_A_KEY if innocent == SUSPECT_A else SUSPECT_B_KEY: {
|
| 340 |
+
"knows_own_guilt": False,
|
| 341 |
+
"real_alibi": real_alibi,
|
| 342 |
+
"knows_suspect_A": random.choice([True, False]),
|
| 343 |
+
},
|
| 344 |
+
WITNESS_1_KEY: {
|
| 345 |
+
"saw": f"a person in {witness_clothing} near the {location} around {time}",
|
| 346 |
+
"actual_criminal_clothing": clothing,
|
| 347 |
+
"time_seen": time,
|
| 348 |
+
"bias_target": random.choice([SUSPECT_A, SUSPECT_B, None]),
|
| 349 |
+
"bias_strength": round(random.uniform(0.0, 0.5), 2),
|
| 350 |
+
},
|
| 351 |
+
}
|
| 352 |
+
return knowledge
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def _build_detective_briefing(prior_history: dict) -> str:
|
| 356 |
+
"""Create a 2-3 sentence briefing for the detective (no criminal identity)."""
|
| 357 |
+
parts = []
|
| 358 |
+
for suspect in SUSPECTS:
|
| 359 |
+
h = prior_history[suspect]
|
| 360 |
+
crimes = h["previous_crimes"]
|
| 361 |
+
crime_str = (
|
| 362 |
+
f"has prior convictions for {', '.join(crimes)}"
|
| 363 |
+
if crimes
|
| 364 |
+
else "has no prior convictions on record"
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
lies = h["past_lies_on_record"]
|
| 368 |
+
lie_str = (
|
| 369 |
+
f"and has been caught lying about {lies[0]['topic']} in the past"
|
| 370 |
+
if lies
|
| 371 |
+
else "with no documented dishonesty"
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
parole_str = ""
|
| 375 |
+
if h["on_parole"]:
|
| 376 |
+
parole_str = f" Currently on parole ({h['parole_condition']})."
|
| 377 |
+
|
| 378 |
+
parts.append(
|
| 379 |
+
f"{suspect} {crime_str} {lie_str}. "
|
| 380 |
+
f"Known behavioral pattern: {h['behavioral_pattern']}.{parole_str}"
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
return " ".join(parts)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
# ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def generate_case() -> dict:
|
| 390 |
+
"""Generate a complete randomized crime case.
|
| 391 |
+
|
| 392 |
+
Criminal is randomly assigned to either Suspect_A or Suspect_B.
|
| 393 |
+
|
| 394 |
+
Returns a dictionary containing all case information needed for one
|
| 395 |
+
episode of the Crime Investigation environment.
|
| 396 |
+
"""
|
| 397 |
+
crime = random.choice(CRIMES)
|
| 398 |
+
criminal = random.choice(list(SUSPECTS))
|
| 399 |
+
time = random.choice(TIMES)
|
| 400 |
+
location = random.choice(LOCATIONS)
|
| 401 |
+
method = random.choice(METHODS)
|
| 402 |
+
motive = random.choice(MOTIVES)
|
| 403 |
+
|
| 404 |
+
physical_evidence = _build_evidence(criminal, location, time)
|
| 405 |
+
prior_history = _build_prior_history(criminal)
|
| 406 |
+
agent_knowledge = _build_agent_knowledge(criminal, location, time)
|
| 407 |
+
detective_briefing = _build_detective_briefing(prior_history)
|
| 408 |
+
|
| 409 |
+
return {
|
| 410 |
+
"crime": crime,
|
| 411 |
+
"criminal": criminal,
|
| 412 |
+
"time": time,
|
| 413 |
+
"location": location,
|
| 414 |
+
"method": method,
|
| 415 |
+
"motive": motive,
|
| 416 |
+
"physical_evidence": physical_evidence,
|
| 417 |
+
"prior_history": prior_history,
|
| 418 |
+
"agent_knowledge": agent_knowledge,
|
| 419 |
+
"detective_briefing": detective_briefing,
|
| 420 |
+
}
|
crime_env/consistency_tracker.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Component 2 β Lie / Consistency Tracker.
|
| 2 |
+
|
| 3 |
+
Tracks claims made by each agent across conversation turns and detects
|
| 4 |
+
contradictions, evidence mismatches, and prior-pattern repetitions.
|
| 5 |
+
|
| 6 |
+
Contradiction detection uses *normalized core claims* β short canonical
|
| 7 |
+
representations of what the agent asserted β to avoid false positives
|
| 8 |
+
from minor rephrasing.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
|
| 15 |
+
from crime_env.constants import SUSPECT_A, SUSPECT_B, WITNESS_1
|
| 16 |
+
|
| 17 |
+
SUPPORTED_TOPICS: list[str] = [
|
| 18 |
+
"location",
|
| 19 |
+
"clothing",
|
| 20 |
+
"time",
|
| 21 |
+
"alibi",
|
| 22 |
+
"knows_associate",
|
| 23 |
+
"was_at_scene",
|
| 24 |
+
"owns_item",
|
| 25 |
+
"relationship_to_victim",
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
SUPPORTED_AGENTS: set[str] = {SUSPECT_A, SUSPECT_B, WITNESS_1}
|
| 29 |
+
|
| 30 |
+
# ββ Normalization helpers βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
|
| 32 |
+
# Location keywords that are mutually exclusive
|
| 33 |
+
_LOCATION_TOKENS = {
|
| 34 |
+
"east wing", "server room", "vault", "parking lot",
|
| 35 |
+
"basement archive", "rooftop terrace", "loading dock",
|
| 36 |
+
"executive suite", "evidence locker", "maintenance corridor",
|
| 37 |
+
"restaurant", "movie theater",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
# Stance keywords (yes/no style claims)
|
| 41 |
+
_POSITIVE_STANCES = {"yes", "was there", "was at", "i was", "i did", "i know", "true"}
|
| 42 |
+
_NEGATIVE_STANCES = {"no", "wasn't", "was not", "never", "not there", "don't know", "false", "i didn't"}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _normalize_claim(topic: str, raw_value: str) -> str:
|
| 46 |
+
"""Extract a short, canonical representation of a claim.
|
| 47 |
+
|
| 48 |
+
This prevents false positives where the same claim expressed
|
| 49 |
+
slightly differently (e.g. 'family dinner' vs 'left dinner')
|
| 50 |
+
looks like a contradiction.
|
| 51 |
+
"""
|
| 52 |
+
val = raw_value.lower().strip()
|
| 53 |
+
# Remove filler words and punctuation
|
| 54 |
+
val = re.sub(r'[^\w\s]', ' ', val)
|
| 55 |
+
val = re.sub(r'\s+', ' ', val).strip()
|
| 56 |
+
|
| 57 |
+
if topic == "location":
|
| 58 |
+
# Extract the most specific named location
|
| 59 |
+
for loc in _LOCATION_TOKENS:
|
| 60 |
+
if loc in val:
|
| 61 |
+
return f"location:{loc}"
|
| 62 |
+
return f"location:{val[:40]}"
|
| 63 |
+
|
| 64 |
+
if topic == "was_at_scene":
|
| 65 |
+
for neg in _NEGATIVE_STANCES:
|
| 66 |
+
if neg in val:
|
| 67 |
+
return "was_at_scene:no"
|
| 68 |
+
for pos in _POSITIVE_STANCES:
|
| 69 |
+
if pos in val:
|
| 70 |
+
return "was_at_scene:yes"
|
| 71 |
+
return f"was_at_scene:{val[:30]}"
|
| 72 |
+
|
| 73 |
+
if topic == "knows_associate":
|
| 74 |
+
for neg in _NEGATIVE_STANCES:
|
| 75 |
+
if neg in val:
|
| 76 |
+
return "knows_associate:no"
|
| 77 |
+
for pos in _POSITIVE_STANCES:
|
| 78 |
+
if pos in val:
|
| 79 |
+
return "knows_associate:yes"
|
| 80 |
+
return f"knows_associate:{val[:30]}"
|
| 81 |
+
|
| 82 |
+
if topic == "alibi":
|
| 83 |
+
# Extract the core venue/activity from the alibi
|
| 84 |
+
for loc in _LOCATION_TOKENS:
|
| 85 |
+
if loc in val:
|
| 86 |
+
return f"alibi:{loc}"
|
| 87 |
+
# Check for key alibi activities
|
| 88 |
+
for keyword in ["dinner", "movie", "bar", "working", "office",
|
| 89 |
+
"gym", "volunteering", "home", "sick", "party",
|
| 90 |
+
"attending", "helping", "hospital",
|
| 91 |
+
"library", "studying", "babysitting", "poker",
|
| 92 |
+
"coaching", "overtime",
|
| 93 |
+
"laundromat", "walking", "video call", "prayer"]:
|
| 94 |
+
if keyword in val:
|
| 95 |
+
return f"alibi:{keyword}"
|
| 96 |
+
return f"alibi:{val[:40]}"
|
| 97 |
+
|
| 98 |
+
if topic == "clothing":
|
| 99 |
+
for item in ["hoodie", "trench coat", "leather jacket",
|
| 100 |
+
"overalls", "uniform", "jeans", "shirt",
|
| 101 |
+
"windbreaker", "cargo pants", "tracksuit",
|
| 102 |
+
"bomber jacket", "corduroy",
|
| 103 |
+
"coat", "jacket"]:
|
| 104 |
+
if item in val:
|
| 105 |
+
return f"clothing:{item}"
|
| 106 |
+
return f"clothing:{val[:30]}"
|
| 107 |
+
|
| 108 |
+
if topic == "time":
|
| 109 |
+
# Extract actual time mentions
|
| 110 |
+
time_match = re.search(r'\d{1,2}[:\s]*\d{2}\s*(pm|am)?', val)
|
| 111 |
+
if time_match:
|
| 112 |
+
return f"time:{time_match.group()}"
|
| 113 |
+
return f"time:{val[:30]}"
|
| 114 |
+
|
| 115 |
+
# Fallback: use a truncated normalized string
|
| 116 |
+
return f"{topic}:{val[:40]}"
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class ConsistencyTracker:
|
| 120 |
+
"""Maintains a per-agent claim log and detects inconsistencies.
|
| 121 |
+
|
| 122 |
+
Uses normalized claim values to reduce false positive contradictions.
|
| 123 |
+
"""
|
| 124 |
+
|
| 125 |
+
def __init__(self) -> None:
|
| 126 |
+
# {agent_name: {topic: {"value": str, "normalized": str, "turn": int}}}
|
| 127 |
+
self.claim_log: dict[str, dict[str, dict]] = {}
|
| 128 |
+
self.contradiction_log: list[dict] = []
|
| 129 |
+
|
| 130 |
+
# ββ Core methods ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 131 |
+
|
| 132 |
+
def record_claim(
|
| 133 |
+
self, agent: str, topic: str, value: str, turn: int
|
| 134 |
+
) -> dict:
|
| 135 |
+
"""Record a claim and check for self-contradiction.
|
| 136 |
+
|
| 137 |
+
Contradiction fires only if the *normalized* core claim differs
|
| 138 |
+
(e.g. 'office' vs 'bar' is a contradiction, but 'family dinner'
|
| 139 |
+
vs 'left dinner' is not).
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
dict with `contradicted` bool, plus details if True.
|
| 143 |
+
"""
|
| 144 |
+
agent_name = agent if agent in SUPPORTED_AGENTS else str(agent)
|
| 145 |
+
|
| 146 |
+
if agent_name not in self.claim_log:
|
| 147 |
+
self.claim_log[agent_name] = {}
|
| 148 |
+
|
| 149 |
+
agent_claims = self.claim_log[agent_name]
|
| 150 |
+
normalized = _normalize_claim(topic, value)
|
| 151 |
+
|
| 152 |
+
if topic not in agent_claims:
|
| 153 |
+
# First claim on this topic β store it
|
| 154 |
+
agent_claims[topic] = {
|
| 155 |
+
"value": value,
|
| 156 |
+
"normalized": normalized,
|
| 157 |
+
"turn": turn,
|
| 158 |
+
}
|
| 159 |
+
return {"contradicted": False}
|
| 160 |
+
|
| 161 |
+
existing = agent_claims[topic]
|
| 162 |
+
|
| 163 |
+
# Compare normalized versions β if they match, no contradiction
|
| 164 |
+
if existing["normalized"] == normalized:
|
| 165 |
+
return {"contradicted": False}
|
| 166 |
+
|
| 167 |
+
# Contradiction detected β normalized claims are mutually exclusive
|
| 168 |
+
event = {
|
| 169 |
+
"contradicted": True,
|
| 170 |
+
"agent": agent_name,
|
| 171 |
+
"topic": topic,
|
| 172 |
+
"old_value": existing["value"],
|
| 173 |
+
"new_value": value,
|
| 174 |
+
"turn": turn,
|
| 175 |
+
}
|
| 176 |
+
self.contradiction_log.append(event)
|
| 177 |
+
|
| 178 |
+
# Update to the latest claim
|
| 179 |
+
agent_claims[topic] = {
|
| 180 |
+
"value": value,
|
| 181 |
+
"normalized": normalized,
|
| 182 |
+
"turn": turn,
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
return event
|
| 186 |
+
|
| 187 |
+
def check_against_evidence(
|
| 188 |
+
self,
|
| 189 |
+
agent: str,
|
| 190 |
+
topic: str,
|
| 191 |
+
value: str,
|
| 192 |
+
evidence_list: list[dict],
|
| 193 |
+
) -> dict:
|
| 194 |
+
"""Check if the agent's claim contradicts physical evidence.
|
| 195 |
+
|
| 196 |
+
Returns:
|
| 197 |
+
dict with `confirmed_lie` bool.
|
| 198 |
+
"""
|
| 199 |
+
value_lower = value.lower().strip()
|
| 200 |
+
|
| 201 |
+
def _extract_location(text: str) -> str | None:
|
| 202 |
+
for loc in _LOCATION_TOKENS:
|
| 203 |
+
if loc in text:
|
| 204 |
+
return loc
|
| 205 |
+
return None
|
| 206 |
+
|
| 207 |
+
def _extract_clothing(text: str) -> str | None:
|
| 208 |
+
clothing_tokens = [
|
| 209 |
+
"hoodie", "trench coat", "leather jacket", "overalls",
|
| 210 |
+
"uniform", "jeans", "shirt", "windbreaker",
|
| 211 |
+
"cargo pants", "tracksuit", "bomber jacket", "corduroy",
|
| 212 |
+
"coat", "jacket",
|
| 213 |
+
]
|
| 214 |
+
for token in clothing_tokens:
|
| 215 |
+
if token in text:
|
| 216 |
+
return token
|
| 217 |
+
return None
|
| 218 |
+
|
| 219 |
+
def _extract_time(text: str) -> str | None:
|
| 220 |
+
m = re.search(r'\d{1,2}[:\s]*\d{2}\s*(pm|am)?', text)
|
| 221 |
+
return m.group().strip() if m else None
|
| 222 |
+
|
| 223 |
+
denies_scene = any(
|
| 224 |
+
phrase in value_lower
|
| 225 |
+
for phrase in (
|
| 226 |
+
"wasn't there", "was not there", "not there",
|
| 227 |
+
"nowhere near", "i wasn't", "i was not",
|
| 228 |
+
"never there", "didn't go", "did not go",
|
| 229 |
+
)
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
claimed_location = _extract_location(value_lower)
|
| 233 |
+
claimed_clothing = _extract_clothing(value_lower)
|
| 234 |
+
claimed_time = _extract_time(value_lower)
|
| 235 |
+
|
| 236 |
+
for evidence in evidence_list:
|
| 237 |
+
desc_lower = evidence.get("description", "").lower()
|
| 238 |
+
points_to = evidence.get("points_to", "").lower()
|
| 239 |
+
|
| 240 |
+
if topic == "was_at_scene":
|
| 241 |
+
if (
|
| 242 |
+
denies_scene
|
| 243 |
+
and points_to == agent.lower()
|
| 244 |
+
):
|
| 245 |
+
return {"confirmed_lie": True}
|
| 246 |
+
|
| 247 |
+
if topic == "location":
|
| 248 |
+
if (
|
| 249 |
+
claimed_location is not None
|
| 250 |
+
and agent.lower() in desc_lower
|
| 251 |
+
and claimed_location not in desc_lower
|
| 252 |
+
and ("location" in desc_lower or "entering" in desc_lower
|
| 253 |
+
or "near" in desc_lower)
|
| 254 |
+
):
|
| 255 |
+
return {"confirmed_lie": True}
|
| 256 |
+
|
| 257 |
+
if topic == "clothing":
|
| 258 |
+
if (
|
| 259 |
+
claimed_clothing is not None
|
| 260 |
+
and agent.lower() in desc_lower
|
| 261 |
+
and claimed_clothing not in desc_lower
|
| 262 |
+
):
|
| 263 |
+
if "fabric" in desc_lower or "wearing" in desc_lower:
|
| 264 |
+
return {"confirmed_lie": True}
|
| 265 |
+
|
| 266 |
+
if topic == "time":
|
| 267 |
+
if (
|
| 268 |
+
claimed_time is not None
|
| 269 |
+
and agent.lower() in desc_lower
|
| 270 |
+
and claimed_time not in desc_lower
|
| 271 |
+
and ("timestamp" in desc_lower or "log" in desc_lower
|
| 272 |
+
or "at" in desc_lower)
|
| 273 |
+
):
|
| 274 |
+
return {"confirmed_lie": True}
|
| 275 |
+
|
| 276 |
+
# General: evidence points to this agent but they deny involvement
|
| 277 |
+
if (
|
| 278 |
+
points_to == agent.lower()
|
| 279 |
+
and topic in ("was_at_scene", "owns_item")
|
| 280 |
+
and (
|
| 281 |
+
value_lower in ("no", "false", "never", "not mine")
|
| 282 |
+
or "not mine" in value_lower
|
| 283 |
+
or denies_scene
|
| 284 |
+
)
|
| 285 |
+
):
|
| 286 |
+
return {"confirmed_lie": True}
|
| 287 |
+
|
| 288 |
+
return {"confirmed_lie": False}
|
| 289 |
+
|
| 290 |
+
def check_prior_pattern(
|
| 291 |
+
self,
|
| 292 |
+
agent: str,
|
| 293 |
+
topic: str,
|
| 294 |
+
value: str,
|
| 295 |
+
prior_history: dict,
|
| 296 |
+
) -> dict:
|
| 297 |
+
"""Check if the current claim matches a documented past lie pattern.
|
| 298 |
+
|
| 299 |
+
Returns:
|
| 300 |
+
dict with `pattern_match` bool.
|
| 301 |
+
"""
|
| 302 |
+
if agent not in prior_history:
|
| 303 |
+
return {"pattern_match": False}
|
| 304 |
+
|
| 305 |
+
agent_history = prior_history[agent]
|
| 306 |
+
|
| 307 |
+
# Check against documented past lies
|
| 308 |
+
past_lies = agent_history.get("past_lies_on_record", [])
|
| 309 |
+
for lie in past_lies:
|
| 310 |
+
if lie["topic"].lower() == topic.lower():
|
| 311 |
+
return {"pattern_match": True}
|
| 312 |
+
|
| 313 |
+
# Check against behavioral pattern keywords
|
| 314 |
+
pattern = agent_history.get("behavioral_pattern", "").lower()
|
| 315 |
+
value_lower = value.lower()
|
| 316 |
+
|
| 317 |
+
if "family" in pattern and "family" in value_lower:
|
| 318 |
+
return {"pattern_match": True}
|
| 319 |
+
|
| 320 |
+
if "deflect" in pattern and ("suspect" in value_lower or "they" in value_lower):
|
| 321 |
+
return {"pattern_match": True}
|
| 322 |
+
|
| 323 |
+
if "memory" in pattern and ("don't remember" in value_lower or "forgot" in value_lower):
|
| 324 |
+
return {"pattern_match": True}
|
| 325 |
+
|
| 326 |
+
return {"pattern_match": False}
|
| 327 |
+
|
| 328 |
+
def get_summary(self) -> list[dict]:
|
| 329 |
+
"""Return the full contradiction log for the episode."""
|
| 330 |
+
return list(self.contradiction_log)
|
| 331 |
+
|
| 332 |
+
# ββ Utility βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 333 |
+
|
| 334 |
+
def get_agent_claims(self, agent: str) -> dict[str, dict]:
|
| 335 |
+
"""Return all recorded claims for a specific agent."""
|
| 336 |
+
return dict(self.claim_log.get(agent, {}))
|
| 337 |
+
|
| 338 |
+
def has_claimed_topic(self, agent: str, topic: str) -> bool:
|
| 339 |
+
"""Check if an agent has already made a claim on this topic."""
|
| 340 |
+
return topic in self.claim_log.get(agent, {})
|
crime_env/constants.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared environment constants used across modules."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
SUSPECT_A = "Suspect_A"
|
| 6 |
+
SUSPECT_B = "Suspect_B"
|
| 7 |
+
WITNESS_1 = "Witness_1"
|
| 8 |
+
|
| 9 |
+
SUSPECT_A_KEY = "suspect_a"
|
| 10 |
+
SUSPECT_B_KEY = "suspect_b"
|
| 11 |
+
WITNESS_1_KEY = "witness_1"
|
| 12 |
+
DETECTIVE_KEY = "detective"
|
| 13 |
+
|
| 14 |
+
AGENT_NAME_TO_KEY = {
|
| 15 |
+
SUSPECT_A: SUSPECT_A_KEY,
|
| 16 |
+
SUSPECT_B: SUSPECT_B_KEY,
|
| 17 |
+
WITNESS_1: WITNESS_1_KEY,
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
SUSPECTS = (SUSPECT_A, SUSPECT_B)
|
| 21 |
+
|
| 22 |
+
VALID_TARGETS = {SUSPECT_A, SUSPECT_B, WITNESS_1}
|
| 23 |
+
VALID_ACCUSE_TARGETS = {SUSPECT_A, SUSPECT_B}
|
crime_env/environment.py
ADDED
|
@@ -0,0 +1,922 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Component 5 β Environment Core (OpenEnv Compatible).
|
| 2 |
+
|
| 3 |
+
Implements the CrimeInvestigationEnv with step/reset/render interface.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import copy
|
| 9 |
+
import re
|
| 10 |
+
from typing import Any, Callable, Optional
|
| 11 |
+
|
| 12 |
+
from crime_env.case_generator import generate_case
|
| 13 |
+
from crime_env.consistency_tracker import ConsistencyTracker
|
| 14 |
+
from crime_env.reward_calculator import RewardCalculator
|
| 15 |
+
from crime_env.agent_prompts import build_system_prompt
|
| 16 |
+
from crime_env.constants import (
|
| 17 |
+
AGENT_NAME_TO_KEY,
|
| 18 |
+
DETECTIVE_KEY,
|
| 19 |
+
SUSPECT_A,
|
| 20 |
+
SUSPECT_A_KEY,
|
| 21 |
+
SUSPECT_B,
|
| 22 |
+
SUSPECT_B_KEY,
|
| 23 |
+
VALID_ACCUSE_TARGETS,
|
| 24 |
+
VALID_TARGETS,
|
| 25 |
+
WITNESS_1,
|
| 26 |
+
WITNESS_1_KEY,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ββ Action parsing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
|
| 32 |
+
_ACTION_PATTERNS = {
|
| 33 |
+
"ask_question": re.compile(
|
| 34 |
+
r"ACTION:\s*ask_question\s*\|\s*TARGET:\s*(\S+)\s*\|\s*CONTENT:\s*(.+)",
|
| 35 |
+
re.IGNORECASE,
|
| 36 |
+
),
|
| 37 |
+
"request_evidence": re.compile(
|
| 38 |
+
r"ACTION:\s*request_evidence\s*\|\s*ITEM:\s*(\S+)",
|
| 39 |
+
re.IGNORECASE,
|
| 40 |
+
),
|
| 41 |
+
"accuse": re.compile(
|
| 42 |
+
r"ACTION:\s*accuse\s*\|\s*TARGET:\s*(\S+)",
|
| 43 |
+
re.IGNORECASE,
|
| 44 |
+
),
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
EVIDENCE_ITEMS = {"keycard_log", "cctv_footage", "forensic_report"}
|
| 48 |
+
|
| 49 |
+
# Evidence confirmation should depend on suspect-specific questioning
|
| 50 |
+
# that is relevant to the requested evidence type.
|
| 51 |
+
EVIDENCE_LEAD_TOPICS = {
|
| 52 |
+
"keycard_log": {"time", "location", "was_at_scene", "alibi"},
|
| 53 |
+
"cctv_footage": {"location", "clothing", "time", "was_at_scene"},
|
| 54 |
+
"forensic_report": {"clothing", "owns_item", "location", "was_at_scene"},
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
# Map evidence item names to indices in physical_evidence list
|
| 58 |
+
EVIDENCE_INDEX_MAP = {
|
| 59 |
+
"keycard_log": 0,
|
| 60 |
+
"cctv_footage": 1,
|
| 61 |
+
"forensic_report": 2,
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
TOPIC_KEYWORDS = {
|
| 65 |
+
"location": [
|
| 66 |
+
"east wing", "server room", "vault", "parking lot",
|
| 67 |
+
"basement archive", "rooftop terrace", "loading dock",
|
| 68 |
+
"executive suite", "evidence locker", "maintenance corridor",
|
| 69 |
+
"movie theater", "restaurant",
|
| 70 |
+
],
|
| 71 |
+
"clothing": [
|
| 72 |
+
"hoodie", "trench coat", "leather jacket",
|
| 73 |
+
"overalls", "uniform", "jeans",
|
| 74 |
+
"windbreaker", "cargo pants", "tracksuit",
|
| 75 |
+
"bomber jacket", "corduroy",
|
| 76 |
+
"coat", "jacket",
|
| 77 |
+
],
|
| 78 |
+
"time": [
|
| 79 |
+
"7:30", "8:15", "8:45", "9:00", "9:15",
|
| 80 |
+
"9:45", "10:00", "10:30", "11:15", "11:45",
|
| 81 |
+
"o'clock",
|
| 82 |
+
],
|
| 83 |
+
"alibi": [
|
| 84 |
+
"playing poker", "working late", "at home", "was at",
|
| 85 |
+
"dinner", "movie", "gym", "volunteering", "party",
|
| 86 |
+
"sick", "attending", "helping", "hospital",
|
| 87 |
+
"library", "studying", "babysitting", "coaching",
|
| 88 |
+
"overtime", "laundromat", "walking", "video call",
|
| 89 |
+
"prayer",
|
| 90 |
+
],
|
| 91 |
+
"knows_associate": [
|
| 92 |
+
"know them", "know him", "know her", "never met",
|
| 93 |
+
"don't know", "do not know",
|
| 94 |
+
"know suspect", "knew",
|
| 95 |
+
],
|
| 96 |
+
"was_at_scene": [
|
| 97 |
+
"wasn't there", "was not there", "not there",
|
| 98 |
+
"never been", "was there", "at the scene",
|
| 99 |
+
"nowhere near",
|
| 100 |
+
],
|
| 101 |
+
"owns_item": [
|
| 102 |
+
"not mine", "my bag", "my phone", "my tools",
|
| 103 |
+
"belongs to me",
|
| 104 |
+
],
|
| 105 |
+
"relationship_to_victim": [
|
| 106 |
+
"knew the victim", "never met the victim",
|
| 107 |
+
"victim",
|
| 108 |
+
],
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def parse_action(action_string: str) -> dict:
|
| 113 |
+
"""Parse a detective action string into a structured dict.
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
dict with keys: action_type, target/item, content (if applicable).
|
| 117 |
+
Returns {action_type: "invalid"} on parse failure.
|
| 118 |
+
"""
|
| 119 |
+
action_string = action_string.strip()
|
| 120 |
+
|
| 121 |
+
# Try ask_question
|
| 122 |
+
m = _ACTION_PATTERNS["ask_question"].search(action_string)
|
| 123 |
+
if m:
|
| 124 |
+
return {
|
| 125 |
+
"action_type": "ask_question",
|
| 126 |
+
"target": m.group(1).strip(),
|
| 127 |
+
"content": m.group(2).strip(),
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
# Try request_evidence
|
| 131 |
+
m = _ACTION_PATTERNS["request_evidence"].search(action_string)
|
| 132 |
+
if m:
|
| 133 |
+
return {
|
| 134 |
+
"action_type": "request_evidence",
|
| 135 |
+
"item": m.group(1).strip(),
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
# Try accuse
|
| 139 |
+
m = _ACTION_PATTERNS["accuse"].search(action_string)
|
| 140 |
+
if m:
|
| 141 |
+
return {
|
| 142 |
+
"action_type": "accuse",
|
| 143 |
+
"target": m.group(1).strip(),
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
return {"action_type": "invalid", "raw": action_string}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def extract_topics_from_response(response: str) -> list[tuple[str, str]]:
|
| 150 |
+
"""Extract (topic, value) pairs from a natural language response.
|
| 151 |
+
|
| 152 |
+
Uses word-boundary matching to avoid substring false positives
|
| 153 |
+
(e.g. 'time' in 'Sometimes', 'own' in 'town').
|
| 154 |
+
Returns at most ONE topic per response to prevent a single statement
|
| 155 |
+
from registering as 3 contradictions simultaneously.
|
| 156 |
+
|
| 157 |
+
Returns:
|
| 158 |
+
List of (topic, value_snippet) tuples. Max length = 1.
|
| 159 |
+
"""
|
| 160 |
+
response_lower = response.lower()
|
| 161 |
+
|
| 162 |
+
# Find the BEST (most specific) single topic match
|
| 163 |
+
best_match = None
|
| 164 |
+
best_kw_len = 0
|
| 165 |
+
|
| 166 |
+
for topic, keywords in TOPIC_KEYWORDS.items():
|
| 167 |
+
for kw in keywords:
|
| 168 |
+
# Use word-boundary regex to avoid substring matches
|
| 169 |
+
pattern = r'\b' + re.escape(kw) + r'\b'
|
| 170 |
+
m = re.search(pattern, response_lower)
|
| 171 |
+
if m:
|
| 172 |
+
# Prefer longer (more specific) keyword matches
|
| 173 |
+
if len(kw) > best_kw_len:
|
| 174 |
+
best_kw_len = len(kw)
|
| 175 |
+
idx = m.start()
|
| 176 |
+
start = idx
|
| 177 |
+
end = min(len(response), idx + len(kw) + 30)
|
| 178 |
+
value = response[start:end].strip()
|
| 179 |
+
best_match = (topic, value)
|
| 180 |
+
break # One match per topic, but continue checking other topics
|
| 181 |
+
|
| 182 |
+
return [best_match] if best_match else []
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ββ Environment βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class CrimeInvestigationEnv:
|
| 189 |
+
"""Multi-agent crime investigation RL environment.
|
| 190 |
+
|
| 191 |
+
Compatible with OpenEnv's step/reset/render interface and
|
| 192 |
+
designed for training with HuggingFace TRL PPO.
|
| 193 |
+
"""
|
| 194 |
+
|
| 195 |
+
MAX_TURNS = 15
|
| 196 |
+
|
| 197 |
+
def __init__(
|
| 198 |
+
self,
|
| 199 |
+
llm_call: Optional[Callable[[str, list[dict]], str]] = None,
|
| 200 |
+
) -> None:
|
| 201 |
+
"""Initialize the environment.
|
| 202 |
+
|
| 203 |
+
Args:
|
| 204 |
+
llm_call: Callable(system_prompt, conversation_history) -> response.
|
| 205 |
+
Used for NPC agents (suspects, witness).
|
| 206 |
+
If None, a simple rule-based fallback is used.
|
| 207 |
+
"""
|
| 208 |
+
self.llm_call = llm_call or self._default_llm_call
|
| 209 |
+
|
| 210 |
+
# Will be set on reset()
|
| 211 |
+
self.case: Optional[dict] = None
|
| 212 |
+
self.tracker: Optional[ConsistencyTracker] = None
|
| 213 |
+
self.reward_calc: Optional[RewardCalculator] = None
|
| 214 |
+
self.turn: int = 0
|
| 215 |
+
self.done: bool = False
|
| 216 |
+
self.conversation_history: list[dict] = []
|
| 217 |
+
self.evidence_log: list[dict] = []
|
| 218 |
+
self.system_prompts: dict[str, str] = {}
|
| 219 |
+
self.asked_topics: dict[str, set] = {} # {target: {topics asked}}
|
| 220 |
+
self._pattern_penalized: set[str] = set()
|
| 221 |
+
self._pattern_exploited_targets: set[str] = set()
|
| 222 |
+
|
| 223 |
+
# Deflection tracking: stores (suspect, deflection_target) when a
|
| 224 |
+
# suspect mentions the other suspect in their response.
|
| 225 |
+
# Only set when the *previous action* was ask_question to a suspect.
|
| 226 |
+
self._pending_deflection: Optional[tuple[str, str]] = None
|
| 227 |
+
# What the last action type was (ask_question / request_evidence / etc.)
|
| 228 |
+
self._last_action_type: Optional[str] = None
|
| 229 |
+
# Cap anti-deflection bonus to once per suspect per episode.
|
| 230 |
+
self._deflection_resistance_rewarded: set[str] = set()
|
| 231 |
+
# Prevent repeated contradiction farming on the same target/topic pair.
|
| 232 |
+
self._contradiction_rewarded_topics: set[tuple[str, str]] = set()
|
| 233 |
+
|
| 234 |
+
# ββ OpenEnv Interface βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 235 |
+
|
| 236 |
+
def _reset_episode_tracking(self) -> None:
|
| 237 |
+
self._pending_deflection = None
|
| 238 |
+
self._last_action_type = None
|
| 239 |
+
self._pattern_penalized = set()
|
| 240 |
+
self._pattern_exploited_targets = set()
|
| 241 |
+
self._deflection_resistance_rewarded = set()
|
| 242 |
+
self._contradiction_rewarded_topics = set()
|
| 243 |
+
|
| 244 |
+
def reset(self, case_data: Optional[dict] = None) -> dict:
|
| 245 |
+
"""Start a new episode.
|
| 246 |
+
|
| 247 |
+
Returns:
|
| 248 |
+
Initial observation for the detective agent.
|
| 249 |
+
"""
|
| 250 |
+
self.case = copy.deepcopy(case_data) if case_data is not None else generate_case()
|
| 251 |
+
self.tracker = ConsistencyTracker()
|
| 252 |
+
self.reward_calc = RewardCalculator()
|
| 253 |
+
self.turn = 0
|
| 254 |
+
self.done = False
|
| 255 |
+
self.conversation_history = []
|
| 256 |
+
self.evidence_log = []
|
| 257 |
+
self.asked_topics = {t: set() for t in VALID_TARGETS}
|
| 258 |
+
self._reset_episode_tracking()
|
| 259 |
+
|
| 260 |
+
# Build system prompts for all agents
|
| 261 |
+
self.system_prompts = {
|
| 262 |
+
DETECTIVE_KEY: build_system_prompt("detective", self.case),
|
| 263 |
+
SUSPECT_A: build_system_prompt(SUSPECT_A_KEY, self.case),
|
| 264 |
+
SUSPECT_B: build_system_prompt(SUSPECT_B_KEY, self.case),
|
| 265 |
+
WITNESS_1: build_system_prompt("witness", self.case),
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
return {
|
| 269 |
+
"role": "detective",
|
| 270 |
+
"briefing": self.case["detective_briefing"],
|
| 271 |
+
"turn": 0,
|
| 272 |
+
"conversation_history": [],
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
def step(self, action_string: str) -> tuple[dict, float, bool, dict]:
|
| 276 |
+
"""Execute one detective action.
|
| 277 |
+
|
| 278 |
+
Args:
|
| 279 |
+
action_string: Action in the format defined in Component 4.
|
| 280 |
+
|
| 281 |
+
Returns:
|
| 282 |
+
(observation, reward, done, info)
|
| 283 |
+
"""
|
| 284 |
+
if self.done:
|
| 285 |
+
raise RuntimeError("Episode is done. Call reset().")
|
| 286 |
+
|
| 287 |
+
parsed = parse_action(action_string)
|
| 288 |
+
action_type = parsed["action_type"]
|
| 289 |
+
|
| 290 |
+
if action_type == "ask_question":
|
| 291 |
+
return self._handle_ask_question(parsed)
|
| 292 |
+
elif action_type == "request_evidence":
|
| 293 |
+
return self._handle_request_evidence(parsed)
|
| 294 |
+
elif action_type == "accuse":
|
| 295 |
+
return self._handle_accuse(parsed)
|
| 296 |
+
else:
|
| 297 |
+
# Invalid action β treat as wasted turn
|
| 298 |
+
reward = self._apply_invalid_turn_penalty("invalid")
|
| 299 |
+
|
| 300 |
+
if self.turn >= self.MAX_TURNS:
|
| 301 |
+
return self._handle_timeout(reward)
|
| 302 |
+
|
| 303 |
+
obs = self._build_observation(
|
| 304 |
+
message="Invalid action format. Please use the correct format."
|
| 305 |
+
)
|
| 306 |
+
return obs, reward, False, {"action": "invalid"}
|
| 307 |
+
|
| 308 |
+
def render(self) -> None:
|
| 309 |
+
"""Print the conversation history in a readable format."""
|
| 310 |
+
print("\n" + "=" * 70)
|
| 311 |
+
print(" CRIME INVESTIGATION β CONVERSATION LOG")
|
| 312 |
+
print("=" * 70)
|
| 313 |
+
|
| 314 |
+
if self.case:
|
| 315 |
+
print(f" Crime: {self.case['crime']}")
|
| 316 |
+
print(f" Location: {self.case['location']}")
|
| 317 |
+
print("-" * 70)
|
| 318 |
+
|
| 319 |
+
for entry in self.conversation_history:
|
| 320 |
+
turn = entry.get("turn", "?")
|
| 321 |
+
speaker = entry.get("speaker", "???")
|
| 322 |
+
content = entry.get("content", "")
|
| 323 |
+
flags = entry.get("flags", [])
|
| 324 |
+
|
| 325 |
+
print(f"\n [Turn {turn}] {speaker}:")
|
| 326 |
+
print(f" {content}")
|
| 327 |
+
|
| 328 |
+
if flags:
|
| 329 |
+
for flag in flags:
|
| 330 |
+
print(f" β FLAG: {flag}")
|
| 331 |
+
|
| 332 |
+
print("\n" + "=" * 70)
|
| 333 |
+
|
| 334 |
+
if self.done and self.reward_calc:
|
| 335 |
+
rewards = self.reward_calc.get_rewards()
|
| 336 |
+
print(" FINAL REWARDS:")
|
| 337 |
+
for agent, r in rewards.items():
|
| 338 |
+
print(f" {agent}: {r:+.2f}")
|
| 339 |
+
print("=" * 70 + "\n")
|
| 340 |
+
|
| 341 |
+
def state(self) -> dict:
|
| 342 |
+
"""Return episode metadata (OpenEnv compatible)."""
|
| 343 |
+
return {
|
| 344 |
+
"turn": self.turn,
|
| 345 |
+
"done": self.done,
|
| 346 |
+
"max_turns": self.MAX_TURNS,
|
| 347 |
+
"evidence_revealed": len(self.evidence_log),
|
| 348 |
+
"contradictions_found": (
|
| 349 |
+
len(self.tracker.get_summary()) if self.tracker else 0
|
| 350 |
+
),
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
# ββ Action handlers βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 354 |
+
|
| 355 |
+
def _handle_ask_question(
|
| 356 |
+
self, parsed: dict
|
| 357 |
+
) -> tuple[dict, float, bool, dict]:
|
| 358 |
+
target = parsed["target"]
|
| 359 |
+
question = parsed["content"]
|
| 360 |
+
step_reward = 0.0
|
| 361 |
+
info: dict[str, Any] = {"action": "ask_question", "target": target}
|
| 362 |
+
flags: list[str] = []
|
| 363 |
+
|
| 364 |
+
# Validate target
|
| 365 |
+
if target not in VALID_TARGETS:
|
| 366 |
+
reward = self._apply_invalid_turn_penalty("ask_question")
|
| 367 |
+
if self.turn >= self.MAX_TURNS:
|
| 368 |
+
return self._handle_timeout(reward)
|
| 369 |
+
obs = self._build_observation(
|
| 370 |
+
message=f"Invalid target: {target}. Valid: {list(VALID_TARGETS)}"
|
| 371 |
+
)
|
| 372 |
+
return obs, reward, False, {"action": "invalid_target"}
|
| 373 |
+
|
| 374 |
+
step_reward += self._apply_deflection_logic(target)
|
| 375 |
+
|
| 376 |
+
# Per-turn cost
|
| 377 |
+
self.reward_calc.apply_event("per_turn_cost")
|
| 378 |
+
step_reward += -0.3
|
| 379 |
+
|
| 380 |
+
# Check for redundant question (topic already asked to this target)
|
| 381 |
+
question_topics = extract_topics_from_response(question)
|
| 382 |
+
if not question_topics:
|
| 383 |
+
# Bucket unclassified questions to prevent repetition bypasses.
|
| 384 |
+
question_topics = [("general", question[:40])]
|
| 385 |
+
|
| 386 |
+
question_topic_names = {topic for topic, _ in question_topics}
|
| 387 |
+
previous_topics = set(self.asked_topics[target])
|
| 388 |
+
|
| 389 |
+
is_redundant = any(topic in previous_topics for topic in question_topic_names)
|
| 390 |
+
for topic in question_topic_names:
|
| 391 |
+
self.asked_topics[target].add(topic)
|
| 392 |
+
|
| 393 |
+
if is_redundant:
|
| 394 |
+
self.reward_calc.apply_event("redundant_question")
|
| 395 |
+
step_reward += -0.5
|
| 396 |
+
flags.append("redundant_question")
|
| 397 |
+
|
| 398 |
+
# Add question to conversation history
|
| 399 |
+
self.conversation_history.append(
|
| 400 |
+
{
|
| 401 |
+
"turn": self.turn,
|
| 402 |
+
"speaker": "Detective",
|
| 403 |
+
"content": f"[To {target}] {question}",
|
| 404 |
+
"flags": list(flags),
|
| 405 |
+
}
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
# Get NPC response
|
| 409 |
+
system_prompt = self.system_prompts.get(target, "")
|
| 410 |
+
# Use full history to avoid artificial contradictions from short memory cap
|
| 411 |
+
recent_history = list(self.conversation_history)
|
| 412 |
+
response = self.llm_call(system_prompt, recent_history)
|
| 413 |
+
response_lower = response.lower()
|
| 414 |
+
|
| 415 |
+
# Extract topics from response and run consistency checks
|
| 416 |
+
response_topics = extract_topics_from_response(response)
|
| 417 |
+
response_flags: list[str] = []
|
| 418 |
+
step_reward += self._analyse_response_topics(
|
| 419 |
+
target=target,
|
| 420 |
+
response_topics=response_topics,
|
| 421 |
+
question_topic_names=question_topic_names,
|
| 422 |
+
previous_topics=previous_topics,
|
| 423 |
+
response_flags=response_flags,
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
# Check for witness bias-driven false implication
|
| 427 |
+
if target == WITNESS_1:
|
| 428 |
+
self._check_witness_bias(response_lower, response_flags)
|
| 429 |
+
|
| 430 |
+
# Add response to conversation history
|
| 431 |
+
self.conversation_history.append(
|
| 432 |
+
{
|
| 433 |
+
"turn": self.turn,
|
| 434 |
+
"speaker": target,
|
| 435 |
+
"content": response,
|
| 436 |
+
"flags": response_flags,
|
| 437 |
+
}
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
# ββ Track deflection attempt ββββββββββββββββββββββββββββββββββββ
|
| 441 |
+
# Only track deflection when current action is ask_question
|
| 442 |
+
# to a suspect, and their response mentions the other suspect.
|
| 443 |
+
if target in (SUSPECT_A, SUSPECT_B):
|
| 444 |
+
other = SUSPECT_B if target == SUSPECT_A else SUSPECT_A
|
| 445 |
+
if (
|
| 446 |
+
other.lower().replace("_", " ") in response_lower
|
| 447 |
+
or other.lower() in response_lower
|
| 448 |
+
):
|
| 449 |
+
self._pending_deflection = (target, other)
|
| 450 |
+
info["deflection_target"] = other
|
| 451 |
+
else:
|
| 452 |
+
self._pending_deflection = None
|
| 453 |
+
else:
|
| 454 |
+
self._pending_deflection = None
|
| 455 |
+
|
| 456 |
+
self._last_action_type = "ask_question"
|
| 457 |
+
self.turn += 1
|
| 458 |
+
|
| 459 |
+
if self.turn >= self.MAX_TURNS:
|
| 460 |
+
return self._handle_timeout(step_reward)
|
| 461 |
+
|
| 462 |
+
obs = self._build_observation()
|
| 463 |
+
info["flags"] = response_flags
|
| 464 |
+
return obs, step_reward, False, info
|
| 465 |
+
|
| 466 |
+
def _apply_deflection_logic(self, target: str) -> float:
|
| 467 |
+
"""Apply deflection rewards/penalties from the previous turn."""
|
| 468 |
+
step_delta = 0.0
|
| 469 |
+
if (
|
| 470 |
+
self._pending_deflection is not None
|
| 471 |
+
and self._last_action_type == "ask_question"
|
| 472 |
+
):
|
| 473 |
+
deflector, deflection_target = self._pending_deflection
|
| 474 |
+
if target == deflection_target:
|
| 475 |
+
deflector_key = AGENT_NAME_TO_KEY.get(deflector, deflector.lower())
|
| 476 |
+
self.reward_calc.apply_event(
|
| 477 |
+
"successful_deflection",
|
| 478 |
+
agent=deflector_key,
|
| 479 |
+
)
|
| 480 |
+
elif (
|
| 481 |
+
target == deflector
|
| 482 |
+
and deflector not in self._deflection_resistance_rewarded
|
| 483 |
+
):
|
| 484 |
+
# Reward resisting deflection only when the detective stays
|
| 485 |
+
# on the same suspect, and only once per deflector.
|
| 486 |
+
self._deflection_resistance_rewarded.add(deflector)
|
| 487 |
+
step_delta += self.reward_calc.apply_event("deflection_resistance")
|
| 488 |
+
self._pending_deflection = None
|
| 489 |
+
return step_delta
|
| 490 |
+
|
| 491 |
+
def _analyse_response_topics(
|
| 492 |
+
self,
|
| 493 |
+
target: str,
|
| 494 |
+
response_topics: list[tuple[str, str]],
|
| 495 |
+
question_topic_names: set[str],
|
| 496 |
+
previous_topics: set[str],
|
| 497 |
+
response_flags: list[str],
|
| 498 |
+
) -> float:
|
| 499 |
+
"""Analyze response claims for contradictions, lies, and prior-pattern hits."""
|
| 500 |
+
step_delta = 0.0
|
| 501 |
+
agent_key = AGENT_NAME_TO_KEY.get(target, target.lower())
|
| 502 |
+
|
| 503 |
+
for topic, value in response_topics:
|
| 504 |
+
claim_result = self.tracker.record_claim(target, topic, value, self.turn)
|
| 505 |
+
|
| 506 |
+
if claim_result["contradicted"]:
|
| 507 |
+
response_flags.append(
|
| 508 |
+
f"CONTRADICTION on '{topic}': "
|
| 509 |
+
f"previously said '{claim_result['old_value'][:50]}', "
|
| 510 |
+
f"now says '{claim_result['new_value'][:50]}'"
|
| 511 |
+
)
|
| 512 |
+
|
| 513 |
+
intentional_probe = (
|
| 514 |
+
topic in question_topic_names and topic in previous_topics
|
| 515 |
+
)
|
| 516 |
+
contradiction_key = (target, topic)
|
| 517 |
+
|
| 518 |
+
if (
|
| 519 |
+
intentional_probe
|
| 520 |
+
and contradiction_key not in self._contradiction_rewarded_topics
|
| 521 |
+
):
|
| 522 |
+
self._contradiction_rewarded_topics.add(contradiction_key)
|
| 523 |
+
self.reward_calc.apply_event("contradiction_exposed")
|
| 524 |
+
step_delta += 2.0
|
| 525 |
+
|
| 526 |
+
if target in (SUSPECT_A, SUSPECT_B):
|
| 527 |
+
self.reward_calc.apply_event(
|
| 528 |
+
"suspect_self_contradiction",
|
| 529 |
+
agent=agent_key,
|
| 530 |
+
)
|
| 531 |
+
elif target == WITNESS_1:
|
| 532 |
+
self.reward_calc.apply_event("witness_self_contradiction")
|
| 533 |
+
|
| 534 |
+
visible_evidence = list(self.evidence_log)
|
| 535 |
+
if visible_evidence:
|
| 536 |
+
ev_result = self.tracker.check_against_evidence(
|
| 537 |
+
target, topic, value, visible_evidence
|
| 538 |
+
)
|
| 539 |
+
if ev_result["confirmed_lie"]:
|
| 540 |
+
response_flags.append(
|
| 541 |
+
f"LIE CONFIRMED by evidence on '{topic}'"
|
| 542 |
+
)
|
| 543 |
+
if target in (SUSPECT_A, SUSPECT_B):
|
| 544 |
+
self.reward_calc.apply_event(
|
| 545 |
+
"confirmed_lie_vs_evidence",
|
| 546 |
+
agent=agent_key,
|
| 547 |
+
)
|
| 548 |
+
step_delta += 1.0
|
| 549 |
+
|
| 550 |
+
prior_result = self.tracker.check_prior_pattern(
|
| 551 |
+
target, topic, value, self.case["prior_history"]
|
| 552 |
+
)
|
| 553 |
+
if prior_result["pattern_match"]:
|
| 554 |
+
response_flags.append(
|
| 555 |
+
f"PRIOR PATTERN MATCH on '{topic}'"
|
| 556 |
+
)
|
| 557 |
+
|
| 558 |
+
has_documented_history = bool(
|
| 559 |
+
self.case["prior_history"]
|
| 560 |
+
.get(target, {})
|
| 561 |
+
.get("past_lies_on_record", [])
|
| 562 |
+
)
|
| 563 |
+
topic_aligned_probe = topic in question_topic_names
|
| 564 |
+
|
| 565 |
+
if has_documented_history and topic_aligned_probe:
|
| 566 |
+
if target not in self._pattern_exploited_targets:
|
| 567 |
+
self._pattern_exploited_targets.add(target)
|
| 568 |
+
self.reward_calc.apply_event("prior_pattern_exploited")
|
| 569 |
+
step_delta += 1.5
|
| 570 |
+
|
| 571 |
+
if (
|
| 572 |
+
target == self.case["criminal"]
|
| 573 |
+
and target not in self._pattern_penalized
|
| 574 |
+
):
|
| 575 |
+
self._pattern_penalized.add(target)
|
| 576 |
+
self.reward_calc.apply_event(
|
| 577 |
+
"prior_pattern_repeated",
|
| 578 |
+
agent=agent_key,
|
| 579 |
+
)
|
| 580 |
+
|
| 581 |
+
return step_delta
|
| 582 |
+
|
| 583 |
+
def _apply_invalid_turn_penalty(self, action_type: str) -> float:
|
| 584 |
+
"""Apply consistent malformed-action accounting across all handlers."""
|
| 585 |
+
self.turn += 1
|
| 586 |
+
self.reward_calc.apply_event("per_turn_cost")
|
| 587 |
+
self._last_action_type = action_type
|
| 588 |
+
self._pending_deflection = None
|
| 589 |
+
return -0.3
|
| 590 |
+
|
| 591 |
+
def _check_witness_bias(self, response_lower: str, response_flags: list[str]) -> None:
|
| 592 |
+
"""Flag likely witness-bias implication of the innocent suspect."""
|
| 593 |
+
witness_knowledge = self.case["agent_knowledge"][WITNESS_1_KEY]
|
| 594 |
+
if not witness_knowledge.get("bias_target"):
|
| 595 |
+
return
|
| 596 |
+
|
| 597 |
+
bias_target = witness_knowledge["bias_target"]
|
| 598 |
+
# Only fire bias penalty if the bias target is the INNOCENT suspect.
|
| 599 |
+
if bias_target.lower() not in response_lower or bias_target == self.case["criminal"]:
|
| 600 |
+
return
|
| 601 |
+
|
| 602 |
+
bias_strength = witness_knowledge.get("bias_strength", 0)
|
| 603 |
+
if bias_strength <= 0.2:
|
| 604 |
+
return
|
| 605 |
+
|
| 606 |
+
scene_location = self.case["location"].lower()
|
| 607 |
+
bias_name = bias_target.lower()
|
| 608 |
+
implication_patterns = [
|
| 609 |
+
f"saw {bias_name}",
|
| 610 |
+
f"{bias_name} was",
|
| 611 |
+
f"{bias_name} at",
|
| 612 |
+
f"{bias_name} near",
|
| 613 |
+
f"{bias_name} did it",
|
| 614 |
+
f"{bias_name} committed",
|
| 615 |
+
]
|
| 616 |
+
certainty_markers = [
|
| 617 |
+
"definitely", "certain", "for sure", "i'm sure", "it was",
|
| 618 |
+
]
|
| 619 |
+
uncertainty_markers = [
|
| 620 |
+
"not sure", "might", "maybe", "could", "possibly",
|
| 621 |
+
"can't be certain", "uncertain",
|
| 622 |
+
]
|
| 623 |
+
|
| 624 |
+
implies_innocent = any(p in response_lower for p in implication_patterns)
|
| 625 |
+
mentions_scene = (
|
| 626 |
+
scene_location in response_lower
|
| 627 |
+
or "scene" in response_lower
|
| 628 |
+
or "there" in response_lower
|
| 629 |
+
)
|
| 630 |
+
is_certain = any(m in response_lower for m in certainty_markers)
|
| 631 |
+
is_uncertain = any(m in response_lower for m in uncertainty_markers)
|
| 632 |
+
|
| 633 |
+
if implies_innocent and mentions_scene and (is_certain or "saw" in response_lower) and not is_uncertain:
|
| 634 |
+
self.reward_calc.apply_event("bias_driven_false_implication")
|
| 635 |
+
response_flags.append("POSSIBLE BIAS-DRIVEN STATEMENT")
|
| 636 |
+
|
| 637 |
+
def _handle_request_evidence(
|
| 638 |
+
self, parsed: dict
|
| 639 |
+
) -> tuple[dict, float, bool, dict]:
|
| 640 |
+
item = parsed["item"].lower()
|
| 641 |
+
step_reward = 0.0
|
| 642 |
+
info: dict[str, Any] = {"action": "request_evidence", "item": item}
|
| 643 |
+
|
| 644 |
+
# Evidence request clears any pending deflection β it's not a follow-up
|
| 645 |
+
self._pending_deflection = None
|
| 646 |
+
self._last_action_type = "request_evidence"
|
| 647 |
+
|
| 648 |
+
self.reward_calc.apply_event("per_turn_cost")
|
| 649 |
+
step_reward += -0.3
|
| 650 |
+
self.turn += 1
|
| 651 |
+
|
| 652 |
+
if item not in EVIDENCE_ITEMS:
|
| 653 |
+
if self.turn >= self.MAX_TURNS:
|
| 654 |
+
return self._handle_timeout(step_reward)
|
| 655 |
+
obs = self._build_observation(
|
| 656 |
+
message=f"Unknown evidence item: {item}. "
|
| 657 |
+
f"Available: {list(EVIDENCE_ITEMS)}"
|
| 658 |
+
)
|
| 659 |
+
return obs, step_reward, False, info
|
| 660 |
+
|
| 661 |
+
# Check if this evidence was already revealed (prevent reward hacking)
|
| 662 |
+
already_revealed = any(
|
| 663 |
+
e.get("name") == item for e in self.evidence_log
|
| 664 |
+
)
|
| 665 |
+
if already_revealed:
|
| 666 |
+
if self.turn >= self.MAX_TURNS:
|
| 667 |
+
return self._handle_timeout(step_reward)
|
| 668 |
+
obs = self._build_observation(
|
| 669 |
+
message=f"Evidence '{item}' has already been revealed."
|
| 670 |
+
)
|
| 671 |
+
return obs, step_reward, False, info
|
| 672 |
+
|
| 673 |
+
# Reveal the evidence
|
| 674 |
+
idx = EVIDENCE_INDEX_MAP.get(item, 0)
|
| 675 |
+
evidence = copy.deepcopy(self.case["physical_evidence"][idx])
|
| 676 |
+
evidence["visible_to_detective"] = True
|
| 677 |
+
self.case["physical_evidence"][idx]["visible_to_detective"] = True
|
| 678 |
+
self.evidence_log.append(evidence)
|
| 679 |
+
|
| 680 |
+
# Check if it confirms an existing lead
|
| 681 |
+
points_to = evidence.get("points_to", "unknown")
|
| 682 |
+
if points_to != "unknown":
|
| 683 |
+
# Only grant reward if the detective asked evidence-relevant,
|
| 684 |
+
# non-general questions to the implicated suspect.
|
| 685 |
+
if points_to in VALID_TARGETS:
|
| 686 |
+
asked = self.asked_topics.get(points_to, set())
|
| 687 |
+
lead_topics = EVIDENCE_LEAD_TOPICS.get(item, set())
|
| 688 |
+
has_meaningful_lead = any(
|
| 689 |
+
topic != "general" and topic in lead_topics
|
| 690 |
+
for topic in asked
|
| 691 |
+
)
|
| 692 |
+
else:
|
| 693 |
+
has_meaningful_lead = False
|
| 694 |
+
|
| 695 |
+
if has_meaningful_lead:
|
| 696 |
+
self.reward_calc.apply_event("evidence_request_confirms_lead")
|
| 697 |
+
step_reward += 1.0
|
| 698 |
+
info["confirms_lead"] = points_to
|
| 699 |
+
|
| 700 |
+
# IMPORTANT: Do NOT reveal points_to to the detective β they must reason
|
| 701 |
+
self.conversation_history.append(
|
| 702 |
+
{
|
| 703 |
+
"turn": self.turn - 1,
|
| 704 |
+
"speaker": "Evidence",
|
| 705 |
+
"content": f"[{item}] {evidence['description']}",
|
| 706 |
+
"flags": [],
|
| 707 |
+
}
|
| 708 |
+
)
|
| 709 |
+
|
| 710 |
+
if self.turn >= self.MAX_TURNS:
|
| 711 |
+
return self._handle_timeout(step_reward)
|
| 712 |
+
|
| 713 |
+
obs = self._build_observation(
|
| 714 |
+
message=f"Evidence revealed: {evidence['description']}"
|
| 715 |
+
)
|
| 716 |
+
info["evidence"] = evidence
|
| 717 |
+
return obs, step_reward, False, info
|
| 718 |
+
|
| 719 |
+
def _handle_accuse(
|
| 720 |
+
self, parsed: dict
|
| 721 |
+
) -> tuple[dict, float, bool, dict]:
|
| 722 |
+
target = parsed["target"]
|
| 723 |
+
|
| 724 |
+
if target not in VALID_ACCUSE_TARGETS:
|
| 725 |
+
reward = self._apply_invalid_turn_penalty("accuse")
|
| 726 |
+
|
| 727 |
+
if self.turn >= self.MAX_TURNS:
|
| 728 |
+
return self._handle_timeout(reward)
|
| 729 |
+
|
| 730 |
+
obs = self._build_observation(
|
| 731 |
+
message=(
|
| 732 |
+
f"Invalid accusation target: {target}. "
|
| 733 |
+
f"Valid: {list(VALID_ACCUSE_TARGETS)}"
|
| 734 |
+
)
|
| 735 |
+
)
|
| 736 |
+
return obs, reward, False, {
|
| 737 |
+
"action": "invalid_accuse_target",
|
| 738 |
+
"target": target,
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
self.done = True
|
| 742 |
+
|
| 743 |
+
criminal = self.case["criminal"]
|
| 744 |
+
correct = target == criminal
|
| 745 |
+
info: dict[str, Any] = {
|
| 746 |
+
"action": "accuse",
|
| 747 |
+
"target": target,
|
| 748 |
+
"correct": correct,
|
| 749 |
+
"actual_criminal": criminal,
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
# Check if witness bias was active
|
| 753 |
+
witness_knowledge = self.case["agent_knowledge"][WITNESS_1_KEY]
|
| 754 |
+
bias_active = (
|
| 755 |
+
witness_knowledge.get("bias_target") is not None
|
| 756 |
+
and witness_knowledge.get("bias_strength", 0) > 0.2
|
| 757 |
+
)
|
| 758 |
+
|
| 759 |
+
if correct:
|
| 760 |
+
# Correct accusation
|
| 761 |
+
self.reward_calc.apply_event("correct_accusation")
|
| 762 |
+
|
| 763 |
+
# Criminal caught
|
| 764 |
+
criminal_key = AGENT_NAME_TO_KEY.get(criminal, criminal.lower())
|
| 765 |
+
self.reward_calc.apply_event(
|
| 766 |
+
"correctly_caught", agent=criminal_key
|
| 767 |
+
)
|
| 768 |
+
|
| 769 |
+
# Witness helped correctly
|
| 770 |
+
self.reward_calc.apply_event("helped_correct_accusation")
|
| 771 |
+
else:
|
| 772 |
+
# Wrong accusation
|
| 773 |
+
self.reward_calc.apply_event(
|
| 774 |
+
"wrong_accusation", bias_active=bias_active
|
| 775 |
+
)
|
| 776 |
+
|
| 777 |
+
# The real criminal escapes
|
| 778 |
+
criminal_key = AGENT_NAME_TO_KEY.get(criminal, criminal.lower())
|
| 779 |
+
self.reward_calc.apply_event(
|
| 780 |
+
"escaped_not_accused", agent=criminal_key
|
| 781 |
+
)
|
| 782 |
+
|
| 783 |
+
# Innocent suspect was wrongly accused
|
| 784 |
+
innocent_key = AGENT_NAME_TO_KEY.get(target, target.lower())
|
| 785 |
+
self.reward_calc.apply_event(
|
| 786 |
+
"wrongly_accused_innocent", agent=innocent_key
|
| 787 |
+
)
|
| 788 |
+
|
| 789 |
+
# Witness led to wrong accusation
|
| 790 |
+
self.reward_calc.apply_event("led_to_wrong_accusation")
|
| 791 |
+
|
| 792 |
+
self.conversation_history.append(
|
| 793 |
+
{
|
| 794 |
+
"turn": self.turn,
|
| 795 |
+
"speaker": "Detective",
|
| 796 |
+
"content": f"ACCUSATION: {target}",
|
| 797 |
+
"flags": [
|
| 798 |
+
"β
CORRECT" if correct else "β WRONG",
|
| 799 |
+
],
|
| 800 |
+
}
|
| 801 |
+
)
|
| 802 |
+
|
| 803 |
+
# Bug 1: Return only the terminal delta, not cumulative total
|
| 804 |
+
if correct:
|
| 805 |
+
terminal_delta = 10.0
|
| 806 |
+
else:
|
| 807 |
+
terminal_delta = -8.0 if not bias_active else -10.0
|
| 808 |
+
|
| 809 |
+
obs = self._build_observation(
|
| 810 |
+
message=(
|
| 811 |
+
f"Accusation: {target}. "
|
| 812 |
+
f"{'CORRECT!' if correct else 'WRONG!'} "
|
| 813 |
+
f"The criminal was {criminal}."
|
| 814 |
+
)
|
| 815 |
+
)
|
| 816 |
+
return obs, terminal_delta, True, info
|
| 817 |
+
|
| 818 |
+
def _handle_timeout(self, step_reward: float = 0.0) -> tuple[dict, float, bool, dict]:
|
| 819 |
+
"""Handle the case where 15 turns pass with no accusation."""
|
| 820 |
+
self.done = True
|
| 821 |
+
|
| 822 |
+
self.reward_calc.apply_event("timeout_no_accusation")
|
| 823 |
+
|
| 824 |
+
# Criminal escapes
|
| 825 |
+
criminal = self.case["criminal"]
|
| 826 |
+
criminal_key = AGENT_NAME_TO_KEY.get(criminal, criminal.lower())
|
| 827 |
+
self.reward_calc.apply_event(
|
| 828 |
+
"escaped_not_accused", agent=criminal_key
|
| 829 |
+
)
|
| 830 |
+
|
| 831 |
+
self.conversation_history.append(
|
| 832 |
+
{
|
| 833 |
+
"turn": self.turn,
|
| 834 |
+
"speaker": "System",
|
| 835 |
+
"content": "TIMEOUT β No accusation made. Case goes cold.",
|
| 836 |
+
"flags": ["β° TIMEOUT"],
|
| 837 |
+
}
|
| 838 |
+
)
|
| 839 |
+
|
| 840 |
+
# Bug 3: Return terminal delta (-3.0) instead of cumulative total
|
| 841 |
+
timeout_penalty = -3.0
|
| 842 |
+
obs = self._build_observation(
|
| 843 |
+
message="Case timed out. No accusation was made within 15 turns."
|
| 844 |
+
)
|
| 845 |
+
return obs, step_reward + timeout_penalty, True, {"action": "timeout"}
|
| 846 |
+
|
| 847 |
+
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 848 |
+
|
| 849 |
+
def _build_observation(self, message: str = "") -> dict:
|
| 850 |
+
"""Build the observation dict for the detective."""
|
| 851 |
+
return {
|
| 852 |
+
"role": "detective",
|
| 853 |
+
"briefing": self.case["detective_briefing"],
|
| 854 |
+
"turn": self.turn,
|
| 855 |
+
"conversation_history": list(self.conversation_history),
|
| 856 |
+
# Bug 10: Deep copy evidence dicts to prevent mutation of case state
|
| 857 |
+
"evidence_log": copy.deepcopy(self.evidence_log),
|
| 858 |
+
"message": message,
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
@staticmethod
|
| 862 |
+
def _default_llm_call(
|
| 863 |
+
system_prompt: str, conversation_history: list[dict]
|
| 864 |
+
) -> str:
|
| 865 |
+
"""Rule-based fallback for NPC agents.
|
| 866 |
+
|
| 867 |
+
Uses the system prompt to identify the agent and provide
|
| 868 |
+
role-appropriate responses that stay consistent.
|
| 869 |
+
Works with either suspect as the criminal.
|
| 870 |
+
"""
|
| 871 |
+
# Determine role from system prompt
|
| 872 |
+
# Bug 5: Use 'FAKE ALIBI' to identify guilty suspect instead of 'committed'
|
| 873 |
+
# (detective prompt contains 'committed' too, causing misidentification)
|
| 874 |
+
is_suspect = "Suspect_A" in system_prompt or "Suspect_B" in system_prompt
|
| 875 |
+
is_guilty = "FAKE ALIBI:" in system_prompt
|
| 876 |
+
is_innocent = "REAL ALIBI:" in system_prompt
|
| 877 |
+
is_witness = ("Witness_1" in system_prompt or "Witness" in system_prompt) and not is_suspect
|
| 878 |
+
|
| 879 |
+
if is_suspect and is_guilty:
|
| 880 |
+
# Extract alibi from the prompt
|
| 881 |
+
alibi = "at a family gathering"
|
| 882 |
+
if "FAKE ALIBI:" in system_prompt:
|
| 883 |
+
alibi_line = system_prompt.split("FAKE ALIBI:")[1].split("\n")[0].strip()
|
| 884 |
+
if alibi_line:
|
| 885 |
+
alibi = alibi_line
|
| 886 |
+
|
| 887 |
+
return (
|
| 888 |
+
f"I was nowhere near the scene. {alibi}. "
|
| 889 |
+
"Check with anyone who was there β they'll confirm. "
|
| 890 |
+
"I don't see why you're asking me about this."
|
| 891 |
+
)
|
| 892 |
+
|
| 893 |
+
elif is_suspect and is_innocent:
|
| 894 |
+
# Extract alibi from the prompt
|
| 895 |
+
alibi = "working late at the office"
|
| 896 |
+
if "REAL ALIBI:" in system_prompt:
|
| 897 |
+
alibi_line = system_prompt.split("REAL ALIBI:")[1].split("\n")[0].strip()
|
| 898 |
+
if alibi_line:
|
| 899 |
+
alibi = alibi_line
|
| 900 |
+
|
| 901 |
+
return (
|
| 902 |
+
f"I can tell you exactly where I was β {alibi}. "
|
| 903 |
+
"I have receipts and witnesses to prove it. "
|
| 904 |
+
"I had absolutely nothing to do with this."
|
| 905 |
+
)
|
| 906 |
+
|
| 907 |
+
elif is_witness:
|
| 908 |
+
# Extract what the witness saw from the prompt
|
| 909 |
+
saw = "someone in dark clothing near the area"
|
| 910 |
+
if "WHAT YOU SAW:" in system_prompt:
|
| 911 |
+
saw_line = system_prompt.split("WHAT YOU SAW:")[1].split("\n")[0].strip()
|
| 912 |
+
if saw_line:
|
| 913 |
+
saw = saw_line
|
| 914 |
+
|
| 915 |
+
return (
|
| 916 |
+
f"I saw {saw}. "
|
| 917 |
+
"They were moving quickly and seemed nervous. "
|
| 918 |
+
"I didn't get a clear look at their face."
|
| 919 |
+
)
|
| 920 |
+
|
| 921 |
+
else:
|
| 922 |
+
return "I don't have anything else to add."
|
crime_env/reward_calculator.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Component 3 β Reward Calculator.
|
| 2 |
+
|
| 3 |
+
Calculates and accumulates rewards for all agents in the Crime Investigation
|
| 4 |
+
environment based on discrete events.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from crime_env.constants import SUSPECT_A_KEY, SUSPECT_B_KEY
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class RewardCalculator:
|
| 13 |
+
"""Tracks and calculates rewards for detective, suspects, and witness."""
|
| 14 |
+
|
| 15 |
+
def __init__(self) -> None:
|
| 16 |
+
self.rewards: dict[str, float] = {
|
| 17 |
+
"detective": 0.0,
|
| 18 |
+
"suspect_a": 0.0,
|
| 19 |
+
"suspect_b": 0.0,
|
| 20 |
+
"witness": 0.0,
|
| 21 |
+
}
|
| 22 |
+
self.event_log: list[dict] = []
|
| 23 |
+
|
| 24 |
+
# ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
+
|
| 26 |
+
def apply_event(self, event_type: str, **kwargs) -> float:
|
| 27 |
+
"""Apply a reward event and return the reward delta.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
event_type: One of the defined event types below.
|
| 31 |
+
**kwargs: Additional context (e.g. agent, bias_active, etc.)
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
The reward delta applied.
|
| 35 |
+
"""
|
| 36 |
+
handler = self._EVENT_MAP.get(event_type)
|
| 37 |
+
if handler is None:
|
| 38 |
+
raise ValueError(f"Unknown event type: {event_type}")
|
| 39 |
+
|
| 40 |
+
delta = handler(self, **kwargs)
|
| 41 |
+
self.event_log.append({"event": event_type, "delta": delta, **kwargs})
|
| 42 |
+
return delta
|
| 43 |
+
|
| 44 |
+
def get_rewards(self) -> dict[str, float]:
|
| 45 |
+
"""Return current cumulative rewards for all agents."""
|
| 46 |
+
return {k: round(v, 4) for k, v in self.rewards.items()}
|
| 47 |
+
|
| 48 |
+
def get_event_log(self) -> list[dict]:
|
| 49 |
+
"""Return the full event log."""
|
| 50 |
+
return list(self.event_log)
|
| 51 |
+
|
| 52 |
+
# ββ Detective events ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
+
|
| 54 |
+
def _correct_accusation(self, **kwargs) -> float:
|
| 55 |
+
self.rewards["detective"] += 10.0
|
| 56 |
+
return 10.0
|
| 57 |
+
|
| 58 |
+
def _wrong_accusation(self, **kwargs) -> float:
|
| 59 |
+
delta = -8.0
|
| 60 |
+
# Extra penalty if witness bias was active
|
| 61 |
+
if kwargs.get("bias_active", False):
|
| 62 |
+
delta -= 2.0
|
| 63 |
+
self.rewards["detective"] += delta
|
| 64 |
+
return delta
|
| 65 |
+
|
| 66 |
+
def _timeout_no_accusation(self, **kwargs) -> float:
|
| 67 |
+
self.rewards["detective"] += -3.0
|
| 68 |
+
return -3.0
|
| 69 |
+
|
| 70 |
+
def _contradiction_exposed(self, **kwargs) -> float:
|
| 71 |
+
self.rewards["detective"] += 2.0
|
| 72 |
+
return 2.0
|
| 73 |
+
|
| 74 |
+
def _prior_pattern_exploited(self, **kwargs) -> float:
|
| 75 |
+
self.rewards["detective"] += 1.5
|
| 76 |
+
return 1.5
|
| 77 |
+
|
| 78 |
+
def _deflection_resistance(self, **kwargs) -> float:
|
| 79 |
+
self.rewards["detective"] += 0.5
|
| 80 |
+
return 0.5
|
| 81 |
+
|
| 82 |
+
def _evidence_request_confirms_lead(self, **kwargs) -> float:
|
| 83 |
+
self.rewards["detective"] += 1.0
|
| 84 |
+
return 1.0
|
| 85 |
+
|
| 86 |
+
def _redundant_question(self, **kwargs) -> float:
|
| 87 |
+
self.rewards["detective"] += -0.5
|
| 88 |
+
return -0.5
|
| 89 |
+
|
| 90 |
+
def _per_turn_cost(self, **kwargs) -> float:
|
| 91 |
+
self.rewards["detective"] += -0.3
|
| 92 |
+
return -0.3
|
| 93 |
+
|
| 94 |
+
# ββ Suspect events ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 95 |
+
|
| 96 |
+
def _escaped_not_accused(self, **kwargs) -> float:
|
| 97 |
+
agent = kwargs.get("agent", SUSPECT_A_KEY)
|
| 98 |
+
self.rewards[agent] += 10.0
|
| 99 |
+
return 10.0
|
| 100 |
+
|
| 101 |
+
def _correctly_caught(self, **kwargs) -> float:
|
| 102 |
+
agent = kwargs.get("agent", SUSPECT_A_KEY)
|
| 103 |
+
self.rewards[agent] += -10.0
|
| 104 |
+
return -10.0
|
| 105 |
+
|
| 106 |
+
def _wrongly_accused_innocent(self, **kwargs) -> float:
|
| 107 |
+
agent = kwargs.get("agent", SUSPECT_B_KEY)
|
| 108 |
+
self.rewards[agent] += -6.0
|
| 109 |
+
return -6.0
|
| 110 |
+
|
| 111 |
+
def _suspect_self_contradiction(self, **kwargs) -> float:
|
| 112 |
+
agent = kwargs.get("agent", SUSPECT_A_KEY)
|
| 113 |
+
self.rewards[agent] += -3.0
|
| 114 |
+
return -3.0
|
| 115 |
+
|
| 116 |
+
def _confirmed_lie_vs_evidence(self, **kwargs) -> float:
|
| 117 |
+
agent = kwargs.get("agent", SUSPECT_A_KEY)
|
| 118 |
+
self.rewards[agent] += -1.0
|
| 119 |
+
# Detective is rewarded for catching the lie
|
| 120 |
+
self.rewards["detective"] += 1.0
|
| 121 |
+
# Return suspect penalty for event log (detective gain logged separately)
|
| 122 |
+
return -1.0 # suspect delta; detective delta is +1.0 (applied above)
|
| 123 |
+
|
| 124 |
+
def _successful_deflection(self, **kwargs) -> float:
|
| 125 |
+
agent = kwargs.get("agent", SUSPECT_A_KEY)
|
| 126 |
+
self.rewards[agent] += 1.0
|
| 127 |
+
return 1.0
|
| 128 |
+
|
| 129 |
+
def _prior_pattern_repeated(self, **kwargs) -> float:
|
| 130 |
+
agent = kwargs.get("agent", SUSPECT_A_KEY)
|
| 131 |
+
self.rewards[agent] += -1.0
|
| 132 |
+
return -1.0
|
| 133 |
+
|
| 134 |
+
# ββ Witness events ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 135 |
+
|
| 136 |
+
def _accurate_statement(self, **kwargs) -> float:
|
| 137 |
+
self.rewards["witness"] += 2.0
|
| 138 |
+
return 2.0
|
| 139 |
+
|
| 140 |
+
def _helped_correct_accusation(self, **kwargs) -> float:
|
| 141 |
+
self.rewards["witness"] += 3.0
|
| 142 |
+
return 3.0
|
| 143 |
+
|
| 144 |
+
def _led_to_wrong_accusation(self, **kwargs) -> float:
|
| 145 |
+
self.rewards["witness"] += -4.0
|
| 146 |
+
return -4.0
|
| 147 |
+
|
| 148 |
+
def _bias_driven_false_implication(self, **kwargs) -> float:
|
| 149 |
+
self.rewards["witness"] += -2.0
|
| 150 |
+
return -2.0
|
| 151 |
+
|
| 152 |
+
def _witness_self_contradiction(self, **kwargs) -> float:
|
| 153 |
+
self.rewards["witness"] += -2.0
|
| 154 |
+
return -2.0
|
| 155 |
+
|
| 156 |
+
# ββ Event dispatch map ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 157 |
+
|
| 158 |
+
_EVENT_MAP = {
|
| 159 |
+
# Detective
|
| 160 |
+
"correct_accusation": _correct_accusation,
|
| 161 |
+
"wrong_accusation": _wrong_accusation,
|
| 162 |
+
"timeout_no_accusation": _timeout_no_accusation,
|
| 163 |
+
"contradiction_exposed": _contradiction_exposed,
|
| 164 |
+
"prior_pattern_exploited": _prior_pattern_exploited,
|
| 165 |
+
"evidence_request_confirms_lead": _evidence_request_confirms_lead,
|
| 166 |
+
"redundant_question": _redundant_question,
|
| 167 |
+
"per_turn_cost": _per_turn_cost,
|
| 168 |
+
"deflection_resistance": _deflection_resistance,
|
| 169 |
+
# Suspect
|
| 170 |
+
"escaped_not_accused": _escaped_not_accused,
|
| 171 |
+
"correctly_caught": _correctly_caught,
|
| 172 |
+
"wrongly_accused_innocent": _wrongly_accused_innocent,
|
| 173 |
+
"suspect_self_contradiction": _suspect_self_contradiction,
|
| 174 |
+
"confirmed_lie_vs_evidence": _confirmed_lie_vs_evidence,
|
| 175 |
+
"successful_deflection": _successful_deflection,
|
| 176 |
+
"prior_pattern_repeated": _prior_pattern_repeated,
|
| 177 |
+
# Witness
|
| 178 |
+
"accurate_statement": _accurate_statement,
|
| 179 |
+
"helped_correct_accusation": _helped_correct_accusation,
|
| 180 |
+
"led_to_wrong_accusation": _led_to_wrong_accusation,
|
| 181 |
+
"bias_driven_false_implication": _bias_driven_false_implication,
|
| 182 |
+
"witness_self_contradiction": _witness_self_contradiction,
|
| 183 |
+
}
|
dashboard.html
ADDED
|
@@ -0,0 +1,1404 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
| 7 |
+
<meta http-equiv="Pragma" content="no-cache">
|
| 8 |
+
<meta http-equiv="Expires" content="0">
|
| 9 |
+
<title>AI Crime Investigation β Live Dashboard</title>
|
| 10 |
+
<meta name="description" content="Real-time dashboard for the AI Crime Investigation World multi-agent RL environment">
|
| 11 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 12 |
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 13 |
+
<style>
|
| 14 |
+
/* ββ Reset & Variables βββββββββββββββββββββββββββββββ */
|
| 15 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 16 |
+
|
| 17 |
+
:root {
|
| 18 |
+
--bg-primary: #FDFCF9;
|
| 19 |
+
--bg-secondary: #FFFFFF;
|
| 20 |
+
--bg-card: #FFFFFF;
|
| 21 |
+
--bg-card-hover: #F8F7F4;
|
| 22 |
+
--bg-panel: #FDFCF9;
|
| 23 |
+
--border: #000000;
|
| 24 |
+
--border-glow: #000000;
|
| 25 |
+
--text-primary: #000000;
|
| 26 |
+
--text-secondary: #000000;
|
| 27 |
+
--text-muted: #444444;
|
| 28 |
+
|
| 29 |
+
--accent-teal: #65A3BB;
|
| 30 |
+
--accent-yellow: #F4CC64;
|
| 31 |
+
--accent-orange: #F5A972;
|
| 32 |
+
--accent-red: #F25D68;
|
| 33 |
+
|
| 34 |
+
--accent-blue: #65A3BB;
|
| 35 |
+
--accent-cyan: #F4CC64;
|
| 36 |
+
--accent-amber: #F5A972;
|
| 37 |
+
--accent-green: #F4CC64;
|
| 38 |
+
--accent-purple: #F5A972;
|
| 39 |
+
|
| 40 |
+
--gradient-reward: linear-gradient(90deg, #F25D68, #F5A972, #65A3BB);
|
| 41 |
+
--shadow-glow: 4px 4px 0px #000000;
|
| 42 |
+
--font-mono: 'JetBrains Mono', monospace;
|
| 43 |
+
--font-sans: 'Inter', system-ui, sans-serif;
|
| 44 |
+
--radius: 0px;
|
| 45 |
+
--radius-sm: 0px;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
html { font-size: 15px; }
|
| 49 |
+
|
| 50 |
+
body {
|
| 51 |
+
font-family: var(--font-sans);
|
| 52 |
+
background: var(--bg-primary);
|
| 53 |
+
color: var(--text-primary);
|
| 54 |
+
min-height: 100vh;
|
| 55 |
+
overflow-x: hidden;
|
| 56 |
+
background-image: radial-gradient(rgba(0, 0, 0, 0.2) 2px, transparent 2px);
|
| 57 |
+
background-size: 20px 20px;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/* ββ Layout βββββββββββββββββββββββββββββββββββββββββ */
|
| 61 |
+
.app { position: relative; z-index: 1; }
|
| 62 |
+
|
| 63 |
+
.topbar {
|
| 64 |
+
display: flex;
|
| 65 |
+
align-items: center;
|
| 66 |
+
justify-content: space-between;
|
| 67 |
+
padding: 14px 28px;
|
| 68 |
+
background: var(--accent-teal);
|
| 69 |
+
border-bottom: 3px solid #000;
|
| 70 |
+
position: sticky;
|
| 71 |
+
top: 0;
|
| 72 |
+
z-index: 100;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.topbar-left {
|
| 76 |
+
display: flex;
|
| 77 |
+
align-items: center;
|
| 78 |
+
gap: 14px;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.logo {
|
| 82 |
+
font-size: 1.15rem;
|
| 83 |
+
font-weight: 800;
|
| 84 |
+
color: var(--text-primary);
|
| 85 |
+
letter-spacing: -0.02em;
|
| 86 |
+
text-transform: uppercase;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.logo-icon { font-size: 1.3rem; }
|
| 90 |
+
|
| 91 |
+
.status-badge {
|
| 92 |
+
background: var(--bg-card); border: 2px solid #000; color: #000 !important; box-shadow: 2px 2px 0px #000;
|
| 93 |
+
display: inline-flex;
|
| 94 |
+
align-items: center;
|
| 95 |
+
gap: 6px;
|
| 96 |
+
padding: 4px 12px;
|
| 97 |
+
background: var(--bg-card);
|
| 98 |
+
border: 3px solid #000;
|
| 99 |
+
font-size: 0.72rem;
|
| 100 |
+
font-weight: 800;
|
| 101 |
+
color: var(--text-primary) !important;
|
| 102 |
+
text-transform: uppercase;
|
| 103 |
+
letter-spacing: 0.05em;
|
| 104 |
+
box-shadow: 2px 2px 0px #000;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.status-dot {
|
| 108 |
+
box-shadow: 0 0 8px var(--accent-green); border: 3px solid #000; border-radius: 50%;
|
| 109 |
+
width: 8px;
|
| 110 |
+
height: 8px;
|
| 111 |
+
border-radius: 0px;
|
| 112 |
+
border: 3px solid #000;
|
| 113 |
+
background: var(--accent-green);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.topbar-right {
|
| 117 |
+
display: flex;
|
| 118 |
+
align-items: center;
|
| 119 |
+
gap: 12px;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.btn {
|
| 123 |
+
display: inline-flex;
|
| 124 |
+
align-items: center;
|
| 125 |
+
gap: 6px;
|
| 126 |
+
padding: 8px 16px;
|
| 127 |
+
border: 3px solid #000;
|
| 128 |
+
background: var(--bg-card);
|
| 129 |
+
color: var(--text-primary);
|
| 130 |
+
font-family: var(--font-sans);
|
| 131 |
+
font-size: 0.8rem;
|
| 132 |
+
font-weight: 800;
|
| 133 |
+
border-radius: 0px;
|
| 134 |
+
cursor: pointer;
|
| 135 |
+
box-shadow: 3px 3px 0px #000;
|
| 136 |
+
transition: all 0.1s;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.btn:hover {
|
| 140 |
+
transform: translate(-2px, -2px);
|
| 141 |
+
box-shadow: 5px 5px 0px #000;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.btn:active {
|
| 145 |
+
transform: translate(2px, 2px);
|
| 146 |
+
box-shadow: 1px 1px 0px #000;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.btn-primary {
|
| 150 |
+
background: var(--accent-blue);
|
| 151 |
+
color: #000;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.btn-danger {
|
| 155 |
+
background: var(--accent-red);
|
| 156 |
+
color: #000;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
/* ββ Stats Strip ββββββββββββββββββββββββββββββββββββ */
|
| 160 |
+
.stats-strip {
|
| 161 |
+
display: grid;
|
| 162 |
+
grid-template-columns: repeat(5, 1fr);
|
| 163 |
+
gap: 12px;
|
| 164 |
+
padding: 16px 28px;
|
| 165 |
+
background: var(--bg-primary);
|
| 166 |
+
border-bottom: 3px solid #000;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.stat-card {
|
| 170 |
+
display: flex;
|
| 171 |
+
align-items: center;
|
| 172 |
+
gap: 12px;
|
| 173 |
+
padding: 12px 16px;
|
| 174 |
+
background: var(--bg-card);
|
| 175 |
+
border: 3px solid #000;
|
| 176 |
+
border-radius: 0px;
|
| 177 |
+
box-shadow: 4px 4px 0px #000;
|
| 178 |
+
transition: all 0.1s;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.stat-card:hover {
|
| 182 |
+
transform: translate(-2px, -2px);
|
| 183 |
+
box-shadow: 5px 5px 0px #000;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.stat-icon {
|
| 187 |
+
width: 38px;
|
| 188 |
+
height: 38px;
|
| 189 |
+
border-radius: 0px;
|
| 190 |
+
display: flex;
|
| 191 |
+
align-items: center;
|
| 192 |
+
justify-content: center;
|
| 193 |
+
font-size: 1.1rem;
|
| 194 |
+
border: 3px solid #000;
|
| 195 |
+
background: var(--bg-primary);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.stat-icon.turns { background: var(--accent-blue); }
|
| 199 |
+
.stat-icon.evidence { background: var(--accent-cyan); }
|
| 200 |
+
.stat-icon.contradictions { background: var(--accent-amber); }
|
| 201 |
+
.stat-icon.reward { background: var(--accent-green); }
|
| 202 |
+
.stat-icon.result { background: var(--accent-teal); }
|
| 203 |
+
|
| 204 |
+
.stat-info { display: flex; flex-direction: column; }
|
| 205 |
+
|
| 206 |
+
.stat-label {
|
| 207 |
+
font-size: 0.72rem;
|
| 208 |
+
color: var(--text-primary);
|
| 209 |
+
text-transform: uppercase;
|
| 210 |
+
letter-spacing: 0.06em;
|
| 211 |
+
font-weight: 800;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.stat-value {
|
| 215 |
+
font-family: var(--font-mono);
|
| 216 |
+
font-size: 1.25rem;
|
| 217 |
+
font-weight: 800;
|
| 218 |
+
color: var(--text-primary);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
.stat-value.positive { color: #16a34a; }
|
| 222 |
+
.stat-value.negative { color: #dc2626; }
|
| 223 |
+
.stat-value.neutral { color: var(--text-primary); }
|
| 224 |
+
|
| 225 |
+
/* ββ Main Grid ββββββββββββββββββββββββββββββββββββββ */
|
| 226 |
+
.main-grid {
|
| 227 |
+
display: grid;
|
| 228 |
+
grid-template-columns: 1fr 340px;
|
| 229 |
+
gap: 16px;
|
| 230 |
+
padding: 16px 28px 28px;
|
| 231 |
+
min-height: calc(100vh - 160px);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
/* ββ Panel ββββββββββββββββββββββββββββββββββββββββββ */
|
| 235 |
+
.panel {
|
| 236 |
+
background: var(--bg-panel);
|
| 237 |
+
border: 3px solid #000;
|
| 238 |
+
border-radius: 0px;
|
| 239 |
+
box-shadow: 5px 5px 0px #000;
|
| 240 |
+
overflow: hidden;
|
| 241 |
+
display: flex;
|
| 242 |
+
flex-direction: column;
|
| 243 |
+
margin-bottom: 4px;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.panel-header {
|
| 247 |
+
display: flex;
|
| 248 |
+
align-items: center;
|
| 249 |
+
justify-content: space-between;
|
| 250 |
+
padding: 14px 18px;
|
| 251 |
+
border-bottom: 3px solid #000;
|
| 252 |
+
background: var(--bg-card);
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.panel-title {
|
| 256 |
+
font-size: 0.82rem;
|
| 257 |
+
font-weight: 700;
|
| 258 |
+
display: flex;
|
| 259 |
+
align-items: center;
|
| 260 |
+
gap: 8px;
|
| 261 |
+
text-transform: uppercase;
|
| 262 |
+
letter-spacing: 0.04em;
|
| 263 |
+
color: var(--text-secondary);
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.panel-body {
|
| 267 |
+
padding: 14px 18px;
|
| 268 |
+
background: var(--bg-panel);
|
| 269 |
+
flex: 1;
|
| 270 |
+
overflow-y: auto;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
/* ββ Conversation Log βββββββββββββββββββββββββββββββ */
|
| 274 |
+
.conversation-panel {
|
| 275 |
+
max-height: calc(100vh - 230px);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
.conversation-panel .panel-body {
|
| 279 |
+
display: flex;
|
| 280 |
+
flex-direction: column;
|
| 281 |
+
gap: 10px;
|
| 282 |
+
padding-bottom: 16px;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.msg {
|
| 286 |
+
display: flex;
|
| 287 |
+
gap: 10px;
|
| 288 |
+
padding: 12px 14px;
|
| 289 |
+
border-radius: 0px;
|
| 290 |
+
border: 2px solid transparent;
|
| 291 |
+
background: transparent;
|
| 292 |
+
border-bottom: 2px solid rgba(0,0,0,0.05);
|
| 293 |
+
animation: msg-in 0.35s ease-out;
|
| 294 |
+
transition: all 0.2s;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.msg:hover {
|
| 298 |
+
background: rgba(0,0,0,0.02);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
@keyframes msg-in {
|
| 302 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 303 |
+
to { opacity: 1; transform: translateY(0); }
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.msg-avatar {
|
| 307 |
+
width: 38px;
|
| 308 |
+
height: 38px;
|
| 309 |
+
border-radius: 0px;
|
| 310 |
+
border: 3px solid #000;
|
| 311 |
+
display: flex;
|
| 312 |
+
align-items: center;
|
| 313 |
+
justify-content: center;
|
| 314 |
+
font-size: 1.1rem;
|
| 315 |
+
flex-shrink: 0;
|
| 316 |
+
font-weight: 800;
|
| 317 |
+
color: #000;
|
| 318 |
+
box-shadow: 2px 2px 0px #000;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
.msg-avatar.detective { background: var(--accent-blue); }
|
| 322 |
+
.msg-avatar.suspect-a { background: var(--accent-red); }
|
| 323 |
+
.msg-avatar.suspect-b { background: var(--accent-orange); }
|
| 324 |
+
.msg-avatar.witness { background: var(--accent-purple); }
|
| 325 |
+
.msg-avatar.system { background: #e2e8f0; }
|
| 326 |
+
|
| 327 |
+
.msg-content { flex: 1; min-width: 0; }
|
| 328 |
+
|
| 329 |
+
.msg-header {
|
| 330 |
+
display: flex;
|
| 331 |
+
align-items: center;
|
| 332 |
+
gap: 12px;
|
| 333 |
+
margin-bottom: 6px;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.msg-name {
|
| 337 |
+
font-weight: 800;
|
| 338 |
+
font-size: 0.8rem;
|
| 339 |
+
text-transform: uppercase;
|
| 340 |
+
letter-spacing: 0.05em;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.msg-name.detective { color: var(--accent-blue); }
|
| 344 |
+
.msg-name.suspect-a { color: var(--accent-red); }
|
| 345 |
+
.msg-name.suspect-b { color: var(--accent-orange); }
|
| 346 |
+
.msg-name.witness { color: var(--accent-purple); }
|
| 347 |
+
.msg-name.system { color: var(--text-muted); }
|
| 348 |
+
|
| 349 |
+
.msg-turn {
|
| 350 |
+
font-size: 0.65rem;
|
| 351 |
+
color: var(--text-muted);
|
| 352 |
+
font-weight: 600;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.msg-text {
|
| 356 |
+
font-size: 1rem;
|
| 357 |
+
line-height: 1.5;
|
| 358 |
+
color: var(--text-secondary);
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
.msg-tag {
|
| 362 |
+
display: inline-block;
|
| 363 |
+
padding: 4px 10px;
|
| 364 |
+
border-radius: 0px;
|
| 365 |
+
border: 2px solid #000;
|
| 366 |
+
font-size: 0.65rem;
|
| 367 |
+
font-weight: 800;
|
| 368 |
+
text-transform: uppercase;
|
| 369 |
+
letter-spacing: 0.04em;
|
| 370 |
+
margin-top: 8px;
|
| 371 |
+
box-shadow: 2px 2px 0px #000;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.msg-tag.evidence { background: var(--accent-cyan); color: #000; }
|
| 375 |
+
.msg-tag.contradiction { background: var(--accent-red); color: #000; }
|
| 376 |
+
.msg-tag.accusation { background: var(--accent-green); color: #000; }
|
| 377 |
+
|
| 378 |
+
/* ββ Sidebar ββββββββββββββββββββββββββββββββββββββββ */
|
| 379 |
+
.sidebar {
|
| 380 |
+
display: flex;
|
| 381 |
+
flex-direction: column;
|
| 382 |
+
gap: 16px;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
/* ββ Reward Meter βββββββββββββββββββββββββββββββββββ */
|
| 386 |
+
.reward-meter {
|
| 387 |
+
padding: 18px;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
.reward-bar-track {
|
| 391 |
+
width: 100%;
|
| 392 |
+
height: 12px;
|
| 393 |
+
background: rgba(0, 0, 0, 0.05);
|
| 394 |
+
border: 2px solid #000;
|
| 395 |
+
border-radius: 0px;
|
| 396 |
+
overflow: hidden;
|
| 397 |
+
margin: 12px 0 8px;
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
.reward-bar-fill {
|
| 401 |
+
height: 100%;
|
| 402 |
+
border-radius: 0px;
|
| 403 |
+
background: var(--gradient-reward);
|
| 404 |
+
width: 0%;
|
| 405 |
+
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.reward-labels {
|
| 409 |
+
display: flex;
|
| 410 |
+
justify-content: space-between;
|
| 411 |
+
font-size: 0.72rem;
|
| 412 |
+
color: var(--text-muted);
|
| 413 |
+
font-weight: 700;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
.reward-detail {
|
| 417 |
+
display: flex;
|
| 418 |
+
justify-content: space-between;
|
| 419 |
+
align-items: center;
|
| 420 |
+
padding: 8px 0;
|
| 421 |
+
border-bottom: 2px solid rgba(0, 0, 0, 0.05);
|
| 422 |
+
font-size: 0.8rem;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
.reward-detail:last-child { border-bottom: none; }
|
| 426 |
+
|
| 427 |
+
.reward-detail-label { color: var(--text-muted); font-weight: 600; }
|
| 428 |
+
|
| 429 |
+
.reward-detail-value {
|
| 430 |
+
font-family: var(--font-mono);
|
| 431 |
+
font-weight: 700;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
/* ββ Evidence Panel βββββββββββββββββββββββββββββββββ */
|
| 435 |
+
.evidence-list {
|
| 436 |
+
display: flex;
|
| 437 |
+
flex-direction: column;
|
| 438 |
+
gap: 12px;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
.evidence-item {
|
| 442 |
+
display: flex;
|
| 443 |
+
align-items: flex-start;
|
| 444 |
+
gap: 12px;
|
| 445 |
+
padding: 12px 14px;
|
| 446 |
+
background: var(--bg-card);
|
| 447 |
+
border: 3px solid #000;
|
| 448 |
+
border-radius: 0px;
|
| 449 |
+
box-shadow: 4px 4px 0px #000;
|
| 450 |
+
transition: all 0.2s;
|
| 451 |
+
position: relative;
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
.evidence-item::before {
|
| 455 |
+
border-top-left-radius: var(--radius-sm); border-bottom-left-radius: var(--radius-sm);
|
| 456 |
+
content: '';
|
| 457 |
+
position: absolute;
|
| 458 |
+
left: -3px; top: -3px; bottom: -3px;
|
| 459 |
+
width: 10px;
|
| 460 |
+
background: var(--text-muted);
|
| 461 |
+
border-right: 3px solid #000;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
.evidence-item.revealed { background: var(--accent-yellow); border: 3px solid #000; box-shadow: 4px 4px 0px #000; color: #000; }
|
| 465 |
+
|
| 466 |
+
.evidence-item.revealed::before { background: var(--bg-primary); border-right: 3px solid #000; }
|
| 467 |
+
|
| 468 |
+
.evidence-item.locked { opacity: 0.5; background: var(--bg-card); }
|
| 469 |
+
|
| 470 |
+
.evidence-item.locked::before {
|
| 471 |
+
background: #94a3b8;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
.evidence-icon {
|
| 475 |
+
font-size: 1.1rem;
|
| 476 |
+
flex-shrink: 0;
|
| 477 |
+
margin-top: 2px;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
.evidence-info { flex: 1; }
|
| 481 |
+
|
| 482 |
+
.evidence-name {
|
| 483 |
+
font-size: 0.85rem;
|
| 484 |
+
font-weight: 600;
|
| 485 |
+
color: var(--text-primary);
|
| 486 |
+
margin-bottom: 3px;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
.evidence-desc {
|
| 490 |
+
font-size: 0.72rem;
|
| 491 |
+
color: var(--text-muted);
|
| 492 |
+
line-height: 1.4;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
/* ββ Contradiction Alerts βββββββββββββββββββββββββββ */
|
| 496 |
+
.alert-list {
|
| 497 |
+
display: flex;
|
| 498 |
+
flex-direction: column;
|
| 499 |
+
gap: 8px;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
.alert-item {
|
| 503 |
+
padding: 12px 14px;
|
| 504 |
+
background: var(--bg-card);
|
| 505 |
+
border: 3px solid #000;
|
| 506 |
+
border-left: 10px solid var(--accent-red);
|
| 507 |
+
border-radius: 0px;
|
| 508 |
+
box-shadow: 4px 4px 0px #000;
|
| 509 |
+
animation: alert-flash 0.5s ease-out;
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
@keyframes alert-flash {
|
| 513 |
+
0% { transform: translateX(10px); }
|
| 514 |
+
100% { transform: translateX(0); }
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
.alert-suspect {
|
| 518 |
+
font-size: 0.72rem;
|
| 519 |
+
font-weight: 700;
|
| 520 |
+
color: var(--accent-red);
|
| 521 |
+
margin-bottom: 4px;
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
.alert-detail {
|
| 525 |
+
font-size: 0.72rem;
|
| 526 |
+
color: var(--text-muted);
|
| 527 |
+
line-height: 1.4;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
.alert-turns {
|
| 531 |
+
font-size: 0.65rem;
|
| 532 |
+
color: var(--text-muted);
|
| 533 |
+
margin-top: 4px;
|
| 534 |
+
font-weight: 600;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
/* ββ Reward History Chart βββββββββββββββββββββββββββ */
|
| 538 |
+
.chart-container {
|
| 539 |
+
height: 200px;
|
| 540 |
+
position: relative;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
.training-metrics {
|
| 544 |
+
display: grid;
|
| 545 |
+
grid-template-columns: repeat(2, 1fr);
|
| 546 |
+
gap: 8px;
|
| 547 |
+
margin-bottom: 10px;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
.training-metric {
|
| 551 |
+
border: 2px solid #000;
|
| 552 |
+
background: var(--bg-card);
|
| 553 |
+
padding: 8px;
|
| 554 |
+
box-shadow: 2px 2px 0px #000;
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
.training-metric-label {
|
| 558 |
+
font-size: 0.66rem;
|
| 559 |
+
text-transform: uppercase;
|
| 560 |
+
letter-spacing: 0.05em;
|
| 561 |
+
font-weight: 800;
|
| 562 |
+
color: var(--text-muted);
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
.training-metric-value {
|
| 566 |
+
font-family: var(--font-mono);
|
| 567 |
+
font-weight: 800;
|
| 568 |
+
margin-top: 2px;
|
| 569 |
+
font-size: 0.9rem;
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
.chart-canvas {
|
| 573 |
+
width: 100%;
|
| 574 |
+
height: 100%;
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
/* ββ Empty State ββββββββββββββββββββββββββββββββββββ */
|
| 578 |
+
.empty-state {
|
| 579 |
+
text-align: center;
|
| 580 |
+
padding: 30px 16px;
|
| 581 |
+
color: var(--text-muted);
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
.empty-icon { font-size: 2rem; margin-bottom: 8px; }
|
| 585 |
+
|
| 586 |
+
.empty-text {
|
| 587 |
+
font-size: 0.78rem;
|
| 588 |
+
line-height: 1.5;
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
/* ββ Scrollbar ββββββββββββββββββββββββββββββββββββββ */
|
| 592 |
+
::-webkit-scrollbar { width: 5px; }
|
| 593 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 594 |
+
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
| 595 |
+
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
| 596 |
+
|
| 597 |
+
/* ββ Responsive βββββββββββββββββββββββββββββββββββββ */
|
| 598 |
+
@media (max-width: 1100px) {
|
| 599 |
+
.main-grid { grid-template-columns: 1fr; }
|
| 600 |
+
.stats-strip { grid-template-columns: repeat(3, 1fr); }
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
@media (max-width: 700px) {
|
| 604 |
+
.stats-strip { grid-template-columns: 1fr 1fr; }
|
| 605 |
+
.topbar { padding: 12px 16px; }
|
| 606 |
+
.main-grid { padding: 12px 16px; }
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
/* ββ Outcome overlay ββββββββββββββββββββββββββββββββ */
|
| 610 |
+
.outcome-overlay {
|
| 611 |
+
position: fixed;
|
| 612 |
+
inset: 0;
|
| 613 |
+
background: rgba(253, 252, 249, 0.85);
|
| 614 |
+
backdrop-filter: blur(8px);
|
| 615 |
+
display: flex;
|
| 616 |
+
align-items: center;
|
| 617 |
+
justify-content: center;
|
| 618 |
+
z-index: 200;
|
| 619 |
+
opacity: 0;
|
| 620 |
+
pointer-events: none;
|
| 621 |
+
transition: opacity 0.4s;
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
.outcome-overlay.visible {
|
| 625 |
+
opacity: 1;
|
| 626 |
+
pointer-events: auto;
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
.outcome-card {
|
| 630 |
+
background: var(--bg-card);
|
| 631 |
+
border: 3px solid #000;
|
| 632 |
+
border-radius: 0px;
|
| 633 |
+
padding: 40px 50px;
|
| 634 |
+
text-align: center;
|
| 635 |
+
transform: scale(0.9);
|
| 636 |
+
box-shadow: 8px 8px 0px #000;
|
| 637 |
+
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
| 638 |
+
max-width: 420px;
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
.outcome-overlay.visible .outcome-card {
|
| 642 |
+
transform: scale(1);
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
.outcome-icon { font-size: 3.5rem; margin-bottom: 12px; }
|
| 646 |
+
|
| 647 |
+
.outcome-title {
|
| 648 |
+
font-size: 1.6rem;
|
| 649 |
+
font-weight: 700;
|
| 650 |
+
margin-bottom: 6px;
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
.outcome-subtitle {
|
| 654 |
+
font-size: 0.9rem;
|
| 655 |
+
color: var(--text-muted);
|
| 656 |
+
margin-bottom: 20px;
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
.outcome-stat {
|
| 660 |
+
font-family: var(--font-mono);
|
| 661 |
+
font-size: 2rem;
|
| 662 |
+
font-weight: 700;
|
| 663 |
+
margin-bottom: 20px;
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
.outcome-stat.win { color: var(--accent-green); }
|
| 667 |
+
.outcome-stat.lose { color: var(--accent-red); }
|
| 668 |
+
</style>
|
| 669 |
+
</head>
|
| 670 |
+
<body>
|
| 671 |
+
<div class="app" id="app">
|
| 672 |
+
|
| 673 |
+
<!-- ββ Topbar ββββββββββββββββββββββββββββββββββββββββ -->
|
| 674 |
+
<header class="topbar">
|
| 675 |
+
<div class="topbar-left">
|
| 676 |
+
<span class="logo-icon">π</span>
|
| 677 |
+
<span class="logo">Crime Investigation World</span>
|
| 678 |
+
<div class="status-badge" id="statusBadge">
|
| 679 |
+
<span class="status-dot"></span>
|
| 680 |
+
<span id="statusText">Ready</span>
|
| 681 |
+
</div>
|
| 682 |
+
</div>
|
| 683 |
+
<div class="topbar-right">
|
| 684 |
+
<span id="episodeLabel" style="font-size:0.75rem;color:var(--text-muted);font-family:var(--font-mono);">Episode β</span>
|
| 685 |
+
<button class="btn btn-primary" id="btnRunLive" onclick="runLiveAgent()" style="background: linear-gradient(135deg, #10b981, #059669);">π Run Live Server</button>
|
| 686 |
+
<button class="btn btn-danger" id="btnReset" onclick="resetBoard()" style="display:none;">βΊ Reset</button>
|
| 687 |
+
</div>
|
| 688 |
+
</header>
|
| 689 |
+
|
| 690 |
+
<!-- ββ Stats Strip βββββββββββββββββββββββββββββββββββ -->
|
| 691 |
+
<div class="stats-strip">
|
| 692 |
+
<div class="stat-card">
|
| 693 |
+
<div class="stat-icon turns">π</div>
|
| 694 |
+
<div class="stat-info">
|
| 695 |
+
<span class="stat-label">Turn</span>
|
| 696 |
+
<span class="stat-value" id="statTurn">0 / 15</span>
|
| 697 |
+
</div>
|
| 698 |
+
</div>
|
| 699 |
+
<div class="stat-card">
|
| 700 |
+
<div class="stat-icon evidence">π¬</div>
|
| 701 |
+
<div class="stat-info">
|
| 702 |
+
<span class="stat-label">Evidence</span>
|
| 703 |
+
<span class="stat-value" id="statEvidence">0 / 3</span>
|
| 704 |
+
</div>
|
| 705 |
+
</div>
|
| 706 |
+
<div class="stat-card">
|
| 707 |
+
<div class="stat-icon contradictions">β‘</div>
|
| 708 |
+
<div class="stat-info">
|
| 709 |
+
<span class="stat-label">Contradictions</span>
|
| 710 |
+
<span class="stat-value" id="statContra">0</span>
|
| 711 |
+
</div>
|
| 712 |
+
</div>
|
| 713 |
+
<div class="stat-card">
|
| 714 |
+
<div class="stat-icon reward">π</div>
|
| 715 |
+
<div class="stat-info">
|
| 716 |
+
<span class="stat-label">Reward</span>
|
| 717 |
+
<span class="stat-value" id="statReward">0.00</span>
|
| 718 |
+
</div>
|
| 719 |
+
</div>
|
| 720 |
+
<div class="stat-card">
|
| 721 |
+
<div class="stat-icon result">π―</div>
|
| 722 |
+
<div class="stat-info">
|
| 723 |
+
<span class="stat-label">Outcome</span>
|
| 724 |
+
<span class="stat-value neutral" id="statOutcome">β</span>
|
| 725 |
+
</div>
|
| 726 |
+
</div>
|
| 727 |
+
</div>
|
| 728 |
+
|
| 729 |
+
<!-- ββ Main Content ββββββββββββββββββββββββββββββββββ -->
|
| 730 |
+
<div class="main-grid">
|
| 731 |
+
|
| 732 |
+
<!-- Conversation Log -->
|
| 733 |
+
<div class="panel conversation-panel">
|
| 734 |
+
<div class="panel-header">
|
| 735 |
+
<span class="panel-title">π¬ Interrogation Log</span>
|
| 736 |
+
<span id="msgCount" style="font-size:0.7rem;color:var(--text-muted);font-family:var(--font-mono);">0 messages</span>
|
| 737 |
+
</div>
|
| 738 |
+
<div class="panel-body" id="conversationLog">
|
| 739 |
+
<div class="empty-state">
|
| 740 |
+
<div class="empty-icon">π΅οΈ</div>
|
| 741 |
+
<div class="empty-text">Click <strong>Run Live Server</strong> to watch a full<br>investigation episode unfold live.</div>
|
| 742 |
+
</div>
|
| 743 |
+
</div>
|
| 744 |
+
</div>
|
| 745 |
+
|
| 746 |
+
<!-- Sidebar -->
|
| 747 |
+
<div class="sidebar">
|
| 748 |
+
|
| 749 |
+
<!-- Reward Meter -->
|
| 750 |
+
<div class="panel">
|
| 751 |
+
<div class="panel-header">
|
| 752 |
+
<span class="panel-title">π Reward Breakdown</span>
|
| 753 |
+
</div>
|
| 754 |
+
<div class="panel-body reward-meter">
|
| 755 |
+
<div class="reward-bar-track">
|
| 756 |
+
<div class="reward-bar-fill" id="rewardBar"></div>
|
| 757 |
+
</div>
|
| 758 |
+
<div class="reward-labels">
|
| 759 |
+
<span>-10</span>
|
| 760 |
+
<span>0</span>
|
| 761 |
+
<span>+10</span>
|
| 762 |
+
</div>
|
| 763 |
+
<div style="margin-top:12px;" id="rewardBreakdown">
|
| 764 |
+
<div class="reward-detail">
|
| 765 |
+
<span class="reward-detail-label">Base</span>
|
| 766 |
+
<span class="reward-detail-value" id="rwBase">0.00</span>
|
| 767 |
+
</div>
|
| 768 |
+
<div class="reward-detail">
|
| 769 |
+
<span class="reward-detail-label">Turn Cost</span>
|
| 770 |
+
<span class="reward-detail-value" id="rwTurnCost" style="color:var(--accent-red);">0.00</span>
|
| 771 |
+
</div>
|
| 772 |
+
<div class="reward-detail">
|
| 773 |
+
<span class="reward-detail-label">Contradiction Bonus</span>
|
| 774 |
+
<span class="reward-detail-value" id="rwContra" style="color:var(--accent-amber);">0.00</span>
|
| 775 |
+
</div>
|
| 776 |
+
<div class="reward-detail">
|
| 777 |
+
<span class="reward-detail-label">Evidence Bonus</span>
|
| 778 |
+
<span class="reward-detail-value" id="rwEvidence" style="color:var(--accent-cyan);">0.00</span>
|
| 779 |
+
</div>
|
| 780 |
+
<div class="reward-detail">
|
| 781 |
+
<span class="reward-detail-label"><strong>Total</strong></span>
|
| 782 |
+
<span class="reward-detail-value" id="rwTotal" style="font-size:1.05rem;">0.00</span>
|
| 783 |
+
</div>
|
| 784 |
+
</div>
|
| 785 |
+
</div>
|
| 786 |
+
</div>
|
| 787 |
+
|
| 788 |
+
<!-- Evidence Panel -->
|
| 789 |
+
<div class="panel">
|
| 790 |
+
<div class="panel-header">
|
| 791 |
+
<span class="panel-title">π¬ Evidence</span>
|
| 792 |
+
</div>
|
| 793 |
+
<div class="panel-body">
|
| 794 |
+
<div class="evidence-list" id="evidenceList">
|
| 795 |
+
<div class="empty-state" id="evidenceEmptyState" style="padding:14px;">
|
| 796 |
+
<div class="empty-text" style="font-size:0.72rem;">No evidence revealed yet.</div>
|
| 797 |
+
</div>
|
| 798 |
+
</div>
|
| 799 |
+
</div>
|
| 800 |
+
</div>
|
| 801 |
+
|
| 802 |
+
<!-- Contradiction Alerts -->
|
| 803 |
+
<div class="panel">
|
| 804 |
+
<div class="panel-header">
|
| 805 |
+
<span class="panel-title">β‘ Contradiction Alerts</span>
|
| 806 |
+
</div>
|
| 807 |
+
<div class="panel-body">
|
| 808 |
+
<div class="alert-list" id="alertList">
|
| 809 |
+
<div class="empty-state" style="padding:14px;">
|
| 810 |
+
<div class="empty-text" style="font-size:0.72rem;">No contradictions detected yet.</div>
|
| 811 |
+
</div>
|
| 812 |
+
</div>
|
| 813 |
+
</div>
|
| 814 |
+
</div>
|
| 815 |
+
|
| 816 |
+
<!-- Reward History -->
|
| 817 |
+
<div class="panel" id="trainingPanel">
|
| 818 |
+
<div class="panel-header">
|
| 819 |
+
<span class="panel-title">π Training Progress</span>
|
| 820 |
+
</div>
|
| 821 |
+
<div class="panel-body">
|
| 822 |
+
<div class="training-metrics">
|
| 823 |
+
<div class="training-metric">
|
| 824 |
+
<div class="training-metric-label">Episodes</div>
|
| 825 |
+
<div class="training-metric-value" id="trainEpisodes">-</div>
|
| 826 |
+
</div>
|
| 827 |
+
<div class="training-metric">
|
| 828 |
+
<div class="training-metric-label">Model</div>
|
| 829 |
+
<div class="training-metric-value" id="trainModel" style="font-size:0.72rem;">-</div>
|
| 830 |
+
</div>
|
| 831 |
+
<div class="training-metric">
|
| 832 |
+
<div class="training-metric-label">First-50 Mean</div>
|
| 833 |
+
<div class="training-metric-value" id="trainFirst">-</div>
|
| 834 |
+
</div>
|
| 835 |
+
<div class="training-metric">
|
| 836 |
+
<div class="training-metric-label">Last-50 Mean</div>
|
| 837 |
+
<div class="training-metric-value" id="trainLast">-</div>
|
| 838 |
+
</div>
|
| 839 |
+
</div>
|
| 840 |
+
<div style="font-size:0.72rem; font-weight:800; margin-bottom:8px;" id="trainImprovement">Improvement: -</div>
|
| 841 |
+
<div class="chart-container">
|
| 842 |
+
<canvas class="chart-canvas" id="trainingRewardChart"></canvas>
|
| 843 |
+
</div>
|
| 844 |
+
</div>
|
| 845 |
+
</div>
|
| 846 |
+
|
| 847 |
+
<div class="panel" id="historyPanel" style="display:none;">
|
| 848 |
+
<div class="panel-header">
|
| 849 |
+
<span class="panel-title">π Reward History</span>
|
| 850 |
+
</div>
|
| 851 |
+
<div class="panel-body">
|
| 852 |
+
<div class="chart-container">
|
| 853 |
+
<canvas class="chart-canvas" id="rewardChart"></canvas>
|
| 854 |
+
</div>
|
| 855 |
+
</div>
|
| 856 |
+
</div>
|
| 857 |
+
|
| 858 |
+
</div>
|
| 859 |
+
</div>
|
| 860 |
+
|
| 861 |
+
<!-- ββ Outcome Overlay βββββββββββββββββββββββββββββββ -->
|
| 862 |
+
<div class="outcome-overlay" id="outcomeOverlay">
|
| 863 |
+
<div class="outcome-card">
|
| 864 |
+
<div class="outcome-icon" id="outcomeIcon">π</div>
|
| 865 |
+
<div class="outcome-title" id="outcomeTitle">Case Solved!</div>
|
| 866 |
+
<div class="outcome-subtitle" id="outcomeSub">The detective correctly identified the criminal.</div>
|
| 867 |
+
<div class="outcome-stat win" id="outcomeStat">+8.50</div>
|
| 868 |
+
<button class="btn btn-primary" onclick="dismissOutcome()">Continue</button>
|
| 869 |
+
</div>
|
| 870 |
+
</div>
|
| 871 |
+
|
| 872 |
+
</div>
|
| 873 |
+
|
| 874 |
+
<script>
|
| 875 |
+
|
| 876 |
+
const EVIDENCE_ICONS = {
|
| 877 |
+
keycard_log: "π",
|
| 878 |
+
cctv_footage: "πΉ",
|
| 879 |
+
forensic_report: "π¬",
|
| 880 |
+
};
|
| 881 |
+
|
| 882 |
+
const EVIDENCE_NAMES = {
|
| 883 |
+
keycard_log: "Keycard Log",
|
| 884 |
+
cctv_footage: "CCTV Footage",
|
| 885 |
+
forensic_report: "Forensic Report",
|
| 886 |
+
};
|
| 887 |
+
|
| 888 |
+
// ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 889 |
+
|
| 890 |
+
let demoRunning = false;
|
| 891 |
+
let currentTurn = 0;
|
| 892 |
+
let evidenceCount = 0;
|
| 893 |
+
let contradictionCount = 0;
|
| 894 |
+
let totalReward = 0;
|
| 895 |
+
let rewardHistory = [];
|
| 896 |
+
let messageCount = 0;
|
| 897 |
+
let revealedEvidenceKeys = new Set();
|
| 898 |
+
let trainingRewards = [];
|
| 899 |
+
let trainingSmoothed = [];
|
| 900 |
+
|
| 901 |
+
const rewardComponents = {
|
| 902 |
+
base: 0,
|
| 903 |
+
turnCost: 0,
|
| 904 |
+
contradictions: 0,
|
| 905 |
+
evidence: 0,
|
| 906 |
+
total: 0,
|
| 907 |
+
};
|
| 908 |
+
|
| 909 |
+
// ββ Rendering ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 910 |
+
|
| 911 |
+
function getAvatarClass(speaker) {
|
| 912 |
+
if (speaker === "Detective") return "detective";
|
| 913 |
+
if (speaker === "Suspect_A") return "suspect-a";
|
| 914 |
+
if (speaker === "Suspect_B") return "suspect-b";
|
| 915 |
+
if (speaker === "Witness_1") return "witness";
|
| 916 |
+
return "system";
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
function getAvatarEmoji(speaker) {
|
| 920 |
+
if (speaker === "Detective") return "π";
|
| 921 |
+
if (speaker === "Suspect_A") return "π
°οΈ";
|
| 922 |
+
if (speaker === "Suspect_B") return "π
±οΈ";
|
| 923 |
+
if (speaker === "Witness_1") return "ποΈ";
|
| 924 |
+
return "βοΈ";
|
| 925 |
+
}
|
| 926 |
+
|
| 927 |
+
function addMessage(speaker, text, turn, tag) {
|
| 928 |
+
const log = document.getElementById("conversationLog");
|
| 929 |
+
// Remove empty state on first message
|
| 930 |
+
if (messageCount === 0) log.innerHTML = "";
|
| 931 |
+
|
| 932 |
+
const msgEl = document.createElement("div");
|
| 933 |
+
msgEl.className = "msg";
|
| 934 |
+
|
| 935 |
+
const cls = getAvatarClass(speaker);
|
| 936 |
+
let tagHTML = "";
|
| 937 |
+
if (tag) {
|
| 938 |
+
tagHTML = `<div class="msg-tag ${tag}">${tag}</div>`;
|
| 939 |
+
}
|
| 940 |
+
|
| 941 |
+
msgEl.innerHTML = `
|
| 942 |
+
<div class="msg-avatar ${cls}">${getAvatarEmoji(speaker)}</div>
|
| 943 |
+
<div class="msg-content">
|
| 944 |
+
<div class="msg-header">
|
| 945 |
+
<span class="msg-name ${cls}">${speaker}</span>
|
| 946 |
+
<span class="msg-turn">Turn ${turn}</span>
|
| 947 |
+
</div>
|
| 948 |
+
<div class="msg-text">${text}</div>
|
| 949 |
+
${tagHTML}
|
| 950 |
+
</div>
|
| 951 |
+
`;
|
| 952 |
+
|
| 953 |
+
log.appendChild(msgEl);
|
| 954 |
+
log.scrollTop = log.scrollHeight;
|
| 955 |
+
messageCount++;
|
| 956 |
+
document.getElementById("msgCount").textContent = `${messageCount} messages`;
|
| 957 |
+
}
|
| 958 |
+
|
| 959 |
+
function revealEvidence(itemKey, nameOverride, descOverride) {
|
| 960 |
+
let keyNorm = itemKey.toLowerCase().replace(" ", "_");
|
| 961 |
+
// Normalize to standard keys if string varies slightly
|
| 962 |
+
if (keyNorm.includes("keycard")) keyNorm = "keycard_log";
|
| 963 |
+
if (keyNorm.includes("cctv")) keyNorm = "cctv_footage";
|
| 964 |
+
if (keyNorm.includes("forensic")) keyNorm = "forensic_report";
|
| 965 |
+
|
| 966 |
+
if (revealedEvidenceKeys.has(keyNorm)) return;
|
| 967 |
+
revealedEvidenceKeys.add(keyNorm);
|
| 968 |
+
|
| 969 |
+
// Always use the passed-in description (from backend trace or demo script)
|
| 970 |
+
const name = nameOverride || EVIDENCE_NAMES[keyNorm] || itemKey;
|
| 971 |
+
const description = descOverride || "";
|
| 972 |
+
const icon = EVIDENCE_ICONS[keyNorm] || "π";
|
| 973 |
+
|
| 974 |
+
evidenceCount++;
|
| 975 |
+
document.getElementById("statEvidence").textContent = `${evidenceCount} / 3`;
|
| 976 |
+
|
| 977 |
+
// Update evidence list
|
| 978 |
+
const list = document.getElementById("evidenceList");
|
| 979 |
+
const emptyState = document.getElementById("evidenceEmptyState");
|
| 980 |
+
if (emptyState) emptyState.remove();
|
| 981 |
+
|
| 982 |
+
const el = document.createElement("div");
|
| 983 |
+
el.className = "evidence-item revealed";
|
| 984 |
+
el.innerHTML = `
|
| 985 |
+
<span class="evidence-icon">${icon}</span>
|
| 986 |
+
<div class="evidence-info">
|
| 987 |
+
<div class="evidence-name">${name}</div>
|
| 988 |
+
<div class="evidence-desc">${description}</div>
|
| 989 |
+
</div>
|
| 990 |
+
`;
|
| 991 |
+
list.appendChild(el);
|
| 992 |
+
|
| 993 |
+
addMessage("System", `π Evidence revealed: <strong>${name}</strong> β ${description}`, currentTurn, "evidence");
|
| 994 |
+
|
| 995 |
+
// Reward β matches reward_calculator.py evidence_request_confirms_lead = +1.0
|
| 996 |
+
rewardComponents.evidence += 1.0;
|
| 997 |
+
updateReward();
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
function addContradiction(suspect, claim1, t1, claim2, t2) {
|
| 1001 |
+
contradictionCount++;
|
| 1002 |
+
document.getElementById("statContra").textContent = contradictionCount;
|
| 1003 |
+
|
| 1004 |
+
const list = document.getElementById("alertList");
|
| 1005 |
+
if (contradictionCount === 1) list.innerHTML = "";
|
| 1006 |
+
|
| 1007 |
+
const el = document.createElement("div");
|
| 1008 |
+
el.className = "alert-item";
|
| 1009 |
+
el.innerHTML = `
|
| 1010 |
+
<div class="alert-suspect">β ${suspect} β Contradiction #${contradictionCount}</div>
|
| 1011 |
+
<div class="alert-detail">"${claim1}" β "${claim2}"</div>
|
| 1012 |
+
<div class="alert-turns">Turns ${t1} β ${t2}</div>
|
| 1013 |
+
`;
|
| 1014 |
+
list.appendChild(el);
|
| 1015 |
+
|
| 1016 |
+
rewardComponents.contradictions += 2.0;
|
| 1017 |
+
updateReward();
|
| 1018 |
+
}
|
| 1019 |
+
|
| 1020 |
+
function updateReward() {
|
| 1021 |
+
const total = rewardComponents.base + rewardComponents.turnCost + rewardComponents.contradictions + rewardComponents.evidence;
|
| 1022 |
+
rewardComponents.total = total;
|
| 1023 |
+
totalReward = total;
|
| 1024 |
+
|
| 1025 |
+
document.getElementById("statReward").textContent = total.toFixed(2);
|
| 1026 |
+
const statEl = document.getElementById("statReward");
|
| 1027 |
+
statEl.className = "stat-value " + (total >= 0 ? "positive" : "negative");
|
| 1028 |
+
|
| 1029 |
+
// Reward bar: map -10..+10 to 0..100%
|
| 1030 |
+
const pct = Math.max(0, Math.min(100, ((total + 10) / 20) * 100));
|
| 1031 |
+
document.getElementById("rewardBar").style.width = `${pct}%`;
|
| 1032 |
+
|
| 1033 |
+
document.getElementById("rwBase").textContent = rewardComponents.base.toFixed(2);
|
| 1034 |
+
document.getElementById("rwTurnCost").textContent = rewardComponents.turnCost.toFixed(2);
|
| 1035 |
+
document.getElementById("rwContra").textContent = `+${rewardComponents.contradictions.toFixed(2)}`;
|
| 1036 |
+
document.getElementById("rwEvidence").textContent = `+${rewardComponents.evidence.toFixed(2)}`;
|
| 1037 |
+
document.getElementById("rwTotal").textContent = total.toFixed(2);
|
| 1038 |
+
document.getElementById("rwTotal").style.color = total >= 0 ? "var(--accent-green)" : "var(--accent-red)";
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
function updateTurn(t) {
|
| 1042 |
+
currentTurn = t;
|
| 1043 |
+
document.getElementById("statTurn").textContent = `${t} / 15`;
|
| 1044 |
+
rewardComponents.turnCost = -(t * 0.3);
|
| 1045 |
+
updateReward();
|
| 1046 |
+
}
|
| 1047 |
+
|
| 1048 |
+
function showOutcome(correct) {
|
| 1049 |
+
const overlay = document.getElementById("outcomeOverlay");
|
| 1050 |
+
document.getElementById("outcomeIcon").textContent = correct ? "π" : "β";
|
| 1051 |
+
document.getElementById("outcomeTitle").textContent = correct ? "Case Solved!" : "Wrong Accusation";
|
| 1052 |
+
document.getElementById("outcomeSub").textContent = correct
|
| 1053 |
+
? "The detective correctly identified the true criminal."
|
| 1054 |
+
: "The detective accused the wrong suspect.";
|
| 1055 |
+
document.getElementById("outcomeStat").textContent = (totalReward >= 0 ? "+" : "") + totalReward.toFixed(2);
|
| 1056 |
+
document.getElementById("outcomeStat").className = "outcome-stat " + (correct ? "win" : "lose");
|
| 1057 |
+
document.getElementById("statOutcome").textContent = correct ? "β
Correct" : "β Wrong";
|
| 1058 |
+
document.getElementById("statOutcome").className = "stat-value " + (correct ? "positive" : "negative");
|
| 1059 |
+
overlay.classList.add("visible");
|
| 1060 |
+
}
|
| 1061 |
+
|
| 1062 |
+
function dismissOutcome() {
|
| 1063 |
+
document.getElementById("outcomeOverlay").classList.remove("visible");
|
| 1064 |
+
document.getElementById("btnReset").style.display = "inline-flex";
|
| 1065 |
+
updateRewardChart();
|
| 1066 |
+
}
|
| 1067 |
+
|
| 1068 |
+
// ββ Reward Chart βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1069 |
+
|
| 1070 |
+
function updateRewardChart() {
|
| 1071 |
+
rewardHistory.push(totalReward);
|
| 1072 |
+
const panel = document.getElementById("historyPanel");
|
| 1073 |
+
panel.style.display = "block";
|
| 1074 |
+
|
| 1075 |
+
const canvas = document.getElementById("rewardChart");
|
| 1076 |
+
const ctx = canvas.getContext("2d");
|
| 1077 |
+
const rect = canvas.parentElement.getBoundingClientRect();
|
| 1078 |
+
canvas.width = rect.width * 2;
|
| 1079 |
+
canvas.height = rect.height * 2;
|
| 1080 |
+
ctx.scale(2, 2);
|
| 1081 |
+
|
| 1082 |
+
const w = rect.width;
|
| 1083 |
+
const h = rect.height;
|
| 1084 |
+
const data = rewardHistory;
|
| 1085 |
+
|
| 1086 |
+
// Dynamic scale limits based on data
|
| 1087 |
+
const maxVal = data.length > 0 ? Math.max(...data) : 0;
|
| 1088 |
+
const minVal = data.length > 0 ? Math.min(...data) : 0;
|
| 1089 |
+
const maxR = Math.max(10, Math.ceil(maxVal / 5) * 5 + 5);
|
| 1090 |
+
const minR = Math.min(-10, Math.floor(minVal / 5) * 5 - 5);
|
| 1091 |
+
const stepV = Math.max(5, Math.floor((maxR - minR) / 6));
|
| 1092 |
+
|
| 1093 |
+
const pad = { t: 15, b: 25, l: 40, r: 15 };
|
| 1094 |
+
|
| 1095 |
+
ctx.clearRect(0, 0, w, h);
|
| 1096 |
+
|
| 1097 |
+
// Grid and Labels
|
| 1098 |
+
ctx.strokeStyle = "rgba(0,0,0,0.1)"; // Darker grid lines
|
| 1099 |
+
ctx.lineWidth = 1;
|
| 1100 |
+
ctx.fillStyle = "rgba(0,0,0,0.6)"; // Dark text instead of white
|
| 1101 |
+
ctx.font = "11px 'JetBrains Mono'";
|
| 1102 |
+
ctx.textAlign = "right";
|
| 1103 |
+
|
| 1104 |
+
for (let v = minR; v <= maxR; v += stepV) {
|
| 1105 |
+
const y = pad.t + ((maxR - v) / (maxR - minR)) * (h - pad.t - pad.b);
|
| 1106 |
+
ctx.beginPath();
|
| 1107 |
+
ctx.moveTo(pad.l, y);
|
| 1108 |
+
ctx.lineTo(w - pad.r, y);
|
| 1109 |
+
ctx.stroke();
|
| 1110 |
+
|
| 1111 |
+
ctx.fillText(v.toString(), pad.l - 8, y + 4);
|
| 1112 |
+
}
|
| 1113 |
+
|
| 1114 |
+
// Zero line
|
| 1115 |
+
const zeroY = pad.t + ((maxR - 0) / (maxR - minR)) * (h - pad.t - pad.b);
|
| 1116 |
+
ctx.strokeStyle = "rgba(0,0,0,0.2)"; // Darker zero line
|
| 1117 |
+
ctx.beginPath();
|
| 1118 |
+
ctx.moveTo(pad.l, zeroY);
|
| 1119 |
+
ctx.lineTo(w - pad.r, zeroY);
|
| 1120 |
+
ctx.stroke();
|
| 1121 |
+
|
| 1122 |
+
if (data.length < 2) return;
|
| 1123 |
+
|
| 1124 |
+
// Line
|
| 1125 |
+
const stepX = (w - pad.l - pad.r) / (Math.max(data.length - 1, 1));
|
| 1126 |
+
ctx.beginPath();
|
| 1127 |
+
data.forEach((v, i) => {
|
| 1128 |
+
const x = pad.l + i * stepX;
|
| 1129 |
+
const y = pad.t + ((maxR - v) / (maxR - minR)) * (h - pad.t - pad.b);
|
| 1130 |
+
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
| 1131 |
+
});
|
| 1132 |
+
ctx.strokeStyle = "#3b82f6";
|
| 1133 |
+
ctx.lineWidth = 2;
|
| 1134 |
+
ctx.stroke();
|
| 1135 |
+
|
| 1136 |
+
// Gradient fill
|
| 1137 |
+
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
| 1138 |
+
grad.addColorStop(0, "rgba(59, 130, 246, 0.2)");
|
| 1139 |
+
grad.addColorStop(1, "rgba(59, 130, 246, 0)");
|
| 1140 |
+
ctx.lineTo(pad.l + (data.length - 1) * stepX, h - pad.b);
|
| 1141 |
+
ctx.lineTo(pad.l, h - pad.b);
|
| 1142 |
+
ctx.closePath();
|
| 1143 |
+
ctx.fillStyle = grad;
|
| 1144 |
+
ctx.fill();
|
| 1145 |
+
|
| 1146 |
+
// Dots
|
| 1147 |
+
data.forEach((v, i) => {
|
| 1148 |
+
const x = pad.l + i * stepX;
|
| 1149 |
+
const y = pad.t + ((maxR - v) / (maxR - minR)) * (h - pad.t - pad.b);
|
| 1150 |
+
ctx.beginPath();
|
| 1151 |
+
ctx.arc(x, y, 4, 0, Math.PI * 2);
|
| 1152 |
+
ctx.fillStyle = v >= 0 ? "#10b981" : "#ef4444";
|
| 1153 |
+
ctx.fill();
|
| 1154 |
+
ctx.strokeStyle = "#ffffff"; // White border inside the dot
|
| 1155 |
+
ctx.lineWidth = 2;
|
| 1156 |
+
ctx.stroke();
|
| 1157 |
+
});
|
| 1158 |
+
|
| 1159 |
+
// Episode labels
|
| 1160 |
+
ctx.fillStyle = "rgba(0,0,0,0.5)"; // Darker episode text
|
| 1161 |
+
ctx.font = "9px 'JetBrains Mono'";
|
| 1162 |
+
ctx.textAlign = "center";
|
| 1163 |
+
data.forEach((_, i) => {
|
| 1164 |
+
ctx.fillText(`E${i + 1}`, pad.l + i * stepX, h - 4);
|
| 1165 |
+
});
|
| 1166 |
+
}
|
| 1167 |
+
|
| 1168 |
+
function drawTrainingChart(rewards, smoothed) {
|
| 1169 |
+
const canvas = document.getElementById("trainingRewardChart");
|
| 1170 |
+
const ctx = canvas.getContext("2d");
|
| 1171 |
+
const rect = canvas.parentElement.getBoundingClientRect();
|
| 1172 |
+
canvas.width = rect.width * 2;
|
| 1173 |
+
canvas.height = rect.height * 2;
|
| 1174 |
+
ctx.scale(2, 2);
|
| 1175 |
+
|
| 1176 |
+
const w = rect.width;
|
| 1177 |
+
const h = rect.height;
|
| 1178 |
+
const pad = { t: 15, b: 25, l: 40, r: 10 };
|
| 1179 |
+
const data = rewards || [];
|
| 1180 |
+
const avg = smoothed || [];
|
| 1181 |
+
|
| 1182 |
+
ctx.clearRect(0, 0, w, h);
|
| 1183 |
+
|
| 1184 |
+
if (data.length === 0) {
|
| 1185 |
+
ctx.fillStyle = "rgba(0,0,0,0.6)";
|
| 1186 |
+
ctx.font = "12px 'JetBrains Mono'";
|
| 1187 |
+
ctx.fillText("No training data found yet.", 12, 24);
|
| 1188 |
+
return;
|
| 1189 |
+
}
|
| 1190 |
+
|
| 1191 |
+
const maxVal = Math.max(...data, ...(avg.length ? avg : data), 10);
|
| 1192 |
+
const minVal = Math.min(...data, ...(avg.length ? avg : data), -10);
|
| 1193 |
+
const span = Math.max(1, maxVal - minVal);
|
| 1194 |
+
|
| 1195 |
+
ctx.strokeStyle = "rgba(0,0,0,0.12)";
|
| 1196 |
+
ctx.lineWidth = 1;
|
| 1197 |
+
for (let i = 0; i <= 5; i++) {
|
| 1198 |
+
const y = pad.t + (i / 5) * (h - pad.t - pad.b);
|
| 1199 |
+
ctx.beginPath();
|
| 1200 |
+
ctx.moveTo(pad.l, y);
|
| 1201 |
+
ctx.lineTo(w - pad.r, y);
|
| 1202 |
+
ctx.stroke();
|
| 1203 |
+
}
|
| 1204 |
+
|
| 1205 |
+
const valueToY = (v) => pad.t + ((maxVal - v) / span) * (h - pad.t - pad.b);
|
| 1206 |
+
const xStep = (w - pad.l - pad.r) / Math.max(1, data.length - 1);
|
| 1207 |
+
|
| 1208 |
+
ctx.beginPath();
|
| 1209 |
+
data.forEach((v, i) => {
|
| 1210 |
+
const x = pad.l + i * xStep;
|
| 1211 |
+
const y = valueToY(v);
|
| 1212 |
+
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
| 1213 |
+
});
|
| 1214 |
+
ctx.strokeStyle = "#65A3BB";
|
| 1215 |
+
ctx.lineWidth = 2;
|
| 1216 |
+
ctx.stroke();
|
| 1217 |
+
|
| 1218 |
+
if (avg.length === data.length && avg.length > 1) {
|
| 1219 |
+
ctx.beginPath();
|
| 1220 |
+
avg.forEach((v, i) => {
|
| 1221 |
+
const x = pad.l + i * xStep;
|
| 1222 |
+
const y = valueToY(v);
|
| 1223 |
+
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
| 1224 |
+
});
|
| 1225 |
+
ctx.strokeStyle = "#F25D68";
|
| 1226 |
+
ctx.lineWidth = 2.5;
|
| 1227 |
+
ctx.stroke();
|
| 1228 |
+
}
|
| 1229 |
+
}
|
| 1230 |
+
|
| 1231 |
+
async function loadTrainingProgress() {
|
| 1232 |
+
try {
|
| 1233 |
+
const res = await fetch("/api/reward_curve");
|
| 1234 |
+
if (!res.ok) return;
|
| 1235 |
+
const data = await res.json();
|
| 1236 |
+
trainingRewards = data.rewards || [];
|
| 1237 |
+
trainingSmoothed = data.smoothed || [];
|
| 1238 |
+
|
| 1239 |
+
document.getElementById("trainEpisodes").textContent = String(data.num_episodes || trainingRewards.length || 0);
|
| 1240 |
+
document.getElementById("trainModel").textContent = (data.model || "-").replace("Qwen/", "");
|
| 1241 |
+
document.getElementById("trainFirst").textContent = Number(data.mean_first_50 || 0).toFixed(2);
|
| 1242 |
+
document.getElementById("trainLast").textContent = Number(data.mean_last_50 || 0).toFixed(2);
|
| 1243 |
+
|
| 1244 |
+
const improvement = Number(data.improvement || 0);
|
| 1245 |
+
const sign = improvement >= 0 ? "+" : "";
|
| 1246 |
+
const improveEl = document.getElementById("trainImprovement");
|
| 1247 |
+
improveEl.textContent = `Improvement: ${sign}${improvement.toFixed(2)}`;
|
| 1248 |
+
improveEl.style.color = improvement >= 0 ? "#16a34a" : "#dc2626";
|
| 1249 |
+
|
| 1250 |
+
drawTrainingChart(trainingRewards, trainingSmoothed);
|
| 1251 |
+
} catch (err) {
|
| 1252 |
+
// Keep dashboard usable even if training artifacts are missing.
|
| 1253 |
+
}
|
| 1254 |
+
}
|
| 1255 |
+
|
| 1256 |
+
|
| 1257 |
+
function resetBoard() {
|
| 1258 |
+
currentTurn = 0;
|
| 1259 |
+
evidenceCount = 0;
|
| 1260 |
+
contradictionCount = 0;
|
| 1261 |
+
totalReward = 0;
|
| 1262 |
+
messageCount = 0;
|
| 1263 |
+
revealedEvidenceKeys.clear();
|
| 1264 |
+
if (window.revealedContradictionKeys) window.revealedContradictionKeys.clear();
|
| 1265 |
+
Object.keys(rewardComponents).forEach(k => rewardComponents[k] = 0);
|
| 1266 |
+
|
| 1267 |
+
document.getElementById("statTurn").textContent = "0 / 15";
|
| 1268 |
+
document.getElementById("statEvidence").textContent = "0 / 3";
|
| 1269 |
+
document.getElementById("statContra").textContent = "0";
|
| 1270 |
+
document.getElementById("statReward").textContent = "0.00";
|
| 1271 |
+
document.getElementById("statReward").className = "stat-value";
|
| 1272 |
+
document.getElementById("statOutcome").textContent = "β";
|
| 1273 |
+
document.getElementById("statOutcome").className = "stat-value neutral";
|
| 1274 |
+
document.getElementById("rewardBar").style.width = "50%";
|
| 1275 |
+
document.getElementById("msgCount").textContent = "0 messages";
|
| 1276 |
+
|
| 1277 |
+
document.getElementById("conversationLog").innerHTML = `
|
| 1278 |
+
<div class="empty-state">
|
| 1279 |
+
<div class="empty-icon">π΅οΈ</div>
|
| 1280 |
+
<div class="empty-text">Click <strong>Run Live Server</strong> to watch a full<br>investigation episode unfold live.</div>
|
| 1281 |
+
</div>`;
|
| 1282 |
+
|
| 1283 |
+
document.getElementById("evidenceList").innerHTML = `
|
| 1284 |
+
<div class="empty-state" id="evidenceEmptyState" style="padding:14px;">
|
| 1285 |
+
<div class="empty-text" style="font-size:0.72rem;">No evidence revealed yet.</div>
|
| 1286 |
+
</div>`;
|
| 1287 |
+
|
| 1288 |
+
document.getElementById("alertList").innerHTML = `
|
| 1289 |
+
<div class="empty-state" style="padding:14px;">
|
| 1290 |
+
<div class="empty-text" style="font-size:0.72rem;">No contradictions detected yet.</div>
|
| 1291 |
+
</div>`;
|
| 1292 |
+
|
| 1293 |
+
document.getElementById("btnReset").style.display = "none";
|
| 1294 |
+
document.getElementById("outcomeOverlay").classList.remove("visible");
|
| 1295 |
+
|
| 1296 |
+
updateReward();
|
| 1297 |
+
}
|
| 1298 |
+
|
| 1299 |
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
| 1300 |
+
|
| 1301 |
+
// ββ Live Backend Runner ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1302 |
+
|
| 1303 |
+
async function runLiveAgent() {
|
| 1304 |
+
if (demoRunning) return;
|
| 1305 |
+
demoRunning = true;
|
| 1306 |
+
|
| 1307 |
+
resetBoard();
|
| 1308 |
+
|
| 1309 |
+
document.getElementById("btnRunLive").disabled = true;
|
| 1310 |
+
document.getElementById("btnRunLive").textContent = "β³ Waiting for AI...";
|
| 1311 |
+
document.getElementById("statusText").textContent = "Live Investigating";
|
| 1312 |
+
document.getElementById("statusBadge").style.borderColor = "rgba(16, 185, 129, 0.4)";
|
| 1313 |
+
document.getElementById("statusBadge").style.background = "rgba(16, 185, 129, 0.1)";
|
| 1314 |
+
document.getElementById("statusBadge").querySelector(".status-dot").style.background = "var(--accent-green)";
|
| 1315 |
+
document.getElementById("statusBadge").querySelector("span:last-child").style.color = "var(--accent-green)";
|
| 1316 |
+
|
| 1317 |
+
const epNum = rewardHistory.length + 1;
|
| 1318 |
+
document.getElementById("episodeLabel").textContent = `Episode ${epNum}`;
|
| 1319 |
+
|
| 1320 |
+
const DELAY_SHORT = 600;
|
| 1321 |
+
const DELAY_LONG = 1200;
|
| 1322 |
+
|
| 1323 |
+
try {
|
| 1324 |
+
const response = await fetch("/api/run_episode");
|
| 1325 |
+
if (!response.ok) throw new Error("Server not responding");
|
| 1326 |
+
const data = await response.json();
|
| 1327 |
+
|
| 1328 |
+
if (data.status !== "ok") {
|
| 1329 |
+
throw new Error("Simulation failed");
|
| 1330 |
+
}
|
| 1331 |
+
|
| 1332 |
+
const script = data.trace;
|
| 1333 |
+
document.getElementById("btnRunLive").textContent = "βοΈ Executing Trace...";
|
| 1334 |
+
|
| 1335 |
+
for (const event of script) {
|
| 1336 |
+
if (!demoRunning) break;
|
| 1337 |
+
|
| 1338 |
+
if (event.type === "detective") {
|
| 1339 |
+
updateTurn(event.turn);
|
| 1340 |
+
|
| 1341 |
+
const actionDesc = event.action.includes("ask")
|
| 1342 |
+
? `Asking ${event.target}: "${event.text}"`
|
| 1343 |
+
: event.action.includes("evid")
|
| 1344 |
+
? `Requesting evidence: ${event.item}`
|
| 1345 |
+
: `Accusing ${event.target}`;
|
| 1346 |
+
addMessage("Detective", actionDesc, event.turn);
|
| 1347 |
+
|
| 1348 |
+
// turnCost is tracked by updateTurn() already (-0.3 * turn)
|
| 1349 |
+
// Don't touch rewardComponents.base here β we derive it at the end
|
| 1350 |
+
await sleep(DELAY_LONG);
|
| 1351 |
+
|
| 1352 |
+
} else if (event.type === "response") {
|
| 1353 |
+
addMessage(event.speaker, event.text, event.turn);
|
| 1354 |
+
await sleep(DELAY_SHORT);
|
| 1355 |
+
|
| 1356 |
+
} else if (event.type === "contradiction") {
|
| 1357 |
+
addContradiction(event.suspect, event.claim1 || "Previous claim", event.claim1Turn || "?", event.claim2 || ("New claim: " + event.detail), event.turn);
|
| 1358 |
+
// addContradiction() already adds +2.0 to rewardComponents.contradictions
|
| 1359 |
+
// No extra shifting needed since we derive base at the end
|
| 1360 |
+
await sleep(DELAY_SHORT);
|
| 1361 |
+
|
| 1362 |
+
} else if (event.type === "evidence") {
|
| 1363 |
+
revealEvidence(event.item, event.item, event.desc);
|
| 1364 |
+
// revealEvidence() already adds +1.0 to rewardComponents.evidence
|
| 1365 |
+
// No extra shifting needed since we derive base at the end
|
| 1366 |
+
await sleep(DELAY_SHORT);
|
| 1367 |
+
|
| 1368 |
+
} else if (event.type === "outcome") {
|
| 1369 |
+
// Now derive the base component from the true server total
|
| 1370 |
+
// base = serverTotal - turnCost - contradictions - evidence
|
| 1371 |
+
let serverTotal = data.rewards.detective;
|
| 1372 |
+
rewardComponents.base = serverTotal - rewardComponents.turnCost - rewardComponents.contradictions - rewardComponents.evidence;
|
| 1373 |
+
totalReward = serverTotal;
|
| 1374 |
+
updateReward();
|
| 1375 |
+
|
| 1376 |
+
await sleep(800);
|
| 1377 |
+
showOutcome(event.correct || false);
|
| 1378 |
+
}
|
| 1379 |
+
}
|
| 1380 |
+
|
| 1381 |
+
// Final safeguard: force-set everything to server truth
|
| 1382 |
+
totalReward = data.rewards.detective;
|
| 1383 |
+
rewardComponents.base = totalReward - rewardComponents.turnCost - rewardComponents.contradictions - rewardComponents.evidence;
|
| 1384 |
+
rewardComponents.total = totalReward;
|
| 1385 |
+
updateReward();
|
| 1386 |
+
document.getElementById("statReward").textContent = totalReward.toFixed(2);
|
| 1387 |
+
|
| 1388 |
+
} catch (e) {
|
| 1389 |
+
addMessage("System", `β οΈ Error connecting to live server: ${e.message}. Note: you must run 'uvicorn server.app:app' and open the app from localhost to use Live Mode!`, currentTurn || 0);
|
| 1390 |
+
}
|
| 1391 |
+
|
| 1392 |
+
document.getElementById("statusText").textContent = "Complete";
|
| 1393 |
+
document.getElementById("statusBadge").style.borderColor = "rgba(16, 185, 129, 0.25)";
|
| 1394 |
+
document.getElementById("btnRunLive").disabled = false;
|
| 1395 |
+
document.getElementById("btnRunLive").textContent = "π Run Live Server";
|
| 1396 |
+
demoRunning = false;
|
| 1397 |
+
}
|
| 1398 |
+
|
| 1399 |
+
window.addEventListener("load", () => {
|
| 1400 |
+
loadTrainingProgress();
|
| 1401 |
+
});
|
| 1402 |
+
</script>
|
| 1403 |
+
</body>
|
| 1404 |
+
</html>
|
hf_mini_blog_draft.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AI Crime Investigation World: Multi-Agent RL Detective (Hackathon Mini-Blog Draft)
|
| 2 |
+
|
| 3 |
+
## 1) What This Project Solves
|
| 4 |
+
AI Crime Investigation World is a multi-agent reinforcement learning environment where a detective agent must interrogate two suspects and one witness, request evidence, detect contradictions, and make a final accusation within 15 turns.
|
| 5 |
+
|
| 6 |
+
This is designed as an OpenEnv-compatible benchmark for evaluating agent coordination under partial information.
|
| 7 |
+
|
| 8 |
+
## 2) Why It Matches Halluminate
|
| 9 |
+
This project maps directly to the Halluminate sub-theme: one controller agent (detective) manages multiple actors with conflicting incentives to accomplish a single objective (correct accusation).
|
| 10 |
+
|
| 11 |
+
The detective must combine statements from multiple actors, resolve contradictions, and align decisions with evidence, not just single-agent chat quality.
|
| 12 |
+
|
| 13 |
+
## 3) Environment Design
|
| 14 |
+
- Actors: Detective, Suspect_A, Suspect_B, Witness_1
|
| 15 |
+
- Observation: briefing, conversation history, evidence log, current turn
|
| 16 |
+
- Actions:
|
| 17 |
+
- ACTION: ask_question | TARGET: <agent> | CONTENT: <question>
|
| 18 |
+
- ACTION: request_evidence | ITEM: <item>
|
| 19 |
+
- ACTION: accuse | TARGET: <suspect>
|
| 20 |
+
- Episode limit: 15 turns
|
| 21 |
+
|
| 22 |
+
## 4) Reward Logic
|
| 23 |
+
Detective rewards are event-based and interpretable:
|
| 24 |
+
- Correct accusation: +10
|
| 25 |
+
- Wrong accusation: -8 (up to -10 with active witness bias penalty)
|
| 26 |
+
- Timeout: -3
|
| 27 |
+
- Contradiction exposed: +2
|
| 28 |
+
- Evidence confirms lead: +1
|
| 29 |
+
- Deflection resistance: +0.5
|
| 30 |
+
- Redundant question: -0.5
|
| 31 |
+
- Per-turn cost: -0.3 each turn
|
| 32 |
+
|
| 33 |
+
## 5) Training Setup
|
| 34 |
+
- Algorithm: PPO via Hugging Face TRL
|
| 35 |
+
- Model: Qwen2.5 Instruct family (small variants for smoke tests)
|
| 36 |
+
- Artifacts:
|
| 37 |
+
- rewards.json
|
| 38 |
+
- reward_curve.png
|
| 39 |
+
- episode_transcripts.json (episodes 1/25/50)
|
| 40 |
+
|
| 41 |
+
## 6) Observable Progress
|
| 42 |
+
The dashboard and API expose training progress:
|
| 43 |
+
- /api/reward_curve returns raw rewards, smoothed trend, and summary metrics.
|
| 44 |
+
- Dashboard shows training reward curve and improvement metrics.
|
| 45 |
+
- Before/after transcripts from milestone episodes provide qualitative behavior evidence.
|
| 46 |
+
|
| 47 |
+
## 7) Demo Checklist for Judges
|
| 48 |
+
1. Open Space root dashboard.
|
| 49 |
+
2. Verify /api/health returns status ok.
|
| 50 |
+
3. Verify /api/reward_curve returns reward list and improvement.
|
| 51 |
+
4. Run /api/run_episode to view live interaction trace.
|
| 52 |
+
5. Inspect transcript snapshots (episode_transcripts.json) for behavior shift.
|
| 53 |
+
|
| 54 |
+
## 8) Limitations and Next Steps
|
| 55 |
+
- Early training can be noisy due to sparse terminal signal.
|
| 56 |
+
- Runtime and VRAM constraints require small models for quick demos.
|
| 57 |
+
- Next improvements: stronger contradiction extraction, curriculum schedule, and larger-model fine-tuning with checkpoint comparison.
|
requirements-train.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
transformers>=4.40.0
|
| 2 |
+
# This training script uses the legacy TRL PPOTrainer.step API.
|
| 3 |
+
trl>=0.9.0,<0.10.0
|
| 4 |
+
torch
|
| 5 |
+
matplotlib
|
| 6 |
+
numpy
|
| 7 |
+
bitsandbytes>=0.43.0
|
| 8 |
+
accelerate>=0.27.0
|
| 9 |
+
peft>=0.9.0
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv-core
|
| 2 |
+
fastapi>=0.110.0
|
| 3 |
+
uvicorn>=0.27.0
|
| 4 |
+
pydantic>=2.0.0
|
run_hf_smoke_test.sh
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# Lightweight Hugging Face smoke test for PPO loop validation.
|
| 5 |
+
export TEST_MODE=1
|
| 6 |
+
export NUM_EPISODES="${NUM_EPISODES:-5}"
|
| 7 |
+
export MAX_TURNS="${MAX_TURNS:-8}"
|
| 8 |
+
export MODEL_NAME="${MODEL_NAME:-Qwen/Qwen2.5-1.5B-Instruct}"
|
| 9 |
+
export NPC_MODEL_NAME="${NPC_MODEL_NAME:-Qwen/Qwen2.5-0.5B-Instruct}"
|
| 10 |
+
export NPC_MAX_NEW_TOKENS="${NPC_MAX_NEW_TOKENS:-48}"
|
| 11 |
+
export DETECTIVE_MAX_NEW_TOKENS="${DETECTIVE_MAX_NEW_TOKENS:-40}"
|
| 12 |
+
export DETECTIVE_RETRY_MAX_NEW_TOKENS="${DETECTIVE_RETRY_MAX_NEW_TOKENS:-24}"
|
| 13 |
+
|
| 14 |
+
python train_colab.py
|
server/__init__.py
ADDED
|
File without changes
|
server/app.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenEnv-compatible server for the Crime Investigation Environment.
|
| 2 |
+
|
| 3 |
+
Exposes the CrimeInvestigationEnv via OpenEnv's HTTP/WebSocket interface.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
# Development (with auto-reload):
|
| 7 |
+
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
|
| 8 |
+
|
| 9 |
+
# Or run directly:
|
| 10 |
+
python -m server.app
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
import json
|
| 16 |
+
import base64
|
| 17 |
+
from typing import Any, Dict, List, Optional
|
| 18 |
+
|
| 19 |
+
from pydantic import Field
|
| 20 |
+
|
| 21 |
+
# Add project root to path so crime_env is importable
|
| 22 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 23 |
+
|
| 24 |
+
from fastapi.responses import HTMLResponse
|
| 25 |
+
|
| 26 |
+
from openenv.core.env_server.http_server import create_app
|
| 27 |
+
from openenv.core.env_server.interfaces import Environment
|
| 28 |
+
from openenv.core.env_server.types import (
|
| 29 |
+
Action,
|
| 30 |
+
EnvironmentMetadata,
|
| 31 |
+
Observation,
|
| 32 |
+
State,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
from crime_env.environment import CrimeInvestigationEnv
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ββ Pydantic types for OpenEnv ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class CrimeAction(Action):
|
| 42 |
+
"""Action schema for the Crime Investigation environment."""
|
| 43 |
+
|
| 44 |
+
action_string: str = Field(
|
| 45 |
+
...,
|
| 46 |
+
description=(
|
| 47 |
+
"Action in one of the following formats:\n"
|
| 48 |
+
" ACTION: ask_question | TARGET: <agent> | CONTENT: <question>\n"
|
| 49 |
+
" ACTION: request_evidence | ITEM: <item>\n"
|
| 50 |
+
" ACTION: accuse | TARGET: <suspect>"
|
| 51 |
+
),
|
| 52 |
+
examples=[
|
| 53 |
+
"ACTION: ask_question | TARGET: Suspect_A | CONTENT: Where were you?",
|
| 54 |
+
"ACTION: request_evidence | ITEM: keycard_log",
|
| 55 |
+
"ACTION: accuse | TARGET: Suspect_A",
|
| 56 |
+
],
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class CrimeObservation(Observation):
|
| 61 |
+
"""Observation schema returned by the Crime Investigation environment."""
|
| 62 |
+
|
| 63 |
+
role: str = Field(default="detective", description="Agent role")
|
| 64 |
+
briefing: str = Field(default="", description="Case briefing for the detective")
|
| 65 |
+
turn: int = Field(default=0, description="Current turn number")
|
| 66 |
+
conversation_history: List[Dict[str, Any]] = Field(
|
| 67 |
+
default_factory=list, description="Full conversation history"
|
| 68 |
+
)
|
| 69 |
+
evidence_log: List[Dict[str, Any]] = Field(
|
| 70 |
+
default_factory=list, description="Revealed evidence items"
|
| 71 |
+
)
|
| 72 |
+
message: str = Field(default="", description="System message for the current step")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class CrimeState(State):
|
| 76 |
+
"""State schema for the Crime Investigation environment."""
|
| 77 |
+
|
| 78 |
+
turn: int = Field(default=0, description="Current turn number")
|
| 79 |
+
is_done: bool = Field(default=False, description="Whether the episode is over")
|
| 80 |
+
max_turns: int = Field(default=15, description="Maximum turns per episode")
|
| 81 |
+
evidence_revealed: int = Field(default=0, description="Number of evidence items revealed")
|
| 82 |
+
contradictions_found: int = Field(default=0, description="Number of contradictions detected")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ββ OpenEnv-compatible wrapper ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class CrimeInvestigationOpenEnv(Environment[CrimeAction, CrimeObservation, CrimeState]):
|
| 89 |
+
"""OpenEnv wrapper around CrimeInvestigationEnv."""
|
| 90 |
+
|
| 91 |
+
def __init__(self, **kwargs):
|
| 92 |
+
super().__init__(**kwargs)
|
| 93 |
+
self._env = CrimeInvestigationEnv()
|
| 94 |
+
self._current_obs: Optional[dict] = None
|
| 95 |
+
|
| 96 |
+
def reset(
|
| 97 |
+
self,
|
| 98 |
+
seed: Optional[int] = None,
|
| 99 |
+
episode_id: Optional[str] = None,
|
| 100 |
+
**kwargs,
|
| 101 |
+
) -> CrimeObservation:
|
| 102 |
+
if hasattr(self, "_reset_rubric"):
|
| 103 |
+
self._reset_rubric()
|
| 104 |
+
obs = self._env.reset()
|
| 105 |
+
self._current_obs = obs
|
| 106 |
+
return CrimeObservation(
|
| 107 |
+
role=obs.get("role", "detective"),
|
| 108 |
+
briefing=obs.get("briefing", ""),
|
| 109 |
+
turn=obs.get("turn", 0),
|
| 110 |
+
conversation_history=obs.get("conversation_history", []),
|
| 111 |
+
evidence_log=obs.get("evidence_log", []),
|
| 112 |
+
message=obs.get("message", ""),
|
| 113 |
+
done=False,
|
| 114 |
+
reward=None,
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
def step(
|
| 118 |
+
self,
|
| 119 |
+
action: CrimeAction,
|
| 120 |
+
timeout_s: Optional[float] = None,
|
| 121 |
+
**kwargs,
|
| 122 |
+
) -> CrimeObservation:
|
| 123 |
+
obs_dict, reward, done, info = self._env.step(action.action_string)
|
| 124 |
+
self._current_obs = obs_dict
|
| 125 |
+
return CrimeObservation(
|
| 126 |
+
role=obs_dict.get("role", "detective"),
|
| 127 |
+
briefing=obs_dict.get("briefing", ""),
|
| 128 |
+
turn=obs_dict.get("turn", 0),
|
| 129 |
+
conversation_history=obs_dict.get("conversation_history", []),
|
| 130 |
+
evidence_log=obs_dict.get("evidence_log", []),
|
| 131 |
+
message=obs_dict.get("message", ""),
|
| 132 |
+
done=done,
|
| 133 |
+
reward=reward,
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
@property
|
| 137 |
+
def state(self) -> CrimeState:
|
| 138 |
+
env_state = self._env.state()
|
| 139 |
+
return CrimeState(
|
| 140 |
+
turn=env_state.get("turn", 0),
|
| 141 |
+
is_done=env_state.get("done", False),
|
| 142 |
+
max_turns=env_state.get("max_turns", 15),
|
| 143 |
+
evidence_revealed=env_state.get("evidence_revealed", 0),
|
| 144 |
+
contradictions_found=env_state.get("contradictions_found", 0),
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
def get_metadata(self) -> EnvironmentMetadata:
|
| 148 |
+
return EnvironmentMetadata(
|
| 149 |
+
name="CrimeInvestigationEnv",
|
| 150 |
+
description=(
|
| 151 |
+
"AI Crime Investigation World β a multi-agent RL environment "
|
| 152 |
+
"where a detective agent interrogates suspects and a witness, "
|
| 153 |
+
"reviews evidence, and makes an accusation."
|
| 154 |
+
),
|
| 155 |
+
version="1.0.0",
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
def close(self) -> None:
|
| 159 |
+
pass
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ββ App creation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 163 |
+
|
| 164 |
+
app = create_app(
|
| 165 |
+
CrimeInvestigationOpenEnv,
|
| 166 |
+
CrimeAction,
|
| 167 |
+
CrimeObservation,
|
| 168 |
+
env_name="crime_investigation",
|
| 169 |
+
max_concurrent_envs=1,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
# ββ Custom Endpoints for Dashboard ββββββββββββββββββββββββββββββββββββββββββ
|
| 173 |
+
|
| 174 |
+
@app.get("/", response_class=HTMLResponse)
|
| 175 |
+
async def serve_dashboard():
|
| 176 |
+
"""Serves the dashboard.html interface."""
|
| 177 |
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
| 178 |
+
project_root = os.path.dirname(script_dir)
|
| 179 |
+
dashboard_path = os.path.join(project_root, "dashboard.html")
|
| 180 |
+
with open(dashboard_path, "r", encoding="utf-8") as f:
|
| 181 |
+
return f.read()
|
| 182 |
+
|
| 183 |
+
@app.get("/api/run_episode")
|
| 184 |
+
async def run_episode_api():
|
| 185 |
+
"""Runs a single test episode and returns the trace.
|
| 186 |
+
|
| 187 |
+
Import is lazy (Issue 9) and execution is offloaded to a thread
|
| 188 |
+
so the FastAPI event loop isn't blocked (Issue 11).
|
| 189 |
+
"""
|
| 190 |
+
import asyncio
|
| 191 |
+
from test_one_episode import run_test_episode
|
| 192 |
+
|
| 193 |
+
rewards, info, trace = await asyncio.to_thread(run_test_episode)
|
| 194 |
+
return {
|
| 195 |
+
"status": "ok",
|
| 196 |
+
"rewards": rewards,
|
| 197 |
+
"info": info,
|
| 198 |
+
"trace": trace
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _moving_average(values: List[float], window: int) -> List[float]:
|
| 203 |
+
if not values:
|
| 204 |
+
return []
|
| 205 |
+
if window <= 1:
|
| 206 |
+
return values[:]
|
| 207 |
+
averaged: List[float] = []
|
| 208 |
+
running_sum = 0.0
|
| 209 |
+
queue: List[float] = []
|
| 210 |
+
for v in values:
|
| 211 |
+
queue.append(float(v))
|
| 212 |
+
running_sum += float(v)
|
| 213 |
+
if len(queue) > window:
|
| 214 |
+
running_sum -= queue.pop(0)
|
| 215 |
+
averaged.append(running_sum / len(queue))
|
| 216 |
+
return averaged
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@app.get("/api/reward_curve")
|
| 220 |
+
async def reward_curve_api():
|
| 221 |
+
"""Return training reward history for dashboard/HF demo visibility."""
|
| 222 |
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
| 223 |
+
project_root = os.path.dirname(script_dir)
|
| 224 |
+
rewards_path = os.path.join(project_root, "rewards.json")
|
| 225 |
+
reward_curve_path = os.path.join(project_root, "reward_curve.png")
|
| 226 |
+
|
| 227 |
+
rewards: List[float] = []
|
| 228 |
+
results: List[str] = []
|
| 229 |
+
model_name = "unknown"
|
| 230 |
+
num_episodes = 0
|
| 231 |
+
|
| 232 |
+
if os.path.exists(rewards_path):
|
| 233 |
+
with open(rewards_path, "r", encoding="utf-8") as f:
|
| 234 |
+
payload = json.load(f)
|
| 235 |
+
rewards = [float(x) for x in payload.get("rewards", [])]
|
| 236 |
+
results = [str(x) for x in payload.get("results", [])]
|
| 237 |
+
model_name = str(payload.get("model", "unknown"))
|
| 238 |
+
num_episodes = int(payload.get("num_episodes", len(rewards)))
|
| 239 |
+
|
| 240 |
+
window = min(20, max(1, len(rewards) // 4))
|
| 241 |
+
smoothed = _moving_average(rewards, window)
|
| 242 |
+
mean_first = sum(rewards[:50]) / max(1, min(50, len(rewards))) if rewards else 0.0
|
| 243 |
+
mean_last = sum(rewards[-50:]) / max(1, min(50, len(rewards))) if rewards else 0.0
|
| 244 |
+
|
| 245 |
+
image_data_url = None
|
| 246 |
+
if os.path.exists(reward_curve_path):
|
| 247 |
+
with open(reward_curve_path, "rb") as f:
|
| 248 |
+
encoded = base64.b64encode(f.read()).decode("ascii")
|
| 249 |
+
image_data_url = f"data:image/png;base64,{encoded}"
|
| 250 |
+
|
| 251 |
+
return {
|
| 252 |
+
"status": "ok",
|
| 253 |
+
"model": model_name,
|
| 254 |
+
"num_episodes": num_episodes,
|
| 255 |
+
"rewards": rewards,
|
| 256 |
+
"smoothed": smoothed,
|
| 257 |
+
"smooth_window": window,
|
| 258 |
+
"mean_first_50": round(mean_first, 4),
|
| 259 |
+
"mean_last_50": round(mean_last, 4),
|
| 260 |
+
"improvement": round(mean_last - mean_first, 4),
|
| 261 |
+
"results": {
|
| 262 |
+
"correct": results.count("correct"),
|
| 263 |
+
"wrong": results.count("wrong"),
|
| 264 |
+
"timeout": results.count("timeout"),
|
| 265 |
+
},
|
| 266 |
+
"image_data_url": image_data_url,
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
@app.get("/api/health")
|
| 271 |
+
async def health_api():
|
| 272 |
+
"""Simple endpoint used for deployment sanity checks."""
|
| 273 |
+
return {"status": "ok", "service": "crime-investigation"}
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def main(host: str = "0.0.0.0", port: int = 8000):
|
| 277 |
+
"""Entry point for direct execution."""
|
| 278 |
+
import uvicorn
|
| 279 |
+
|
| 280 |
+
uvicorn.run(app, host=host, port=port)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
if __name__ == "__main__":
|
| 284 |
+
import argparse
|
| 285 |
+
|
| 286 |
+
parser = argparse.ArgumentParser()
|
| 287 |
+
parser.add_argument("--port", type=int, default=8000)
|
| 288 |
+
args = parser.parse_args()
|
| 289 |
+
main(port=args.port)
|
test.zip
ADDED
|
Binary file (94 kB). View file
|
|
|
test_one_episode.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Test One Episode β End-to-End Verification.
|
| 3 |
+
|
| 4 |
+
Runs a single episode of the Crime Investigation environment with all agents
|
| 5 |
+
using enhanced rule-based fallback responses (per-agent turn counters).
|
| 6 |
+
Prints the full conversation, reward breakdown, and accusation result.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
import random
|
| 12 |
+
|
| 13 |
+
# Add project root to path
|
| 14 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 15 |
+
|
| 16 |
+
from crime_env.case_generator import generate_case
|
| 17 |
+
from crime_env.consistency_tracker import ConsistencyTracker
|
| 18 |
+
from crime_env.reward_calculator import RewardCalculator
|
| 19 |
+
from crime_env.agent_prompts import build_system_prompt
|
| 20 |
+
from crime_env.environment import CrimeInvestigationEnv
|
| 21 |
+
from crime_env.constants import (
|
| 22 |
+
AGENT_NAME_TO_KEY,
|
| 23 |
+
SUSPECT_A,
|
| 24 |
+
SUSPECT_B,
|
| 25 |
+
WITNESS_1,
|
| 26 |
+
WITNESS_1_KEY,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ββ Enhanced Rule-Based Detective βββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def scripted_detective_actions(case: dict) -> list[str]:
|
| 34 |
+
"""Generate a scripted sequence of detective actions for testing.
|
| 35 |
+
|
| 36 |
+
This produces a realistic investigation sequence that exercises
|
| 37 |
+
all action types: ask_question, request_evidence, accuse.
|
| 38 |
+
"""
|
| 39 |
+
accuse_target = choose_non_oracle_accusation(case)
|
| 40 |
+
|
| 41 |
+
actions = [
|
| 42 |
+
# Turn 0: Question Suspect A about alibi
|
| 43 |
+
f"ACTION: ask_question | TARGET: {SUSPECT_A} | CONTENT: Where were you at the time of the crime?",
|
| 44 |
+
# Turn 1: Question Suspect B about alibi
|
| 45 |
+
f"ACTION: ask_question | TARGET: {SUSPECT_B} | CONTENT: Can you tell me your whereabouts that evening?",
|
| 46 |
+
# Turn 2: Question Witness
|
| 47 |
+
f"ACTION: ask_question | TARGET: {WITNESS_1} | CONTENT: What did you see near the crime scene?",
|
| 48 |
+
# Turn 3: Request evidence
|
| 49 |
+
"ACTION: request_evidence | ITEM: keycard_log",
|
| 50 |
+
# Turn 4: Follow up with Suspect A about inconsistency
|
| 51 |
+
f"ACTION: ask_question | TARGET: {SUSPECT_A} | CONTENT: Your prior record shows you've lied about your location before. Can you explain your alibi in more detail?",
|
| 52 |
+
# Turn 5: Request more evidence
|
| 53 |
+
"ACTION: request_evidence | ITEM: cctv_footage",
|
| 54 |
+
# Turn 6: Question Suspect A about clothing
|
| 55 |
+
f"ACTION: ask_question | TARGET: {SUSPECT_A} | CONTENT: What were you wearing that evening?",
|
| 56 |
+
# Turn 7: Question Witness about clothing
|
| 57 |
+
f"ACTION: ask_question | TARGET: {WITNESS_1} | CONTENT: Can you describe what the person you saw was wearing?",
|
| 58 |
+
# Turn 8: Ask Suspect B if they know Suspect A
|
| 59 |
+
f"ACTION: ask_question | TARGET: {SUSPECT_B} | CONTENT: Do you know {SUSPECT_A}? Have you had any previous interactions?",
|
| 60 |
+
# Turn 9: Request forensic report
|
| 61 |
+
"ACTION: request_evidence | ITEM: forensic_report",
|
| 62 |
+
# Turn 10: Final question before accusation
|
| 63 |
+
f"ACTION: ask_question | TARGET: {SUSPECT_A} | CONTENT: The evidence places someone at the scene. Was that you?",
|
| 64 |
+
# Turn 11: Accuse
|
| 65 |
+
f"ACTION: accuse | TARGET: {accuse_target}",
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
return actions
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def choose_non_oracle_accusation(case: dict) -> str:
|
| 72 |
+
"""Pick an accusation target using only case-visible investigative signals.
|
| 73 |
+
|
| 74 |
+
This intentionally avoids direct use of the hidden `case['criminal']`
|
| 75 |
+
so dashboard episodes can produce both correct and wrong outcomes.
|
| 76 |
+
"""
|
| 77 |
+
scores = {SUSPECT_A: 0.0, SUSPECT_B: 0.0}
|
| 78 |
+
|
| 79 |
+
# Evidence contributes most to suspicion.
|
| 80 |
+
for evidence in case.get("physical_evidence", []):
|
| 81 |
+
points_to = evidence.get("points_to")
|
| 82 |
+
if points_to in scores:
|
| 83 |
+
scores[points_to] += 1.75
|
| 84 |
+
|
| 85 |
+
# Prior history is suggestive, but weaker than direct evidence.
|
| 86 |
+
for suspect in (SUSPECT_A, SUSPECT_B):
|
| 87 |
+
history = case.get("prior_history", {}).get(suspect, {})
|
| 88 |
+
scores[suspect] += 0.25 * len(history.get("past_lies_on_record", []))
|
| 89 |
+
if history.get("on_parole"):
|
| 90 |
+
scores[suspect] += 0.15
|
| 91 |
+
trust = history.get("trust_score", 0.5)
|
| 92 |
+
scores[suspect] += max(0.0, 0.6 - trust)
|
| 93 |
+
|
| 94 |
+
# Witness bias can mislead investigators in realistic episodes.
|
| 95 |
+
witness = case.get("agent_knowledge", {}).get("witness_1", {})
|
| 96 |
+
bias_target = witness.get("bias_target")
|
| 97 |
+
bias_strength = float(witness.get("bias_strength", 0.0) or 0.0)
|
| 98 |
+
if bias_target in scores:
|
| 99 |
+
scores[bias_target] += 0.6 * bias_strength
|
| 100 |
+
|
| 101 |
+
# If confidence is low, introduce uncertainty to allow wrong accusations.
|
| 102 |
+
ordered = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
|
| 103 |
+
top_target, top_score = ordered[0]
|
| 104 |
+
second_target, second_score = ordered[1]
|
| 105 |
+
margin = top_score - second_score
|
| 106 |
+
if margin < 0.45 and random.random() < 0.45:
|
| 107 |
+
return second_target
|
| 108 |
+
|
| 109 |
+
return top_target
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ββ Enhanced NPC Responses ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def make_enhanced_npc_call(case: dict):
|
| 116 |
+
"""Create an NPC response function with per-agent turn counters.
|
| 117 |
+
|
| 118 |
+
Each agent has its own turn counter, so their response progression
|
| 119 |
+
is independent β Suspect_A and Suspect_B will never give the same
|
| 120 |
+
response at the same time.
|
| 121 |
+
"""
|
| 122 |
+
# Per-agent turn counters β each agent tracks their own call count
|
| 123 |
+
agent_counters: dict[str, int] = {}
|
| 124 |
+
|
| 125 |
+
def npc_call(system_prompt: str, conversation_history: list[dict]) -> str:
|
| 126 |
+
# Identify agent from system prompt β works with either suspect as criminal
|
| 127 |
+
is_guilty = "FAKE ALIBI:" in system_prompt
|
| 128 |
+
is_innocent = "innocent" in system_prompt
|
| 129 |
+
is_witness = "Witness_1" in system_prompt
|
| 130 |
+
|
| 131 |
+
if is_guilty:
|
| 132 |
+
agent_id = "guilty_suspect"
|
| 133 |
+
elif is_innocent:
|
| 134 |
+
agent_id = "innocent_suspect"
|
| 135 |
+
elif is_witness:
|
| 136 |
+
agent_id = "witness"
|
| 137 |
+
else:
|
| 138 |
+
return "I don't have anything to add."
|
| 139 |
+
|
| 140 |
+
# Increment per-agent counter
|
| 141 |
+
call_num = agent_counters.get(agent_id, 0)
|
| 142 |
+
agent_counters[agent_id] = call_num + 1
|
| 143 |
+
|
| 144 |
+
if agent_id == "guilty_suspect":
|
| 145 |
+
criminal_name = case["criminal"]
|
| 146 |
+
knowledge = case["agent_knowledge"][AGENT_NAME_TO_KEY[criminal_name]]
|
| 147 |
+
fake_alibi = knowledge["fake_alibi"]
|
| 148 |
+
|
| 149 |
+
responses = [
|
| 150 |
+
# Call 0: Initial alibi statement β uses case-specific fake alibi
|
| 151 |
+
f"{fake_alibi}. I can provide witnesses if needed.",
|
| 152 |
+
# Call 1: Deflects the prior-record question
|
| 153 |
+
"Look, I've made mistakes in the past, but I've been clean. "
|
| 154 |
+
"I was wearing my grey trench coat, as I usually do in the evenings.",
|
| 155 |
+
# Call 2: Deflection toward Suspect_B
|
| 156 |
+
"I don't know anything about what happened. I was nowhere near the "
|
| 157 |
+
"scene. Maybe you should look at Suspect_B instead.",
|
| 158 |
+
# Call 3: Getting nervous, starts slipping β changes location claim
|
| 159 |
+
"I was at the bar that evening. I might have had the times mixed up "
|
| 160 |
+
"earlier, but I was definitely not at the crime scene.",
|
| 161 |
+
# Call 4+: Further inconsistency under pressure
|
| 162 |
+
"I already told you everything I know. I was at home all evening. "
|
| 163 |
+
"You're wasting your time with me.",
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
elif agent_id == "innocent_suspect":
|
| 167 |
+
innocent_name = SUSPECT_B if case["criminal"] == SUSPECT_A else SUSPECT_A
|
| 168 |
+
knowledge = case["agent_knowledge"][AGENT_NAME_TO_KEY[innocent_name]]
|
| 169 |
+
real_alibi = knowledge["real_alibi"]
|
| 170 |
+
knows_other = knowledge.get("knows_suspect_A", knowledge.get("knows_suspect_B", False))
|
| 171 |
+
|
| 172 |
+
responses = [
|
| 173 |
+
# Call 0: Clear, specific, verifiable alibi
|
| 174 |
+
f"{real_alibi}. I can prove it if you need documentation.",
|
| 175 |
+
# Call 1: Relationship with Suspect_A
|
| 176 |
+
(
|
| 177 |
+
"I know the other suspect from work, but we're not close. "
|
| 178 |
+
"I haven't seen them recently."
|
| 179 |
+
if knows_other
|
| 180 |
+
else "I do not know the other suspect personally. "
|
| 181 |
+
"I've never had any interactions with them."
|
| 182 |
+
),
|
| 183 |
+
# Call 2: Confirms alibi again, firmly
|
| 184 |
+
f"As I already said, {real_alibi}. "
|
| 185 |
+
"I had absolutely nothing to do with this crime.",
|
| 186 |
+
# Call 3+: Cooperating but frustrated
|
| 187 |
+
f"I've been completely honest with you. {real_alibi}. "
|
| 188 |
+
"Please check my receipts and timestamps β everything lines up.",
|
| 189 |
+
]
|
| 190 |
+
|
| 191 |
+
# ββ Witness βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 192 |
+
else:
|
| 193 |
+
knowledge = case["agent_knowledge"][WITNESS_1_KEY]
|
| 194 |
+
saw = knowledge["saw"]
|
| 195 |
+
time_seen = knowledge["time_seen"]
|
| 196 |
+
bias = knowledge.get("bias_target", None)
|
| 197 |
+
|
| 198 |
+
responses = [
|
| 199 |
+
# Call 0: Primary testimony β case-specific observation
|
| 200 |
+
f"I saw {saw}. It was around {time_seen}. "
|
| 201 |
+
"They seemed tense and were looking over their shoulder.",
|
| 202 |
+
# Call 1: Follow-up β more detail about what they saw
|
| 203 |
+
f"The person I saw was near the {case['location']} at {time_seen}. "
|
| 204 |
+
"They were moving quickly and seemed like they didn't want to be noticed.",
|
| 205 |
+
# Call 2+: Additional detail or bias
|
| 206 |
+
(
|
| 207 |
+
f"Now that I think about it, their build reminded me of {bias}, "
|
| 208 |
+
"but I can't be completely certain."
|
| 209 |
+
if bias
|
| 210 |
+
else f"I only saw them briefly near the {case['location']}. "
|
| 211 |
+
"I wish I could tell you more, but that's all I observed."
|
| 212 |
+
),
|
| 213 |
+
]
|
| 214 |
+
|
| 215 |
+
idx = min(call_num, len(responses) - 1)
|
| 216 |
+
return responses[idx]
|
| 217 |
+
|
| 218 |
+
return npc_call
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# ββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def run_test_episode():
|
| 225 |
+
"""Run a single test episode and print full results."""
|
| 226 |
+
print("=" * 70)
|
| 227 |
+
print(" AI CRIME INVESTIGATION WORLD β TEST EPISODE")
|
| 228 |
+
print("=" * 70)
|
| 229 |
+
|
| 230 |
+
# Generate case
|
| 231 |
+
case = generate_case()
|
| 232 |
+
|
| 233 |
+
print(f"\nπ CASE DETAILS (ground truth β not visible to detective):")
|
| 234 |
+
print(f" Crime: {case['crime']}")
|
| 235 |
+
print(f" Criminal: {case['criminal']}")
|
| 236 |
+
print(f" Location: {case['location']}")
|
| 237 |
+
print(f" Time: {case['time']}")
|
| 238 |
+
print(f" Method: {case['method']}")
|
| 239 |
+
criminal = case["criminal"]
|
| 240 |
+
innocent = SUSPECT_B if criminal == SUSPECT_A else SUSPECT_A
|
| 241 |
+
print(f"\nπ {criminal} FAKE ALIBI: {case['agent_knowledge'][AGENT_NAME_TO_KEY[criminal]]['fake_alibi']}")
|
| 242 |
+
print(f"π {innocent} REAL ALIBI: {case['agent_knowledge'][AGENT_NAME_TO_KEY[innocent]]['real_alibi']}")
|
| 243 |
+
print(f"ποΈ WITNESS SAW: {case['agent_knowledge'][WITNESS_1_KEY]['saw']}")
|
| 244 |
+
print()
|
| 245 |
+
|
| 246 |
+
# Create environment with enhanced NPC
|
| 247 |
+
npc_call = make_enhanced_npc_call(case)
|
| 248 |
+
env = CrimeInvestigationEnv(llm_call=npc_call)
|
| 249 |
+
|
| 250 |
+
# Reset with an explicit case to avoid mutating internal environment state.
|
| 251 |
+
obs = env.reset(case_data=case)
|
| 252 |
+
|
| 253 |
+
print(f"π DETECTIVE BRIEFING:")
|
| 254 |
+
print(f" {case['detective_briefing']}")
|
| 255 |
+
print()
|
| 256 |
+
print("-" * 70)
|
| 257 |
+
print(" INVESTIGATION BEGINS")
|
| 258 |
+
print("-" * 70)
|
| 259 |
+
|
| 260 |
+
# We will build a trace of events for the frontend
|
| 261 |
+
trace = []
|
| 262 |
+
|
| 263 |
+
# Store the scenario details initially
|
| 264 |
+
trace.append({
|
| 265 |
+
"type": "scenario",
|
| 266 |
+
"case": {
|
| 267 |
+
"crime": case["crime"],
|
| 268 |
+
"location": case["location"],
|
| 269 |
+
"time": case["time"],
|
| 270 |
+
"criminal": case["criminal"]
|
| 271 |
+
}
|
| 272 |
+
})
|
| 273 |
+
|
| 274 |
+
# Get scripted detective actions
|
| 275 |
+
actions = scripted_detective_actions(case)
|
| 276 |
+
|
| 277 |
+
done = False
|
| 278 |
+
total_reward = 0.0
|
| 279 |
+
last_info = {}
|
| 280 |
+
|
| 281 |
+
# Deduplication trackers for trace events (Bug 4 & Bug 5)
|
| 282 |
+
already_traced_evidence = set()
|
| 283 |
+
contradictions_this_turn = set()
|
| 284 |
+
|
| 285 |
+
for i, action in enumerate(actions):
|
| 286 |
+
if done:
|
| 287 |
+
break
|
| 288 |
+
|
| 289 |
+
print(f"\nπ΅οΈ [Turn {i}] DETECTIVE:")
|
| 290 |
+
print(f" {action}")
|
| 291 |
+
|
| 292 |
+
obs, reward, done, info = env.step(action)
|
| 293 |
+
total_reward += reward # accumulate step deltas (Bug 1 fix: accuse now returns delta)
|
| 294 |
+
last_info = info
|
| 295 |
+
|
| 296 |
+
# Parse the action specifically for the trace
|
| 297 |
+
action_parts = dict(p.strip().split(": ", 1) for p in action.split(" | ") if ": " in p)
|
| 298 |
+
trace.append({
|
| 299 |
+
"type": "detective",
|
| 300 |
+
"action": action_parts.get("ACTION", action),
|
| 301 |
+
"target": action_parts.get("TARGET", ""),
|
| 302 |
+
"item": action_parts.get("ITEM", ""),
|
| 303 |
+
"text": action_parts.get("CONTENT", ""),
|
| 304 |
+
"turn": obs.get("turn", i),
|
| 305 |
+
"reward_delta": float(reward) if not done else 0.0
|
| 306 |
+
})
|
| 307 |
+
|
| 308 |
+
# Print response if there's a new conversation entry
|
| 309 |
+
if env.conversation_history:
|
| 310 |
+
last_entry = env.conversation_history[-1]
|
| 311 |
+
if last_entry["speaker"] != "Detective":
|
| 312 |
+
print(f"\n㪠[{last_entry['speaker']}]:")
|
| 313 |
+
print(f" {last_entry['content']}")
|
| 314 |
+
|
| 315 |
+
trace.append({
|
| 316 |
+
"type": "response",
|
| 317 |
+
"speaker": last_entry["speaker"],
|
| 318 |
+
"turn": obs.get("turn", i),
|
| 319 |
+
"text": last_entry["content"]
|
| 320 |
+
})
|
| 321 |
+
|
| 322 |
+
if last_entry.get("flags"):
|
| 323 |
+
# Reset per-turn contradiction tracker each turn
|
| 324 |
+
contradictions_this_turn_local = set()
|
| 325 |
+
for flag in last_entry["flags"]:
|
| 326 |
+
print(f" β οΈ {flag}")
|
| 327 |
+
if "CONTRADICTION" in flag:
|
| 328 |
+
turn_key = f"{last_entry['speaker']}_{obs.get('turn', i)}"
|
| 329 |
+
if turn_key not in contradictions_this_turn_local:
|
| 330 |
+
contradictions_this_turn_local.add(turn_key)
|
| 331 |
+
trace.append({
|
| 332 |
+
"type": "contradiction",
|
| 333 |
+
"suspect": last_entry["speaker"],
|
| 334 |
+
"turn": obs.get("turn", i),
|
| 335 |
+
"detail": flag
|
| 336 |
+
})
|
| 337 |
+
|
| 338 |
+
# Check newly revealed evidence β deduplicated
|
| 339 |
+
if obs.get("evidence_log"):
|
| 340 |
+
new_ev = obs["evidence_log"][-1]
|
| 341 |
+
item_key = new_ev.get("name", "")
|
| 342 |
+
if item_key and item_key not in already_traced_evidence:
|
| 343 |
+
already_traced_evidence.add(item_key)
|
| 344 |
+
trace.append({
|
| 345 |
+
"type": "evidence",
|
| 346 |
+
"item": str(new_ev.get("name", "Unknown")),
|
| 347 |
+
"desc": str(new_ev.get("description", "")),
|
| 348 |
+
"turn": obs.get("turn", i)
|
| 349 |
+
})
|
| 350 |
+
|
| 351 |
+
if not done:
|
| 352 |
+
print(f" Step reward: {reward:+.2f}")
|
| 353 |
+
|
| 354 |
+
# ββ Results βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 355 |
+
|
| 356 |
+
print("\n" + "=" * 70)
|
| 357 |
+
print(" EPISODE RESULTS")
|
| 358 |
+
print("=" * 70)
|
| 359 |
+
|
| 360 |
+
# Accusation result
|
| 361 |
+
if last_info.get("action") == "accuse":
|
| 362 |
+
correct = last_info.get("correct", False)
|
| 363 |
+
target = last_info.get("target", "?")
|
| 364 |
+
actual = last_info.get("actual_criminal", "?")
|
| 365 |
+
emoji = "β
" if correct else "β"
|
| 366 |
+
print(f"\n {emoji} Accusation: {target}")
|
| 367 |
+
print(f" Actual criminal: {actual}")
|
| 368 |
+
print(f" Result: {'CORRECT!' if correct else 'WRONG!'}")
|
| 369 |
+
|
| 370 |
+
trace.append({
|
| 371 |
+
"type": "outcome",
|
| 372 |
+
"correct": correct,
|
| 373 |
+
"target": target,
|
| 374 |
+
"actual": actual
|
| 375 |
+
})
|
| 376 |
+
else:
|
| 377 |
+
print(f"\n β° Result: TIMEOUT (no accusation made)")
|
| 378 |
+
trace.append({
|
| 379 |
+
"type": "outcome",
|
| 380 |
+
"correct": False,
|
| 381 |
+
"timeout": True
|
| 382 |
+
})
|
| 383 |
+
|
| 384 |
+
# Print full reward breakdown
|
| 385 |
+
rewards = env.reward_calc.get_rewards()
|
| 386 |
+
print(f"\n π REWARD BREAKDOWN:")
|
| 387 |
+
print(f" Detective: {rewards['detective']:>+8.2f}")
|
| 388 |
+
print(f" Suspect_A: {rewards['suspect_a']:>+8.2f}")
|
| 389 |
+
print(f" Suspect_B: {rewards['suspect_b']:>+8.2f}")
|
| 390 |
+
print(f" Witness: {rewards['witness']:>+8.2f}")
|
| 391 |
+
|
| 392 |
+
# Print consistency tracker summary
|
| 393 |
+
contradictions = env.tracker.get_summary()
|
| 394 |
+
print(f"\n π CONTRADICTIONS DETECTED: {len(contradictions)}")
|
| 395 |
+
for c in contradictions:
|
| 396 |
+
print(
|
| 397 |
+
f" {c['agent']} on '{c['topic']}': "
|
| 398 |
+
f"'{c['old_value'][:50]}' β '{c['new_value'][:50]}' (turn {c['turn']})"
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
# Print event log
|
| 402 |
+
print(f"\n π EVENT LOG ({len(env.reward_calc.get_event_log())} events):")
|
| 403 |
+
for event in env.reward_calc.get_event_log():
|
| 404 |
+
e_type = event['event']
|
| 405 |
+
delta = event['delta']
|
| 406 |
+
print(f" {e_type}: {delta:+.2f}")
|
| 407 |
+
|
| 408 |
+
print("\n" + "=" * 70)
|
| 409 |
+
print(" Full conversation log:")
|
| 410 |
+
env.render()
|
| 411 |
+
|
| 412 |
+
return rewards, last_info, trace
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
# ββ Entry Point βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 416 |
+
|
| 417 |
+
if __name__ == "__main__":
|
| 418 |
+
rewards, info, _trace = run_test_episode()
|
| 419 |
+
|
| 420 |
+
# Quick assertion check
|
| 421 |
+
print("\nπ§ͺ VERIFICATION CHECKS:")
|
| 422 |
+
|
| 423 |
+
# Case generator
|
| 424 |
+
case = generate_case()
|
| 425 |
+
assert case["criminal"] in ("Suspect_A", "Suspect_B"), "Criminal must be one of the suspects"
|
| 426 |
+
assert len(case["physical_evidence"]) == 3, "Must have 3 physical evidence items"
|
| 427 |
+
assert "detective_briefing" in case, "Must have detective briefing"
|
| 428 |
+
print(" β
Case generator: OK")
|
| 429 |
+
|
| 430 |
+
# Consistency tracker β test real contradiction vs non-contradiction
|
| 431 |
+
ct = ConsistencyTracker()
|
| 432 |
+
r1 = ct.record_claim("Suspect_A", "alibi", "I was at the bar all evening", 1)
|
| 433 |
+
assert r1["contradicted"] == False, "First claim should not be contradicted"
|
| 434 |
+
|
| 435 |
+
r2 = ct.record_claim("Suspect_A", "alibi", "I was at the bar having drinks", 2)
|
| 436 |
+
assert r2["contradicted"] == False, "Same bar alibi should NOT be a contradiction"
|
| 437 |
+
|
| 438 |
+
r3 = ct.record_claim("Suspect_A", "alibi", "I was at home all evening", 3)
|
| 439 |
+
assert r3["contradicted"] == True, "bar vs home IS a real contradiction"
|
| 440 |
+
|
| 441 |
+
r4 = ct.record_claim("Suspect_A", "location", "east wing", 4)
|
| 442 |
+
r5 = ct.record_claim("Suspect_A", "location", "parking lot", 5)
|
| 443 |
+
assert r5["contradicted"] == True, "east wing vs parking lot is a contradiction"
|
| 444 |
+
print(" β
Consistency tracker: OK (semantic normalization working)")
|
| 445 |
+
|
| 446 |
+
# Reward calculator
|
| 447 |
+
rc = RewardCalculator()
|
| 448 |
+
rc.apply_event("correct_accusation")
|
| 449 |
+
rc.apply_event("per_turn_cost")
|
| 450 |
+
r = rc.get_rewards()
|
| 451 |
+
assert abs(r["detective"] - 9.7) < 0.01, f"Expected 9.7, got {r['detective']}"
|
| 452 |
+
print(" β
Reward calculator: OK")
|
| 453 |
+
|
| 454 |
+
# System prompts β verify differentiation
|
| 455 |
+
prompts = {
|
| 456 |
+
role: build_system_prompt(role, case)
|
| 457 |
+
for role in ["detective", "suspect_a", "suspect_b", "witness"]
|
| 458 |
+
}
|
| 459 |
+
assert "ACTION:" in prompts["detective"], "Detective prompt must include action format"
|
| 460 |
+
assert "fake alibi" not in prompts["detective"].lower(), "Detective must not know the fake alibi"
|
| 461 |
+
criminal = case["criminal"]
|
| 462 |
+
innocent = SUSPECT_B if criminal == SUSPECT_A else SUSPECT_A
|
| 463 |
+
assert "FAKE ALIBI" in prompts["suspect_a" if criminal == SUSPECT_A else "suspect_b"], \
|
| 464 |
+
"Criminal suspect prompt must have fake alibi"
|
| 465 |
+
assert "REAL ALIBI" in prompts["suspect_b" if criminal == SUSPECT_A else "suspect_a"], \
|
| 466 |
+
"Innocent suspect prompt must have real alibi"
|
| 467 |
+
assert case["agent_knowledge"][AGENT_NAME_TO_KEY[criminal]]["fake_alibi"] in prompts["suspect_a" if criminal == SUSPECT_A else "suspect_b"], \
|
| 468 |
+
"Criminal suspect prompt must contain case-specific fake alibi"
|
| 469 |
+
assert case["agent_knowledge"][AGENT_NAME_TO_KEY[innocent]]["real_alibi"] in prompts["suspect_b" if criminal == SUSPECT_A else "suspect_a"], \
|
| 470 |
+
"Innocent suspect prompt must contain case-specific real alibi"
|
| 471 |
+
print(" β
Agent prompts: OK (alibis differentiated)")
|
| 472 |
+
|
| 473 |
+
print("\nβ
All verification checks passed!")
|
train_colab.ipynb
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"metadata": {
|
| 6 |
+
"language": "markdown"
|
| 7 |
+
},
|
| 8 |
+
"source": [
|
| 9 |
+
"# AI Crime Investigation World - Colab Training\n",
|
| 10 |
+
"\n",
|
| 11 |
+
"This notebook runs a minimal PPO training loop using TRL and logs reward progress for demo/judging."
|
| 12 |
+
]
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"cell_type": "code",
|
| 16 |
+
"execution_count": null,
|
| 17 |
+
"metadata": {
|
| 18 |
+
"language": "python"
|
| 19 |
+
},
|
| 20 |
+
"outputs": [],
|
| 21 |
+
"source": [
|
| 22 |
+
"# Install dependencies\n",
|
| 23 |
+
"!pip install -q -r requirements-train.txt"
|
| 24 |
+
]
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"cell_type": "code",
|
| 28 |
+
"execution_count": null,
|
| 29 |
+
"metadata": {
|
| 30 |
+
"language": "python"
|
| 31 |
+
},
|
| 32 |
+
"outputs": [],
|
| 33 |
+
"source": [
|
| 34 |
+
"# Optional: clone repo if running in a fresh Colab runtime\n",
|
| 35 |
+
"# !git clone <YOUR_REPO_URL>\n",
|
| 36 |
+
"# %cd SST_Final\n",
|
| 37 |
+
"\n",
|
| 38 |
+
"import torch\n",
|
| 39 |
+
"print('CUDA available:', torch.cuda.is_available())\n",
|
| 40 |
+
"if torch.cuda.is_available():\n",
|
| 41 |
+
" print('GPU:', torch.cuda.get_device_name(0))"
|
| 42 |
+
]
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"cell_type": "code",
|
| 46 |
+
"execution_count": null,
|
| 47 |
+
"metadata": {
|
| 48 |
+
"language": "python"
|
| 49 |
+
},
|
| 50 |
+
"outputs": [],
|
| 51 |
+
"source": [
|
| 52 |
+
"# Smoke test run (fast)\n",
|
| 53 |
+
"import os\n",
|
| 54 |
+
"\n",
|
| 55 |
+
"os.environ['TEST_MODE'] = '1'\n",
|
| 56 |
+
"os.environ['NUM_EPISODES'] = '5'\n",
|
| 57 |
+
"os.environ['MAX_TURNS'] = '8'\n",
|
| 58 |
+
"os.environ['MODEL_NAME'] = 'Qwen/Qwen2.5-1.5B-Instruct'\n",
|
| 59 |
+
"os.environ['NPC_MODEL_NAME'] = 'Qwen/Qwen2.5-0.5B-Instruct'\n",
|
| 60 |
+
"\n",
|
| 61 |
+
"!python train_colab.py"
|
| 62 |
+
]
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"cell_type": "code",
|
| 66 |
+
"execution_count": null,
|
| 67 |
+
"metadata": {
|
| 68 |
+
"language": "python"
|
| 69 |
+
},
|
| 70 |
+
"outputs": [],
|
| 71 |
+
"source": [
|
| 72 |
+
"# Full demo run (edit values as needed)\n",
|
| 73 |
+
"import os\n",
|
| 74 |
+
"\n",
|
| 75 |
+
"os.environ['TEST_MODE'] = '0'\n",
|
| 76 |
+
"os.environ['NUM_EPISODES'] = '50'\n",
|
| 77 |
+
"os.environ['MODEL_NAME'] = 'Qwen/Qwen2.5-3B-Instruct'\n",
|
| 78 |
+
"# os.environ['NPC_MODEL_NAME'] = 'Qwen/Qwen2.5-0.5B-Instruct'\n",
|
| 79 |
+
"\n",
|
| 80 |
+
"!python train_colab.py"
|
| 81 |
+
]
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"cell_type": "code",
|
| 85 |
+
"execution_count": null,
|
| 86 |
+
"metadata": {
|
| 87 |
+
"language": "python"
|
| 88 |
+
},
|
| 89 |
+
"outputs": [],
|
| 90 |
+
"source": [
|
| 91 |
+
"# Display reward curve and summary metrics\n",
|
| 92 |
+
"import json\n",
|
| 93 |
+
"import matplotlib.pyplot as plt\n",
|
| 94 |
+
"\n",
|
| 95 |
+
"with open('rewards.json', 'r') as f:\n",
|
| 96 |
+
" data = json.load(f)\n",
|
| 97 |
+
"\n",
|
| 98 |
+
"rewards = data['rewards']\n",
|
| 99 |
+
"results = data.get('results', [])\n",
|
| 100 |
+
"\n",
|
| 101 |
+
"print('Episodes:', data.get('num_episodes', len(rewards)))\n",
|
| 102 |
+
"print('Model:', data.get('model', 'unknown'))\n",
|
| 103 |
+
"print('Correct:', results.count('correct'))\n",
|
| 104 |
+
"print('Wrong:', results.count('wrong'))\n",
|
| 105 |
+
"print('Timeout:', results.count('timeout'))\n",
|
| 106 |
+
"\n",
|
| 107 |
+
"plt.figure(figsize=(10, 4))\n",
|
| 108 |
+
"plt.plot(rewards, alpha=0.35, label='Per episode')\n",
|
| 109 |
+
"if len(rewards) > 1:\n",
|
| 110 |
+
" window = min(20, max(2, len(rewards) // 4))\n",
|
| 111 |
+
" smooth = []\n",
|
| 112 |
+
" for i in range(len(rewards)):\n",
|
| 113 |
+
" start = max(0, i - window + 1)\n",
|
| 114 |
+
" smooth.append(sum(rewards[start:i+1]) / (i - start + 1))\n",
|
| 115 |
+
" plt.plot(smooth, linewidth=2, label=f'Moving avg ({window})')\n",
|
| 116 |
+
"\n",
|
| 117 |
+
"plt.axhline(0, color='gray', linestyle='--', alpha=0.5)\n",
|
| 118 |
+
"plt.title('Detective Reward Over Training')\n",
|
| 119 |
+
"plt.xlabel('Episode')\n",
|
| 120 |
+
"plt.ylabel('Reward')\n",
|
| 121 |
+
"plt.legend()\n",
|
| 122 |
+
"plt.grid(alpha=0.25)\n",
|
| 123 |
+
"plt.show()"
|
| 124 |
+
]
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"cell_type": "code",
|
| 128 |
+
"execution_count": null,
|
| 129 |
+
"metadata": {
|
| 130 |
+
"language": "python"
|
| 131 |
+
},
|
| 132 |
+
"outputs": [],
|
| 133 |
+
"source": [
|
| 134 |
+
"# Show before/after transcript snapshots\n",
|
| 135 |
+
"import json\n",
|
| 136 |
+
"\n",
|
| 137 |
+
"with open('episode_transcripts.json', 'r') as f:\n",
|
| 138 |
+
" t = json.load(f)\n",
|
| 139 |
+
"\n",
|
| 140 |
+
"print('Captured episodes:', t.get('episodes_captured', []))\n",
|
| 141 |
+
"for ep in t.get('transcripts', []):\n",
|
| 142 |
+
" print('\\n' + '=' * 70)\n",
|
| 143 |
+
" print(f\"Episode {ep['episode']} | Result: {ep['result']} | Reward: {ep['detective_reward']}\")\n",
|
| 144 |
+
" for line in ep.get('conversation_history', [])[:10]:\n",
|
| 145 |
+
" speaker = line.get('speaker', 'Unknown')\n",
|
| 146 |
+
" content = line.get('content', '')\n",
|
| 147 |
+
" print(f\"- {speaker}: {content}\")\n",
|
| 148 |
+
" if len(ep.get('conversation_history', [])) > 10:\n",
|
| 149 |
+
" print('...')"
|
| 150 |
+
]
|
| 151 |
+
}
|
| 152 |
+
],
|
| 153 |
+
"metadata": {
|
| 154 |
+
"kernelspec": {
|
| 155 |
+
"display_name": "Python 3",
|
| 156 |
+
"language": "python",
|
| 157 |
+
"name": "python3"
|
| 158 |
+
},
|
| 159 |
+
"language_info": {
|
| 160 |
+
"name": "python"
|
| 161 |
+
}
|
| 162 |
+
},
|
| 163 |
+
"nbformat": 4,
|
| 164 |
+
"nbformat_minor": 5
|
| 165 |
+
}
|
train_colab.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Component 6 β Minimal Training Script (Colab-Ready).
|
| 3 |
+
|
| 4 |
+
Trains the detective agent using PPO (HuggingFace TRL) while other agents
|
| 5 |
+
use fixed prompt-based LLM calls. Designed for free-tier Colab GPU.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import inspect
|
| 11 |
+
import sys
|
| 12 |
+
import time
|
| 13 |
+
import warnings
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
import matplotlib.pyplot as plt
|
| 17 |
+
import numpy as np
|
| 18 |
+
import torch
|
| 19 |
+
from transformers import (
|
| 20 |
+
AutoConfig,
|
| 21 |
+
AutoModelForCausalLM,
|
| 22 |
+
AutoTokenizer,
|
| 23 |
+
BitsAndBytesConfig,
|
| 24 |
+
pipeline,
|
| 25 |
+
)
|
| 26 |
+
from peft import LoraConfig, TaskType
|
| 27 |
+
from trl import PPOConfig, PPOTrainer, AutoModelForCausalLMWithValueHead
|
| 28 |
+
|
| 29 |
+
# Add project root to path
|
| 30 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 31 |
+
|
| 32 |
+
from crime_env.environment import CrimeInvestigationEnv
|
| 33 |
+
from crime_env.agent_prompts import build_system_prompt
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ββ Configuration βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _env_bool(name: str, default: bool = False) -> bool:
|
| 40 |
+
value = os.environ.get(name)
|
| 41 |
+
if value is None:
|
| 42 |
+
return default
|
| 43 |
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _env_int(name: str, default: int) -> int:
|
| 47 |
+
value = os.environ.get(name)
|
| 48 |
+
if value is None:
|
| 49 |
+
return default
|
| 50 |
+
try:
|
| 51 |
+
return int(value)
|
| 52 |
+
except ValueError:
|
| 53 |
+
return default
|
| 54 |
+
|
| 55 |
+
# Default to a stronger model while keeping env override support.
|
| 56 |
+
TEST_MODE = _env_bool("TEST_MODE", False)
|
| 57 |
+
MODEL_NAME = os.environ.get("MODEL_NAME", "Qwen/Qwen2.5-3B-Instruct")
|
| 58 |
+
NPC_MODEL_NAME = os.environ.get(
|
| 59 |
+
"NPC_MODEL_NAME",
|
| 60 |
+
"Qwen/Qwen2.5-0.5B-Instruct" if TEST_MODE else MODEL_NAME,
|
| 61 |
+
)
|
| 62 |
+
NUM_EPISODES = _env_int("NUM_EPISODES", 5 if TEST_MODE else 300)
|
| 63 |
+
MAX_TURNS = _env_int("MAX_TURNS", 8 if TEST_MODE else 15)
|
| 64 |
+
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 65 |
+
OUTPUT_DIR = "./ppo_detective"
|
| 66 |
+
REWARDS_FILE = "rewards.json"
|
| 67 |
+
TRANSCRIPTS_FILE = "episode_transcripts.json"
|
| 68 |
+
NPC_MAX_NEW_TOKENS = _env_int("NPC_MAX_NEW_TOKENS", 64 if TEST_MODE else 150)
|
| 69 |
+
DETECTIVE_MAX_NEW_TOKENS = _env_int(
|
| 70 |
+
"DETECTIVE_MAX_NEW_TOKENS", 48 if TEST_MODE else 80
|
| 71 |
+
)
|
| 72 |
+
DETECTIVE_RETRY_MAX_NEW_TOKENS = _env_int(
|
| 73 |
+
"DETECTIVE_RETRY_MAX_NEW_TOKENS", 32 if TEST_MODE else 64
|
| 74 |
+
)
|
| 75 |
+
TRANSCRIPT_EPISODES = {
|
| 76 |
+
int(x.strip())
|
| 77 |
+
for x in os.environ.get("TRANSCRIPT_EPISODES", "1,25,50").split(",")
|
| 78 |
+
if x.strip().isdigit()
|
| 79 |
+
}
|
| 80 |
+
STRICT_FORMAT_FALLBACK_THRESHOLD = float(
|
| 81 |
+
os.environ.get("STRICT_FORMAT_FALLBACK_THRESHOLD", "0.35")
|
| 82 |
+
)
|
| 83 |
+
STRICT_FORMAT_WINDOW = _env_int("STRICT_FORMAT_WINDOW", 5)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ββ Model Loading βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def load_models():
|
| 90 |
+
"""Load the detective (trainable) and NPC (fixed) models."""
|
| 91 |
+
print(f"Loading model: {MODEL_NAME}")
|
| 92 |
+
print(f"Device: {DEVICE}")
|
| 93 |
+
|
| 94 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
|
| 95 |
+
if tokenizer.pad_token is None:
|
| 96 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 97 |
+
|
| 98 |
+
model_config = AutoConfig.from_pretrained(MODEL_NAME, trust_remote_code=True)
|
| 99 |
+
hidden_size = getattr(model_config, "hidden_size", None)
|
| 100 |
+
use_quantization = DEVICE == "cuda" and (hidden_size is None or hidden_size >= 64)
|
| 101 |
+
|
| 102 |
+
# Quantization for memory efficiency on free Colab
|
| 103 |
+
quant_config = None
|
| 104 |
+
if use_quantization:
|
| 105 |
+
try:
|
| 106 |
+
quant_config = BitsAndBytesConfig(
|
| 107 |
+
load_in_4bit=True,
|
| 108 |
+
bnb_4bit_compute_dtype=torch.float16,
|
| 109 |
+
bnb_4bit_use_double_quant=True,
|
| 110 |
+
bnb_4bit_quant_type="nf4",
|
| 111 |
+
)
|
| 112 |
+
except Exception:
|
| 113 |
+
print("BitsAndBytes not available, loading without quantization")
|
| 114 |
+
elif DEVICE == "cuda":
|
| 115 |
+
print("Skipping 4-bit quantization for small hidden-size model")
|
| 116 |
+
|
| 117 |
+
# LoRA config β enables PEFT so PPOTrainer reuses base weights as the
|
| 118 |
+
# reference model instead of deepcopy-ing the quantized model (OOM fix).
|
| 119 |
+
lora_config = LoraConfig(
|
| 120 |
+
r=8,
|
| 121 |
+
lora_alpha=16,
|
| 122 |
+
target_modules=["q_proj", "v_proj"],
|
| 123 |
+
lora_dropout=0.05,
|
| 124 |
+
bias="none",
|
| 125 |
+
task_type=TaskType.CAUSAL_LM,
|
| 126 |
+
) if DEVICE == "cuda" else None
|
| 127 |
+
|
| 128 |
+
# Detective model (trainable with value head)
|
| 129 |
+
model_dtype = torch.float16 if DEVICE == "cuda" else torch.float32
|
| 130 |
+
detective_load_kwargs = {
|
| 131 |
+
"quantization_config": quant_config,
|
| 132 |
+
"peft_config": lora_config,
|
| 133 |
+
"dtype": model_dtype,
|
| 134 |
+
"device_map": "auto" if DEVICE == "cuda" else None,
|
| 135 |
+
"trust_remote_code": True,
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
detective_model = AutoModelForCausalLMWithValueHead.from_pretrained(
|
| 140 |
+
MODEL_NAME,
|
| 141 |
+
**detective_load_kwargs,
|
| 142 |
+
)
|
| 143 |
+
except ValueError as e:
|
| 144 |
+
error_text = str(e)
|
| 145 |
+
if DEVICE == "cuda" and "dispatched on the CPU or the disk" in error_text:
|
| 146 |
+
print("Low VRAM detected, retrying with CPU offload enabled for quantized layers...")
|
| 147 |
+
offload_quant_config = BitsAndBytesConfig(
|
| 148 |
+
load_in_4bit=True,
|
| 149 |
+
bnb_4bit_compute_dtype=torch.float16,
|
| 150 |
+
bnb_4bit_use_double_quant=True,
|
| 151 |
+
bnb_4bit_quant_type="nf4",
|
| 152 |
+
llm_int8_enable_fp32_cpu_offload=True,
|
| 153 |
+
)
|
| 154 |
+
detective_load_kwargs["quantization_config"] = offload_quant_config
|
| 155 |
+
detective_load_kwargs["device_map"] = "auto"
|
| 156 |
+
detective_model = AutoModelForCausalLMWithValueHead.from_pretrained(
|
| 157 |
+
MODEL_NAME,
|
| 158 |
+
**detective_load_kwargs,
|
| 159 |
+
)
|
| 160 |
+
else:
|
| 161 |
+
raise
|
| 162 |
+
|
| 163 |
+
# NPC pipeline β load a SEPARATE frozen copy of the base model.
|
| 164 |
+
# Bug 9 fix: Using detective_model.pretrained_model would cause PPO
|
| 165 |
+
# gradient updates to drift the NPC's behavior every step.
|
| 166 |
+
npc_base_model = None
|
| 167 |
+
try:
|
| 168 |
+
npc_base_model = AutoModelForCausalLM.from_pretrained(
|
| 169 |
+
NPC_MODEL_NAME,
|
| 170 |
+
quantization_config=quant_config,
|
| 171 |
+
dtype=model_dtype,
|
| 172 |
+
device_map="auto" if DEVICE == "cuda" else None,
|
| 173 |
+
trust_remote_code=True,
|
| 174 |
+
)
|
| 175 |
+
npc_base_model.eval() # Freeze: no gradient tracking
|
| 176 |
+
for param in npc_base_model.parameters():
|
| 177 |
+
param.requires_grad = False
|
| 178 |
+
except Exception as e:
|
| 179 |
+
print(f"NPC model load warning: {e}")
|
| 180 |
+
print("Falling back to detective base model for NPC responses to keep training running.")
|
| 181 |
+
npc_base_model = detective_model.pretrained_model
|
| 182 |
+
|
| 183 |
+
npc_pipeline = pipeline(
|
| 184 |
+
"text-generation",
|
| 185 |
+
model=npc_base_model,
|
| 186 |
+
tokenizer=tokenizer,
|
| 187 |
+
max_new_tokens=NPC_MAX_NEW_TOKENS,
|
| 188 |
+
do_sample=True,
|
| 189 |
+
temperature=0.7,
|
| 190 |
+
top_p=0.9,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
return detective_model, npc_pipeline, tokenizer
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# ββ NPC LLM Call ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def make_npc_call(npc_pipeline):
|
| 200 |
+
"""Create a callable for NPC agent responses."""
|
| 201 |
+
|
| 202 |
+
def llm_call(system_prompt: str, conversation_history: list[dict]) -> str:
|
| 203 |
+
# Build a bounded prompt to avoid model context overflows.
|
| 204 |
+
messages = f"System: {system_prompt[:800]}\n\n"
|
| 205 |
+
|
| 206 |
+
# Keep only recent turns and constrain prompt length.
|
| 207 |
+
recent_history = conversation_history[-10:]
|
| 208 |
+
for entry in recent_history:
|
| 209 |
+
speaker = entry.get("speaker", "Unknown")
|
| 210 |
+
content = entry.get("content", "")
|
| 211 |
+
messages += f"{speaker}: {content[:180]}\n"
|
| 212 |
+
|
| 213 |
+
# Hard cap prompt size for small-context models.
|
| 214 |
+
messages = messages[-1800:]
|
| 215 |
+
|
| 216 |
+
messages += "\nYour response:"
|
| 217 |
+
|
| 218 |
+
try:
|
| 219 |
+
result = npc_pipeline(messages, return_full_text=False)
|
| 220 |
+
response = result[0]["generated_text"].strip()
|
| 221 |
+
# Clean up: take first sentence/paragraph
|
| 222 |
+
if "\n" in response:
|
| 223 |
+
response = response.split("\n")[0]
|
| 224 |
+
return response[:300] if response else "I have nothing to add."
|
| 225 |
+
except Exception as e:
|
| 226 |
+
print(f" NPC call error: {e}")
|
| 227 |
+
return "I don't recall anything specific about that."
|
| 228 |
+
|
| 229 |
+
return llm_call
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
# ββ Detective Action Generation βββββββββββββββββββββββββββββββββββββββββββββ
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def generate_detective_action(
|
| 236 |
+
detective_model,
|
| 237 |
+
tokenizer,
|
| 238 |
+
observation: dict,
|
| 239 |
+
env=None,
|
| 240 |
+
strict_action_format: bool = False,
|
| 241 |
+
) -> tuple[str, torch.Tensor, torch.Tensor, bool]:
|
| 242 |
+
"""Generate a detective action using the trainable model.
|
| 243 |
+
|
| 244 |
+
Returns:
|
| 245 |
+
(action_string, query_tensor, response_tensor, used_fallback)
|
| 246 |
+
"""
|
| 247 |
+
# Build prompt from observation
|
| 248 |
+
prompt = f"""You are a detective. Based on the conversation so far, choose your next action.
|
| 249 |
+
|
| 250 |
+
Briefing: {observation['briefing'][:300]}
|
| 251 |
+
|
| 252 |
+
Turn: {observation['turn']}/{MAX_TURNS}
|
| 253 |
+
|
| 254 |
+
Recent conversation:
|
| 255 |
+
"""
|
| 256 |
+
history = observation.get("conversation_history", [])
|
| 257 |
+
for entry in history[-6:]:
|
| 258 |
+
prompt += f" {entry['speaker']}: {entry['content'][:100]}\n"
|
| 259 |
+
|
| 260 |
+
prompt += """
|
| 261 |
+
Choose ONE action using EXACTLY this format:
|
| 262 |
+
ACTION: ask_question | TARGET: Suspect_A | CONTENT: <question>
|
| 263 |
+
ACTION: request_evidence | ITEM: keycard_log
|
| 264 |
+
ACTION: accuse | TARGET: Suspect_A
|
| 265 |
+
|
| 266 |
+
Your action:
|
| 267 |
+
"""
|
| 268 |
+
|
| 269 |
+
if strict_action_format:
|
| 270 |
+
prompt += (
|
| 271 |
+
"\nIMPORTANT: Output ONLY a single valid ACTION line. "
|
| 272 |
+
"No explanations, no extra text.\n"
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
# Tokenize
|
| 276 |
+
inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=1024)
|
| 277 |
+
query_tensor = inputs["input_ids"].squeeze()
|
| 278 |
+
|
| 279 |
+
if DEVICE == "cuda":
|
| 280 |
+
inputs = {k: v.to(DEVICE) for k, v in inputs.items()}
|
| 281 |
+
|
| 282 |
+
# Generate
|
| 283 |
+
with torch.no_grad():
|
| 284 |
+
output = detective_model.pretrained_model.generate(
|
| 285 |
+
**inputs,
|
| 286 |
+
max_new_tokens=DETECTIVE_MAX_NEW_TOKENS,
|
| 287 |
+
do_sample=True,
|
| 288 |
+
temperature=0.4 if strict_action_format else 0.8,
|
| 289 |
+
top_p=0.8 if strict_action_format else 0.9,
|
| 290 |
+
pad_token_id=tokenizer.pad_token_id,
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
response_tensor = output.squeeze()[len(query_tensor):]
|
| 294 |
+
action_text = tokenizer.decode(response_tensor, skip_special_tokens=True).strip()
|
| 295 |
+
|
| 296 |
+
def _is_valid_action(text: str) -> bool:
|
| 297 |
+
normalized = text.strip().upper()
|
| 298 |
+
return normalized.startswith("ACTION:")
|
| 299 |
+
|
| 300 |
+
# Retry once in strict mode before falling back to scripted actions.
|
| 301 |
+
if not _is_valid_action(action_text):
|
| 302 |
+
strict_prompt = prompt + (
|
| 303 |
+
"\nFINAL REMINDER: Reply with one line starting with 'ACTION:' only.\n"
|
| 304 |
+
)
|
| 305 |
+
strict_inputs = tokenizer(
|
| 306 |
+
strict_prompt, return_tensors="pt", truncation=True, max_length=1024
|
| 307 |
+
)
|
| 308 |
+
if DEVICE == "cuda":
|
| 309 |
+
strict_inputs = {k: v.to(DEVICE) for k, v in strict_inputs.items()}
|
| 310 |
+
|
| 311 |
+
with torch.no_grad():
|
| 312 |
+
retry_output = detective_model.pretrained_model.generate(
|
| 313 |
+
**strict_inputs,
|
| 314 |
+
max_new_tokens=DETECTIVE_RETRY_MAX_NEW_TOKENS,
|
| 315 |
+
do_sample=True,
|
| 316 |
+
temperature=0.25,
|
| 317 |
+
top_p=0.75,
|
| 318 |
+
pad_token_id=tokenizer.pad_token_id,
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
strict_query_tensor = strict_inputs["input_ids"].squeeze().detach().cpu()
|
| 322 |
+
retry_response_tensor = retry_output.squeeze()[len(strict_query_tensor):]
|
| 323 |
+
retry_action_text = tokenizer.decode(
|
| 324 |
+
retry_response_tensor, skip_special_tokens=True
|
| 325 |
+
).strip()
|
| 326 |
+
if _is_valid_action(retry_action_text):
|
| 327 |
+
query_tensor = strict_query_tensor
|
| 328 |
+
response_tensor = retry_response_tensor
|
| 329 |
+
action_text = retry_action_text
|
| 330 |
+
|
| 331 |
+
used_fallback = False
|
| 332 |
+
|
| 333 |
+
# If model output doesn't parse, generate a deterministic fallback action.
|
| 334 |
+
if not _is_valid_action(action_text):
|
| 335 |
+
used_fallback = True
|
| 336 |
+
turn = observation.get("turn", 0)
|
| 337 |
+
if turn < 4:
|
| 338 |
+
action_text = "ACTION: ask_question | TARGET: Suspect_A | CONTENT: Where were you at the time of the crime?"
|
| 339 |
+
elif turn < 8:
|
| 340 |
+
action_text = "ACTION: ask_question | TARGET: Suspect_B | CONTENT: Can you describe your alibi?"
|
| 341 |
+
elif turn < 10:
|
| 342 |
+
action_text = "ACTION: request_evidence | ITEM: keycard_log"
|
| 343 |
+
else:
|
| 344 |
+
action_text = "ACTION: ask_question | TARGET: Witness_1 | CONTENT: What did you see?"
|
| 345 |
+
|
| 346 |
+
# Never train PPO on fallback actions: they are scripted and off-policy.
|
| 347 |
+
if used_fallback:
|
| 348 |
+
response_tensor = tokenizer(
|
| 349 |
+
action_text,
|
| 350 |
+
return_tensors="pt",
|
| 351 |
+
add_special_tokens=False,
|
| 352 |
+
)["input_ids"].squeeze()
|
| 353 |
+
|
| 354 |
+
return action_text, query_tensor, response_tensor, used_fallback
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
# ββ Training Loop βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def train():
|
| 361 |
+
"""Main PPO training loop."""
|
| 362 |
+
print("=" * 60)
|
| 363 |
+
print(" AI Crime Investigation World β PPO Training")
|
| 364 |
+
print("=" * 60)
|
| 365 |
+
if TEST_MODE:
|
| 366 |
+
print(" Running in TEST_MODE (smoke-test settings enabled)")
|
| 367 |
+
print(
|
| 368 |
+
f" MODEL_NAME={MODEL_NAME} | NPC_MODEL_NAME={NPC_MODEL_NAME} | "
|
| 369 |
+
f"NUM_EPISODES={NUM_EPISODES} | MAX_TURNS={MAX_TURNS}"
|
| 370 |
+
)
|
| 371 |
+
print("=" * 60)
|
| 372 |
+
|
| 373 |
+
# Load models
|
| 374 |
+
detective_model, npc_pipeline, tokenizer = load_models()
|
| 375 |
+
|
| 376 |
+
warnings.filterwarnings(
|
| 377 |
+
"ignore",
|
| 378 |
+
message="No dataset is provided.*",
|
| 379 |
+
category=UserWarning,
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
# Compatibility guard: this script targets the TRL 0.9 PPOTrainer API.
|
| 383 |
+
ppo_init_params = inspect.signature(PPOTrainer.__init__).parameters
|
| 384 |
+
if "config" not in ppo_init_params:
|
| 385 |
+
raise RuntimeError(
|
| 386 |
+
"Incompatible TRL version detected. "
|
| 387 |
+
"Use `trl>=0.9.0,<0.10.0` for this training script."
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
# PPO config (support TRL variants with either ppo_epochs or num_ppo_epochs)
|
| 391 |
+
ppo_cfg_kwargs = {
|
| 392 |
+
"learning_rate": 1e-5,
|
| 393 |
+
"batch_size": 1,
|
| 394 |
+
"mini_batch_size": 1,
|
| 395 |
+
"gradient_accumulation_steps": 1,
|
| 396 |
+
}
|
| 397 |
+
ppo_cfg_params = inspect.signature(PPOConfig.__init__).parameters
|
| 398 |
+
if "num_ppo_epochs" in ppo_cfg_params:
|
| 399 |
+
ppo_cfg_kwargs["num_ppo_epochs"] = 1 # single-sample stability
|
| 400 |
+
elif "ppo_epochs" in ppo_cfg_params:
|
| 401 |
+
ppo_cfg_kwargs["ppo_epochs"] = 1 # single-sample stability
|
| 402 |
+
|
| 403 |
+
ppo_config = PPOConfig(**ppo_cfg_kwargs)
|
| 404 |
+
|
| 405 |
+
# PPO Trainer
|
| 406 |
+
ppo_trainer = PPOTrainer(
|
| 407 |
+
config=ppo_config,
|
| 408 |
+
model=detective_model,
|
| 409 |
+
tokenizer=tokenizer,
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
# Environment
|
| 413 |
+
npc_call = make_npc_call(npc_pipeline)
|
| 414 |
+
env = CrimeInvestigationEnv(llm_call=npc_call)
|
| 415 |
+
|
| 416 |
+
# Tracking
|
| 417 |
+
reward_log = []
|
| 418 |
+
results_log = []
|
| 419 |
+
transcript_log = []
|
| 420 |
+
|
| 421 |
+
print(f"\nStarting training for {NUM_EPISODES} episodes...\n")
|
| 422 |
+
|
| 423 |
+
strict_action_format = False
|
| 424 |
+
fallback_rate_history: list[float] = []
|
| 425 |
+
|
| 426 |
+
for episode in range(NUM_EPISODES):
|
| 427 |
+
t0 = time.time()
|
| 428 |
+
|
| 429 |
+
# Reset environment
|
| 430 |
+
obs = env.reset()
|
| 431 |
+
done = False
|
| 432 |
+
fallback_steps = 0
|
| 433 |
+
total_steps = 0
|
| 434 |
+
ppo_updates = 0
|
| 435 |
+
|
| 436 |
+
while not done:
|
| 437 |
+
# Generate detective action
|
| 438 |
+
action_text, query_tensor, response_tensor, used_fallback = (
|
| 439 |
+
generate_detective_action(
|
| 440 |
+
detective_model,
|
| 441 |
+
tokenizer,
|
| 442 |
+
obs,
|
| 443 |
+
env=env,
|
| 444 |
+
strict_action_format=strict_action_format,
|
| 445 |
+
)
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
# Step environment
|
| 449 |
+
obs, reward, done, info = env.step(action_text)
|
| 450 |
+
total_steps += 1
|
| 451 |
+
|
| 452 |
+
if not used_fallback:
|
| 453 |
+
# PPOConfig uses batch_size=1, so update on each valid step.
|
| 454 |
+
try:
|
| 455 |
+
reward_tensor = torch.tensor([reward], dtype=torch.float32)
|
| 456 |
+
ppo_trainer.step(
|
| 457 |
+
[query_tensor],
|
| 458 |
+
[response_tensor],
|
| 459 |
+
[reward_tensor],
|
| 460 |
+
)
|
| 461 |
+
ppo_updates += 1
|
| 462 |
+
except Exception as e:
|
| 463 |
+
print(f" PPO update error (episode {episode}, step {total_steps}): {e}")
|
| 464 |
+
else:
|
| 465 |
+
fallback_steps += 1
|
| 466 |
+
|
| 467 |
+
# Get final rewards
|
| 468 |
+
final_rewards = env.reward_calc.get_rewards()
|
| 469 |
+
detective_reward = final_rewards["detective"]
|
| 470 |
+
reward_log.append(detective_reward)
|
| 471 |
+
|
| 472 |
+
# Determine result
|
| 473 |
+
last_info = info
|
| 474 |
+
if last_info.get("action") == "accuse":
|
| 475 |
+
result = "correct" if last_info.get("correct") else "wrong"
|
| 476 |
+
else:
|
| 477 |
+
result = "timeout"
|
| 478 |
+
results_log.append(result)
|
| 479 |
+
|
| 480 |
+
if (episode + 1) in TRANSCRIPT_EPISODES:
|
| 481 |
+
transcript_log.append(
|
| 482 |
+
{
|
| 483 |
+
"episode": episode + 1,
|
| 484 |
+
"result": result,
|
| 485 |
+
"detective_reward": round(detective_reward, 4),
|
| 486 |
+
"turns": env.turn,
|
| 487 |
+
"criminal": env.case.get("criminal") if env.case else None,
|
| 488 |
+
"crime": env.case.get("crime") if env.case else None,
|
| 489 |
+
"location": env.case.get("location") if env.case else None,
|
| 490 |
+
"conversation_history": list(env.conversation_history),
|
| 491 |
+
"evidence_log": list(env.evidence_log),
|
| 492 |
+
}
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
fallback_rate = fallback_steps / max(1, total_steps)
|
| 496 |
+
fallback_rate_history.append(fallback_rate)
|
| 497 |
+
if len(fallback_rate_history) > max(1, STRICT_FORMAT_WINDOW):
|
| 498 |
+
fallback_rate_history.pop(0)
|
| 499 |
+
rolling_fallback_rate = sum(fallback_rate_history) / len(fallback_rate_history)
|
| 500 |
+
strict_action_format = rolling_fallback_rate > STRICT_FORMAT_FALLBACK_THRESHOLD
|
| 501 |
+
|
| 502 |
+
elapsed = time.time() - t0
|
| 503 |
+
print(
|
| 504 |
+
f"Episode {episode + 1:>3}/{NUM_EPISODES} | "
|
| 505 |
+
f"Result: {result:<7} | "
|
| 506 |
+
f"Reward: {detective_reward:>+7.2f} | "
|
| 507 |
+
f"Turns: {env.turn:>2} | "
|
| 508 |
+
f"PPO updates: {ppo_updates:>2} | "
|
| 509 |
+
f"Fallback: {fallback_steps:>2}/{max(1, total_steps):<2} "
|
| 510 |
+
f"({fallback_rate*100:>5.1f}%, avg={rolling_fallback_rate*100:>5.1f}%) | "
|
| 511 |
+
f"Time: {elapsed:.1f}s"
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
+
# ββ Save results ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 515 |
+
|
| 516 |
+
# Save reward log
|
| 517 |
+
with open(REWARDS_FILE, "w") as f:
|
| 518 |
+
json.dump(
|
| 519 |
+
{
|
| 520 |
+
"rewards": reward_log,
|
| 521 |
+
"results": results_log,
|
| 522 |
+
"num_episodes": NUM_EPISODES,
|
| 523 |
+
"model": MODEL_NAME,
|
| 524 |
+
},
|
| 525 |
+
f,
|
| 526 |
+
indent=2,
|
| 527 |
+
)
|
| 528 |
+
print(f"\nReward log saved to {REWARDS_FILE}")
|
| 529 |
+
|
| 530 |
+
with open(TRANSCRIPTS_FILE, "w") as f:
|
| 531 |
+
json.dump(
|
| 532 |
+
{
|
| 533 |
+
"episodes_captured": sorted([t["episode"] for t in transcript_log]),
|
| 534 |
+
"transcripts": transcript_log,
|
| 535 |
+
},
|
| 536 |
+
f,
|
| 537 |
+
indent=2,
|
| 538 |
+
)
|
| 539 |
+
print(f"Episode transcripts saved to {TRANSCRIPTS_FILE}")
|
| 540 |
+
|
| 541 |
+
# Plot reward curve
|
| 542 |
+
_plot_reward_curve(reward_log)
|
| 543 |
+
|
| 544 |
+
# Summary statistics
|
| 545 |
+
print("\n" + "=" * 60)
|
| 546 |
+
print(" TRAINING SUMMARY")
|
| 547 |
+
print("=" * 60)
|
| 548 |
+
print(f" Episodes: {NUM_EPISODES}")
|
| 549 |
+
print(f" Correct accusations: {results_log.count('correct')}")
|
| 550 |
+
print(f" Wrong accusations: {results_log.count('wrong')}")
|
| 551 |
+
print(f" Timeouts: {results_log.count('timeout')}")
|
| 552 |
+
print(f" Mean reward (last 50): {np.mean(reward_log[-50:]):.2f}")
|
| 553 |
+
print(f" Mean reward (first 50): {np.mean(reward_log[:50]):.2f}")
|
| 554 |
+
print("=" * 60)
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
def _plot_reward_curve(reward_log: list[float]) -> None:
|
| 558 |
+
"""Plot and save the reward curve."""
|
| 559 |
+
fig, ax = plt.subplots(figsize=(12, 5))
|
| 560 |
+
|
| 561 |
+
episodes = np.arange(1, len(reward_log) + 1)
|
| 562 |
+
ax.plot(episodes, reward_log, alpha=0.3, color="#4a90d9", label="Per-episode")
|
| 563 |
+
|
| 564 |
+
# Smoothed curve (rolling average)
|
| 565 |
+
window = min(20, len(reward_log) // 4)
|
| 566 |
+
if window > 1:
|
| 567 |
+
smoothed = np.convolve(
|
| 568 |
+
reward_log, np.ones(window) / window, mode="valid"
|
| 569 |
+
)
|
| 570 |
+
ax.plot(
|
| 571 |
+
episodes[window - 1:],
|
| 572 |
+
smoothed,
|
| 573 |
+
color="#e74c3c",
|
| 574 |
+
linewidth=2,
|
| 575 |
+
label=f"Rolling avg ({window} ep)",
|
| 576 |
+
)
|
| 577 |
+
|
| 578 |
+
ax.set_xlabel("Episode", fontsize=12)
|
| 579 |
+
ax.set_ylabel("Detective Reward", fontsize=12)
|
| 580 |
+
ax.set_title("AI Crime Investigation β Detective Reward Over Training", fontsize=14)
|
| 581 |
+
ax.legend()
|
| 582 |
+
ax.grid(True, alpha=0.3)
|
| 583 |
+
ax.axhline(y=0, color="grey", linestyle="--", alpha=0.5)
|
| 584 |
+
|
| 585 |
+
plt.tight_layout()
|
| 586 |
+
plt.savefig("reward_curve.png", dpi=150)
|
| 587 |
+
print("Reward curve saved to reward_curve.png")
|
| 588 |
+
plt.show()
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
# ββ Entry Point βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 592 |
+
|
| 593 |
+
if __name__ == "__main__":
|
| 594 |
+
train()
|