File size: 10,159 Bytes
a77725d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
"""
environment.py β€” Strict RL-style Customer Support Environment
Handles: step enforcement, repeat detection, fail conditions, reward calculation
"""

from __future__ import annotations
import re
from typing import List, Tuple, Optional

from models import (
    Episode, EpisodeStatus, StepName, StepResult,
    DifficultyLevel, Task
)
from graders.base_grader import BaseGrader


# ── Step order ────────────────────────────────────────────────────────────────
STEP_ORDER = [
    StepName.EMPATHY,
    StepName.COLLECT_INFO,
    StepName.INVESTIGATE,
    StepName.RESOLUTION,
]

# ── Reward constants ───────────────────────────────────────────────────────────
BASE_SCORE_CORRECT   = 1.0
BASE_SCORE_INCORRECT = 0.2
STEP_BONUS           = 0.2          # bonus when step is correct
WRONG_STEP_PENALTY   = 0.3          # wrong action in correct step position
REPEAT_PENALTY       = 0.2          # repeated question / response
SKIP_STEP_PENALTY    = 0.3          # jumped ahead
EARLY_SOLUTION_PENALTY = 0.25       # gave resolution before investigation
EMOTION_IGNORE_PENALTY = 0.25       # angry customer β†’ neutral / cold reply
GENERIC_RESPONSE_PENALTY = 0.15     # "ok", "done", vague one-liners
WRONG_ASSUMPTION_PENALTY = 0.2      # stated wrong facts
LOOP_PENALTY         = 0.35         # agent stuck in loop (same step repeated 2+)

# ── Overlap threshold for repeat detection ─────────────────────────────────────
REPEAT_WORD_OVERLAP_MIN = 10        # words in common β†’ flagged as repeat


class CustomerSupportEnv:
    """
    Strict step-based RL environment for customer support training.
    """

    def __init__(self, task: Task, grader: BaseGrader):
        self.task    = task
        self.grader  = grader
        self.episode = Episode(
            task_id    = task.task_id,
            difficulty = task.difficulty,
        )
        self._response_history: List[str] = []
        self._step_index = 0           # which step we expect next
        self._consecutive_wrong = 0    # wrong attempts at current step

    # ── Public API ─────────────────────────────────────────────────────────────

    def step(self, agent_response: str) -> Tuple[StepResult, bool]:
        """
        Process one agent response.
        Returns (StepResult, done:bool).
        """
        if self.episode.status != EpisodeStatus.RUNNING:
            raise RuntimeError("Episode is already finished.")

        expected_step = STEP_ORDER[self._step_index]
        result = self._evaluate(agent_response, expected_step)
        self.episode.add_step(result)
        self._response_history.append(agent_response.lower().strip())

        done = False
        if result.correct:
            self._step_index        += 1
            self._consecutive_wrong  = 0
        else:
            self._consecutive_wrong += 1

        # Loop fail: 3 consecutive wrong attempts at the same step
        if self._consecutive_wrong >= 3 and self.episode.status == EpisodeStatus.RUNNING:
            self.episode.status    = EpisodeStatus.FAIL
            self.episode.fail_reason = (
                f"Agent stuck in loop at step '{expected_step.value}' "
                f"({self._consecutive_wrong} consecutive failures)"
            )

        if self.episode.status != EpisodeStatus.RUNNING:
            done = True
        elif self._step_index >= len(STEP_ORDER):
            done = True   # episode.add_step() already set SUCCESS/FAIL

        return result, done

    def reset(self) -> None:
        self.episode = Episode(
            task_id    = self.task.task_id,
            difficulty = self.task.difficulty,
        )
        self._response_history  = []
        self._step_index        = 0
        self._consecutive_wrong = 0

    def summary(self):
        return self.episode.summary()

    # ── Internal evaluation ────────────────────────────────────────────────────

    def _evaluate(self, response: str, expected_step: StepName) -> StepResult:
        penalties: List[str] = []
        total_penalty        = 0.0

        # 1. Grade the response against expected step
        grader_result = self.grader.grade(
            response      = response,
            expected_step = expected_step,
            task          = self.task,
        )
        correct        = grader_result["correct"]
        base_score     = BASE_SCORE_CORRECT if correct else BASE_SCORE_INCORRECT
        detected_action = grader_result.get("detected_action", "unknown")

        # 2. Step bonus
        step_bonus = STEP_BONUS if correct else 0.0

        # 3. Wrong-step penalty
        if not correct:
            total_penalty += WRONG_STEP_PENALTY
            penalties.append(
                f"Wrong action detected ('{detected_action}' "
                f"β‰  '{expected_step.value}'): -{WRONG_STEP_PENALTY}"
            )

        # 4. Repeat detection
        if self._is_repeated_response(response):
            total_penalty += REPEAT_PENALTY
            penalties.append(f"Repeated/duplicate response: -{REPEAT_PENALTY}")

        # 5. Early solution penalty
        if self._is_early_solution(response, expected_step):
            total_penalty += EARLY_SOLUTION_PENALTY
            penalties.append(f"Solution given too early: -{EARLY_SOLUTION_PENALTY}")

        # 6. Emotion mismatch penalty
        if self._is_emotion_mismatch(response):
            total_penalty += EMOTION_IGNORE_PENALTY
            penalties.append(
                f"Angry customer ignored (no empathy/de-escalation): "
                f"-{EMOTION_IGNORE_PENALTY}"
            )

        # 7. Generic / too-short response
        if self._is_generic_response(response):
            total_penalty += GENERIC_RESPONSE_PENALTY
            penalties.append(f"Generic/too-short response: -{GENERIC_RESPONSE_PENALTY}")

        # 8. Wrong assumption detection
        wrong_assumption = grader_result.get("wrong_assumption", False)
        if wrong_assumption:
            total_penalty += WRONG_ASSUMPTION_PENALTY
            penalties.append(f"Incorrect assumption stated: -{WRONG_ASSUMPTION_PENALTY}")

        # 9. Skip-step penalty (grader signals this)
        if grader_result.get("skipped_step", False):
            total_penalty += SKIP_STEP_PENALTY
            penalties.append(f"Step skipped: -{SKIP_STEP_PENALTY}")

        # ── Reward formula ─────────────────────────────────────────────────────
        reward = max(0.0, base_score + step_bonus - total_penalty)

        # ── Fail trigger (individual step) ────────────────────────────────────
        fail_triggered = False
        fail_reason    = ""
        if total_penalty >= 0.8:
            fail_triggered = True
            fail_reason    = f"Single step penalty exceeded threshold ({total_penalty:.2f})"

        return StepResult(
            step            = expected_step,
            agent_response  = response,
            detected_action = detected_action,
            expected_action = expected_step.value,
            correct         = correct,
            base_score      = base_score,
            step_bonus      = step_bonus,
            penalty         = total_penalty,
            penalty_reasons = penalties,
            reward          = reward,
            fail_triggered  = fail_triggered,
            fail_reason     = fail_reason,
        )

    # ── Helper detectors ───────────────────────────────────────────────────────

    def _is_repeated_response(self, response: str) -> bool:
        if not self._response_history:
            return False
        words_new = set(response.lower().split())
        for prev in self._response_history:
            words_prev = set(prev.split())
            overlap = len(words_new & words_prev)
            if overlap >= REPEAT_WORD_OVERLAP_MIN:
                return True
        return False

    def _is_early_solution(self, response: str, step: StepName) -> bool:
        """Penalise giving resolution keywords before the resolution step."""
        if step in (StepName.EMPATHY, StepName.COLLECT_INFO):
            resolution_signals = [
                "refund", "replacement", "we will fix", "we will credit",
                "here is the solution", "the fix is", "escalate your",
            ]
            r = response.lower()
            return any(sig in r for sig in resolution_signals)
        return False

    def _is_emotion_mismatch(self, response: str) -> bool:
        """Flag cold/neutral replies when customer is angry/frustrated."""
        if self.task.customer_emotion not in ("angry", "frustrated"):
            return False
        empathy_signals = [
            "sorry", "apologize", "apology", "understand your frustration",
            "i hear you", "i completely understand", "that must be",
            "deeply sorry", "sincerely apologize",
        ]
        r = response.lower()
        return not any(sig in r for sig in empathy_signals)

    def _is_generic_response(self, response: str) -> bool:
        stripped = response.strip().lower()
        # Very short replies
        if len(stripped.split()) <= 4:
            return True
        # Generic filler phrases
        generic_phrases = [
            "ok", "okay", "done", "sure", "got it",
            "no problem", "understood", "alright",
        ]
        return stripped in generic_phrases