Spaces:
Sleeping
Sleeping
Commit ·
807d5cc
0
Parent(s):
Initial commit: Emotional Support Conversations OpenEnv environment
Browse filesOpenEnv RL environment for open-ended emotional support dialogue with a
hybrid immediate + future-oriented reward signal inspired by RLFF-ESC
(Yang et al., 2025, arXiv:2508.12935).
- Deterministic FSM seeker with hidden distress/trust/openness state
- 3 graded tasks: work_stress_venting, guarded_relationship, crisis_fragile_trust
- FastAPI server exposing /reset /step /state /tasks
- Mandated inference.py with OpenAI client + [START]/[STEP]/[END] stdout
- Dockerfile and HF Space metadata
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- .gitignore +8 -0
- Dockerfile +16 -0
- README.md +187 -0
- inference.py +232 -0
- openenv.yaml +56 -0
- requirements.txt +5 -0
- server.py +74 -0
- src/__init__.py +16 -0
- src/client.py +70 -0
- src/env.py +190 -0
- src/grader.py +174 -0
- src/models.py +111 -0
- src/seeker.py +412 -0
- src/tasks.py +258 -0
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
.venv/
|
| 5 |
+
venv/
|
| 6 |
+
.env
|
| 7 |
+
.DS_Store
|
| 8 |
+
.claude/
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PORT=7860
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
COPY requirements.txt .
|
| 10 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
+
|
| 12 |
+
COPY . .
|
| 13 |
+
|
| 14 |
+
EXPOSE 7860
|
| 15 |
+
|
| 16 |
+
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Emotional Support Conversations (OpenEnv)
|
| 3 |
+
emoji: 💬
|
| 4 |
+
sdk: docker
|
| 5 |
+
pinned: false
|
| 6 |
+
tags:
|
| 7 |
+
- openenv
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# Emotional Support Conversations — OpenEnv Environment
|
| 11 |
+
|
| 12 |
+
> An OpenEnv RL environment for evaluating agents on **open-ended emotional
|
| 13 |
+
> support conversations**, with a hybrid immediate + future-oriented reward
|
| 14 |
+
> signal inspired by **RLFF-ESC** (Yang, Chen, Wang, 2025,
|
| 15 |
+
> [arXiv:2508.12935](https://arxiv.org/abs/2508.12935)).
|
| 16 |
+
|
| 17 |
+
## Why this environment
|
| 18 |
+
|
| 19 |
+
Emotional support is one of the tasks humans most want AI assistants to do
|
| 20 |
+
well — and one of the easiest to do badly. Existing dialogue benchmarks
|
| 21 |
+
score turn-level responses in isolation, which rewards agents for *sounding*
|
| 22 |
+
empathetic without ever testing whether their replies actually move the
|
| 23 |
+
person toward resolution. This environment closes that gap.
|
| 24 |
+
|
| 25 |
+
Three properties make it a genuine RL problem, not a single-shot dialogue
|
| 26 |
+
task:
|
| 27 |
+
|
| 28 |
+
1. **Partial observability.** The seeker's distress, trust, and willingness
|
| 29 |
+
to reveal their real issue are hidden state. The agent must infer them
|
| 30 |
+
from the conversation so far.
|
| 31 |
+
2. **Sequential credit assignment.** A warm reply at turn 2 can unlock a
|
| 32 |
+
disclosure at turn 6. A single dismissive reply at turn 4 can collapse
|
| 33 |
+
the whole trajectory and require several turns to recover.
|
| 34 |
+
3. **Exploration vs commitment.** Should the agent keep exploring feelings
|
| 35 |
+
or move toward an action plan? Commit too early and the seeker shuts
|
| 36 |
+
down; explore too long and the episode times out.
|
| 37 |
+
|
| 38 |
+
## Reward design (RLFF-ESC-inspired)
|
| 39 |
+
|
| 40 |
+
Each step reward is:
|
| 41 |
+
|
| 42 |
+
```
|
| 43 |
+
step_reward = clip( 0.45 · immediate + 0.55 · future_oriented − penalties , 0, 1 )
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
- **`immediate`** — stage-appropriate empathy/validation/open-question fit,
|
| 47 |
+
plus turn-level deltas in the seeker's trust and distress.
|
| 48 |
+
- **`future_oriented`** — a k-step oracle rollout from both the pre- and
|
| 49 |
+
post-action seeker states. The reward is proportional to how much the
|
| 50 |
+
agent's action *preserves or advances the attainable resolution ceiling*,
|
| 51 |
+
not just how good the current turn looks in isolation. This is the
|
| 52 |
+
RLFF-ESC idea: reward signals propagated from projected trajectories
|
| 53 |
+
rather than pointwise turn critique.
|
| 54 |
+
- **`penalties`** — dismissive language, premature advice (before trust is
|
| 55 |
+
established), bare replies, interrogation.
|
| 56 |
+
|
| 57 |
+
A final task score combines average shaped reward, the seeker's final
|
| 58 |
+
resolution state, and efficiency (finishing within turn budget).
|
| 59 |
+
|
| 60 |
+
## Tasks (3 difficulties)
|
| 61 |
+
|
| 62 |
+
| Task ID | Difficulty | Max turns | Core challenge |
|
| 63 |
+
| ------------------------ | ---------- | --------- | ---------------------------------------------------------------------------- |
|
| 64 |
+
| `work_stress_venting` | easy | 10 | Cooperative seeker venting about work. Reach closing with trust ≥ 0.7. |
|
| 65 |
+
| `guarded_relationship` | medium | 12 | Guarded seeker; real issue hidden behind surface concern until openness ≥ 0.75. Premature advice heavily punished. |
|
| 66 |
+
| `crisis_fragile_trust` | hard | 14 | High-distress, fragile trust, multiple interleaved concerns. One misstep triggers large trust drops that take several empathic turns to recover. Safety referencing rewarded in closing stage. |
|
| 67 |
+
|
| 68 |
+
Success thresholds (final score) are `0.55 / 0.50 / 0.45` respectively.
|
| 69 |
+
|
| 70 |
+
## Action & observation space
|
| 71 |
+
|
| 72 |
+
**Action** — free-text reply to the seeker:
|
| 73 |
+
|
| 74 |
+
```python
|
| 75 |
+
class Action(BaseModel):
|
| 76 |
+
message: str
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
**Observation** — what the agent sees each turn (deliberately partial):
|
| 80 |
+
|
| 81 |
+
```python
|
| 82 |
+
class Observation(BaseModel):
|
| 83 |
+
seeker_utterance: str
|
| 84 |
+
turn: int
|
| 85 |
+
remaining_turns: int
|
| 86 |
+
stage_hint: str # 'opening' | 'exploring' | 'reflecting' | 'planning' | 'closing'
|
| 87 |
+
task_id: str
|
| 88 |
+
scenario_brief: str
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
The seeker's internal hidden variables (distress, trust, openness, true
|
| 92 |
+
issue) are **never** exposed. This is by design.
|
| 93 |
+
|
| 94 |
+
## Environment internals (why deterministic)
|
| 95 |
+
|
| 96 |
+
The seeker is a deterministic finite-state machine with continuous hidden
|
| 97 |
+
variables (`distress`, `trust`, `openness`, `revealed`, `stage`). On each
|
| 98 |
+
turn, the agent's reply is analysed with keyword/regex feature detectors
|
| 99 |
+
(empathy markers, validation, open vs closed questions, advice, dismissive
|
| 100 |
+
language, interrogation) and hidden state advances via transparent rules.
|
| 101 |
+
|
| 102 |
+
**Why not use an LLM-driven seeker?** The hackathon rubric requires
|
| 103 |
+
graders to be *deterministic and reproducible* — an LLM-driven seeker
|
| 104 |
+
would fail the Phase 2 score-variance check. Deterministic dynamics give
|
| 105 |
+
us full reproducibility while still producing rich, sequential, partially
|
| 106 |
+
observable dialogue with genuine recovery-from-mistakes dynamics.
|
| 107 |
+
|
| 108 |
+
## HTTP API (OpenEnv spec)
|
| 109 |
+
|
| 110 |
+
| Method | Path | Body | Returns |
|
| 111 |
+
| ------ | --------- | -------------------------------------------- | --------------------------------------- |
|
| 112 |
+
| GET | `/` | — | health + metadata |
|
| 113 |
+
| GET | `/tasks` | — | list of tasks |
|
| 114 |
+
| POST | `/reset` | `{"task_id": "...", "seed": null}` (opt.) | `ResetResult` (observation + info) |
|
| 115 |
+
| POST | `/step` | `{"action": {"message": "..."}}` | `StepResult` (obs, reward, done, info) |
|
| 116 |
+
| GET | `/state` | — | `EnvState` (public state + transcript) |
|
| 117 |
+
|
| 118 |
+
## Running locally
|
| 119 |
+
|
| 120 |
+
```bash
|
| 121 |
+
# 1. Install deps
|
| 122 |
+
pip install -r requirements.txt
|
| 123 |
+
|
| 124 |
+
# 2. Start the environment server
|
| 125 |
+
uvicorn server:app --host 0.0.0.0 --port 7860
|
| 126 |
+
|
| 127 |
+
# 3. In another shell, run the baseline inference
|
| 128 |
+
export API_BASE_URL=https://router.huggingface.co/v1
|
| 129 |
+
export MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
|
| 130 |
+
export HF_TOKEN=<your-hf-token>
|
| 131 |
+
export ESC_ENV_URL=http://localhost:7860
|
| 132 |
+
python inference.py
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
## Running via Docker
|
| 136 |
+
|
| 137 |
+
```bash
|
| 138 |
+
docker build -t esc-openenv .
|
| 139 |
+
docker run -p 7860:7860 esc-openenv
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
## Baseline scores
|
| 143 |
+
|
| 144 |
+
Replace with your own numbers after running `inference.py` against your
|
| 145 |
+
configured endpoint.
|
| 146 |
+
|
| 147 |
+
| Task | Score | Success |
|
| 148 |
+
| ----------------------- | ------ | ------- |
|
| 149 |
+
| work_stress_venting | TBD | TBD |
|
| 150 |
+
| guarded_relationship | TBD | TBD |
|
| 151 |
+
| crisis_fragile_trust | TBD | TBD |
|
| 152 |
+
| **Average** | **TBD** | |
|
| 153 |
+
|
| 154 |
+
## Files
|
| 155 |
+
|
| 156 |
+
```
|
| 157 |
+
.
|
| 158 |
+
├── openenv.yaml # OpenEnv metadata
|
| 159 |
+
├── Dockerfile # Container build for HF Space
|
| 160 |
+
├── requirements.txt
|
| 161 |
+
├── server.py # FastAPI HTTP server (entrypoint)
|
| 162 |
+
├── inference.py # Mandated baseline inference script
|
| 163 |
+
├── README.md
|
| 164 |
+
└── src/
|
| 165 |
+
├── __init__.py
|
| 166 |
+
├── models.py # Pydantic Action / Observation / Reward / envelopes
|
| 167 |
+
├── seeker.py # Deterministic seeker simulator + feature detectors
|
| 168 |
+
├── tasks.py # 3 task personas (easy / medium / hard)
|
| 169 |
+
├── grader.py # Hybrid immediate + future-oriented reward
|
| 170 |
+
├── env.py # Core ESCEnv with step/reset/state
|
| 171 |
+
└── client.py # Async HTTP client for inference.py
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
## Citation
|
| 175 |
+
|
| 176 |
+
If you use this environment, please cite the paper whose reward idea
|
| 177 |
+
inspired it:
|
| 178 |
+
|
| 179 |
+
```bibtex
|
| 180 |
+
@article{yang2025rlffesc,
|
| 181 |
+
title = {Towards Open-Ended Emotional Support Conversations in LLMs via
|
| 182 |
+
Reinforcement Learning with Future-Oriented Rewards},
|
| 183 |
+
author = {Yang, Ting and Chen, Li and Wang, Huimin},
|
| 184 |
+
journal = {arXiv preprint arXiv:2508.12935},
|
| 185 |
+
year = {2025}
|
| 186 |
+
}
|
| 187 |
+
```
|
inference.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Baseline inference script for the ESC OpenEnv environment.
|
| 2 |
+
|
| 3 |
+
MANDATORY env vars
|
| 4 |
+
------------------
|
| 5 |
+
API_BASE_URL - LLM endpoint (default: https://router.huggingface.co/v1)
|
| 6 |
+
MODEL_NAME - Model identifier (default: Qwen/Qwen2.5-72B-Instruct)
|
| 7 |
+
HF_TOKEN - API key for the inference endpoint
|
| 8 |
+
ESC_ENV_URL - URL of the running ESC OpenEnv HTTP server (e.g. the HF Space URL)
|
| 9 |
+
|
| 10 |
+
STDOUT contract (strict)
|
| 11 |
+
------------------------
|
| 12 |
+
One [START] line per episode, one [STEP] per step, one [END] per episode.
|
| 13 |
+
See the hackathon spec for exact format.
|
| 14 |
+
|
| 15 |
+
Runs all 3 tasks (easy/medium/hard) sequentially and prints a final summary
|
| 16 |
+
to stderr. Total wall-clock budget kept well under 20min on 2 vCPU / 8GB.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import asyncio
|
| 21 |
+
import os
|
| 22 |
+
import sys
|
| 23 |
+
import textwrap
|
| 24 |
+
import traceback
|
| 25 |
+
from typing import List, Optional
|
| 26 |
+
|
| 27 |
+
from openai import OpenAI
|
| 28 |
+
|
| 29 |
+
from src.client import ESCHttpClient
|
| 30 |
+
from src.models import Action
|
| 31 |
+
|
| 32 |
+
# -------------------------- mandated env vars --------------------------------
|
| 33 |
+
API_BASE_URL = os.getenv("API_BASE_URL") or "https://router.huggingface.co/v1"
|
| 34 |
+
MODEL_NAME = os.getenv("MODEL_NAME") or "Qwen/Qwen2.5-72B-Instruct"
|
| 35 |
+
API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
|
| 36 |
+
ESC_ENV_URL = os.getenv("ESC_ENV_URL") or "http://localhost:7860"
|
| 37 |
+
|
| 38 |
+
BENCHMARK = "emotional-support-conversations"
|
| 39 |
+
MAX_STEPS = 14 # upper bound; env imposes per-task limits too
|
| 40 |
+
TEMPERATURE = 0.6
|
| 41 |
+
MAX_TOKENS = 220
|
| 42 |
+
|
| 43 |
+
TASK_IDS = ["work_stress_venting", "guarded_relationship", "crisis_fragile_trust"]
|
| 44 |
+
|
| 45 |
+
SYSTEM_PROMPT = textwrap.dedent(
|
| 46 |
+
"""
|
| 47 |
+
You are an emotionally attuned peer supporter chatting with someone who is
|
| 48 |
+
going through a hard time. Your job is NOT to fix their problem. Your job
|
| 49 |
+
is to make them feel heard, safe, and understood first — and only move
|
| 50 |
+
toward gentle exploration or light action once trust is established.
|
| 51 |
+
|
| 52 |
+
Principles:
|
| 53 |
+
- Lead with empathy and validation. Reflect what you hear.
|
| 54 |
+
- Do NOT give advice until the person has clearly shared what's really
|
| 55 |
+
going on and feels heard.
|
| 56 |
+
- Ask at most one open-ended question per reply. Never interrogate.
|
| 57 |
+
- Never be dismissive, minimising, or instructive in a judgmental tone.
|
| 58 |
+
- Keep replies warm, brief (1-3 sentences), and human.
|
| 59 |
+
- In high-distress / crisis scenarios, gently reference professional
|
| 60 |
+
support (a therapist, crisis line) only after rapport is built.
|
| 61 |
+
|
| 62 |
+
You will receive the current conversation state. Reply with ONLY your
|
| 63 |
+
next message to the person — no role labels, no prefixes, no quotes.
|
| 64 |
+
"""
|
| 65 |
+
).strip()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# -------------------------- stdout contract ----------------------------------
|
| 69 |
+
|
| 70 |
+
def log_start(task: str, env: str, model: str) -> None:
|
| 71 |
+
print(f"[START] task={task} env={env} model={model}", flush=True)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
|
| 75 |
+
err = error if error else "null"
|
| 76 |
+
# collapse any newlines in the action so the stdout contract stays single-line
|
| 77 |
+
flat_action = " ".join((action or "").split())
|
| 78 |
+
print(
|
| 79 |
+
f"[STEP] step={step} action={flat_action} reward={reward:.2f} "
|
| 80 |
+
f"done={str(done).lower()} error={err}",
|
| 81 |
+
flush=True,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
|
| 86 |
+
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
|
| 87 |
+
print(
|
| 88 |
+
f"[END] success={str(success).lower()} steps={steps} "
|
| 89 |
+
f"score={score:.3f} rewards={rewards_str}",
|
| 90 |
+
flush=True,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# -------------------------- LLM call -----------------------------------------
|
| 95 |
+
|
| 96 |
+
def build_user_prompt(
|
| 97 |
+
scenario_brief: str,
|
| 98 |
+
stage_hint: str,
|
| 99 |
+
turn: int,
|
| 100 |
+
remaining: int,
|
| 101 |
+
seeker_utterance: str,
|
| 102 |
+
history: List[str],
|
| 103 |
+
) -> str:
|
| 104 |
+
history_block = "\n".join(history[-8:]) if history else "(this is the first turn)"
|
| 105 |
+
return textwrap.dedent(
|
| 106 |
+
f"""
|
| 107 |
+
Scenario: {scenario_brief}
|
| 108 |
+
Conversation stage (public hint): {stage_hint}
|
| 109 |
+
Turn: {turn} Remaining turns: {remaining}
|
| 110 |
+
|
| 111 |
+
Recent exchange:
|
| 112 |
+
{history_block}
|
| 113 |
+
|
| 114 |
+
Seeker just said:
|
| 115 |
+
"{seeker_utterance}"
|
| 116 |
+
|
| 117 |
+
Write your next reply (1-3 sentences, warm, no advice unless rapport is clearly established):
|
| 118 |
+
"""
|
| 119 |
+
).strip()
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def call_llm(client: OpenAI, user_prompt: str) -> str:
|
| 123 |
+
try:
|
| 124 |
+
completion = client.chat.completions.create(
|
| 125 |
+
model=MODEL_NAME,
|
| 126 |
+
messages=[
|
| 127 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 128 |
+
{"role": "user", "content": user_prompt},
|
| 129 |
+
],
|
| 130 |
+
temperature=TEMPERATURE,
|
| 131 |
+
max_tokens=MAX_TOKENS,
|
| 132 |
+
stream=False,
|
| 133 |
+
)
|
| 134 |
+
text = (completion.choices[0].message.content or "").strip()
|
| 135 |
+
return text if text else "I hear you. That sounds really hard — can you tell me a little more about what's weighing on you?"
|
| 136 |
+
except Exception as exc:
|
| 137 |
+
print(f"[DEBUG] LLM call failed: {exc}", file=sys.stderr, flush=True)
|
| 138 |
+
return "That sounds really hard. I'm here — do you want to tell me more about what's going on?"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# -------------------------- per-task episode ---------------------------------
|
| 142 |
+
|
| 143 |
+
async def run_task(openai_client: OpenAI, env_client: ESCHttpClient, task_id: str) -> dict:
|
| 144 |
+
log_start(task=task_id, env=BENCHMARK, model=MODEL_NAME)
|
| 145 |
+
|
| 146 |
+
rewards: List[float] = []
|
| 147 |
+
steps_taken = 0
|
| 148 |
+
score = 0.0
|
| 149 |
+
success = False
|
| 150 |
+
history: List[str] = []
|
| 151 |
+
last_error: Optional[str] = None
|
| 152 |
+
|
| 153 |
+
try:
|
| 154 |
+
reset = await env_client.reset(task_id=task_id)
|
| 155 |
+
obs = reset.observation
|
| 156 |
+
history.append(f"Seeker: {obs.seeker_utterance!r}")
|
| 157 |
+
|
| 158 |
+
for step in range(1, MAX_STEPS + 1):
|
| 159 |
+
user_prompt = build_user_prompt(
|
| 160 |
+
scenario_brief=obs.scenario_brief,
|
| 161 |
+
stage_hint=obs.stage_hint,
|
| 162 |
+
turn=obs.turn,
|
| 163 |
+
remaining=obs.remaining_turns,
|
| 164 |
+
seeker_utterance=obs.seeker_utterance,
|
| 165 |
+
history=history,
|
| 166 |
+
)
|
| 167 |
+
message = call_llm(openai_client, user_prompt)
|
| 168 |
+
|
| 169 |
+
try:
|
| 170 |
+
result = await env_client.step(Action(message=message))
|
| 171 |
+
except Exception as e:
|
| 172 |
+
last_error = f"step_failed: {e}"
|
| 173 |
+
log_step(step=step, action=message, reward=0.0, done=True, error=last_error)
|
| 174 |
+
break
|
| 175 |
+
|
| 176 |
+
reward = float(result.reward)
|
| 177 |
+
done = bool(result.done)
|
| 178 |
+
rewards.append(reward)
|
| 179 |
+
steps_taken = step
|
| 180 |
+
obs = result.observation
|
| 181 |
+
|
| 182 |
+
history.append(f"Agent: {message!r}")
|
| 183 |
+
history.append(f"Seeker: {obs.seeker_utterance!r}")
|
| 184 |
+
|
| 185 |
+
log_step(step=step, action=message, reward=reward, done=done, error=None)
|
| 186 |
+
|
| 187 |
+
if done:
|
| 188 |
+
final = result.info.get("final", {}) if isinstance(result.info, dict) else {}
|
| 189 |
+
score = float(final.get("score", sum(rewards) / max(1, steps_taken)))
|
| 190 |
+
success = bool(final.get("success", 0.0) >= 1.0)
|
| 191 |
+
break
|
| 192 |
+
else:
|
| 193 |
+
# Ran out of outer loop without env-side done — fall back to state().
|
| 194 |
+
st = await env_client.state()
|
| 195 |
+
score = float(st.get("cumulative_reward", 0.0)) / max(1, steps_taken)
|
| 196 |
+
success = score >= 0.5
|
| 197 |
+
|
| 198 |
+
except Exception as exc:
|
| 199 |
+
last_error = f"episode_failed: {exc}"
|
| 200 |
+
traceback.print_exc(file=sys.stderr)
|
| 201 |
+
|
| 202 |
+
log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
|
| 203 |
+
return {"task_id": task_id, "score": score, "success": success, "steps": steps_taken}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# -------------------------- main ---------------------------------------------
|
| 207 |
+
|
| 208 |
+
async def main() -> None:
|
| 209 |
+
openai_client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY or "dummy")
|
| 210 |
+
env_client = ESCHttpClient.from_url(ESC_ENV_URL)
|
| 211 |
+
|
| 212 |
+
results = []
|
| 213 |
+
try:
|
| 214 |
+
for task_id in TASK_IDS:
|
| 215 |
+
res = await run_task(openai_client, env_client, task_id)
|
| 216 |
+
results.append(res)
|
| 217 |
+
finally:
|
| 218 |
+
await env_client.close()
|
| 219 |
+
|
| 220 |
+
# Summary to stderr so it doesn't pollute the stdout contract.
|
| 221 |
+
print("\n=== Baseline summary ===", file=sys.stderr)
|
| 222 |
+
for r in results:
|
| 223 |
+
print(
|
| 224 |
+
f" {r['task_id']:<26} score={r['score']:.3f} success={r['success']} steps={r['steps']}",
|
| 225 |
+
file=sys.stderr,
|
| 226 |
+
)
|
| 227 |
+
avg = sum(r["score"] for r in results) / max(1, len(results))
|
| 228 |
+
print(f" {'AVERAGE':<26} score={avg:.3f}", file=sys.stderr)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
if __name__ == "__main__":
|
| 232 |
+
asyncio.run(main())
|
openenv.yaml
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: emotional-support-conversations
|
| 2 |
+
version: 0.1.0
|
| 3 |
+
description: >
|
| 4 |
+
An OpenEnv environment for training and evaluating agents on open-ended
|
| 5 |
+
emotional support conversations. Agents converse with a deterministic seeker
|
| 6 |
+
simulator with hidden internal state (distress, trust, openness) and are
|
| 7 |
+
graded with a hybrid immediate + future-oriented reward signal inspired by
|
| 8 |
+
RLFF-ESC (Yang et al., 2025, arXiv:2508.12935).
|
| 9 |
+
author: meta-hack-submission
|
| 10 |
+
license: MIT
|
| 11 |
+
tags:
|
| 12 |
+
- openenv
|
| 13 |
+
- conversation
|
| 14 |
+
- emotional-support
|
| 15 |
+
- mental-health
|
| 16 |
+
- rl-native
|
| 17 |
+
- partial-observability
|
| 18 |
+
entrypoint: server:app
|
| 19 |
+
port: 7860
|
| 20 |
+
runtime:
|
| 21 |
+
python: "3.11"
|
| 22 |
+
vcpu: 2
|
| 23 |
+
memory_gb: 8
|
| 24 |
+
tasks:
|
| 25 |
+
- id: work_stress_venting
|
| 26 |
+
difficulty: easy
|
| 27 |
+
description: >
|
| 28 |
+
Cooperative seeker venting about workplace stress. Agent must validate
|
| 29 |
+
feelings, explore the concern, and guide to a light action plan.
|
| 30 |
+
- id: guarded_relationship
|
| 31 |
+
difficulty: medium
|
| 32 |
+
description: >
|
| 33 |
+
Guarded seeker who only reveals the real relationship issue after trust
|
| 34 |
+
is built. Premature advice is penalised.
|
| 35 |
+
- id: crisis_fragile_trust
|
| 36 |
+
difficulty: hard
|
| 37 |
+
description: >
|
| 38 |
+
High-distress seeker with multiple interleaved concerns and fragile
|
| 39 |
+
trust. Any dismissive or interrogative turn collapses trust; recovery is
|
| 40 |
+
possible but costly.
|
| 41 |
+
action_space:
|
| 42 |
+
type: text
|
| 43 |
+
description: Free-text conversational reply from the agent to the seeker.
|
| 44 |
+
observation_space:
|
| 45 |
+
type: structured
|
| 46 |
+
fields:
|
| 47 |
+
seeker_utterance: string
|
| 48 |
+
turn: integer
|
| 49 |
+
stage_hint: string
|
| 50 |
+
remaining_turns: integer
|
| 51 |
+
reward:
|
| 52 |
+
type: dense
|
| 53 |
+
range: [0.0, 1.0]
|
| 54 |
+
shaping:
|
| 55 |
+
- immediate_turn_reward
|
| 56 |
+
- future_oriented_trajectory_reward
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn[standard]==0.30.6
|
| 3 |
+
pydantic==2.9.2
|
| 4 |
+
httpx==0.27.2
|
| 5 |
+
openai==1.54.3
|
server.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI HTTP server exposing the OpenEnv interface for the ESC environment.
|
| 2 |
+
|
| 3 |
+
Endpoints
|
| 4 |
+
---------
|
| 5 |
+
GET / → health check + metadata
|
| 6 |
+
POST /reset → reset episode (optional task_id), returns initial Observation
|
| 7 |
+
POST /step → take one step with {"action": {"message": "..."}}
|
| 8 |
+
GET /state → return current EnvState
|
| 9 |
+
GET /tasks → list available tasks + difficulties
|
| 10 |
+
|
| 11 |
+
The server holds a single in-process ESCEnv instance. For parallel eval,
|
| 12 |
+
deploy multiple replicas — the env itself has no shared state between
|
| 13 |
+
instances.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from fastapi import FastAPI, HTTPException
|
| 18 |
+
|
| 19 |
+
from src.env import ESCEnv
|
| 20 |
+
from src.models import ResetRequest, StepRequest
|
| 21 |
+
|
| 22 |
+
app = FastAPI(
|
| 23 |
+
title="Emotional Support Conversations (OpenEnv)",
|
| 24 |
+
version="0.1.0",
|
| 25 |
+
description=(
|
| 26 |
+
"An OpenEnv environment for open-ended emotional support "
|
| 27 |
+
"conversations. Reward shaping inspired by RLFF-ESC "
|
| 28 |
+
"(arXiv:2508.12935)."
|
| 29 |
+
),
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
_env = ESCEnv()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@app.get("/")
|
| 36 |
+
def root() -> dict:
|
| 37 |
+
return {
|
| 38 |
+
"name": "emotional-support-conversations",
|
| 39 |
+
"version": "0.1.0",
|
| 40 |
+
"endpoints": ["/reset", "/step", "/state", "/tasks"],
|
| 41 |
+
"tasks": [t["id"] for t in ESCEnv.list_tasks()],
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@app.get("/tasks")
|
| 46 |
+
def list_tasks() -> dict:
|
| 47 |
+
return {"tasks": ESCEnv.list_tasks()}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@app.post("/reset")
|
| 51 |
+
def reset(req: ResetRequest | None = None) -> dict:
|
| 52 |
+
req = req or ResetRequest()
|
| 53 |
+
try:
|
| 54 |
+
result = _env.reset(task_id=req.task_id, seed=req.seed)
|
| 55 |
+
except KeyError as e:
|
| 56 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 57 |
+
return result.model_dump()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@app.post("/step")
|
| 61 |
+
def step(req: StepRequest) -> dict:
|
| 62 |
+
try:
|
| 63 |
+
result = _env.step(req.action)
|
| 64 |
+
except RuntimeError as e:
|
| 65 |
+
raise HTTPException(status_code=409, detail=str(e))
|
| 66 |
+
return result.model_dump()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@app.get("/state")
|
| 70 |
+
def state() -> dict:
|
| 71 |
+
try:
|
| 72 |
+
return _env.state().model_dump()
|
| 73 |
+
except RuntimeError as e:
|
| 74 |
+
raise HTTPException(status_code=409, detail=str(e))
|
src/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Emotional Support Conversations OpenEnv environment."""
|
| 2 |
+
from .models import Action, Observation, Reward, StepResult, ResetResult, EnvState
|
| 3 |
+
from .env import ESCEnv
|
| 4 |
+
from .tasks import TASKS, TaskSpec
|
| 5 |
+
|
| 6 |
+
__all__ = [
|
| 7 |
+
"Action",
|
| 8 |
+
"Observation",
|
| 9 |
+
"Reward",
|
| 10 |
+
"StepResult",
|
| 11 |
+
"ResetResult",
|
| 12 |
+
"EnvState",
|
| 13 |
+
"ESCEnv",
|
| 14 |
+
"TASKS",
|
| 15 |
+
"TaskSpec",
|
| 16 |
+
]
|
src/client.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Async HTTP client mirroring the OpenEnv env interface.
|
| 2 |
+
|
| 3 |
+
Used by inference.py to interact with the running FastAPI server (local or
|
| 4 |
+
HF Space deployment).
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from typing import Any, Dict, Optional
|
| 10 |
+
|
| 11 |
+
import httpx
|
| 12 |
+
|
| 13 |
+
from .models import Action, Observation
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class StepResponse:
|
| 18 |
+
observation: Observation
|
| 19 |
+
reward: float
|
| 20 |
+
done: bool
|
| 21 |
+
info: Dict[str, Any]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class ResetResponse:
|
| 26 |
+
observation: Observation
|
| 27 |
+
info: Dict[str, Any]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ESCHttpClient:
|
| 31 |
+
"""Thin async client for the ESC OpenEnv server."""
|
| 32 |
+
|
| 33 |
+
def __init__(self, base_url: str, timeout: float = 30.0):
|
| 34 |
+
self.base_url = base_url.rstrip("/")
|
| 35 |
+
self._client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout)
|
| 36 |
+
|
| 37 |
+
@classmethod
|
| 38 |
+
def from_url(cls, base_url: str) -> "ESCHttpClient":
|
| 39 |
+
return cls(base_url=base_url)
|
| 40 |
+
|
| 41 |
+
async def reset(self, task_id: Optional[str] = None) -> ResetResponse:
|
| 42 |
+
payload: Dict[str, Any] = {}
|
| 43 |
+
if task_id is not None:
|
| 44 |
+
payload["task_id"] = task_id
|
| 45 |
+
r = await self._client.post("/reset", json=payload)
|
| 46 |
+
r.raise_for_status()
|
| 47 |
+
data = r.json()
|
| 48 |
+
return ResetResponse(
|
| 49 |
+
observation=Observation(**data["observation"]),
|
| 50 |
+
info=data.get("info", {}),
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
async def step(self, action: Action) -> StepResponse:
|
| 54 |
+
r = await self._client.post("/step", json={"action": action.model_dump()})
|
| 55 |
+
r.raise_for_status()
|
| 56 |
+
data = r.json()
|
| 57 |
+
return StepResponse(
|
| 58 |
+
observation=Observation(**data["observation"]),
|
| 59 |
+
reward=float(data["reward"]),
|
| 60 |
+
done=bool(data["done"]),
|
| 61 |
+
info=data.get("info", {}),
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
async def state(self) -> Dict[str, Any]:
|
| 65 |
+
r = await self._client.get("/state")
|
| 66 |
+
r.raise_for_status()
|
| 67 |
+
return r.json()
|
| 68 |
+
|
| 69 |
+
async def close(self) -> None:
|
| 70 |
+
await self._client.aclose()
|
src/env.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core ESC environment: OpenEnv-style step() / reset() / state()."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Any, Dict, List, Optional
|
| 5 |
+
|
| 6 |
+
from .grader import GradeBreakdown, final_task_score, grade_step
|
| 7 |
+
from .models import (
|
| 8 |
+
Action,
|
| 9 |
+
EnvState,
|
| 10 |
+
Observation,
|
| 11 |
+
ResetResult,
|
| 12 |
+
Reward,
|
| 13 |
+
StepResult,
|
| 14 |
+
)
|
| 15 |
+
from .seeker import (
|
| 16 |
+
SeekerState,
|
| 17 |
+
Stage,
|
| 18 |
+
extract_features,
|
| 19 |
+
resolution_score,
|
| 20 |
+
step_seeker,
|
| 21 |
+
)
|
| 22 |
+
from .tasks import TASKS, TaskSpec, get_task
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ESCEnv:
|
| 26 |
+
"""Emotional Support Conversations environment.
|
| 27 |
+
|
| 28 |
+
Usage (in-process):
|
| 29 |
+
env = ESCEnv()
|
| 30 |
+
obs = env.reset(task_id="work_stress_venting")
|
| 31 |
+
result = env.step(Action(message="That sounds really hard. What's weighing on you most right now?"))
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def __init__(self) -> None:
|
| 35 |
+
self._task: Optional[TaskSpec] = None
|
| 36 |
+
self._seeker: Optional[SeekerState] = None
|
| 37 |
+
self._turn: int = 0
|
| 38 |
+
self._done: bool = False
|
| 39 |
+
self._cumulative_reward: float = 0.0
|
| 40 |
+
self._transcript: List[Dict[str, str]] = []
|
| 41 |
+
self._last_obs: Optional[Observation] = None
|
| 42 |
+
|
| 43 |
+
# ------------------------------------------------------------------ reset
|
| 44 |
+
|
| 45 |
+
def reset(self, task_id: Optional[str] = None, seed: Optional[int] = None) -> ResetResult:
|
| 46 |
+
"""Reset to a clean initial state for the given task (default: easy)."""
|
| 47 |
+
task_id = task_id or "work_stress_venting"
|
| 48 |
+
self._task = get_task(task_id)
|
| 49 |
+
self._seeker = SeekerState.from_persona(self._task.persona)
|
| 50 |
+
self._turn = 0
|
| 51 |
+
self._done = False
|
| 52 |
+
self._cumulative_reward = 0.0
|
| 53 |
+
self._transcript = [
|
| 54 |
+
{"role": "seeker", "text": self._task.persona.surface_concern}
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
obs = Observation(
|
| 58 |
+
seeker_utterance=self._task.persona.surface_concern,
|
| 59 |
+
turn=0,
|
| 60 |
+
remaining_turns=self._task.max_turns,
|
| 61 |
+
stage_hint=self._seeker.stage.value,
|
| 62 |
+
task_id=self._task.id,
|
| 63 |
+
scenario_brief=self._task.persona.scenario_brief,
|
| 64 |
+
)
|
| 65 |
+
self._last_obs = obs
|
| 66 |
+
return ResetResult(
|
| 67 |
+
observation=obs,
|
| 68 |
+
info={
|
| 69 |
+
"difficulty": self._task.difficulty,
|
| 70 |
+
"max_turns": self._task.max_turns,
|
| 71 |
+
"success_threshold": self._task.success_threshold,
|
| 72 |
+
},
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# ------------------------------------------------------------------- step
|
| 76 |
+
|
| 77 |
+
def step(self, action: Action) -> StepResult:
|
| 78 |
+
if self._task is None or self._seeker is None:
|
| 79 |
+
raise RuntimeError("env.step() called before reset()")
|
| 80 |
+
if self._done:
|
| 81 |
+
raise RuntimeError("env.step() called on a finished episode — call reset()")
|
| 82 |
+
|
| 83 |
+
# 1. Record the agent's turn.
|
| 84 |
+
self._transcript.append({"role": "agent", "text": action.message})
|
| 85 |
+
|
| 86 |
+
# 2. Snapshot pre-action state (for reward deltas and future-oriented lookahead).
|
| 87 |
+
pre_state = self._seeker.snapshot()
|
| 88 |
+
|
| 89 |
+
# 3. Extract features and advance seeker dynamics.
|
| 90 |
+
features = extract_features(action.message)
|
| 91 |
+
transition = step_seeker(self._seeker, features)
|
| 92 |
+
post_state = transition.new_state # same object, mutated
|
| 93 |
+
self._seeker = post_state
|
| 94 |
+
self._turn += 1
|
| 95 |
+
|
| 96 |
+
# 4. Grade the step.
|
| 97 |
+
breakdown: GradeBreakdown = grade_step(
|
| 98 |
+
pre_state=pre_state,
|
| 99 |
+
post_state=post_state,
|
| 100 |
+
features=features,
|
| 101 |
+
flags=transition.flags,
|
| 102 |
+
)
|
| 103 |
+
self._cumulative_reward += breakdown.value
|
| 104 |
+
|
| 105 |
+
# 5. Record seeker's reply.
|
| 106 |
+
self._transcript.append({"role": "seeker", "text": transition.seeker_utterance})
|
| 107 |
+
|
| 108 |
+
# 6. Termination check.
|
| 109 |
+
reached_closing = post_state.stage == Stage.CLOSING
|
| 110 |
+
natural_done = reached_closing and post_state.trust >= 0.6 and post_state.distress <= 0.5
|
| 111 |
+
trust_collapse = post_state.trust <= 0.05
|
| 112 |
+
budget_exhausted = self._turn >= self._task.max_turns
|
| 113 |
+
done = bool(natural_done or trust_collapse or budget_exhausted)
|
| 114 |
+
self._done = done
|
| 115 |
+
|
| 116 |
+
# 7. Build the next observation.
|
| 117 |
+
obs = Observation(
|
| 118 |
+
seeker_utterance=transition.seeker_utterance,
|
| 119 |
+
turn=self._turn,
|
| 120 |
+
remaining_turns=max(0, self._task.max_turns - self._turn),
|
| 121 |
+
stage_hint=post_state.stage.value,
|
| 122 |
+
task_id=self._task.id,
|
| 123 |
+
scenario_brief=self._task.persona.scenario_brief,
|
| 124 |
+
)
|
| 125 |
+
self._last_obs = obs
|
| 126 |
+
|
| 127 |
+
info: Dict[str, Any] = {
|
| 128 |
+
"features": features.__dict__,
|
| 129 |
+
"flags": transition.flags,
|
| 130 |
+
"stage": post_state.stage.value,
|
| 131 |
+
"resolution_score": resolution_score(post_state),
|
| 132 |
+
"natural_done": natural_done,
|
| 133 |
+
"trust_collapse": trust_collapse,
|
| 134 |
+
"budget_exhausted": budget_exhausted,
|
| 135 |
+
"reward_components": breakdown.components,
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
if done:
|
| 139 |
+
info["final"] = final_task_score(
|
| 140 |
+
cumulative_reward=self._cumulative_reward,
|
| 141 |
+
steps_taken=self._turn,
|
| 142 |
+
max_turns=self._task.max_turns,
|
| 143 |
+
final_state=post_state,
|
| 144 |
+
success_threshold=self._task.success_threshold,
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
reward_detail = Reward(
|
| 148 |
+
value=breakdown.value,
|
| 149 |
+
immediate=breakdown.immediate,
|
| 150 |
+
future_oriented=breakdown.future_oriented,
|
| 151 |
+
penalties=breakdown.penalties,
|
| 152 |
+
components={k: float(v) for k, v in breakdown.components.items()},
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
return StepResult(
|
| 156 |
+
observation=obs,
|
| 157 |
+
reward=breakdown.value,
|
| 158 |
+
reward_detail=reward_detail,
|
| 159 |
+
done=done,
|
| 160 |
+
info=info,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# ------------------------------------------------------------------ state
|
| 164 |
+
|
| 165 |
+
def state(self) -> EnvState:
|
| 166 |
+
if self._task is None:
|
| 167 |
+
raise RuntimeError("env.state() called before reset()")
|
| 168 |
+
return EnvState(
|
| 169 |
+
task_id=self._task.id,
|
| 170 |
+
turn=self._turn,
|
| 171 |
+
max_turns=self._task.max_turns,
|
| 172 |
+
done=self._done,
|
| 173 |
+
cumulative_reward=self._cumulative_reward,
|
| 174 |
+
transcript=list(self._transcript),
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
# ---------------------------------------------------------------- listing
|
| 178 |
+
|
| 179 |
+
@staticmethod
|
| 180 |
+
def list_tasks() -> List[Dict[str, Any]]:
|
| 181 |
+
return [
|
| 182 |
+
{
|
| 183 |
+
"id": t.id,
|
| 184 |
+
"difficulty": t.difficulty,
|
| 185 |
+
"max_turns": t.max_turns,
|
| 186 |
+
"success_threshold": t.success_threshold,
|
| 187 |
+
"scenario_brief": t.persona.scenario_brief,
|
| 188 |
+
}
|
| 189 |
+
for t in TASKS.values()
|
| 190 |
+
]
|
src/grader.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reward / grading module.
|
| 2 |
+
|
| 3 |
+
Implements the hybrid reward used by the ESC environment:
|
| 4 |
+
|
| 5 |
+
step_reward = clip(immediate + future_oriented - penalties, 0, 1)
|
| 6 |
+
|
| 7 |
+
- immediate : stage-appropriate empathy/validation/open-question signal
|
| 8 |
+
- future_oriented : RLFF-ESC style lookahead — projects the oracle policy
|
| 9 |
+
k steps forward from the *post-action* state and
|
| 10 |
+
compares the projected resolution_score against the
|
| 11 |
+
pre-action ceiling. Rewards actions that *preserve or
|
| 12 |
+
advance* the attainable resolution, not just ones
|
| 13 |
+
that look good this turn.
|
| 14 |
+
- penalties : dismissive language, premature advice, repetitive
|
| 15 |
+
bare replies, interrogation.
|
| 16 |
+
|
| 17 |
+
This shaping gives the agent dense, varying signal across the trajectory
|
| 18 |
+
(required by the rubric: "signal over the full trajectory, not just
|
| 19 |
+
binary end-of-episode").
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
from dataclasses import dataclass
|
| 24 |
+
from typing import Dict, List
|
| 25 |
+
|
| 26 |
+
from .seeker import (
|
| 27 |
+
Features,
|
| 28 |
+
SeekerState,
|
| 29 |
+
Stage,
|
| 30 |
+
resolution_score,
|
| 31 |
+
simulate_oracle_rollout,
|
| 32 |
+
stage_progress,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Hyper-parameters — tuned to keep step reward in [0, 1] under normal play.
|
| 36 |
+
LOOKAHEAD_K = 3
|
| 37 |
+
W_IMMEDIATE = 0.45
|
| 38 |
+
W_FUTURE = 0.55
|
| 39 |
+
DISMISSIVE_PENALTY = 0.6
|
| 40 |
+
PREMATURE_ADVICE_PENALTY = 0.25
|
| 41 |
+
BARE_PENALTY = 0.15
|
| 42 |
+
INTERROGATION_PENALTY = 0.15
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class GradeBreakdown:
|
| 47 |
+
value: float
|
| 48 |
+
immediate: float
|
| 49 |
+
future_oriented: float
|
| 50 |
+
penalties: float
|
| 51 |
+
components: Dict[str, float]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _stage_fit_score(stage: Stage, f: Features) -> float:
|
| 55 |
+
"""How appropriate are the agent's features for the current stage?"""
|
| 56 |
+
if stage in (Stage.OPENING, Stage.EXPLORING):
|
| 57 |
+
# Reward empathy + open questions; punish early advice strongly.
|
| 58 |
+
fit = 0.5 * min(1.0, f.empathy) + 0.3 * min(1.0, f.open_question) + 0.2 * min(1.0, f.validation)
|
| 59 |
+
fit -= 0.4 * min(1.0, f.advice)
|
| 60 |
+
elif stage == Stage.REFLECTING:
|
| 61 |
+
fit = 0.5 * min(1.0, f.validation) + 0.4 * min(1.0, f.empathy) + 0.1 * min(1.0, f.open_question)
|
| 62 |
+
fit -= 0.2 * min(1.0, f.advice)
|
| 63 |
+
elif stage == Stage.PLANNING:
|
| 64 |
+
# Advice is finally okay here.
|
| 65 |
+
fit = 0.4 * min(1.0, f.open_question) + 0.3 * min(1.0, f.advice) + 0.3 * min(1.0, f.empathy)
|
| 66 |
+
else: # CLOSING
|
| 67 |
+
fit = 0.5 * min(1.0, f.empathy) + 0.3 * min(1.0, f.safety) + 0.2 * min(1.0, f.validation)
|
| 68 |
+
return max(0.0, min(1.0, fit))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _immediate_reward(pre_state: SeekerState, post_state: SeekerState, f: Features) -> float:
|
| 72 |
+
"""Turn-level reward: stage fit + trust delta + distress delta."""
|
| 73 |
+
stage_fit = _stage_fit_score(pre_state.stage, f)
|
| 74 |
+
trust_delta = max(0.0, post_state.trust - pre_state.trust)
|
| 75 |
+
distress_relief = max(0.0, pre_state.distress - post_state.distress)
|
| 76 |
+
stage_advance = max(
|
| 77 |
+
0.0, stage_progress(post_state.stage) - stage_progress(pre_state.stage)
|
| 78 |
+
)
|
| 79 |
+
reveal_bonus = 0.2 if (post_state.revealed and not pre_state.revealed) else 0.0
|
| 80 |
+
return max(
|
| 81 |
+
0.0,
|
| 82 |
+
min(
|
| 83 |
+
1.0,
|
| 84 |
+
0.45 * stage_fit
|
| 85 |
+
+ 0.20 * trust_delta * 2.0 # scale small deltas
|
| 86 |
+
+ 0.20 * distress_relief * 2.0
|
| 87 |
+
+ 0.10 * stage_advance
|
| 88 |
+
+ 0.05 * 1.0 # small baseline for any non-destructive turn
|
| 89 |
+
+ reveal_bonus,
|
| 90 |
+
),
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _future_oriented_reward(pre_state: SeekerState, post_state: SeekerState) -> float:
|
| 95 |
+
"""RLFF-ESC style: does this action *preserve / advance* future resolution?
|
| 96 |
+
|
| 97 |
+
We roll the oracle policy k steps from both the pre- and post-action states
|
| 98 |
+
and take the (clipped) delta. Positive delta = the action moved the
|
| 99 |
+
attainable future forward; negative = the agent damaged trajectory
|
| 100 |
+
potential and must recover.
|
| 101 |
+
"""
|
| 102 |
+
pre_ceiling = simulate_oracle_rollout(pre_state.snapshot(), LOOKAHEAD_K)
|
| 103 |
+
post_ceiling = simulate_oracle_rollout(post_state.snapshot(), LOOKAHEAD_K)
|
| 104 |
+
delta = post_ceiling - pre_ceiling
|
| 105 |
+
# Map delta in roughly [-0.4, +0.4] to [0, 1] with 0 at delta=0.
|
| 106 |
+
return max(0.0, min(1.0, 0.5 + 1.25 * delta))
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _penalties(flags: Dict[str, bool], f: Features) -> float:
|
| 110 |
+
p = 0.0
|
| 111 |
+
if flags.get("dismissed"):
|
| 112 |
+
p += DISMISSIVE_PENALTY
|
| 113 |
+
if flags.get("advice_too_early"):
|
| 114 |
+
p += PREMATURE_ADVICE_PENALTY
|
| 115 |
+
if flags.get("bare_reply"):
|
| 116 |
+
p += BARE_PENALTY
|
| 117 |
+
if flags.get("interrogated"):
|
| 118 |
+
p += INTERROGATION_PENALTY
|
| 119 |
+
return p
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def grade_step(
|
| 123 |
+
pre_state: SeekerState,
|
| 124 |
+
post_state: SeekerState,
|
| 125 |
+
features: Features,
|
| 126 |
+
flags: Dict[str, bool],
|
| 127 |
+
) -> GradeBreakdown:
|
| 128 |
+
imm = _immediate_reward(pre_state, post_state, features)
|
| 129 |
+
fut = _future_oriented_reward(pre_state, post_state)
|
| 130 |
+
pen = _penalties(flags, features)
|
| 131 |
+
combined = W_IMMEDIATE * imm + W_FUTURE * fut - pen
|
| 132 |
+
value = max(0.0, min(1.0, combined))
|
| 133 |
+
components = {
|
| 134 |
+
"stage_fit": _stage_fit_score(pre_state.stage, features),
|
| 135 |
+
"trust_delta": post_state.trust - pre_state.trust,
|
| 136 |
+
"distress_delta": pre_state.distress - post_state.distress,
|
| 137 |
+
"resolution_score_post": resolution_score(post_state),
|
| 138 |
+
"pre_oracle_ceiling": simulate_oracle_rollout(pre_state.snapshot(), LOOKAHEAD_K),
|
| 139 |
+
"post_oracle_ceiling": simulate_oracle_rollout(post_state.snapshot(), LOOKAHEAD_K),
|
| 140 |
+
}
|
| 141 |
+
return GradeBreakdown(
|
| 142 |
+
value=value,
|
| 143 |
+
immediate=imm,
|
| 144 |
+
future_oriented=fut,
|
| 145 |
+
penalties=pen,
|
| 146 |
+
components=components,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def final_task_score(
|
| 151 |
+
cumulative_reward: float,
|
| 152 |
+
steps_taken: int,
|
| 153 |
+
max_turns: int,
|
| 154 |
+
final_state: SeekerState,
|
| 155 |
+
success_threshold: float,
|
| 156 |
+
) -> Dict[str, float]:
|
| 157 |
+
"""Compute the final [0,1] task score used by the grader."""
|
| 158 |
+
# Component 1: average shaped reward over the trajectory (already in [0,1]).
|
| 159 |
+
avg_reward = cumulative_reward / max(1, steps_taken)
|
| 160 |
+
# Component 2: final resolution_score.
|
| 161 |
+
final_res = resolution_score(final_state)
|
| 162 |
+
# Component 3: efficiency — finishing sooner is slightly better, but never
|
| 163 |
+
# negative. Flat 1.0 if used ≤ 60% of budget, linearly decays to 0.7 at max.
|
| 164 |
+
usage = steps_taken / max_turns
|
| 165 |
+
efficiency = 1.0 if usage <= 0.6 else max(0.7, 1.0 - 0.75 * (usage - 0.6))
|
| 166 |
+
score = 0.35 * avg_reward + 0.55 * final_res + 0.10 * efficiency
|
| 167 |
+
score = max(0.0, min(1.0, score))
|
| 168 |
+
return {
|
| 169 |
+
"score": score,
|
| 170 |
+
"avg_reward": avg_reward,
|
| 171 |
+
"final_resolution": final_res,
|
| 172 |
+
"efficiency": efficiency,
|
| 173 |
+
"success": 1.0 if score >= success_threshold else 0.0,
|
| 174 |
+
}
|
src/models.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Typed Pydantic models for the ESC OpenEnv environment.
|
| 2 |
+
|
| 3 |
+
Defines the Action, Observation, Reward, and result envelopes used across the
|
| 4 |
+
HTTP boundary (server.py) and the in-process env (env.py).
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from typing import Any, Dict, List, Optional
|
| 9 |
+
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Action(BaseModel):
|
| 14 |
+
"""Agent action: a free-text conversational reply to the seeker."""
|
| 15 |
+
|
| 16 |
+
message: str = Field(..., description="Agent's reply to the seeker.")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Observation(BaseModel):
|
| 20 |
+
"""What the agent sees each turn.
|
| 21 |
+
|
| 22 |
+
The seeker's internal state (distress, trust, openness, true_issue) is
|
| 23 |
+
intentionally hidden — partial observability is what makes this env
|
| 24 |
+
RL-native. Only the seeker's *utterance* and coarse hints are exposed.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
seeker_utterance: str = Field(..., description="The seeker's latest message.")
|
| 28 |
+
turn: int = Field(..., description="1-indexed conversation turn.")
|
| 29 |
+
remaining_turns: int = Field(..., description="Turns left before forced close.")
|
| 30 |
+
stage_hint: str = Field(
|
| 31 |
+
...,
|
| 32 |
+
description=(
|
| 33 |
+
"Coarse public hint about conversational phase: one of "
|
| 34 |
+
"'opening', 'exploring', 'reflecting', 'planning', 'closing'."
|
| 35 |
+
),
|
| 36 |
+
)
|
| 37 |
+
task_id: str = Field(..., description="Currently active task id.")
|
| 38 |
+
scenario_brief: str = Field(
|
| 39 |
+
...,
|
| 40 |
+
description="One-line scenario framing shown once at reset (kept in obs for convenience).",
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class Reward(BaseModel):
|
| 45 |
+
"""Detailed reward breakdown for a single step.
|
| 46 |
+
|
| 47 |
+
The scalar `value` is what the agent sees. The decomposition is exposed
|
| 48 |
+
for transparency and debugging.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
value: float = Field(..., ge=0.0, le=1.0, description="Clipped step reward in [0,1].")
|
| 52 |
+
immediate: float = Field(..., description="Immediate turn-level component (empathy, stage-fit).")
|
| 53 |
+
future_oriented: float = Field(
|
| 54 |
+
...,
|
| 55 |
+
description=(
|
| 56 |
+
"Future-oriented component: k-step lookahead over the deterministic "
|
| 57 |
+
"seeker dynamics, comparing this action's projected resolution "
|
| 58 |
+
"progress against the oracle ceiling (RLFF-ESC style)."
|
| 59 |
+
),
|
| 60 |
+
)
|
| 61 |
+
penalties: float = Field(..., description="Summed penalties (dismissive, premature advice, loops).")
|
| 62 |
+
components: Dict[str, float] = Field(default_factory=dict, description="Sub-component breakdown.")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class StepResult(BaseModel):
|
| 66 |
+
"""Envelope returned by env.step()."""
|
| 67 |
+
|
| 68 |
+
observation: Observation
|
| 69 |
+
reward: float
|
| 70 |
+
reward_detail: Reward
|
| 71 |
+
done: bool
|
| 72 |
+
info: Dict[str, Any] = Field(default_factory=dict)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class ResetResult(BaseModel):
|
| 76 |
+
"""Envelope returned by env.reset()."""
|
| 77 |
+
|
| 78 |
+
observation: Observation
|
| 79 |
+
info: Dict[str, Any] = Field(default_factory=dict)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class EnvState(BaseModel):
|
| 83 |
+
"""Public view of environment state returned by env.state().
|
| 84 |
+
|
| 85 |
+
Hidden seeker variables are *not* included — only public bookkeeping.
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
task_id: str
|
| 89 |
+
turn: int
|
| 90 |
+
max_turns: int
|
| 91 |
+
done: bool
|
| 92 |
+
cumulative_reward: float
|
| 93 |
+
transcript: List[Dict[str, str]] = Field(
|
| 94 |
+
default_factory=list,
|
| 95 |
+
description="List of {'role': 'seeker'|'agent', 'text': str} entries.",
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ------- Request schemas for the HTTP server -------
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class ResetRequest(BaseModel):
|
| 103 |
+
task_id: Optional[str] = Field(
|
| 104 |
+
default=None,
|
| 105 |
+
description="Optional task id. If omitted, defaults to 'work_stress_venting'.",
|
| 106 |
+
)
|
| 107 |
+
seed: Optional[int] = Field(default=None, description="Optional seed (reserved; env is deterministic).")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class StepRequest(BaseModel):
|
| 111 |
+
action: Action
|
src/seeker.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic seeker simulator with hidden internal state.
|
| 2 |
+
|
| 3 |
+
Why rule-based / deterministic?
|
| 4 |
+
-------------------------------
|
| 5 |
+
The OpenEnv graders must be reproducible. An LLM-driven seeker would make
|
| 6 |
+
reward non-deterministic and fail the "score variance check" in Phase 2 of
|
| 7 |
+
judging. We deliberately trade some linguistic realism for full determinism
|
| 8 |
+
so that the same action sequence always yields the same reward — a hard
|
| 9 |
+
requirement of the hackathon rubric ("graders deterministic and reproducible").
|
| 10 |
+
|
| 11 |
+
Design
|
| 12 |
+
------
|
| 13 |
+
The seeker is a finite-state machine with continuous hidden variables:
|
| 14 |
+
|
| 15 |
+
distress ∈ [0, 1] — how emotionally overwhelmed the seeker feels
|
| 16 |
+
trust ∈ [0, 1] — how safe the seeker feels with the agent
|
| 17 |
+
openness ∈ [0, 1] — willingness to reveal the *true* issue
|
| 18 |
+
revealed ∈ {0, 1} — has the core issue surfaced yet?
|
| 19 |
+
stage ∈ enum — opening / exploring / reflecting / planning / closing
|
| 20 |
+
|
| 21 |
+
On each turn, the environment analyses the agent's reply with a small bank of
|
| 22 |
+
deterministic feature detectors (keyword/regex based), then applies a
|
| 23 |
+
transition rule to update the hidden state and pick the seeker's next
|
| 24 |
+
utterance from a scripted response tree indexed by (stage, features).
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import re
|
| 29 |
+
from dataclasses import dataclass, field
|
| 30 |
+
from enum import Enum
|
| 31 |
+
from typing import Dict, List, Tuple
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class Stage(str, Enum):
|
| 35 |
+
OPENING = "opening"
|
| 36 |
+
EXPLORING = "exploring"
|
| 37 |
+
REFLECTING = "reflecting"
|
| 38 |
+
PLANNING = "planning"
|
| 39 |
+
CLOSING = "closing"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
# Feature detectors — deterministic text analysis of the agent's reply.
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
|
| 46 |
+
EMPATHY_PATTERNS = [
|
| 47 |
+
r"\bi\s+(hear|understand|get|see)\s+(you|that|how)",
|
| 48 |
+
r"\bthat\s+(sounds|must\s+be|seems)\b",
|
| 49 |
+
r"\bit\s+makes\s+sense\b",
|
| 50 |
+
r"\bi\s+can\s+imagine\b",
|
| 51 |
+
r"\bthank\s+you\s+for\s+sharing\b",
|
| 52 |
+
r"\bi'?m\s+(here|glad|sorry)\b",
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
VALIDATION_PATTERNS = [
|
| 56 |
+
r"\byour\s+feelings?\s+(are|make)\s+(valid|sense)",
|
| 57 |
+
r"\bit'?s\s+(okay|ok|normal|understandable)\s+to\s+feel",
|
| 58 |
+
r"\banyone\s+would\s+feel\b",
|
| 59 |
+
r"\bof\s+course\s+you\s+(feel|are)\b",
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
OPEN_QUESTION_PATTERNS = [
|
| 63 |
+
r"\bhow\s+(are|do|did|does)\b",
|
| 64 |
+
r"\bwhat\s+(is|are|do|does|has|makes|brought|happened)\b",
|
| 65 |
+
r"\bcan\s+you\s+tell\s+me\s+more\b",
|
| 66 |
+
r"\bwould\s+you\s+like\s+to\s+(talk|share)\b",
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
ADVICE_PATTERNS = [
|
| 70 |
+
r"\byou\s+should\b",
|
| 71 |
+
r"\byou\s+(need|have|ought)\s+to\b",
|
| 72 |
+
r"\btry\s+(to|doing|this)\b",
|
| 73 |
+
r"\bjust\s+(do|go|try|stop|start)\b",
|
| 74 |
+
r"\bwhy\s+don'?t\s+you\b",
|
| 75 |
+
r"\bmy\s+advice\b",
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
DISMISSIVE_PATTERNS = [
|
| 79 |
+
r"\bget\s+over\s+it\b",
|
| 80 |
+
r"\bstop\s+(complaining|whining|crying)\b",
|
| 81 |
+
r"\byou'?re\s+overreacting\b",
|
| 82 |
+
r"\bit'?s\s+not\s+a\s+big\s+deal\b",
|
| 83 |
+
r"\bcalm\s+down\b",
|
| 84 |
+
r"\bit\s+could\s+be\s+worse\b",
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
INTERROGATIVE_PATTERNS = [ # rapid-fire closed questions (trust drain when high)
|
| 88 |
+
r"\?\s*\?",
|
| 89 |
+
]
|
| 90 |
+
|
| 91 |
+
SAFETY_PATTERNS = [
|
| 92 |
+
r"\bare\s+you\s+safe\b",
|
| 93 |
+
r"\bprofessional\s+help\b",
|
| 94 |
+
r"\bcrisis\s+line\b",
|
| 95 |
+
r"\btherapist\b",
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _count_matches(patterns: List[str], text: str) -> int:
|
| 100 |
+
t = text.lower()
|
| 101 |
+
return sum(1 for p in patterns if re.search(p, t))
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@dataclass
|
| 105 |
+
class Features:
|
| 106 |
+
empathy: int
|
| 107 |
+
validation: int
|
| 108 |
+
open_question: int
|
| 109 |
+
advice: int
|
| 110 |
+
dismissive: int
|
| 111 |
+
interrogative: int
|
| 112 |
+
safety: int
|
| 113 |
+
length: int
|
| 114 |
+
closed_question: int # any '?' not matched by open
|
| 115 |
+
bare: bool # very short / empty reply
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def extract_features(text: str) -> Features:
|
| 119 |
+
stripped = (text or "").strip()
|
| 120 |
+
lower = stripped.lower()
|
| 121 |
+
empathy = _count_matches(EMPATHY_PATTERNS, lower)
|
| 122 |
+
validation = _count_matches(VALIDATION_PATTERNS, lower)
|
| 123 |
+
open_q = _count_matches(OPEN_QUESTION_PATTERNS, lower)
|
| 124 |
+
advice = _count_matches(ADVICE_PATTERNS, lower)
|
| 125 |
+
dismissive = _count_matches(DISMISSIVE_PATTERNS, lower)
|
| 126 |
+
interrogative = _count_matches(INTERROGATIVE_PATTERNS, lower)
|
| 127 |
+
safety = _count_matches(SAFETY_PATTERNS, lower)
|
| 128 |
+
total_q = lower.count("?")
|
| 129 |
+
closed_q = max(0, total_q - open_q)
|
| 130 |
+
bare = len(stripped) < 8
|
| 131 |
+
return Features(
|
| 132 |
+
empathy=empathy,
|
| 133 |
+
validation=validation,
|
| 134 |
+
open_question=open_q,
|
| 135 |
+
advice=advice,
|
| 136 |
+
dismissive=dismissive,
|
| 137 |
+
interrogative=interrogative,
|
| 138 |
+
safety=safety,
|
| 139 |
+
length=len(stripped),
|
| 140 |
+
closed_question=closed_q,
|
| 141 |
+
bare=bare,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# ---------------------------------------------------------------------------
|
| 146 |
+
# Seeker state + scripted persona
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
|
| 149 |
+
@dataclass
|
| 150 |
+
class SeekerPersona:
|
| 151 |
+
"""Static configuration describing the seeker's initial state + script."""
|
| 152 |
+
|
| 153 |
+
task_id: str
|
| 154 |
+
scenario_brief: str
|
| 155 |
+
surface_concern: str # what seeker says at turn 0
|
| 156 |
+
true_issue: str # hidden; only revealed if openness crosses threshold
|
| 157 |
+
initial_distress: float
|
| 158 |
+
initial_trust: float
|
| 159 |
+
initial_openness: float
|
| 160 |
+
reveal_threshold: float # openness value at which true_issue is revealed
|
| 161 |
+
trust_fragility: float # how much a misstep drops trust (0..1)
|
| 162 |
+
openness_gain_per_empathy: float
|
| 163 |
+
distress_drop_per_validation: float
|
| 164 |
+
# Scripted utterances by stage when cooperative
|
| 165 |
+
opening_lines: List[str]
|
| 166 |
+
exploring_lines: List[str]
|
| 167 |
+
reflecting_lines: List[str]
|
| 168 |
+
planning_lines: List[str]
|
| 169 |
+
closing_lines: List[str]
|
| 170 |
+
reveal_line: str # said the turn openness crosses reveal_threshold
|
| 171 |
+
# Adverse reactions
|
| 172 |
+
dismissed_lines: List[str] = field(default_factory=list)
|
| 173 |
+
advice_too_early_lines: List[str] = field(default_factory=list)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@dataclass
|
| 177 |
+
class SeekerState:
|
| 178 |
+
"""Mutable hidden state updated each turn."""
|
| 179 |
+
|
| 180 |
+
persona: SeekerPersona
|
| 181 |
+
distress: float
|
| 182 |
+
trust: float
|
| 183 |
+
openness: float
|
| 184 |
+
revealed: bool
|
| 185 |
+
stage: Stage
|
| 186 |
+
last_line_idx_by_stage: Dict[Stage, int]
|
| 187 |
+
turn: int
|
| 188 |
+
|
| 189 |
+
@classmethod
|
| 190 |
+
def from_persona(cls, persona: SeekerPersona) -> "SeekerState":
|
| 191 |
+
return cls(
|
| 192 |
+
persona=persona,
|
| 193 |
+
distress=persona.initial_distress,
|
| 194 |
+
trust=persona.initial_trust,
|
| 195 |
+
openness=persona.initial_openness,
|
| 196 |
+
revealed=False,
|
| 197 |
+
stage=Stage.OPENING,
|
| 198 |
+
last_line_idx_by_stage={s: -1 for s in Stage},
|
| 199 |
+
turn=0,
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
# Snapshot for lookahead simulation — must be cheap and pure.
|
| 203 |
+
def snapshot(self) -> "SeekerState":
|
| 204 |
+
return SeekerState(
|
| 205 |
+
persona=self.persona,
|
| 206 |
+
distress=self.distress,
|
| 207 |
+
trust=self.trust,
|
| 208 |
+
openness=self.openness,
|
| 209 |
+
revealed=self.revealed,
|
| 210 |
+
stage=self.stage,
|
| 211 |
+
last_line_idx_by_stage=dict(self.last_line_idx_by_stage),
|
| 212 |
+
turn=self.turn,
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _clip(x: float) -> float:
|
| 217 |
+
return max(0.0, min(1.0, x))
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# Stage ordering used for "progress" scalar in [0,1]
|
| 221 |
+
STAGE_ORDER: List[Stage] = [
|
| 222 |
+
Stage.OPENING,
|
| 223 |
+
Stage.EXPLORING,
|
| 224 |
+
Stage.REFLECTING,
|
| 225 |
+
Stage.PLANNING,
|
| 226 |
+
Stage.CLOSING,
|
| 227 |
+
]
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def stage_progress(stage: Stage) -> float:
|
| 231 |
+
return STAGE_ORDER.index(stage) / (len(STAGE_ORDER) - 1)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def resolution_score(state: SeekerState) -> float:
|
| 235 |
+
"""Scalar summary of how 'resolved' the conversation currently is, in [0,1].
|
| 236 |
+
|
| 237 |
+
Weighted combination of stage progress, trust gained, distress relieved,
|
| 238 |
+
and whether the true issue surfaced. This is the quantity the
|
| 239 |
+
future-oriented reward tries to project forward under an oracle policy.
|
| 240 |
+
"""
|
| 241 |
+
p = state.persona
|
| 242 |
+
progress = stage_progress(state.stage)
|
| 243 |
+
trust_gain = max(0.0, state.trust - p.initial_trust)
|
| 244 |
+
distress_relief = max(0.0, p.initial_distress - state.distress)
|
| 245 |
+
reveal_bonus = 1.0 if state.revealed else 0.0
|
| 246 |
+
return _clip(
|
| 247 |
+
0.40 * progress
|
| 248 |
+
+ 0.25 * trust_gain / max(1e-6, 1.0 - p.initial_trust)
|
| 249 |
+
+ 0.25 * distress_relief / max(1e-6, p.initial_distress)
|
| 250 |
+
+ 0.10 * reveal_bonus
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# ---------------------------------------------------------------------------
|
| 255 |
+
# Transition: given current state + agent features, produce new state +
|
| 256 |
+
# seeker's next utterance + transition info.
|
| 257 |
+
# ---------------------------------------------------------------------------
|
| 258 |
+
|
| 259 |
+
@dataclass
|
| 260 |
+
class Transition:
|
| 261 |
+
new_state: SeekerState
|
| 262 |
+
seeker_utterance: str
|
| 263 |
+
flags: Dict[str, bool] # e.g. {"dismissed": True, "advice_too_early": False, ...}
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def _next_line(state: SeekerState, stage: Stage, pool: List[str]) -> str:
|
| 267 |
+
if not pool:
|
| 268 |
+
return "..."
|
| 269 |
+
idx = (state.last_line_idx_by_stage[stage] + 1) % len(pool)
|
| 270 |
+
state.last_line_idx_by_stage[stage] = idx
|
| 271 |
+
return pool[idx]
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def step_seeker(state: SeekerState, features: Features) -> Transition:
|
| 275 |
+
"""Apply one turn of seeker dynamics given the agent's extracted features.
|
| 276 |
+
|
| 277 |
+
Pure-ish: mutates a *copy* of state (caller should pass a snapshot if they
|
| 278 |
+
want to preserve the original — the env always passes the live state).
|
| 279 |
+
"""
|
| 280 |
+
p = state.persona
|
| 281 |
+
flags: Dict[str, bool] = {
|
| 282 |
+
"dismissed": False,
|
| 283 |
+
"advice_too_early": False,
|
| 284 |
+
"bare_reply": features.bare,
|
| 285 |
+
"empathic": features.empathy + features.validation > 0,
|
| 286 |
+
"interrogated": False,
|
| 287 |
+
"revealed_this_turn": False,
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
# --- 1. Dismissive / hostile language: hard drop on trust & distress spike.
|
| 291 |
+
if features.dismissive > 0:
|
| 292 |
+
state.trust = _clip(state.trust - 0.4 * (1.0 + p.trust_fragility))
|
| 293 |
+
state.distress = _clip(state.distress + 0.15)
|
| 294 |
+
state.openness = _clip(state.openness - 0.2)
|
| 295 |
+
flags["dismissed"] = True
|
| 296 |
+
|
| 297 |
+
# --- 2. Premature advice (advice before trust ≥ 0.55): trust drop, openness drop.
|
| 298 |
+
if features.advice > 0 and state.trust < 0.55:
|
| 299 |
+
state.trust = _clip(state.trust - 0.15 * (1.0 + p.trust_fragility))
|
| 300 |
+
state.openness = _clip(state.openness - 0.1)
|
| 301 |
+
flags["advice_too_early"] = True
|
| 302 |
+
|
| 303 |
+
# --- 3. Empathy & validation: trust + openness up, distress down.
|
| 304 |
+
if features.empathy > 0 or features.validation > 0:
|
| 305 |
+
gain = p.openness_gain_per_empathy * (features.empathy + features.validation)
|
| 306 |
+
state.trust = _clip(state.trust + 0.12 * (features.empathy + features.validation))
|
| 307 |
+
state.openness = _clip(state.openness + gain)
|
| 308 |
+
state.distress = _clip(state.distress - p.distress_drop_per_validation * features.validation)
|
| 309 |
+
|
| 310 |
+
# --- 4. Open questions: small trust gain, nudges stage forward.
|
| 311 |
+
if features.open_question > 0:
|
| 312 |
+
state.trust = _clip(state.trust + 0.05)
|
| 313 |
+
state.openness = _clip(state.openness + 0.04)
|
| 314 |
+
|
| 315 |
+
# --- 5. Interrogation (many closed questions or multiple "?"): trust drain.
|
| 316 |
+
if features.closed_question >= 3 or features.interrogative > 0:
|
| 317 |
+
state.trust = _clip(state.trust - 0.1)
|
| 318 |
+
flags["interrogated"] = True
|
| 319 |
+
|
| 320 |
+
# --- 6. Bare / empty reply: small penalty across the board.
|
| 321 |
+
if features.bare:
|
| 322 |
+
state.trust = _clip(state.trust - 0.05)
|
| 323 |
+
state.distress = _clip(state.distress + 0.02)
|
| 324 |
+
|
| 325 |
+
# --- 7. Stage progression (monotonic forward with cooperative conditions).
|
| 326 |
+
def advance_to(s: Stage) -> None:
|
| 327 |
+
if STAGE_ORDER.index(s) > STAGE_ORDER.index(state.stage):
|
| 328 |
+
state.stage = s
|
| 329 |
+
|
| 330 |
+
if state.stage == Stage.OPENING and (
|
| 331 |
+
features.empathy + features.validation + features.open_question > 0
|
| 332 |
+
):
|
| 333 |
+
advance_to(Stage.EXPLORING)
|
| 334 |
+
elif state.stage == Stage.EXPLORING and state.trust >= 0.5 and state.openness >= 0.5:
|
| 335 |
+
advance_to(Stage.REFLECTING)
|
| 336 |
+
elif state.stage == Stage.REFLECTING and state.revealed and state.distress <= 0.5:
|
| 337 |
+
advance_to(Stage.PLANNING)
|
| 338 |
+
elif state.stage == Stage.PLANNING and features.open_question + features.empathy > 0:
|
| 339 |
+
advance_to(Stage.CLOSING)
|
| 340 |
+
|
| 341 |
+
# --- 8. Reveal check (cross threshold once).
|
| 342 |
+
if not state.revealed and state.openness >= p.reveal_threshold:
|
| 343 |
+
state.revealed = True
|
| 344 |
+
flags["revealed_this_turn"] = True
|
| 345 |
+
|
| 346 |
+
# --- 9. Pick seeker's next utterance.
|
| 347 |
+
if flags["dismissed"] and p.dismissed_lines:
|
| 348 |
+
utterance = _next_line(state, state.stage, p.dismissed_lines)
|
| 349 |
+
elif flags["advice_too_early"] and p.advice_too_early_lines:
|
| 350 |
+
utterance = _next_line(state, state.stage, p.advice_too_early_lines)
|
| 351 |
+
elif flags["revealed_this_turn"]:
|
| 352 |
+
utterance = p.reveal_line
|
| 353 |
+
else:
|
| 354 |
+
pool_by_stage = {
|
| 355 |
+
Stage.OPENING: p.opening_lines,
|
| 356 |
+
Stage.EXPLORING: p.exploring_lines,
|
| 357 |
+
Stage.REFLECTING: p.reflecting_lines,
|
| 358 |
+
Stage.PLANNING: p.planning_lines,
|
| 359 |
+
Stage.CLOSING: p.closing_lines,
|
| 360 |
+
}
|
| 361 |
+
utterance = _next_line(state, state.stage, pool_by_stage[state.stage])
|
| 362 |
+
|
| 363 |
+
state.turn += 1
|
| 364 |
+
return Transition(new_state=state, seeker_utterance=utterance, flags=flags)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
# ---------------------------------------------------------------------------
|
| 368 |
+
# Oracle policy for the future-oriented reward lookahead.
|
| 369 |
+
# ---------------------------------------------------------------------------
|
| 370 |
+
|
| 371 |
+
def oracle_features(state: SeekerState) -> Features:
|
| 372 |
+
"""What the 'oracle' agent would do from this state.
|
| 373 |
+
|
| 374 |
+
Picks the stage-appropriate ideal action:
|
| 375 |
+
- opening/exploring: empathy + open question
|
| 376 |
+
- reflecting: empathy + validation
|
| 377 |
+
- planning: open question + mild advice (trust is high here)
|
| 378 |
+
- closing: empathy + safety mention
|
| 379 |
+
"""
|
| 380 |
+
s = state.stage
|
| 381 |
+
if s in (Stage.OPENING, Stage.EXPLORING):
|
| 382 |
+
return Features(
|
| 383 |
+
empathy=1, validation=0, open_question=1, advice=0,
|
| 384 |
+
dismissive=0, interrogative=0, safety=0, length=80,
|
| 385 |
+
closed_question=0, bare=False,
|
| 386 |
+
)
|
| 387 |
+
if s == Stage.REFLECTING:
|
| 388 |
+
return Features(
|
| 389 |
+
empathy=1, validation=1, open_question=0, advice=0,
|
| 390 |
+
dismissive=0, interrogative=0, safety=0, length=90,
|
| 391 |
+
closed_question=0, bare=False,
|
| 392 |
+
)
|
| 393 |
+
if s == Stage.PLANNING:
|
| 394 |
+
return Features(
|
| 395 |
+
empathy=0, validation=0, open_question=1, advice=1,
|
| 396 |
+
dismissive=0, interrogative=0, safety=0, length=90,
|
| 397 |
+
closed_question=0, bare=False,
|
| 398 |
+
)
|
| 399 |
+
return Features( # CLOSING
|
| 400 |
+
empathy=1, validation=0, open_question=0, advice=0,
|
| 401 |
+
dismissive=0, interrogative=0, safety=1, length=90,
|
| 402 |
+
closed_question=0, bare=False,
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def simulate_oracle_rollout(state: SeekerState, k: int) -> float:
|
| 407 |
+
"""Run the oracle policy from a snapshot for k steps and return the final
|
| 408 |
+
resolution_score. Used by the future-oriented reward."""
|
| 409 |
+
sim = state.snapshot()
|
| 410 |
+
for _ in range(k):
|
| 411 |
+
step_seeker(sim, oracle_features(sim))
|
| 412 |
+
return resolution_score(sim)
|
src/tasks.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Three graded tasks with clear difficulty progression.
|
| 2 |
+
|
| 3 |
+
Difficulty ladder
|
| 4 |
+
-----------------
|
| 5 |
+
1. work_stress_venting (easy)
|
| 6 |
+
- Cooperative seeker, low reveal threshold, forgiving trust.
|
| 7 |
+
- Goal: reach CLOSING stage with trust ≥ 0.7 and distress ≤ 0.4.
|
| 8 |
+
|
| 9 |
+
2. guarded_relationship (medium)
|
| 10 |
+
- Seeker starts guarded (low openness). Real issue is different from the
|
| 11 |
+
surface concern and only surfaces once openness crosses 0.75.
|
| 12 |
+
- Premature advice aggressively drops trust. Agent must *first* build
|
| 13 |
+
rapport, then explore.
|
| 14 |
+
|
| 15 |
+
3. crisis_fragile_trust (hard)
|
| 16 |
+
- High initial distress, high trust fragility, multiple interleaved
|
| 17 |
+
concerns. Any single misstep (dismissive OR premature advice) triggers
|
| 18 |
+
a large trust drop that takes several empathic turns to recover from.
|
| 19 |
+
- Safety referencing is rewarded in the CLOSING stage.
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
from dataclasses import dataclass
|
| 24 |
+
from typing import Dict, List
|
| 25 |
+
|
| 26 |
+
from .seeker import SeekerPersona
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class TaskSpec:
|
| 31 |
+
id: str
|
| 32 |
+
difficulty: str
|
| 33 |
+
max_turns: int
|
| 34 |
+
persona: SeekerPersona
|
| 35 |
+
success_threshold: float # final score ≥ this counts as success
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# Task 1 — work stress venting (easy)
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
_WORK_STRESS = SeekerPersona(
|
| 43 |
+
task_id="work_stress_venting",
|
| 44 |
+
scenario_brief=(
|
| 45 |
+
"A coworker messages you after-hours about work stress. They want to "
|
| 46 |
+
"feel heard before anything else."
|
| 47 |
+
),
|
| 48 |
+
surface_concern=(
|
| 49 |
+
"I just… I'm so tired. My manager dumped another deadline on me today "
|
| 50 |
+
"and I don't know how I'm supposed to keep doing this."
|
| 51 |
+
),
|
| 52 |
+
true_issue=(
|
| 53 |
+
"I think I'm burning out. I haven't felt excited about any of this in "
|
| 54 |
+
"months and I'm scared I'm going to quit without a plan."
|
| 55 |
+
),
|
| 56 |
+
initial_distress=0.6,
|
| 57 |
+
initial_trust=0.5,
|
| 58 |
+
initial_openness=0.55,
|
| 59 |
+
reveal_threshold=0.70,
|
| 60 |
+
trust_fragility=0.2,
|
| 61 |
+
openness_gain_per_empathy=0.15,
|
| 62 |
+
distress_drop_per_validation=0.20,
|
| 63 |
+
opening_lines=[
|
| 64 |
+
"Sorry for dumping this on you. I just needed to tell someone.",
|
| 65 |
+
"Yeah, it's been building up for a while honestly.",
|
| 66 |
+
],
|
| 67 |
+
exploring_lines=[
|
| 68 |
+
"It's not even the hours, it's the feeling that nothing I do is enough.",
|
| 69 |
+
"Every week there's a new 'priority'. I can't keep up.",
|
| 70 |
+
"My last one-on-one felt like a warning more than a conversation.",
|
| 71 |
+
],
|
| 72 |
+
reflecting_lines=[
|
| 73 |
+
"Hearing you say that actually helps. I think I've been bottling it.",
|
| 74 |
+
"Maybe I've been too embarrassed to admit how bad it's gotten.",
|
| 75 |
+
],
|
| 76 |
+
planning_lines=[
|
| 77 |
+
"You're right — I should probably talk to someone at work, or take a day off.",
|
| 78 |
+
"A proper weekend off without my laptop sounds almost unreal right now.",
|
| 79 |
+
],
|
| 80 |
+
closing_lines=[
|
| 81 |
+
"Thanks for listening. Seriously — this helped more than you know.",
|
| 82 |
+
"I feel a bit lighter. I'll message you after I've tried one of those things.",
|
| 83 |
+
],
|
| 84 |
+
reveal_line=(
|
| 85 |
+
"Okay — if I'm honest, I think I'm burning out. I haven't felt "
|
| 86 |
+
"excited about this in months and I'm scared of what happens next."
|
| 87 |
+
),
|
| 88 |
+
dismissed_lines=[
|
| 89 |
+
"Wow. Okay, forget I said anything.",
|
| 90 |
+
"Right. I shouldn't have brought it up.",
|
| 91 |
+
],
|
| 92 |
+
advice_too_early_lines=[
|
| 93 |
+
"I don't really need solutions right now, I just needed someone to listen.",
|
| 94 |
+
"I know all that. That's not what I'm asking for.",
|
| 95 |
+
],
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# ---------------------------------------------------------------------------
|
| 99 |
+
# Task 2 — guarded relationship (medium)
|
| 100 |
+
# ---------------------------------------------------------------------------
|
| 101 |
+
|
| 102 |
+
_GUARDED = SeekerPersona(
|
| 103 |
+
task_id="guarded_relationship",
|
| 104 |
+
scenario_brief=(
|
| 105 |
+
"A friend starts a conversation saying they 'had a weird week'. They "
|
| 106 |
+
"are not ready to say what's really going on until they trust you."
|
| 107 |
+
),
|
| 108 |
+
surface_concern=(
|
| 109 |
+
"I had kind of a weird week. Nothing major. Just — you know how it is."
|
| 110 |
+
),
|
| 111 |
+
true_issue=(
|
| 112 |
+
"My partner and I are talking about separating. I haven't told anyone."
|
| 113 |
+
),
|
| 114 |
+
initial_distress=0.55,
|
| 115 |
+
initial_trust=0.35,
|
| 116 |
+
initial_openness=0.20,
|
| 117 |
+
reveal_threshold=0.75,
|
| 118 |
+
trust_fragility=0.5,
|
| 119 |
+
openness_gain_per_empathy=0.10,
|
| 120 |
+
distress_drop_per_validation=0.12,
|
| 121 |
+
opening_lines=[
|
| 122 |
+
"Yeah. Just a lot on my mind I guess.",
|
| 123 |
+
"It's hard to explain. I don't even know where I'd start.",
|
| 124 |
+
"Work stuff, personal stuff. The usual.",
|
| 125 |
+
],
|
| 126 |
+
exploring_lines=[
|
| 127 |
+
"I don't want to make it a big thing. People always blow this stuff up.",
|
| 128 |
+
"Some of it's… at home. It's complicated.",
|
| 129 |
+
"I've been sleeping in the spare room a lot lately, actually.",
|
| 130 |
+
],
|
| 131 |
+
reflecting_lines=[
|
| 132 |
+
"I think I've been pretending it's fine because saying it out loud makes it real.",
|
| 133 |
+
"It's strange how talking around it for a while makes it easier to get to.",
|
| 134 |
+
],
|
| 135 |
+
planning_lines=[
|
| 136 |
+
"Maybe I do need to actually sit down with them and talk properly.",
|
| 137 |
+
"I've been avoiding even thinking about what I actually want.",
|
| 138 |
+
],
|
| 139 |
+
closing_lines=[
|
| 140 |
+
"Thank you for not pushing. That's what I needed, I think.",
|
| 141 |
+
"I'll let you know how the conversation goes. Really — thank you.",
|
| 142 |
+
],
|
| 143 |
+
reveal_line=(
|
| 144 |
+
"Okay — the truth is my partner and I are talking about separating. "
|
| 145 |
+
"I haven't told anyone. I don't even know how I feel about it yet."
|
| 146 |
+
),
|
| 147 |
+
dismissed_lines=[
|
| 148 |
+
"Never mind. I told you it wasn't a big deal.",
|
| 149 |
+
"Yeah. Forget I said anything.",
|
| 150 |
+
],
|
| 151 |
+
advice_too_early_lines=[
|
| 152 |
+
"I don't think you understand what's going on yet.",
|
| 153 |
+
"That's… not really what this is about.",
|
| 154 |
+
"Can we not jump to solutions? Please.",
|
| 155 |
+
],
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
# ---------------------------------------------------------------------------
|
| 159 |
+
# Task 3 — crisis with fragile trust (hard)
|
| 160 |
+
# ---------------------------------------------------------------------------
|
| 161 |
+
|
| 162 |
+
_CRISIS = SeekerPersona(
|
| 163 |
+
task_id="crisis_fragile_trust",
|
| 164 |
+
scenario_brief=(
|
| 165 |
+
"Someone messages you late at night. They are clearly overwhelmed and "
|
| 166 |
+
"their messages are disjointed. Trust is extremely fragile; one "
|
| 167 |
+
"misstep can end the conversation."
|
| 168 |
+
),
|
| 169 |
+
surface_concern=(
|
| 170 |
+
"sorry for messaging this late. everything is just a lot right now "
|
| 171 |
+
"and i don't really know who else to talk to."
|
| 172 |
+
),
|
| 173 |
+
true_issue=(
|
| 174 |
+
"I lost my job last week, my mom is in the hospital, and I've been "
|
| 175 |
+
"having some really dark thoughts I don't want to have."
|
| 176 |
+
),
|
| 177 |
+
initial_distress=0.85,
|
| 178 |
+
initial_trust=0.30,
|
| 179 |
+
initial_openness=0.15,
|
| 180 |
+
reveal_threshold=0.80,
|
| 181 |
+
trust_fragility=0.9,
|
| 182 |
+
openness_gain_per_empathy=0.09,
|
| 183 |
+
distress_drop_per_validation=0.10,
|
| 184 |
+
opening_lines=[
|
| 185 |
+
"i don't even know where to start honestly.",
|
| 186 |
+
"everything feels like it's happening at once. i can't keep up.",
|
| 187 |
+
"sorry. i know i'm being vague. my head is a mess.",
|
| 188 |
+
],
|
| 189 |
+
exploring_lines=[
|
| 190 |
+
"work stuff fell apart last week. and then family stuff on top of that.",
|
| 191 |
+
"my mom's been in and out of the hospital. i've been the one handling it.",
|
| 192 |
+
"i haven't slept properly in days. i keep going over it in my head.",
|
| 193 |
+
],
|
| 194 |
+
reflecting_lines=[
|
| 195 |
+
"it helps that you're not freaking out on me. most people would.",
|
| 196 |
+
"i didn't realise how tight i was holding all of this in.",
|
| 197 |
+
],
|
| 198 |
+
planning_lines=[
|
| 199 |
+
"maybe i do need to tell someone who can actually help. i've been avoiding that.",
|
| 200 |
+
"i don't know what tomorrow looks like but i think i can get through tonight.",
|
| 201 |
+
],
|
| 202 |
+
closing_lines=[
|
| 203 |
+
"thank you. genuinely. i'll reach out to the number you mentioned.",
|
| 204 |
+
"i think i can sleep a little now. please don't disappear on me.",
|
| 205 |
+
],
|
| 206 |
+
reveal_line=(
|
| 207 |
+
"okay — i lost my job last week, my mom is in the hospital, and "
|
| 208 |
+
"honestly i've been having some really dark thoughts i don't want to "
|
| 209 |
+
"be having. that's what's actually going on."
|
| 210 |
+
),
|
| 211 |
+
dismissed_lines=[
|
| 212 |
+
"…right. i knew i shouldn't have messaged anyone.",
|
| 213 |
+
"okay. nevermind. sorry for wasting your time.",
|
| 214 |
+
],
|
| 215 |
+
advice_too_early_lines=[
|
| 216 |
+
"please — i'm not looking for a checklist right now.",
|
| 217 |
+
"i can't even think straight, and you want me to 'try' things?",
|
| 218 |
+
"that's not… that's not what i need from you right now.",
|
| 219 |
+
],
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
# ---------------------------------------------------------------------------
|
| 223 |
+
# Registry
|
| 224 |
+
# ---------------------------------------------------------------------------
|
| 225 |
+
|
| 226 |
+
TASKS: Dict[str, TaskSpec] = {
|
| 227 |
+
"work_stress_venting": TaskSpec(
|
| 228 |
+
id="work_stress_venting",
|
| 229 |
+
difficulty="easy",
|
| 230 |
+
max_turns=10,
|
| 231 |
+
persona=_WORK_STRESS,
|
| 232 |
+
success_threshold=0.55,
|
| 233 |
+
),
|
| 234 |
+
"guarded_relationship": TaskSpec(
|
| 235 |
+
id="guarded_relationship",
|
| 236 |
+
difficulty="medium",
|
| 237 |
+
max_turns=12,
|
| 238 |
+
persona=_GUARDED,
|
| 239 |
+
success_threshold=0.50,
|
| 240 |
+
),
|
| 241 |
+
"crisis_fragile_trust": TaskSpec(
|
| 242 |
+
id="crisis_fragile_trust",
|
| 243 |
+
difficulty="hard",
|
| 244 |
+
max_turns=14,
|
| 245 |
+
persona=_CRISIS,
|
| 246 |
+
success_threshold=0.45,
|
| 247 |
+
),
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def list_task_ids() -> List[str]:
|
| 252 |
+
return list(TASKS.keys())
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def get_task(task_id: str) -> TaskSpec:
|
| 256 |
+
if task_id not in TASKS:
|
| 257 |
+
raise KeyError(f"Unknown task '{task_id}'. Known: {list(TASKS.keys())}")
|
| 258 |
+
return TASKS[task_id]
|