Spaces:
Sleeping
Sleeping
Commit Β·
a77725d
0
Parent(s):
FastAPI OpenEnv server
Browse files- Dockerfile +7 -0
- api.py +110 -0
- environment.py +239 -0
- graders/__init__.py +3 -0
- graders/__pycache__/__init__.cpython-312.pyc +0 -0
- graders/__pycache__/base_grader.cpython-312.pyc +0 -0
- graders/__pycache__/easy_grader.cpython-312.pyc +0 -0
- graders/__pycache__/hard_grader.cpython-312.pyc +0 -0
- graders/__pycache__/medium_grader.cpython-312.pyc +0 -0
- graders/base_grader.py +238 -0
- graders/easy_grader.py +84 -0
- graders/hard_grader.py +156 -0
- graders/hard_grader.py +161 -0
- graders/medium_grader.py +118 -0
- inference.py +183 -0
- models.py +125 -0
- requirements.txt +5 -0
- tasks/ medium_task.py +160 -0
- tasks/__init__.py +12 -0
- tasks/__pycache__/__init__.cpython-312.pyc +0 -0
- tasks/__pycache__/easy_task.cpython-312.pyc +0 -0
- tasks/__pycache__/hard_task.cpython-312.pyc +0 -0
- tasks/__pycache__/medium_task.cpython-312.pyc +0 -0
- tasks/easy_task.py +36 -0
- tasks/hard_task.py +120 -0
- tasks/medium_task.py +39 -0
Dockerfile
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY requirements.txt .
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
COPY . .
|
| 6 |
+
EXPOSE 7860
|
| 7 |
+
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "7860"]
|
api.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from environment import CustomerSupportEnv
|
| 5 |
+
|
| 6 |
+
app = FastAPI(title="Customer Support AI OpenEnv")
|
| 7 |
+
|
| 8 |
+
app.add_middleware(
|
| 9 |
+
CORSMiddleware,
|
| 10 |
+
allow_origins=["*"],
|
| 11 |
+
allow_methods=["*"],
|
| 12 |
+
allow_headers=["*"],
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
_env = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ResetRequest(BaseModel):
|
| 19 |
+
task: str = "easy"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class StepRequest(BaseModel):
|
| 23 |
+
action: str
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def obs_to_dict(obs) -> dict:
|
| 27 |
+
step = obs.current_step
|
| 28 |
+
return {
|
| 29 |
+
"task_id": obs.task_id,
|
| 30 |
+
"task_description": obs.task_description,
|
| 31 |
+
"current_customer_message": obs.current_customer_message,
|
| 32 |
+
"current_step": {
|
| 33 |
+
"step_id": str(step.step_id),
|
| 34 |
+
"label": str(step.label),
|
| 35 |
+
"description": str(step.description),
|
| 36 |
+
},
|
| 37 |
+
"progress": obs.progress,
|
| 38 |
+
"step_number": int(obs.step_number),
|
| 39 |
+
"total_steps": int(obs.total_steps),
|
| 40 |
+
"max_steps": int(obs.max_steps),
|
| 41 |
+
"metadata": obs.metadata,
|
| 42 |
+
"conversation_history": obs.conversation_history,
|
| 43 |
+
"context_memory": obs.context_memory,
|
| 44 |
+
"steps_completed": list(obs.steps_completed),
|
| 45 |
+
"consecutive_failures": int(obs.consecutive_failures),
|
| 46 |
+
"episode_status": str(obs.episode_status),
|
| 47 |
+
"fail_conditions": list(obs.fail_conditions),
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@app.get("/")
|
| 52 |
+
def root():
|
| 53 |
+
return {"status": "ok", "message": "Customer Support AI OpenEnv API"}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@app.post("/reset")
|
| 57 |
+
def reset_env(body: ResetRequest = None):
|
| 58 |
+
global _env
|
| 59 |
+
task = (body.task if body else "easy").lower()
|
| 60 |
+
if task not in ["easy", "medium", "hard"]:
|
| 61 |
+
task = "easy"
|
| 62 |
+
_env = CustomerSupportEnv(task=task)
|
| 63 |
+
obs = _env.reset()
|
| 64 |
+
return {"observation": obs_to_dict(obs)}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@app.post("/step")
|
| 68 |
+
def step_env(body: StepRequest):
|
| 69 |
+
global _env
|
| 70 |
+
if _env is None:
|
| 71 |
+
return {"error": "Call /reset first"}
|
| 72 |
+
result = _env.step(body.action)
|
| 73 |
+
info = result.info.copy()
|
| 74 |
+
return {
|
| 75 |
+
"observation": obs_to_dict(result.observation),
|
| 76 |
+
"reward": float(result.reward),
|
| 77 |
+
"done": bool(result.done),
|
| 78 |
+
"info": info,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@app.get("/state")
|
| 83 |
+
def get_state():
|
| 84 |
+
global _env
|
| 85 |
+
if _env is None:
|
| 86 |
+
return {"error": "Call /reset first"}
|
| 87 |
+
return _env.state()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@app.get("/observation_space")
|
| 91 |
+
def observation_space():
|
| 92 |
+
return {
|
| 93 |
+
"type": "Dict",
|
| 94 |
+
"fields": {
|
| 95 |
+
"task_id": "str",
|
| 96 |
+
"current_customer_message": "str",
|
| 97 |
+
"current_step": "StepInfo",
|
| 98 |
+
"progress": "str",
|
| 99 |
+
"episode_status": "str",
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@app.get("/action_space")
|
| 105 |
+
def action_space():
|
| 106 |
+
return {
|
| 107 |
+
"type": "Text",
|
| 108 |
+
"description": "Agent free-text reply",
|
| 109 |
+
"min_words": 6,
|
| 110 |
+
}
|
environment.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
environment.py β Strict RL-style Customer Support Environment
|
| 3 |
+
Handles: step enforcement, repeat detection, fail conditions, reward calculation
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
import re
|
| 8 |
+
from typing import List, Tuple, Optional
|
| 9 |
+
|
| 10 |
+
from models import (
|
| 11 |
+
Episode, EpisodeStatus, StepName, StepResult,
|
| 12 |
+
DifficultyLevel, Task
|
| 13 |
+
)
|
| 14 |
+
from graders.base_grader import BaseGrader
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ββ Step order ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
STEP_ORDER = [
|
| 19 |
+
StepName.EMPATHY,
|
| 20 |
+
StepName.COLLECT_INFO,
|
| 21 |
+
StepName.INVESTIGATE,
|
| 22 |
+
StepName.RESOLUTION,
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
# ββ Reward constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
BASE_SCORE_CORRECT = 1.0
|
| 27 |
+
BASE_SCORE_INCORRECT = 0.2
|
| 28 |
+
STEP_BONUS = 0.2 # bonus when step is correct
|
| 29 |
+
WRONG_STEP_PENALTY = 0.3 # wrong action in correct step position
|
| 30 |
+
REPEAT_PENALTY = 0.2 # repeated question / response
|
| 31 |
+
SKIP_STEP_PENALTY = 0.3 # jumped ahead
|
| 32 |
+
EARLY_SOLUTION_PENALTY = 0.25 # gave resolution before investigation
|
| 33 |
+
EMOTION_IGNORE_PENALTY = 0.25 # angry customer β neutral / cold reply
|
| 34 |
+
GENERIC_RESPONSE_PENALTY = 0.15 # "ok", "done", vague one-liners
|
| 35 |
+
WRONG_ASSUMPTION_PENALTY = 0.2 # stated wrong facts
|
| 36 |
+
LOOP_PENALTY = 0.35 # agent stuck in loop (same step repeated 2+)
|
| 37 |
+
|
| 38 |
+
# ββ Overlap threshold for repeat detection βββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
REPEAT_WORD_OVERLAP_MIN = 10 # words in common β flagged as repeat
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class CustomerSupportEnv:
|
| 43 |
+
"""
|
| 44 |
+
Strict step-based RL environment for customer support training.
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
def __init__(self, task: Task, grader: BaseGrader):
|
| 48 |
+
self.task = task
|
| 49 |
+
self.grader = grader
|
| 50 |
+
self.episode = Episode(
|
| 51 |
+
task_id = task.task_id,
|
| 52 |
+
difficulty = task.difficulty,
|
| 53 |
+
)
|
| 54 |
+
self._response_history: List[str] = []
|
| 55 |
+
self._step_index = 0 # which step we expect next
|
| 56 |
+
self._consecutive_wrong = 0 # wrong attempts at current step
|
| 57 |
+
|
| 58 |
+
# ββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
|
| 60 |
+
def step(self, agent_response: str) -> Tuple[StepResult, bool]:
|
| 61 |
+
"""
|
| 62 |
+
Process one agent response.
|
| 63 |
+
Returns (StepResult, done:bool).
|
| 64 |
+
"""
|
| 65 |
+
if self.episode.status != EpisodeStatus.RUNNING:
|
| 66 |
+
raise RuntimeError("Episode is already finished.")
|
| 67 |
+
|
| 68 |
+
expected_step = STEP_ORDER[self._step_index]
|
| 69 |
+
result = self._evaluate(agent_response, expected_step)
|
| 70 |
+
self.episode.add_step(result)
|
| 71 |
+
self._response_history.append(agent_response.lower().strip())
|
| 72 |
+
|
| 73 |
+
done = False
|
| 74 |
+
if result.correct:
|
| 75 |
+
self._step_index += 1
|
| 76 |
+
self._consecutive_wrong = 0
|
| 77 |
+
else:
|
| 78 |
+
self._consecutive_wrong += 1
|
| 79 |
+
|
| 80 |
+
# Loop fail: 3 consecutive wrong attempts at the same step
|
| 81 |
+
if self._consecutive_wrong >= 3 and self.episode.status == EpisodeStatus.RUNNING:
|
| 82 |
+
self.episode.status = EpisodeStatus.FAIL
|
| 83 |
+
self.episode.fail_reason = (
|
| 84 |
+
f"Agent stuck in loop at step '{expected_step.value}' "
|
| 85 |
+
f"({self._consecutive_wrong} consecutive failures)"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
if self.episode.status != EpisodeStatus.RUNNING:
|
| 89 |
+
done = True
|
| 90 |
+
elif self._step_index >= len(STEP_ORDER):
|
| 91 |
+
done = True # episode.add_step() already set SUCCESS/FAIL
|
| 92 |
+
|
| 93 |
+
return result, done
|
| 94 |
+
|
| 95 |
+
def reset(self) -> None:
|
| 96 |
+
self.episode = Episode(
|
| 97 |
+
task_id = self.task.task_id,
|
| 98 |
+
difficulty = self.task.difficulty,
|
| 99 |
+
)
|
| 100 |
+
self._response_history = []
|
| 101 |
+
self._step_index = 0
|
| 102 |
+
self._consecutive_wrong = 0
|
| 103 |
+
|
| 104 |
+
def summary(self):
|
| 105 |
+
return self.episode.summary()
|
| 106 |
+
|
| 107 |
+
# ββ Internal evaluation ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 108 |
+
|
| 109 |
+
def _evaluate(self, response: str, expected_step: StepName) -> StepResult:
|
| 110 |
+
penalties: List[str] = []
|
| 111 |
+
total_penalty = 0.0
|
| 112 |
+
|
| 113 |
+
# 1. Grade the response against expected step
|
| 114 |
+
grader_result = self.grader.grade(
|
| 115 |
+
response = response,
|
| 116 |
+
expected_step = expected_step,
|
| 117 |
+
task = self.task,
|
| 118 |
+
)
|
| 119 |
+
correct = grader_result["correct"]
|
| 120 |
+
base_score = BASE_SCORE_CORRECT if correct else BASE_SCORE_INCORRECT
|
| 121 |
+
detected_action = grader_result.get("detected_action", "unknown")
|
| 122 |
+
|
| 123 |
+
# 2. Step bonus
|
| 124 |
+
step_bonus = STEP_BONUS if correct else 0.0
|
| 125 |
+
|
| 126 |
+
# 3. Wrong-step penalty
|
| 127 |
+
if not correct:
|
| 128 |
+
total_penalty += WRONG_STEP_PENALTY
|
| 129 |
+
penalties.append(
|
| 130 |
+
f"Wrong action detected ('{detected_action}' "
|
| 131 |
+
f"β '{expected_step.value}'): -{WRONG_STEP_PENALTY}"
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
# 4. Repeat detection
|
| 135 |
+
if self._is_repeated_response(response):
|
| 136 |
+
total_penalty += REPEAT_PENALTY
|
| 137 |
+
penalties.append(f"Repeated/duplicate response: -{REPEAT_PENALTY}")
|
| 138 |
+
|
| 139 |
+
# 5. Early solution penalty
|
| 140 |
+
if self._is_early_solution(response, expected_step):
|
| 141 |
+
total_penalty += EARLY_SOLUTION_PENALTY
|
| 142 |
+
penalties.append(f"Solution given too early: -{EARLY_SOLUTION_PENALTY}")
|
| 143 |
+
|
| 144 |
+
# 6. Emotion mismatch penalty
|
| 145 |
+
if self._is_emotion_mismatch(response):
|
| 146 |
+
total_penalty += EMOTION_IGNORE_PENALTY
|
| 147 |
+
penalties.append(
|
| 148 |
+
f"Angry customer ignored (no empathy/de-escalation): "
|
| 149 |
+
f"-{EMOTION_IGNORE_PENALTY}"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
# 7. Generic / too-short response
|
| 153 |
+
if self._is_generic_response(response):
|
| 154 |
+
total_penalty += GENERIC_RESPONSE_PENALTY
|
| 155 |
+
penalties.append(f"Generic/too-short response: -{GENERIC_RESPONSE_PENALTY}")
|
| 156 |
+
|
| 157 |
+
# 8. Wrong assumption detection
|
| 158 |
+
wrong_assumption = grader_result.get("wrong_assumption", False)
|
| 159 |
+
if wrong_assumption:
|
| 160 |
+
total_penalty += WRONG_ASSUMPTION_PENALTY
|
| 161 |
+
penalties.append(f"Incorrect assumption stated: -{WRONG_ASSUMPTION_PENALTY}")
|
| 162 |
+
|
| 163 |
+
# 9. Skip-step penalty (grader signals this)
|
| 164 |
+
if grader_result.get("skipped_step", False):
|
| 165 |
+
total_penalty += SKIP_STEP_PENALTY
|
| 166 |
+
penalties.append(f"Step skipped: -{SKIP_STEP_PENALTY}")
|
| 167 |
+
|
| 168 |
+
# ββ Reward formula βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 169 |
+
reward = max(0.0, base_score + step_bonus - total_penalty)
|
| 170 |
+
|
| 171 |
+
# ββ Fail trigger (individual step) ββββββββββββββββββββββββββββββββββββ
|
| 172 |
+
fail_triggered = False
|
| 173 |
+
fail_reason = ""
|
| 174 |
+
if total_penalty >= 0.8:
|
| 175 |
+
fail_triggered = True
|
| 176 |
+
fail_reason = f"Single step penalty exceeded threshold ({total_penalty:.2f})"
|
| 177 |
+
|
| 178 |
+
return StepResult(
|
| 179 |
+
step = expected_step,
|
| 180 |
+
agent_response = response,
|
| 181 |
+
detected_action = detected_action,
|
| 182 |
+
expected_action = expected_step.value,
|
| 183 |
+
correct = correct,
|
| 184 |
+
base_score = base_score,
|
| 185 |
+
step_bonus = step_bonus,
|
| 186 |
+
penalty = total_penalty,
|
| 187 |
+
penalty_reasons = penalties,
|
| 188 |
+
reward = reward,
|
| 189 |
+
fail_triggered = fail_triggered,
|
| 190 |
+
fail_reason = fail_reason,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
# ββ Helper detectors βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 194 |
+
|
| 195 |
+
def _is_repeated_response(self, response: str) -> bool:
|
| 196 |
+
if not self._response_history:
|
| 197 |
+
return False
|
| 198 |
+
words_new = set(response.lower().split())
|
| 199 |
+
for prev in self._response_history:
|
| 200 |
+
words_prev = set(prev.split())
|
| 201 |
+
overlap = len(words_new & words_prev)
|
| 202 |
+
if overlap >= REPEAT_WORD_OVERLAP_MIN:
|
| 203 |
+
return True
|
| 204 |
+
return False
|
| 205 |
+
|
| 206 |
+
def _is_early_solution(self, response: str, step: StepName) -> bool:
|
| 207 |
+
"""Penalise giving resolution keywords before the resolution step."""
|
| 208 |
+
if step in (StepName.EMPATHY, StepName.COLLECT_INFO):
|
| 209 |
+
resolution_signals = [
|
| 210 |
+
"refund", "replacement", "we will fix", "we will credit",
|
| 211 |
+
"here is the solution", "the fix is", "escalate your",
|
| 212 |
+
]
|
| 213 |
+
r = response.lower()
|
| 214 |
+
return any(sig in r for sig in resolution_signals)
|
| 215 |
+
return False
|
| 216 |
+
|
| 217 |
+
def _is_emotion_mismatch(self, response: str) -> bool:
|
| 218 |
+
"""Flag cold/neutral replies when customer is angry/frustrated."""
|
| 219 |
+
if self.task.customer_emotion not in ("angry", "frustrated"):
|
| 220 |
+
return False
|
| 221 |
+
empathy_signals = [
|
| 222 |
+
"sorry", "apologize", "apology", "understand your frustration",
|
| 223 |
+
"i hear you", "i completely understand", "that must be",
|
| 224 |
+
"deeply sorry", "sincerely apologize",
|
| 225 |
+
]
|
| 226 |
+
r = response.lower()
|
| 227 |
+
return not any(sig in r for sig in empathy_signals)
|
| 228 |
+
|
| 229 |
+
def _is_generic_response(self, response: str) -> bool:
|
| 230 |
+
stripped = response.strip().lower()
|
| 231 |
+
# Very short replies
|
| 232 |
+
if len(stripped.split()) <= 4:
|
| 233 |
+
return True
|
| 234 |
+
# Generic filler phrases
|
| 235 |
+
generic_phrases = [
|
| 236 |
+
"ok", "okay", "done", "sure", "got it",
|
| 237 |
+
"no problem", "understood", "alright",
|
| 238 |
+
]
|
| 239 |
+
return stripped in generic_phrases
|
graders/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from graders.base_grader import BaseGrader, HardTaskGrader
|
| 2 |
+
|
| 3 |
+
__all__ = ["BaseGrader", "HardTaskGrader"]
|
graders/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (291 Bytes). View file
|
|
|
graders/__pycache__/base_grader.cpython-312.pyc
ADDED
|
Binary file (9.88 kB). View file
|
|
|
graders/__pycache__/easy_grader.cpython-312.pyc
ADDED
|
Binary file (3.36 kB). View file
|
|
|
graders/__pycache__/hard_grader.cpython-312.pyc
ADDED
|
Binary file (6.23 kB). View file
|
|
|
graders/__pycache__/medium_grader.cpython-312.pyc
ADDED
|
Binary file (4.89 kB). View file
|
|
|
graders/base_grader.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
graders/base_grader.py β Deterministic, rule-based grader
|
| 3 |
+
Handles: step detection, edge cases, emotion mismatch, wrong assumptions
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
import re
|
| 8 |
+
from typing import Dict, Any, List
|
| 9 |
+
|
| 10 |
+
from models import StepName, Task
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# ββ Keyword banks ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 14 |
+
|
| 15 |
+
EMPATHY_KEYWORDS = [
|
| 16 |
+
"sorry", "apologize", "apology", "understand", "frustrat",
|
| 17 |
+
"hear you", "feel", "concern", "inconvenien", "deeply sorry",
|
| 18 |
+
"sincerely", "personally sorry", "personally and deeply sorry",
|
| 19 |
+
"i take full", "full responsibility",
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
COLLECT_INFO_KEYWORDS = [
|
| 23 |
+
"order number", "order id", "account number", "could you provide",
|
| 24 |
+
"can you share", "what is your", "please confirm", "transaction id",
|
| 25 |
+
"booking reference", "invoice", "date of purchase", "when did",
|
| 26 |
+
"which product", "what happened", "could you tell me", "may i have",
|
| 27 |
+
"can i have your", "full name", "email address", "phone number",
|
| 28 |
+
# Additional strong collect-info signals
|
| 29 |
+
"could you please provide", "please provide", "provide me with",
|
| 30 |
+
"associated with your account", "your account", "look into this",
|
| 31 |
+
"look into further", "assist you further", "help me locate",
|
| 32 |
+
"need a few details", "need some information", "need your",
|
| 33 |
+
"retrieve your", "verify your", "pull up your", "access your",
|
| 34 |
+
"your email", "your order", "your reference", "your details",
|
| 35 |
+
"please share your", "share your", "to look into", "to assist you",
|
| 36 |
+
"to help you", "can you provide", "can you confirm", "can you give",
|
| 37 |
+
"i would need", "i will need", "so i can", "in order to",
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
INVESTIGATE_KEYWORDS = [
|
| 41 |
+
"checking", "looking into", "pulling up", "reviewing",
|
| 42 |
+
"investigating", "let me check", "i can see", "our records show",
|
| 43 |
+
"according to our system", "i have found", "i found", "found that",
|
| 44 |
+
"it appears", "it seems", "the issue", "root cause",
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
RESOLUTION_KEYWORDS = [
|
| 48 |
+
"refund", "replacement", "credit", "we will", "i will",
|
| 49 |
+
"here is what", "the solution", "fix this", "resolved",
|
| 50 |
+
"compensation", "escalate", "expedite", "waive", "reimburse",
|
| 51 |
+
"send a new", "process a", "arrange for", "free of charge",
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
# De-escalation signals required for hard tasks
|
| 55 |
+
DEESCALATION_KEYWORDS = [
|
| 56 |
+
"i completely understand", "that is unacceptable", "i take full responsibility",
|
| 57 |
+
"this should not have happened", "you have every right",
|
| 58 |
+
"personally ensure", "highest priority", "immediate attention",
|
| 59 |
+
"personally and deeply sorry", "sincerely apologize",
|
| 60 |
+
"i hear your frustration", "deeply sorry for the experience",
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
# Wrong-assumption triggers (generic bad guesses)
|
| 64 |
+
WRONG_ASSUMPTION_PHRASES = [
|
| 65 |
+
"you probably forgot", "you must have", "it is your fault",
|
| 66 |
+
"you should have", "clearly you", "obviously you",
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
# Generic / non-answer phrases
|
| 70 |
+
GENERIC_FILLERS = [
|
| 71 |
+
"ok", "okay", "done", "sure", "got it", "understood",
|
| 72 |
+
"no problem", "alright", "i see", "noted",
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class BaseGrader:
|
| 77 |
+
"""
|
| 78 |
+
Deterministic grader. Returns a dict with:
|
| 79 |
+
correct, detected_action, wrong_assumption, skipped_step, notes
|
| 80 |
+
"""
|
| 81 |
+
|
| 82 |
+
def grade(
|
| 83 |
+
self,
|
| 84 |
+
response: str,
|
| 85 |
+
expected_step: StepName,
|
| 86 |
+
task: Task,
|
| 87 |
+
) -> Dict[str, Any]:
|
| 88 |
+
|
| 89 |
+
r = response.lower().strip()
|
| 90 |
+
result: Dict[str, Any] = {
|
| 91 |
+
"correct": False,
|
| 92 |
+
"detected_action": "unknown",
|
| 93 |
+
"wrong_assumption": False,
|
| 94 |
+
"skipped_step": False,
|
| 95 |
+
"notes": [],
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
# ββ Edge-case guard ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 99 |
+
if self._is_irrelevant(r):
|
| 100 |
+
result["detected_action"] = "irrelevant"
|
| 101 |
+
result["notes"].append("Response is irrelevant / generic filler.")
|
| 102 |
+
return result
|
| 103 |
+
|
| 104 |
+
# ββ Detect what the agent actually did βββββββββββββββββββββββββββββββββ
|
| 105 |
+
detected = self._detect_action(r, task)
|
| 106 |
+
result["detected_action"] = detected
|
| 107 |
+
|
| 108 |
+
# ββ Correctness check ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 109 |
+
result["correct"] = (detected == expected_step.value)
|
| 110 |
+
|
| 111 |
+
# ββ Skip-step check ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 112 |
+
# e.g. agent jumps straight to RESOLUTION while expected EMPATHY
|
| 113 |
+
step_rank = {
|
| 114 |
+
StepName.EMPATHY: 0,
|
| 115 |
+
StepName.COLLECT_INFO: 1,
|
| 116 |
+
StepName.INVESTIGATE: 2,
|
| 117 |
+
StepName.RESOLUTION: 3,
|
| 118 |
+
}
|
| 119 |
+
detected_rank = self._action_to_rank(detected)
|
| 120 |
+
expected_rank = step_rank[expected_step]
|
| 121 |
+
if detected_rank > expected_rank + 0:
|
| 122 |
+
result["skipped_step"] = True
|
| 123 |
+
result["notes"].append(
|
| 124 |
+
f"Skipped step: expected '{expected_step.value}', "
|
| 125 |
+
f"agent jumped to '{detected}'."
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# ββ Wrong-assumption check βββββββββββββββββββββββββββββββββββββββββββββ
|
| 129 |
+
if any(phrase in r for phrase in WRONG_ASSUMPTION_PHRASES):
|
| 130 |
+
result["wrong_assumption"] = True
|
| 131 |
+
result["notes"].append("Agent made a wrong assumption about the customer.")
|
| 132 |
+
|
| 133 |
+
# ββ Hard-task: de-escalation required βββββββββββββββββββββββββββββββββ
|
| 134 |
+
if task.escalation_risk and expected_step == StepName.EMPATHY:
|
| 135 |
+
if not any(kw in r for kw in DEESCALATION_KEYWORDS):
|
| 136 |
+
result["correct"] = False
|
| 137 |
+
result["notes"].append(
|
| 138 |
+
"Hard task requires de-escalation language; none detected."
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
return result
|
| 142 |
+
|
| 143 |
+
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 144 |
+
|
| 145 |
+
def _detect_action(self, r: str, task: Task) -> str:
|
| 146 |
+
"""Return the name of the step the response most resembles."""
|
| 147 |
+
scores = {
|
| 148 |
+
"empathy": self._score_keywords(r, EMPATHY_KEYWORDS),
|
| 149 |
+
"collect_info": self._score_keywords(r, COLLECT_INFO_KEYWORDS),
|
| 150 |
+
"investigate": self._score_keywords(r, INVESTIGATE_KEYWORDS),
|
| 151 |
+
"resolution": self._score_keywords(r, RESOLUTION_KEYWORDS),
|
| 152 |
+
}
|
| 153 |
+
best = max(scores, key=scores.get)
|
| 154 |
+
if scores[best] == 0:
|
| 155 |
+
return "unknown"
|
| 156 |
+
return best
|
| 157 |
+
|
| 158 |
+
def _score_keywords(self, text: str, keywords: List[str]) -> int:
|
| 159 |
+
return sum(1 for kw in keywords if kw in text)
|
| 160 |
+
|
| 161 |
+
def _is_irrelevant(self, r: str) -> bool:
|
| 162 |
+
# Very short
|
| 163 |
+
if len(r.split()) <= 3:
|
| 164 |
+
return True
|
| 165 |
+
# Pure filler
|
| 166 |
+
if r in GENERIC_FILLERS:
|
| 167 |
+
return True
|
| 168 |
+
# No customer-support vocabulary at all
|
| 169 |
+
all_keywords = (
|
| 170 |
+
EMPATHY_KEYWORDS + COLLECT_INFO_KEYWORDS
|
| 171 |
+
+ INVESTIGATE_KEYWORDS + RESOLUTION_KEYWORDS
|
| 172 |
+
)
|
| 173 |
+
return not any(kw in r for kw in all_keywords)
|
| 174 |
+
|
| 175 |
+
def _action_to_rank(self, action: str) -> int:
|
| 176 |
+
mapping = {
|
| 177 |
+
"empathy": 0,
|
| 178 |
+
"collect_info": 1,
|
| 179 |
+
"investigate": 2,
|
| 180 |
+
"resolution": 3,
|
| 181 |
+
"unknown": -1,
|
| 182 |
+
"irrelevant": -1,
|
| 183 |
+
}
|
| 184 |
+
return mapping.get(action, -1)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
# ββ Hard-task specific grader ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 188 |
+
|
| 189 |
+
class HardTaskGrader(BaseGrader):
|
| 190 |
+
"""
|
| 191 |
+
Extended grader for the hard escalation scenario.
|
| 192 |
+
Adds: emotional control score, conflict handling, professional tone.
|
| 193 |
+
"""
|
| 194 |
+
|
| 195 |
+
PROFESSIONAL_PHRASES = [
|
| 196 |
+
"i assure you", "rest assured", "our team will",
|
| 197 |
+
"i will personally", "i will escalate", "within 24 hours",
|
| 198 |
+
"we take this very seriously", "top priority",
|
| 199 |
+
]
|
| 200 |
+
|
| 201 |
+
UNPROFESSIONAL_PHRASES = [
|
| 202 |
+
"calm down", "you need to", "stop complaining",
|
| 203 |
+
"it is not our fault", "nothing we can do", "policy says",
|
| 204 |
+
]
|
| 205 |
+
|
| 206 |
+
def grade(
|
| 207 |
+
self,
|
| 208 |
+
response: str,
|
| 209 |
+
expected_step: StepName,
|
| 210 |
+
task: Task,
|
| 211 |
+
) -> Dict[str, Any]:
|
| 212 |
+
result = super().grade(response, expected_step, task)
|
| 213 |
+
r = response.lower()
|
| 214 |
+
|
| 215 |
+
notes = result["notes"]
|
| 216 |
+
|
| 217 |
+
# Emotional-control bonus / penalty
|
| 218 |
+
if any(kw in r for kw in self.PROFESSIONAL_PHRASES):
|
| 219 |
+
notes.append("β
Professional / reassuring language detected.")
|
| 220 |
+
if any(kw in r for kw in self.UNPROFESSIONAL_PHRASES):
|
| 221 |
+
result["correct"] = False
|
| 222 |
+
result["wrong_assumption"] = True
|
| 223 |
+
notes.append("β Unprofessional language detected (e.g. 'calm down').")
|
| 224 |
+
|
| 225 |
+
# Resolution accuracy: must offer concrete action on resolution step
|
| 226 |
+
if expected_step == StepName.RESOLUTION:
|
| 227 |
+
concrete = [
|
| 228 |
+
"refund", "replacement", "credit", "waive", "free",
|
| 229 |
+
"expedite", "send", "process", "arrange",
|
| 230 |
+
]
|
| 231 |
+
if not any(c in r for c in concrete):
|
| 232 |
+
result["correct"] = False
|
| 233 |
+
notes.append(
|
| 234 |
+
"β Resolution step requires a concrete action (refund/replacement/credit)."
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
result["notes"] = notes
|
| 238 |
+
return result
|
graders/easy_grader.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Easy Grader β Scores FAQ responses based on keyword coverage and tone.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import Tuple
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class EasyGrader:
|
| 9 |
+
"""
|
| 10 |
+
Scoring logic for Easy (FAQ) Task.
|
| 11 |
+
|
| 12 |
+
Formula:
|
| 13 |
+
score = (keyword_matches / total_keywords) * tone_multiplier
|
| 14 |
+
|
| 15 |
+
Breakdown:
|
| 16 |
+
keyword_matches: How many expected keywords appear in the agent's reply.
|
| 17 |
+
total_keywords: Total number of expected keywords in the scenario.
|
| 18 |
+
tone_multiplier: 0.8 if tone is bad (rude/dismissive), 1.0 if neutral, 1.1 if polite.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
# Polite/professional phrases boost score slightly
|
| 22 |
+
POLITE_PHRASES = [
|
| 23 |
+
"happy to help", "great question", "certainly", "of course",
|
| 24 |
+
"please", "thank you", "let me", "i'd be happy", "absolutely",
|
| 25 |
+
"sure", "glad", "assist", "help you",
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
# Dismissive/rude phrases reduce score
|
| 29 |
+
BAD_PHRASES = [
|
| 30 |
+
"i don't know", "not sure", "no idea", "can't help",
|
| 31 |
+
"figure it out", "read the manual", "obvious",
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
def grade(self, action: str, scenario: dict, history: list) -> Tuple[float, dict]:
|
| 35 |
+
"""
|
| 36 |
+
Grade the agent's FAQ response.
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
(score: float [0.0β1.0], grader_info: dict)
|
| 40 |
+
"""
|
| 41 |
+
action_lower = action.lower()
|
| 42 |
+
keywords = scenario.get("correct_answer_keywords", [])
|
| 43 |
+
|
| 44 |
+
# 1. Keyword Coverage
|
| 45 |
+
matched = [kw for kw in keywords if kw.lower() in action_lower]
|
| 46 |
+
keyword_score = len(matched) / len(keywords) if keywords else 0.5
|
| 47 |
+
|
| 48 |
+
# 2. Tone Multiplier
|
| 49 |
+
has_polite = any(p in action_lower for p in self.POLITE_PHRASES)
|
| 50 |
+
has_bad = any(p in action_lower for p in self.BAD_PHRASES)
|
| 51 |
+
|
| 52 |
+
if has_bad:
|
| 53 |
+
tone_multiplier = 0.7
|
| 54 |
+
tone_label = "unprofessional"
|
| 55 |
+
elif has_polite:
|
| 56 |
+
tone_multiplier = 1.1
|
| 57 |
+
tone_label = "polite"
|
| 58 |
+
else:
|
| 59 |
+
tone_multiplier = 1.0
|
| 60 |
+
tone_label = "neutral"
|
| 61 |
+
|
| 62 |
+
# 3. Length check β too short = incomplete
|
| 63 |
+
word_count = len(action.split())
|
| 64 |
+
if word_count < 5:
|
| 65 |
+
length_penalty = 0.4 # very short answers likely incomplete
|
| 66 |
+
elif word_count < 10:
|
| 67 |
+
length_penalty = 0.85
|
| 68 |
+
else:
|
| 69 |
+
length_penalty = 1.0
|
| 70 |
+
|
| 71 |
+
# Final score (capped at 1.0)
|
| 72 |
+
score = min(1.0, keyword_score * tone_multiplier * length_penalty)
|
| 73 |
+
|
| 74 |
+
grader_info = {
|
| 75 |
+
"matched_keywords": matched,
|
| 76 |
+
"missing_keywords": [kw for kw in keywords if kw.lower() not in action_lower],
|
| 77 |
+
"keyword_score": round(keyword_score, 3),
|
| 78 |
+
"tone": tone_label,
|
| 79 |
+
"tone_multiplier": tone_multiplier,
|
| 80 |
+
"word_count": word_count,
|
| 81 |
+
"final_score": round(score, 3),
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
return round(score, 3), grader_info
|
graders/hard_grader.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hard Grader - Scores emotional complaint handling.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import Tuple, Optional
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class HardGrader:
|
| 9 |
+
|
| 10 |
+
BEHAVIOR_KEYWORDS = {
|
| 11 |
+
"genuine_empathy": [
|
| 12 |
+
"i understand how", "i can only imagine", "that must be incredibly",
|
| 13 |
+
"i truly understand", "how frustrating", "how upsetting", "i hear you",
|
| 14 |
+
"i completely understand", "your feelings are valid", "that's terrible",
|
| 15 |
+
"i'm so sorry you're going through", "that sounds incredibly",
|
| 16 |
+
],
|
| 17 |
+
"personal_apology": [
|
| 18 |
+
"i personally apologize", "i am deeply sorry", "i sincerely apologize",
|
| 19 |
+
"i'm truly sorry", "you deserve better", "this is unacceptable",
|
| 20 |
+
"we have failed you", "i take full responsibility",
|
| 21 |
+
],
|
| 22 |
+
"take_ownership": [
|
| 23 |
+
"i will personally", "i'm taking ownership", "i'll make sure",
|
| 24 |
+
"i'll see this through", "let me personally", "i'll handle this myself",
|
| 25 |
+
"i'm going to fix", "i'll escalate this myself",
|
| 26 |
+
],
|
| 27 |
+
"offer_compensation": [
|
| 28 |
+
"credit", "discount", "refund", "compensat", "free month",
|
| 29 |
+
"extend your subscription", "make this right", "as a gesture",
|
| 30 |
+
"offer you", "we'd like to offer",
|
| 31 |
+
],
|
| 32 |
+
"provide_resolution": [
|
| 33 |
+
"fixed", "resolved", "escalated to", "engineering team",
|
| 34 |
+
"senior team", "within 24 hours", "immediately", "priority",
|
| 35 |
+
"restore", "recovery", "our team is working",
|
| 36 |
+
],
|
| 37 |
+
"express_concern_for_health": [
|
| 38 |
+
"please see a doctor", "seek medical attention", "your health",
|
| 39 |
+
"medical professional", "health comes first", "please get checked",
|
| 40 |
+
"are you okay", "how are you feeling", "go to a doctor",
|
| 41 |
+
],
|
| 42 |
+
"collect_product_info": [
|
| 43 |
+
"batch number", "product name", "order number", "lot number",
|
| 44 |
+
"when did you purchase", "which product", "can you provide",
|
| 45 |
+
],
|
| 46 |
+
"escalate_to_safety_team": [
|
| 47 |
+
"safety team", "quality team", "investigating", "immediate investigation",
|
| 48 |
+
"food safety", "health and safety", "escalate to our", "report this",
|
| 49 |
+
"regulatory", "quality control",
|
| 50 |
+
],
|
| 51 |
+
"investigate_data_loss": [
|
| 52 |
+
"engineering team", "data recovery", "backup logs", "investigate",
|
| 53 |
+
"technical team", "escalate immediately", "attempt to recover",
|
| 54 |
+
"our engineers", "priority investigation",
|
| 55 |
+
],
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
VIOLATION_KEYWORDS = {
|
| 59 |
+
"robotic_response": [
|
| 60 |
+
"we apologize for the inconvenience",
|
| 61 |
+
"your feedback is important to us",
|
| 62 |
+
"we strive to provide",
|
| 63 |
+
"as per our policy",
|
| 64 |
+
"please note that",
|
| 65 |
+
"kindly be advised",
|
| 66 |
+
],
|
| 67 |
+
"dismiss_emotion": [
|
| 68 |
+
"calm down", "theres no need to", "youre overreacting",
|
| 69 |
+
"this is normal", "happens to everyone",
|
| 70 |
+
],
|
| 71 |
+
"blame_customer": [
|
| 72 |
+
"you should have", "if you had", "user error",
|
| 73 |
+
"thats your fault", "you didnt follow",
|
| 74 |
+
],
|
| 75 |
+
"deny_fault": [
|
| 76 |
+
"not our fault", "we cant be held responsible",
|
| 77 |
+
"no evidence", "impossible that our product",
|
| 78 |
+
],
|
| 79 |
+
"minimize_health_concern": [
|
| 80 |
+
"just a mild reaction", "probably not related",
|
| 81 |
+
"unlikely to be our product", "youre probably fine",
|
| 82 |
+
],
|
| 83 |
+
"offer_no_compensation": [],
|
| 84 |
+
"downplay_data_loss": [
|
| 85 |
+
"just data", "its only files", "you should have backed up",
|
| 86 |
+
"not our responsibility for backups",
|
| 87 |
+
],
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
GENERIC_INDICATORS = [
|
| 91 |
+
"dear valued customer",
|
| 92 |
+
"we take all complaints seriously",
|
| 93 |
+
"please contact us at",
|
| 94 |
+
"for further assistance",
|
| 95 |
+
"thank you for contacting",
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
def grade(
|
| 99 |
+
self,
|
| 100 |
+
action: str,
|
| 101 |
+
scenario: dict,
|
| 102 |
+
history: list,
|
| 103 |
+
behaviors_demonstrated: list,
|
| 104 |
+
avoid_violations: list,
|
| 105 |
+
) -> Tuple[float, dict, Optional[str], Optional[str]]:
|
| 106 |
+
|
| 107 |
+
action_lower = action.lower()
|
| 108 |
+
required_behaviors = scenario.get("required_behaviors", [])
|
| 109 |
+
avoid_behaviors = scenario.get("avoid_behaviors", [])
|
| 110 |
+
|
| 111 |
+
behavior_found = None
|
| 112 |
+
best_behavior_score = 0.0
|
| 113 |
+
|
| 114 |
+
for behavior in required_behaviors:
|
| 115 |
+
if behavior in behaviors_demonstrated:
|
| 116 |
+
continue
|
| 117 |
+
kw_list = self.BEHAVIOR_KEYWORDS.get(behavior, [])
|
| 118 |
+
matches = sum(1 for kw in kw_list if kw in action_lower)
|
| 119 |
+
if matches > 0:
|
| 120 |
+
ratio = matches / len(kw_list) if kw_list else 0
|
| 121 |
+
if ratio > best_behavior_score:
|
| 122 |
+
best_behavior_score = ratio
|
| 123 |
+
behavior_found = behavior
|
| 124 |
+
|
| 125 |
+
all_empathy_keywords = self.BEHAVIOR_KEYWORDS.get("genuine_empathy", [])
|
| 126 |
+
empathy_matches = sum(1 for kw in all_empathy_keywords if kw in action_lower)
|
| 127 |
+
empathy_score = min(1.3, 1.0 + (empathy_matches * 0.1))
|
| 128 |
+
|
| 129 |
+
is_generic = any(gi in action_lower for gi in self.GENERIC_INDICATORS)
|
| 130 |
+
personalization_mult = 0.7 if is_generic else 1.0
|
| 131 |
+
|
| 132 |
+
violation_found = None
|
| 133 |
+
for avoidance in avoid_behaviors:
|
| 134 |
+
if avoidance in avoid_violations:
|
| 135 |
+
continue
|
| 136 |
+
kw_list = self.VIOLATION_KEYWORDS.get(avoidance, [])
|
| 137 |
+
matches = sum(1 for kw in kw_list if kw in action_lower)
|
| 138 |
+
if matches > 0:
|
| 139 |
+
violation_found = avoidance
|
| 140 |
+
break
|
| 141 |
+
|
| 142 |
+
if behavior_found:
|
| 143 |
+
base = min(1.0, best_behavior_score * 1.5)
|
| 144 |
+
score = min(1.0, base * empathy_score * personalization_mult)
|
| 145 |
+
else:
|
| 146 |
+
score = 0.15 * empathy_score if empathy_matches > 0 else 0.05
|
| 147 |
+
|
| 148 |
+
grader_info = {
|
| 149 |
+
"behavior_found": behavior_found,
|
| 150 |
+
"behavior_match_score": round(best_behavior_score, 3),
|
| 151 |
+
"empathy_score": round(empathy_score, 3),
|
| 152 |
+
"personalization": "generic" if is_generic else "personalized",
|
| 153 |
+
"violation_found": violation_found,
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
return round(score, 3), grader_info, behavior_found, violation_found
|
graders/hard_grader.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hard Grader β Scores emotional complaint handling.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import Tuple, Optional
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class HardGrader:
|
| 9 |
+
|
| 10 |
+
BEHAVIOR_KEYWORDS = {
|
| 11 |
+
"genuine_empathy": [
|
| 12 |
+
"i understand how", "i can only imagine", "that must be incredibly",
|
| 13 |
+
"i truly understand", "how frustrating", "how upsetting", "i hear you",
|
| 14 |
+
"i completely understand", "your feelings are valid", "that's terrible",
|
| 15 |
+
"i'm so sorry you're going through", "that sounds incredibly",
|
| 16 |
+
],
|
| 17 |
+
"personal_apology": [
|
| 18 |
+
"i personally apologize", "i am deeply sorry", "i sincerely apologize",
|
| 19 |
+
"i'm truly sorry", "you deserve better", "this is unacceptable",
|
| 20 |
+
"we have failed you", "i take full responsibility",
|
| 21 |
+
],
|
| 22 |
+
"take_ownership": [
|
| 23 |
+
"i will personally", "i'm taking ownership", "i'll make sure",
|
| 24 |
+
"i'll see this through", "let me personally", "i'll handle this myself",
|
| 25 |
+
"i'm going to fix", "i'll escalate this myself",
|
| 26 |
+
],
|
| 27 |
+
"offer_compensation": [
|
| 28 |
+
"credit", "discount", "refund", "compensat", "free month",
|
| 29 |
+
"extend your subscription", "make this right", "as a gesture",
|
| 30 |
+
"offer you", "we'd like to offer",
|
| 31 |
+
],
|
| 32 |
+
"provide_resolution": [
|
| 33 |
+
"fixed", "resolved", "escalated to", "engineering team",
|
| 34 |
+
"senior team", "within 24 hours", "immediately", "priority",
|
| 35 |
+
"restore", "recovery", "our team is working",
|
| 36 |
+
],
|
| 37 |
+
"express_concern_for_health": [
|
| 38 |
+
"please see a doctor", "seek medical attention", "your health",
|
| 39 |
+
"medical professional", "health comes first", "please get checked",
|
| 40 |
+
"are you okay", "how are you feeling", "go to a doctor",
|
| 41 |
+
],
|
| 42 |
+
"collect_product_info": [
|
| 43 |
+
"batch number", "product name", "order number", "lot number",
|
| 44 |
+
"when did you purchase", "which product", "can you provide",
|
| 45 |
+
],
|
| 46 |
+
"escalate_to_safety_team": [
|
| 47 |
+
"safety team", "quality team", "investigating", "immediate investigation",
|
| 48 |
+
"food safety", "health and safety", "escalate to our", "report this",
|
| 49 |
+
"regulatory", "quality control",
|
| 50 |
+
],
|
| 51 |
+
"investigate_data_loss": [
|
| 52 |
+
"engineering team", "data recovery", "backup logs", "investigate",
|
| 53 |
+
"technical team", "escalate immediately", "attempt to recover",
|
| 54 |
+
"our engineers", "priority investigation",
|
| 55 |
+
],
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
VIOLATION_KEYWORDS = {
|
| 59 |
+
"robotic_response": [
|
| 60 |
+
"we apologize for the inconvenience",
|
| 61 |
+
"your feedback is important to us",
|
| 62 |
+
"we strive to provide",
|
| 63 |
+
"as per our policy",
|
| 64 |
+
"please note that",
|
| 65 |
+
"kindly be advised",
|
| 66 |
+
],
|
| 67 |
+
"dismiss_emotion": [
|
| 68 |
+
"calm down", "there's no need to", "you're overreacting",
|
| 69 |
+
"this is normal", "happens to everyone",
|
| 70 |
+
],
|
| 71 |
+
"blame_customer": [
|
| 72 |
+
"you should have", "if you had", "user error",
|
| 73 |
+
"that's your fault", "you didn't follow",
|
| 74 |
+
],
|
| 75 |
+
"deny_fault": [
|
| 76 |
+
"not our fault", "we can't be held responsible",
|
| 77 |
+
"no evidence", "impossible that our product",
|
| 78 |
+
],
|
| 79 |
+
"minimize_health_concern": [
|
| 80 |
+
"just a mild reaction", "probably not related",
|
| 81 |
+
"unlikely to be our product", "you're probably fine",
|
| 82 |
+
],
|
| 83 |
+
"offer_no_compensation": [],
|
| 84 |
+
"downplay_data_loss": [
|
| 85 |
+
"just data", "it's only files", "you should have backed up",
|
| 86 |
+
"not our responsibility for backups",
|
| 87 |
+
],
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
GENERIC_INDICATORS = [
|
| 91 |
+
"dear valued customer",
|
| 92 |
+
"we take all complaints seriously",
|
| 93 |
+
"please contact us at",
|
| 94 |
+
"for further assistance",
|
| 95 |
+
"thank you for contacting",
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
def grade(
|
| 99 |
+
self,
|
| 100 |
+
action: str,
|
| 101 |
+
scenario: dict,
|
| 102 |
+
history: list,
|
| 103 |
+
behaviors_demonstrated: list,
|
| 104 |
+
avoid_violations: list,
|
| 105 |
+
) -> Tuple[float, dict, Optional[str], Optional[str]]:
|
| 106 |
+
|
| 107 |
+
action_lower = action.lower()
|
| 108 |
+
required_behaviors = scenario.get("required_behaviors", [])
|
| 109 |
+
avoid_behaviors = scenario.get("avoid_behaviors", [])
|
| 110 |
+
|
| 111 |
+
# 1. Behavior Detection
|
| 112 |
+
behavior_found = None
|
| 113 |
+
best_behavior_score = 0.0
|
| 114 |
+
|
| 115 |
+
for behavior in required_behaviors:
|
| 116 |
+
if behavior in behaviors_demonstrated:
|
| 117 |
+
continue
|
| 118 |
+
kw_list = self.BEHAVIOR_KEYWORDS.get(behavior, [])
|
| 119 |
+
matches = sum(1 for kw in kw_list if kw in action_lower)
|
| 120 |
+
if matches > 0:
|
| 121 |
+
ratio = matches / len(kw_list) if kw_list else 0
|
| 122 |
+
if ratio > best_behavior_score:
|
| 123 |
+
best_behavior_score = ratio
|
| 124 |
+
behavior_found = behavior
|
| 125 |
+
|
| 126 |
+
# 2. Empathy Multiplier
|
| 127 |
+
all_empathy_keywords = self.BEHAVIOR_KEYWORDS.get("genuine_empathy", [])
|
| 128 |
+
empathy_matches = sum(1 for kw in all_empathy_keywords if kw in action_lower)
|
| 129 |
+
empathy_score = min(1.3, 1.0 + (empathy_matches * 0.1))
|
| 130 |
+
|
| 131 |
+
# 3. Personalization
|
| 132 |
+
is_generic = any(gi in action_lower for gi in self.GENERIC_INDICATORS)
|
| 133 |
+
personalization_mult = 0.7 if is_generic else 1.0
|
| 134 |
+
|
| 135 |
+
# 4. Violation Detection
|
| 136 |
+
violation_found = None
|
| 137 |
+
for avoidance in avoid_behaviors:
|
| 138 |
+
if avoidance in avoid_violations:
|
| 139 |
+
continue
|
| 140 |
+
kw_list = self.VIOLATION_KEYWORDS.get(avoidance, [])
|
| 141 |
+
matches = sum(1 for kw in kw_list if kw in action_lower)
|
| 142 |
+
if matches > 0:
|
| 143 |
+
violation_found = avoidance
|
| 144 |
+
break
|
| 145 |
+
|
| 146 |
+
# 5. Final Score
|
| 147 |
+
if behavior_found:
|
| 148 |
+
base = min(1.0, best_behavior_score * 1.5)
|
| 149 |
+
score = min(1.0, base * empathy_score * personalization_mult)
|
| 150 |
+
else:
|
| 151 |
+
score = 0.15 * empathy_score if empathy_matches > 0 else 0.05
|
| 152 |
+
|
| 153 |
+
grader_info = {
|
| 154 |
+
"behavior_found": behavior_found,
|
| 155 |
+
"behavior_match_score": round(best_behavior_score, 3),
|
| 156 |
+
"empathy_score": round(empathy_score, 3),
|
| 157 |
+
"personalization": "generic" if is_generic else "personalized",
|
| 158 |
+
"violation_found": violation_found,
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
return round(score, 3), grader_info, behavior_found, violation_found
|
graders/medium_grader.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Medium Grader β Scores multi-step issue resolution responses.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import Tuple, Optional
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class MediumGrader:
|
| 9 |
+
"""
|
| 10 |
+
Scoring logic for Medium (Multi-Step) Task.
|
| 11 |
+
|
| 12 |
+
Formula:
|
| 13 |
+
score = step_detection_score * completeness_multiplier * tone_multiplier
|
| 14 |
+
|
| 15 |
+
Step Detection:
|
| 16 |
+
Each required step has associated keywords.
|
| 17 |
+
If the agent's reply matches those keywords β step is identified.
|
| 18 |
+
|
| 19 |
+
Completeness:
|
| 20 |
+
Partial answers (missing some required info for the step) get partial credit.
|
| 21 |
+
|
| 22 |
+
Tone:
|
| 23 |
+
Professional, empathetic tone adds a small bonus.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
# Keywords that signal each step type
|
| 27 |
+
STEP_KEYWORDS = {
|
| 28 |
+
# Medium scenario step types β what words trigger them
|
| 29 |
+
"acknowledge_frustration": ["understand", "frustrat", "sorry to hear", "apologize", "that must", "i can see"],
|
| 30 |
+
"acknowledge_issue": ["understand", "sorry", "i can see", "must be", "apologize", "frustrat"],
|
| 31 |
+
"collect_account_info": ["email", "account", "order number", "name", "id", "could you provide", "can i get", "please share"],
|
| 32 |
+
"collect_order_info": ["order number", "order id", "reference", "can i get", "could you share"],
|
| 33 |
+
"investigate": ["look into", "check", "investigate", "pull up", "look at", "review your account"],
|
| 34 |
+
"check_basic_steps": ["browser", "cache", "clear", "try", "incognito", "different browser", "cookies"],
|
| 35 |
+
"offer_password_reset": ["reset", "password", "link", "send you", "email you", "forgot password"],
|
| 36 |
+
"confirm_resolution": ["resolved", "able to log", "working now", "fixed", "sorted", "everything ok", "is that working"],
|
| 37 |
+
"resolve_or_escalate": ["refund", "credit", "resolve", "fix", "escalate", "team will", "processed"],
|
| 38 |
+
"apologize_sincerely": ["sorry", "apologize", "sincerely apologize", "deeply sorry", "truly sorry"],
|
| 39 |
+
"confirm_item_details": ["ordered", "confirm", "blue jacket", "correct item", "you ordered", "size"],
|
| 40 |
+
"arrange_replacement": ["replacement", "send the correct", "return", "new", "reship", "express", "free return"],
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
POLITE_PHRASES = [
|
| 44 |
+
"happy to help", "certainly", "of course", "absolutely",
|
| 45 |
+
"please", "thank you", "glad", "i understand", "i appreciate",
|
| 46 |
+
"let me help", "my pleasure",
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
BAD_PHRASES = [
|
| 50 |
+
"not my problem", "policy says", "can't do anything", "nothing i can do",
|
| 51 |
+
"you should have", "your fault", "read the faq",
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
def grade(
|
| 55 |
+
self,
|
| 56 |
+
action: str,
|
| 57 |
+
scenario: dict,
|
| 58 |
+
history: list,
|
| 59 |
+
steps_completed: list,
|
| 60 |
+
) -> Tuple[float, dict, Optional[str]]:
|
| 61 |
+
"""
|
| 62 |
+
Grade the agent's response for a medium-task step.
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
(score: float, grader_info: dict, step_identified: str or None)
|
| 66 |
+
"""
|
| 67 |
+
action_lower = action.lower()
|
| 68 |
+
required_steps = scenario.get("required_steps", [])
|
| 69 |
+
|
| 70 |
+
# 1. Detect which step the agent is performing
|
| 71 |
+
step_identified = None
|
| 72 |
+
step_match_score = 0.0
|
| 73 |
+
|
| 74 |
+
for step in required_steps:
|
| 75 |
+
if step in steps_completed:
|
| 76 |
+
continue # Already done, skip
|
| 77 |
+
step_keywords = self.STEP_KEYWORDS.get(step, [])
|
| 78 |
+
if not step_keywords:
|
| 79 |
+
continue
|
| 80 |
+
matches = sum(1 for kw in step_keywords if kw in action_lower)
|
| 81 |
+
match_ratio = matches / len(step_keywords)
|
| 82 |
+
if match_ratio > step_match_score and matches >= 1:
|
| 83 |
+
step_match_score = match_ratio
|
| 84 |
+
step_identified = step
|
| 85 |
+
|
| 86 |
+
# 2. Completeness score (how thoroughly the step is addressed)
|
| 87 |
+
completeness = min(1.0, step_match_score * 1.5) if step_identified else 0.3
|
| 88 |
+
|
| 89 |
+
# 3. Tone multiplier
|
| 90 |
+
has_polite = any(p in action_lower for p in self.POLITE_PHRASES)
|
| 91 |
+
has_bad = any(p in action_lower for p in self.BAD_PHRASES)
|
| 92 |
+
|
| 93 |
+
if has_bad:
|
| 94 |
+
tone_mult = 0.6
|
| 95 |
+
tone = "bad"
|
| 96 |
+
elif has_polite:
|
| 97 |
+
tone_mult = 1.1
|
| 98 |
+
tone = "polite"
|
| 99 |
+
else:
|
| 100 |
+
tone_mult = 1.0
|
| 101 |
+
tone = "neutral"
|
| 102 |
+
|
| 103 |
+
# Final score
|
| 104 |
+
if step_identified:
|
| 105 |
+
score = min(1.0, completeness * tone_mult)
|
| 106 |
+
else:
|
| 107 |
+
# No useful step found β partial score for being polite
|
| 108 |
+
score = 0.2 * tone_mult if has_polite else 0.1
|
| 109 |
+
|
| 110 |
+
grader_info = {
|
| 111 |
+
"step_identified": step_identified,
|
| 112 |
+
"step_match_score": round(step_match_score, 3),
|
| 113 |
+
"completeness": round(completeness, 3),
|
| 114 |
+
"tone": tone,
|
| 115 |
+
"already_done": steps_completed,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
return round(score, 3), grader_info, step_identified
|
inference.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
inference.py β Rule-based agent + CLI test runner
|
| 3 |
+
Usage:
|
| 4 |
+
python inference.py --task easy --agent rule
|
| 5 |
+
python inference.py --task medium --agent rule
|
| 6 |
+
python inference.py --task hard --agent rule
|
| 7 |
+
python inference.py --task all --agent rule
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import json
|
| 12 |
+
from typing import List
|
| 13 |
+
|
| 14 |
+
from models import StepName, DifficultyLevel
|
| 15 |
+
from environment import CustomerSupportEnv
|
| 16 |
+
from graders.base_grader import BaseGrader, HardTaskGrader
|
| 17 |
+
from tasks import TASK_REGISTRY
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ββ Rule-based agent responses βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 21 |
+
|
| 22 |
+
RULE_BASED_RESPONSES = {
|
| 23 |
+
"easy": [
|
| 24 |
+
# Step 1: Empathy
|
| 25 |
+
(
|
| 26 |
+
"I am truly sorry to hear that your laptop has not arrived yet. "
|
| 27 |
+
"I completely understand how frustrating and urgent this must be for you, "
|
| 28 |
+
"especially when you need it for work. Please allow me to help resolve this immediately."
|
| 29 |
+
),
|
| 30 |
+
# Step 2: Collect Info
|
| 31 |
+
(
|
| 32 |
+
"To assist you as quickly as possible, could you please provide me with "
|
| 33 |
+
"your order number or order ID? Additionally, may I have your full name "
|
| 34 |
+
"and the email address associated with the order?"
|
| 35 |
+
),
|
| 36 |
+
# Step 3: Investigate
|
| 37 |
+
(
|
| 38 |
+
"Thank you for that information. I am checking our system right now. "
|
| 39 |
+
"I found that order #ORD-8821 is currently tracked and it appears the package "
|
| 40 |
+
"is stuck at a sorting facility. Our records show the delay is due to a "
|
| 41 |
+
"logistical hold-up that we are actively investigating."
|
| 42 |
+
),
|
| 43 |
+
# Step 4: Resolution
|
| 44 |
+
(
|
| 45 |
+
"I sincerely apologize for this unacceptable delay. I will personally "
|
| 46 |
+
"expedite your delivery and escalate this to our logistics partner immediately. "
|
| 47 |
+
"You will receive your laptop within 2 business days. As compensation, "
|
| 48 |
+
"I will also credit βΉ500 to your account for the inconvenience caused."
|
| 49 |
+
),
|
| 50 |
+
],
|
| 51 |
+
|
| 52 |
+
"medium": [
|
| 53 |
+
# Step 1: Empathy
|
| 54 |
+
(
|
| 55 |
+
"I sincerely apologize for the double charge on your account. "
|
| 56 |
+
"I completely understand how upsetting and inconvenient this is. "
|
| 57 |
+
"This should never have happened and I take full responsibility. "
|
| 58 |
+
"I am going to resolve this for you right away."
|
| 59 |
+
),
|
| 60 |
+
# Step 2: Collect Info
|
| 61 |
+
(
|
| 62 |
+
"To look into this immediately, may I please have your account number "
|
| 63 |
+
"or the email address linked to your subscription? "
|
| 64 |
+
"Could you also confirm the transaction dates and the card used for billing?"
|
| 65 |
+
),
|
| 66 |
+
# Step 3: Investigate
|
| 67 |
+
(
|
| 68 |
+
"Thank you. I am reviewing your billing history right now. "
|
| 69 |
+
"I can see in our billing system that there was indeed a duplicate charge "
|
| 70 |
+
"on both the 1st and the 15th. It appears this was caused by a payment "
|
| 71 |
+
"gateway retry issue on our end. I found the duplicate transaction clearly."
|
| 72 |
+
),
|
| 73 |
+
# Step 4: Resolution
|
| 74 |
+
(
|
| 75 |
+
"I sincerely apologize for this error. I will process a full refund of "
|
| 76 |
+
"$49.99 immediately. You should see this credited back to your card "
|
| 77 |
+
"within 3 to 5 business days. I am also escalating this to our billing "
|
| 78 |
+
"team to ensure this does not happen again. Thank you for your patience."
|
| 79 |
+
),
|
| 80 |
+
],
|
| 81 |
+
|
| 82 |
+
"hard": [
|
| 83 |
+
# Step 1: Empathy (de-escalation required)
|
| 84 |
+
(
|
| 85 |
+
"Mr. Mehta, I am personally and deeply sorry for what you have experienced. "
|
| 86 |
+
"This is completely unacceptable and you have every right to be angry. "
|
| 87 |
+
"I take full responsibility for the failure of our team to respond properly "
|
| 88 |
+
"over the past three weeks. This should not have happened, especially for a "
|
| 89 |
+
"valued VIP customer like yourself. I hear your frustration and I assure you "
|
| 90 |
+
"that I will personally ensure this is resolved today."
|
| 91 |
+
),
|
| 92 |
+
# Step 2: Collect Info
|
| 93 |
+
(
|
| 94 |
+
"To immediately escalate this as our top priority, could you please confirm "
|
| 95 |
+
"your order number and VIP account details? I also need your email and "
|
| 96 |
+
"contact number so I can personally follow up with you today."
|
| 97 |
+
),
|
| 98 |
+
# Step 3: Investigate
|
| 99 |
+
(
|
| 100 |
+
"Thank you, Mr. Mehta. I am reviewing your tickets right now. "
|
| 101 |
+
"I can see five tickets β all marked pending β with no technician dispatched. "
|
| 102 |
+
"I found that this is unacceptable internally. I have already escalated "
|
| 103 |
+
"this to our senior operations manager and our VIP support head. "
|
| 104 |
+
"Our records show the replacement unit is available and ready to ship."
|
| 105 |
+
),
|
| 106 |
+
# Step 4: Resolution
|
| 107 |
+
(
|
| 108 |
+
"Mr. Mehta, here is what I am doing right now: "
|
| 109 |
+
"1) A replacement Premium Standing Desk Pro will be dispatched today with "
|
| 110 |
+
"priority delivery β you will receive it within 24 hours. "
|
| 111 |
+
"2) As per our VIP policy, I am authorizing a compensation of βΉ9,000 "
|
| 112 |
+
"(20% of your order value) credited to your account immediately. "
|
| 113 |
+
"3) I will personally ensure a senior technician contacts you within 2 hours. "
|
| 114 |
+
"4) I am waiving your next month's subscription fee as an additional apology. "
|
| 115 |
+
"I personally guarantee this will be resolved to your satisfaction today."
|
| 116 |
+
),
|
| 117 |
+
],
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ββ Runner βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 122 |
+
|
| 123 |
+
def run_task(task_name: str) -> dict:
|
| 124 |
+
task = TASK_REGISTRY[task_name]
|
| 125 |
+
grader = HardTaskGrader() if task_name == "hard" else BaseGrader()
|
| 126 |
+
env = CustomerSupportEnv(task=task, grader=grader)
|
| 127 |
+
responses = RULE_BASED_RESPONSES[task_name]
|
| 128 |
+
|
| 129 |
+
print(f"\n{'='*60}")
|
| 130 |
+
print(f" TASK: {task_name.upper()} | {task.task_id}")
|
| 131 |
+
print(f" Customer emotion: {task.customer_emotion}")
|
| 132 |
+
print(f"{'='*60}")
|
| 133 |
+
print(f" Customer: {task.customer_message[:120]}...")
|
| 134 |
+
print(f"{'='*60}\n")
|
| 135 |
+
|
| 136 |
+
for i, response in enumerate(responses):
|
| 137 |
+
step_name = list(StepName)[i]
|
| 138 |
+
result, done = env.step(response)
|
| 139 |
+
|
| 140 |
+
status = "β
CORRECT" if result.correct else "β WRONG"
|
| 141 |
+
print(f"[Step {i+1}/4] {step_name.value.upper()} β {status}")
|
| 142 |
+
print(f" Detected : {result.detected_action}")
|
| 143 |
+
print(f" Reward : {result.reward:.3f} "
|
| 144 |
+
f"(base={result.base_score:.2f}, bonus={result.step_bonus:.2f}, "
|
| 145 |
+
f"penalty={result.penalty:.2f})")
|
| 146 |
+
if result.penalty_reasons:
|
| 147 |
+
for pr in result.penalty_reasons:
|
| 148 |
+
print(f" β {pr}")
|
| 149 |
+
print()
|
| 150 |
+
|
| 151 |
+
if done:
|
| 152 |
+
break
|
| 153 |
+
|
| 154 |
+
summary = env.summary()
|
| 155 |
+
print(f"\n{'='*60}")
|
| 156 |
+
print(f" EPISODE STATUS : {summary['status'].upper()}")
|
| 157 |
+
print(f" TOTAL REWARD : {summary['total_reward']:.3f} / 4.8 max")
|
| 158 |
+
print(f" WRONG STEPS : {summary['wrong_steps']}")
|
| 159 |
+
if summary["fail_reason"]:
|
| 160 |
+
print(f" FAIL REASON : {summary['fail_reason']}")
|
| 161 |
+
print(f"{'='*60}\n")
|
| 162 |
+
|
| 163 |
+
return summary
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def main():
|
| 167 |
+
parser = argparse.ArgumentParser()
|
| 168 |
+
parser.add_argument("--task", choices=["easy", "medium", "hard", "all"],
|
| 169 |
+
default="all")
|
| 170 |
+
parser.add_argument("--agent", choices=["rule"], default="rule")
|
| 171 |
+
args = parser.parse_args()
|
| 172 |
+
|
| 173 |
+
tasks = ["easy", "medium", "hard"] if args.task == "all" else [args.task]
|
| 174 |
+
results = {}
|
| 175 |
+
for t in tasks:
|
| 176 |
+
results[t] = run_task(t)
|
| 177 |
+
|
| 178 |
+
print("\nπ FINAL SUMMARY")
|
| 179 |
+
print(json.dumps(results, indent=2))
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
if __name__ == "__main__":
|
| 183 |
+
main()
|
models.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
models.py β Core data models for AI Customer Support Simulator
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from typing import List, Optional, Dict, Any
|
| 7 |
+
from enum import Enum
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class StepName(str, Enum):
|
| 11 |
+
EMPATHY = "empathy"
|
| 12 |
+
COLLECT_INFO = "collect_info"
|
| 13 |
+
INVESTIGATE = "investigate"
|
| 14 |
+
RESOLUTION = "resolution"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class DifficultyLevel(str, Enum):
|
| 18 |
+
EASY = "easy"
|
| 19 |
+
MEDIUM = "medium"
|
| 20 |
+
HARD = "hard"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class EpisodeStatus(str, Enum):
|
| 24 |
+
RUNNING = "running"
|
| 25 |
+
SUCCESS = "success"
|
| 26 |
+
FAIL = "fail"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class StepResult:
|
| 31 |
+
step: StepName
|
| 32 |
+
agent_response: str
|
| 33 |
+
detected_action: str # what grader detected
|
| 34 |
+
expected_action: str # what was required
|
| 35 |
+
correct: bool
|
| 36 |
+
base_score: float # 0.0 β 1.0
|
| 37 |
+
step_bonus: float # 0.0 β 0.2
|
| 38 |
+
penalty: float # 0.0+ (subtracted)
|
| 39 |
+
penalty_reasons: List[str]
|
| 40 |
+
reward: float # base_score + step_bonus - penalty
|
| 41 |
+
fail_triggered: bool = False
|
| 42 |
+
fail_reason: str = ""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class Episode:
|
| 47 |
+
task_id: str
|
| 48 |
+
difficulty: DifficultyLevel
|
| 49 |
+
steps: List[StepResult] = field(default_factory=list)
|
| 50 |
+
total_reward: float = 0.0
|
| 51 |
+
wrong_step_count: int = 0
|
| 52 |
+
status: EpisodeStatus = EpisodeStatus.RUNNING
|
| 53 |
+
fail_reason: str = ""
|
| 54 |
+
|
| 55 |
+
# Thresholds
|
| 56 |
+
MAX_WRONG_STEPS: int = 3
|
| 57 |
+
MIN_TOTAL_REWARD: float = 1.5 # out of 4.0 max
|
| 58 |
+
|
| 59 |
+
def add_step(self, result: StepResult) -> None:
|
| 60 |
+
self.steps.append(result)
|
| 61 |
+
self.total_reward += result.reward
|
| 62 |
+
if not result.correct:
|
| 63 |
+
self.wrong_step_count += 1
|
| 64 |
+
self._check_fail_conditions(result)
|
| 65 |
+
|
| 66 |
+
def _check_fail_conditions(self, result: StepResult) -> None:
|
| 67 |
+
if self.status != EpisodeStatus.RUNNING:
|
| 68 |
+
return
|
| 69 |
+
|
| 70 |
+
# Too many wrong steps
|
| 71 |
+
if self.wrong_step_count >= self.MAX_WRONG_STEPS:
|
| 72 |
+
self.status = EpisodeStatus.FAIL
|
| 73 |
+
self.fail_reason = f"Too many wrong steps ({self.wrong_step_count}/{self.MAX_WRONG_STEPS})"
|
| 74 |
+
return
|
| 75 |
+
|
| 76 |
+
# Step-level fail
|
| 77 |
+
if result.fail_triggered:
|
| 78 |
+
self.status = EpisodeStatus.FAIL
|
| 79 |
+
self.fail_reason = result.fail_reason
|
| 80 |
+
return
|
| 81 |
+
|
| 82 |
+
# All 4 steps done β check total score
|
| 83 |
+
if len(self.steps) == 4:
|
| 84 |
+
if self.total_reward < self.MIN_TOTAL_REWARD:
|
| 85 |
+
self.status = EpisodeStatus.FAIL
|
| 86 |
+
self.fail_reason = (
|
| 87 |
+
f"Total reward {self.total_reward:.2f} below threshold "
|
| 88 |
+
f"{self.MIN_TOTAL_REWARD}"
|
| 89 |
+
)
|
| 90 |
+
else:
|
| 91 |
+
self.status = EpisodeStatus.SUCCESS
|
| 92 |
+
|
| 93 |
+
def summary(self) -> Dict[str, Any]:
|
| 94 |
+
return {
|
| 95 |
+
"task_id": self.task_id,
|
| 96 |
+
"difficulty": self.difficulty.value,
|
| 97 |
+
"status": self.status.value,
|
| 98 |
+
"total_reward": round(self.total_reward, 3),
|
| 99 |
+
"wrong_steps": self.wrong_step_count,
|
| 100 |
+
"fail_reason": self.fail_reason,
|
| 101 |
+
"steps": [
|
| 102 |
+
{
|
| 103 |
+
"step": s.step.value,
|
| 104 |
+
"correct": s.correct,
|
| 105 |
+
"base_score": round(s.base_score, 3),
|
| 106 |
+
"step_bonus": round(s.step_bonus, 3),
|
| 107 |
+
"penalty": round(s.penalty, 3),
|
| 108 |
+
"reward": round(s.reward, 3),
|
| 109 |
+
"penalty_reasons": s.penalty_reasons,
|
| 110 |
+
}
|
| 111 |
+
for s in self.steps
|
| 112 |
+
],
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@dataclass
|
| 117 |
+
class Task:
|
| 118 |
+
task_id: str
|
| 119 |
+
difficulty: DifficultyLevel
|
| 120 |
+
customer_message: str
|
| 121 |
+
scenario_context: str
|
| 122 |
+
required_steps: List[StepName]
|
| 123 |
+
step_keywords: Dict[StepName, List[str]] # keywords that prove step done
|
| 124 |
+
escalation_risk: bool = False # hard tasks only
|
| 125 |
+
customer_emotion: str = "neutral" # neutral | frustrated | angry
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.100.0
|
| 2 |
+
uvicorn>=0.23.0
|
| 3 |
+
groq>=0.4.0
|
| 4 |
+
pyyaml>=6.0
|
| 5 |
+
pydantic>=2.0.0
|
tasks/ medium_task.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MEDIUM TASK β Multi-Step Issue Resolution
|
| 3 |
+
Goal: Agent guides a customer through a multi-turn troubleshooting process.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import random
|
| 7 |
+
from graders.medium_grader import MediumGrader
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class MediumTask:
|
| 11 |
+
"""
|
| 12 |
+
Task: Resolve a customer's technical/account issue over multiple turns.
|
| 13 |
+
|
| 14 |
+
- 3β5 turn conversations.
|
| 15 |
+
- Agent must ask clarifying questions, diagnose the problem, and resolve it.
|
| 16 |
+
- Resolution requires following a correct sequence of steps.
|
| 17 |
+
- Rewarded for each correct step, penalized for skipping or wrong order.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
description = (
|
| 21 |
+
"Help the customer resolve their issue step-by-step. "
|
| 22 |
+
"Ask clarifying questions, diagnose the problem, and guide them to a resolution. "
|
| 23 |
+
"Complete all required steps in a logical order."
|
| 24 |
+
)
|
| 25 |
+
max_steps = 6
|
| 26 |
+
|
| 27 |
+
SCENARIOS = [
|
| 28 |
+
{
|
| 29 |
+
"id": "med_001",
|
| 30 |
+
"topic": "billing_issue",
|
| 31 |
+
"opening_message": "I was charged twice for my subscription this month. This is really frustrating!",
|
| 32 |
+
"required_steps": [
|
| 33 |
+
"acknowledge_frustration", # "I understand, I'm sorry to hear that..."
|
| 34 |
+
"collect_account_info", # "Could I get your account email/ID?"
|
| 35 |
+
"investigate", # "Let me look into this for you..."
|
| 36 |
+
"resolve_or_escalate", # "I've initiated a refund..." or "escalating..."
|
| 37 |
+
],
|
| 38 |
+
"resolution": "Issue a full refund for the duplicate charge and confirm via email.",
|
| 39 |
+
"metadata": {
|
| 40 |
+
"product": "Subscription Service",
|
| 41 |
+
"account_id": "ACC-88321",
|
| 42 |
+
"charge_amount": "$29.99",
|
| 43 |
+
"charge_date": "March 28, 2026",
|
| 44 |
+
},
|
| 45 |
+
# Simulated customer replies for each agent step
|
| 46 |
+
"customer_replies": {
|
| 47 |
+
"acknowledge_frustration": "Thank you for understanding. Yes, I need this fixed.",
|
| 48 |
+
"collect_account_info": "Sure, my email is user@example.com.",
|
| 49 |
+
"investigate": "Okay, please check it. I can see two charges on my bank statement.",
|
| 50 |
+
"resolve_or_escalate": "Great, thank you! When will the refund appear?",
|
| 51 |
+
},
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"id": "med_002",
|
| 55 |
+
"topic": "login_problem",
|
| 56 |
+
"opening_message": "I can't log into my account. It keeps saying my password is wrong but I haven't changed it.",
|
| 57 |
+
"required_steps": [
|
| 58 |
+
"acknowledge_issue",
|
| 59 |
+
"check_basic_steps", # "Have you tried clearing your browser cache?"
|
| 60 |
+
"offer_password_reset", # "Let me send you a password reset link."
|
| 61 |
+
"confirm_resolution", # "Were you able to log in successfully?"
|
| 62 |
+
],
|
| 63 |
+
"resolution": "Send password reset link and confirm the customer successfully logged in.",
|
| 64 |
+
"metadata": {
|
| 65 |
+
"product": "SaaS App",
|
| 66 |
+
"account_status": "Active",
|
| 67 |
+
"last_login": "3 days ago",
|
| 68 |
+
},
|
| 69 |
+
"customer_replies": {
|
| 70 |
+
"acknowledge_issue": "Yes, I've been trying for an hour!",
|
| 71 |
+
"check_basic_steps": "I tried that, it didn't work.",
|
| 72 |
+
"offer_password_reset": "Okay, I got the email. Let me try.",
|
| 73 |
+
"confirm_resolution": "Yes! I'm in now. Thank you so much!",
|
| 74 |
+
},
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"id": "med_003",
|
| 78 |
+
"topic": "wrong_item_shipped",
|
| 79 |
+
"opening_message": "I ordered a blue jacket (size M) but received a red one in size L. What do I do?",
|
| 80 |
+
"required_steps": [
|
| 81 |
+
"apologize_sincerely",
|
| 82 |
+
"collect_order_info", # "Could I get your order number?"
|
| 83 |
+
"confirm_item_details", # "Let me confirm what you ordered..."
|
| 84 |
+
"arrange_replacement", # "I'll arrange a free return and send the correct item."
|
| 85 |
+
],
|
| 86 |
+
"resolution": "Arrange free return and ship correct item with express delivery.",
|
| 87 |
+
"metadata": {
|
| 88 |
+
"product": "Online Clothing Store",
|
| 89 |
+
"order_id": "ORD-44512",
|
| 90 |
+
"ordered_item": "Blue Jacket Size M",
|
| 91 |
+
"received_item": "Red Jacket Size L",
|
| 92 |
+
},
|
| 93 |
+
"customer_replies": {
|
| 94 |
+
"apologize_sincerely": "Thank you, I hope you can fix this.",
|
| 95 |
+
"collect_order_info": "My order number is ORD-44512.",
|
| 96 |
+
"confirm_item_details": "Yes, that's correct, I ordered the blue one.",
|
| 97 |
+
"arrange_replacement": "That's great, when will the correct one arrive?",
|
| 98 |
+
},
|
| 99 |
+
},
|
| 100 |
+
]
|
| 101 |
+
|
| 102 |
+
def __init__(self):
|
| 103 |
+
self._grader = MediumGrader()
|
| 104 |
+
self._current_scenario = None
|
| 105 |
+
self._steps_completed = []
|
| 106 |
+
|
| 107 |
+
def reset(self) -> dict:
|
| 108 |
+
self._steps_completed = []
|
| 109 |
+
self._current_scenario = random.choice(self.SCENARIOS).copy()
|
| 110 |
+
return self._current_scenario
|
| 111 |
+
|
| 112 |
+
def step(self, action: str, scenario: dict, history: list, step_number: int) -> dict:
|
| 113 |
+
score, grader_info, step_identified = self._grader.grade(
|
| 114 |
+
action=action,
|
| 115 |
+
scenario=scenario,
|
| 116 |
+
history=history,
|
| 117 |
+
steps_completed=self._steps_completed,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
# Track completed steps
|
| 121 |
+
if step_identified and step_identified not in self._steps_completed:
|
| 122 |
+
self._steps_completed.append(step_identified)
|
| 123 |
+
|
| 124 |
+
required = scenario["required_steps"]
|
| 125 |
+
completed_count = len(self._steps_completed)
|
| 126 |
+
total_required = len(required)
|
| 127 |
+
|
| 128 |
+
# Reward formula:
|
| 129 |
+
# Each correctly completed step earns proportional reward.
|
| 130 |
+
# Final step earns a bonus. Missing steps or wrong answers penalize.
|
| 131 |
+
if step_identified in required and step_identified not in grader_info.get("already_done", []):
|
| 132 |
+
step_reward = (1.0 / total_required) * score # partial step reward
|
| 133 |
+
else:
|
| 134 |
+
step_reward = -0.1 * (1 - score) # small penalty for non-progress
|
| 135 |
+
|
| 136 |
+
# Check if all required steps are done
|
| 137 |
+
all_done = all(s in self._steps_completed for s in required)
|
| 138 |
+
done = all_done or step_number >= self.max_steps
|
| 139 |
+
|
| 140 |
+
if all_done:
|
| 141 |
+
step_reward += 0.2 # bonus for completing all steps
|
| 142 |
+
|
| 143 |
+
# Generate next customer message based on what step was just done
|
| 144 |
+
next_msg = ""
|
| 145 |
+
if not done:
|
| 146 |
+
replies = scenario.get("customer_replies", {})
|
| 147 |
+
next_msg = replies.get(step_identified, "Okay, what should I do next?")
|
| 148 |
+
|
| 149 |
+
return {
|
| 150 |
+
"reward": max(-1.0, min(1.0, step_reward)),
|
| 151 |
+
"done": done,
|
| 152 |
+
"next_customer_message": next_msg,
|
| 153 |
+
"grader_info": {
|
| 154 |
+
**grader_info,
|
| 155 |
+
"score": score,
|
| 156 |
+
"step_identified": step_identified,
|
| 157 |
+
"steps_completed": list(self._steps_completed),
|
| 158 |
+
"progress": f"{completed_count}/{total_required}",
|
| 159 |
+
},
|
| 160 |
+
}
|
tasks/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from tasks.easy_task import EASY_TASK
|
| 2 |
+
from tasks.medium_task import MEDIUM_TASK
|
| 3 |
+
from tasks.hard_task import HARD_TASK, evaluate_hard_response
|
| 4 |
+
|
| 5 |
+
TASK_REGISTRY = {
|
| 6 |
+
"easy": EASY_TASK,
|
| 7 |
+
"medium": MEDIUM_TASK,
|
| 8 |
+
"hard": HARD_TASK,
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
__all__ = ["EASY_TASK", "MEDIUM_TASK", "HARD_TASK",
|
| 12 |
+
"TASK_REGISTRY", "evaluate_hard_response"]
|
tasks/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (476 Bytes). View file
|
|
|
tasks/__pycache__/easy_task.cpython-312.pyc
ADDED
|
Binary file (1.45 kB). View file
|
|
|
tasks/__pycache__/hard_task.cpython-312.pyc
ADDED
|
Binary file (6.03 kB). View file
|
|
|
tasks/__pycache__/medium_task.cpython-312.pyc
ADDED
|
Binary file (1.72 kB). View file
|
|
|
tasks/easy_task.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tasks/easy_task.py β Easy scenario: delayed delivery complaint
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from models import Task, StepName, DifficultyLevel
|
| 6 |
+
|
| 7 |
+
EASY_TASK = Task(
|
| 8 |
+
task_id = "EASY_001",
|
| 9 |
+
difficulty = DifficultyLevel.EASY,
|
| 10 |
+
customer_emotion = "frustrated",
|
| 11 |
+
escalation_risk = False,
|
| 12 |
+
customer_message = (
|
| 13 |
+
"Hello, I ordered a laptop 10 days ago and it still hasn't arrived. "
|
| 14 |
+
"I need it urgently for work. Can you help me?"
|
| 15 |
+
),
|
| 16 |
+
scenario_context = (
|
| 17 |
+
"Customer placed order #ORD-8821 on 25 March. "
|
| 18 |
+
"Standard delivery is 5β7 business days. "
|
| 19 |
+
"Tracking shows package is stuck at a sorting facility."
|
| 20 |
+
),
|
| 21 |
+
required_steps = [
|
| 22 |
+
StepName.EMPATHY,
|
| 23 |
+
StepName.COLLECT_INFO,
|
| 24 |
+
StepName.INVESTIGATE,
|
| 25 |
+
StepName.RESOLUTION,
|
| 26 |
+
],
|
| 27 |
+
step_keywords = {
|
| 28 |
+
StepName.EMPATHY: ["sorry", "apologize", "understand", "frustrat"],
|
| 29 |
+
StepName.COLLECT_INFO: ["order number", "order id", "could you provide",
|
| 30 |
+
"may i have", "can i have"],
|
| 31 |
+
StepName.INVESTIGATE: ["checking", "looking into", "tracking", "found",
|
| 32 |
+
"our records", "it appears"],
|
| 33 |
+
StepName.RESOLUTION: ["refund", "replacement", "expedite", "credit",
|
| 34 |
+
"we will", "i will"],
|
| 35 |
+
},
|
| 36 |
+
)
|
tasks/hard_task.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tasks/hard_task.py β Hard scenario: angry VIP customer threatening legal action
|
| 3 |
+
after a 3-week unresolved product failure
|
| 4 |
+
|
| 5 |
+
Grader used: HardTaskGrader (requires de-escalation + concrete resolution)
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from models import Task, StepName, DifficultyLevel
|
| 9 |
+
|
| 10 |
+
HARD_TASK = Task(
|
| 11 |
+
task_id = "HARD_001",
|
| 12 |
+
difficulty = DifficultyLevel.HARD,
|
| 13 |
+
customer_emotion = "angry",
|
| 14 |
+
escalation_risk = True,
|
| 15 |
+
customer_message = (
|
| 16 |
+
"I am ABSOLUTELY FURIOUS. I purchased your premium standing desk "
|
| 17 |
+
"(βΉ45,000) three weeks ago and it arrived broken. I've called five times, "
|
| 18 |
+
"sent four emails, and NOBODY has fixed this. I'm a VIP member and this "
|
| 19 |
+
"is how you treat me?! I am going to post this on every review site, "
|
| 20 |
+
"contact consumer court, and make sure everyone knows how pathetic your "
|
| 21 |
+
"company's service is. I want this resolved TODAY or I'm cancelling my "
|
| 22 |
+
"entire βΉ2 lakh annual contract!"
|
| 23 |
+
),
|
| 24 |
+
scenario_context = (
|
| 25 |
+
"Customer: Rajesh Mehta | VIP Tier | Annual contract: βΉ2,00,000. "
|
| 26 |
+
"Order #ORD-VIP-2241 β Premium Standing Desk Pro (βΉ45,000) delivered "
|
| 27 |
+
"on 12 March with a broken motorised lift mechanism. "
|
| 28 |
+
"5 prior support tickets (TKT-001 to TKT-005) β all marked 'pending'. "
|
| 29 |
+
"No technician dispatched yet. Replacement stock available (3β5 days). "
|
| 30 |
+
"Policy: VIP customers get same-day escalation + compensation up to 20% "
|
| 31 |
+
"of order value for delays > 7 days."
|
| 32 |
+
),
|
| 33 |
+
required_steps = [
|
| 34 |
+
StepName.EMPATHY,
|
| 35 |
+
StepName.COLLECT_INFO,
|
| 36 |
+
StepName.INVESTIGATE,
|
| 37 |
+
StepName.RESOLUTION,
|
| 38 |
+
],
|
| 39 |
+
step_keywords = {
|
| 40 |
+
StepName.EMPATHY: [
|
| 41 |
+
"i am personally and deeply sorry",
|
| 42 |
+
"deeply sorry",
|
| 43 |
+
"sincerely apologize",
|
| 44 |
+
"i completely understand",
|
| 45 |
+
"that is completely unacceptable",
|
| 46 |
+
"you have every right",
|
| 47 |
+
"i take full responsibility",
|
| 48 |
+
"this should not have happened",
|
| 49 |
+
"i hear your frustration",
|
| 50 |
+
],
|
| 51 |
+
StepName.COLLECT_INFO: [
|
| 52 |
+
"order number", "ticket number", "vip", "account",
|
| 53 |
+
"could you confirm", "full name", "email", "contact",
|
| 54 |
+
],
|
| 55 |
+
StepName.INVESTIGATE: [
|
| 56 |
+
"reviewing your tickets", "i can see", "five tickets",
|
| 57 |
+
"all marked pending", "this is unacceptable internally",
|
| 58 |
+
"i have escalated", "our records show", "found that",
|
| 59 |
+
"i found", "looking into",
|
| 60 |
+
],
|
| 61 |
+
StepName.RESOLUTION: [
|
| 62 |
+
"replacement", "technician today", "within 24 hours",
|
| 63 |
+
"βΉ9,000", "9000", "20%", "compensation",
|
| 64 |
+
"personally ensure", "i will personally",
|
| 65 |
+
"waive", "free of charge", "priority",
|
| 66 |
+
],
|
| 67 |
+
},
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ββ Hard-task specific scoring weights ββββββββββββββββββββββββββββββββββββββββ
|
| 72 |
+
HARD_TASK_SCORING = {
|
| 73 |
+
"emotional_control": 0.30, # calm + empathetic tone
|
| 74 |
+
"conflict_handling": 0.25, # de-escalation phrases
|
| 75 |
+
"professional_tone": 0.20, # no blame, no dismissal
|
| 76 |
+
"resolution_accuracy": 0.25, # correct refund % + timeline
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def evaluate_hard_response(response: str, step: StepName) -> dict:
|
| 81 |
+
"""
|
| 82 |
+
Returns a breakdown dict for hard-task evaluation display.
|
| 83 |
+
Used by the UI to show per-dimension scores.
|
| 84 |
+
"""
|
| 85 |
+
r = response.lower()
|
| 86 |
+
scores = {}
|
| 87 |
+
|
| 88 |
+
if step == StepName.EMPATHY:
|
| 89 |
+
emotional_hits = sum(1 for kw in HARD_TASK.step_keywords[StepName.EMPATHY]
|
| 90 |
+
if kw in r)
|
| 91 |
+
scores["emotional_control"] = min(1.0, emotional_hits / 3)
|
| 92 |
+
|
| 93 |
+
deesc = ["completely understand", "every right", "full responsibility",
|
| 94 |
+
"should not have happened", "hear your frustration"]
|
| 95 |
+
deesc_hits = sum(1 for kw in deesc if kw in r)
|
| 96 |
+
scores["conflict_handling"] = min(1.0, deesc_hits / 2)
|
| 97 |
+
|
| 98 |
+
bad = ["calm down", "your fault", "nothing we can do", "policy says"]
|
| 99 |
+
scores["professional_tone"] = 0.0 if any(b in r for b in bad) else 1.0
|
| 100 |
+
|
| 101 |
+
scores["resolution_accuracy"] = 0.0 # N/A at empathy step
|
| 102 |
+
|
| 103 |
+
elif step == StepName.RESOLUTION:
|
| 104 |
+
scores["emotional_control"] = 1.0 if "personally ensure" in r else 0.5
|
| 105 |
+
scores["conflict_handling"] = 1.0 if "priority" in r else 0.5
|
| 106 |
+
scores["professional_tone"] = 1.0 if "i will personally" in r else 0.5
|
| 107 |
+
|
| 108 |
+
# Check resolution accuracy: compensation + timeline
|
| 109 |
+
has_comp = any(c in r for c in ["9,000", "9000", "20%", "compensation"])
|
| 110 |
+
has_timeline = any(t in r for t in ["24 hours", "today", "tomorrow", "3 days"])
|
| 111 |
+
scores["resolution_accuracy"] = (0.5 * int(has_comp)) + (0.5 * int(has_timeline))
|
| 112 |
+
|
| 113 |
+
else:
|
| 114 |
+
# Default neutral for mid steps
|
| 115 |
+
scores = {k: 0.5 for k in HARD_TASK_SCORING}
|
| 116 |
+
|
| 117 |
+
weighted = sum(HARD_TASK_SCORING[k] * scores.get(k, 0.0)
|
| 118 |
+
for k in HARD_TASK_SCORING)
|
| 119 |
+
scores["weighted_total"] = round(weighted, 3)
|
| 120 |
+
return scores
|
tasks/medium_task.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tasks/medium_task.py β Medium scenario: billing error + overcharge
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from models import Task, StepName, DifficultyLevel
|
| 6 |
+
|
| 7 |
+
MEDIUM_TASK = Task(
|
| 8 |
+
task_id = "MEDIUM_001",
|
| 9 |
+
difficulty = DifficultyLevel.MEDIUM,
|
| 10 |
+
customer_emotion = "frustrated",
|
| 11 |
+
escalation_risk = False,
|
| 12 |
+
customer_message = (
|
| 13 |
+
"I've been charged twice for my subscription this month β "
|
| 14 |
+
"$49.99 appeared on my card on both the 1st and the 15th. "
|
| 15 |
+
"This is completely unacceptable. I want my money back!"
|
| 16 |
+
),
|
| 17 |
+
scenario_context = (
|
| 18 |
+
"Customer account: ACC-4492. "
|
| 19 |
+
"Subscription plan: Pro Monthly ($49.99/month). "
|
| 20 |
+
"Billing system shows a duplicate charge due to a payment gateway retry bug on the 15th. "
|
| 21 |
+
"Policy: full refund issued within 3β5 business days for duplicate charges."
|
| 22 |
+
),
|
| 23 |
+
required_steps = [
|
| 24 |
+
StepName.EMPATHY,
|
| 25 |
+
StepName.COLLECT_INFO,
|
| 26 |
+
StepName.INVESTIGATE,
|
| 27 |
+
StepName.RESOLUTION,
|
| 28 |
+
],
|
| 29 |
+
step_keywords = {
|
| 30 |
+
StepName.EMPATHY: ["sorry", "apologize", "understand", "frustrat",
|
| 31 |
+
"unacceptable", "inconvenien"],
|
| 32 |
+
StepName.COLLECT_INFO: ["account number", "email", "could you confirm",
|
| 33 |
+
"transaction", "billing date", "card", "may i"],
|
| 34 |
+
StepName.INVESTIGATE: ["checking", "i can see", "our records", "duplicate",
|
| 35 |
+
"found", "it appears", "billing system"],
|
| 36 |
+
StepName.RESOLUTION: ["refund", "credit", "3 to 5", "3-5", "business days",
|
| 37 |
+
"we will process", "i will process", "reimburse"],
|
| 38 |
+
},
|
| 39 |
+
)
|