File size: 8,094 Bytes
753b5ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
tests/test_environment.py β€” Unit tests for the Email Triage environment.

Tests:
  - reset() returns a valid TriageObservation
  - step() returns correct reward type and range
  - All 3 task levels complete without errors
  - Graders return scores in [0.0, 1.0]
  - Perfect action scores >= 0.9
  - Wrong action scores <= 0.5
"""

import pytest
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

from email_triage_rl_hackathon.server.my_environment import EmailTriageEnvironment
from email_triage_rl_hackathon.models import TriageAction
from email_triage_rl_hackathon.server.graders import grade_easy, grade_medium, grade_hard
from email_triage_rl_hackathon.server.email_data import EASY_EMAILS, MEDIUM_EMAILS, HARD_EMAILS


# ─────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────
def perfect_action(email) -> TriageAction:
    """Return the ground-truth action for an email."""
    return TriageAction(
        category=email.gt_category,
        priority=email.gt_priority,
        department=email.gt_department,
        reply=" ".join(email.gt_reply_keywords) + " apologize refund investigate dear",
    )


def wrong_action() -> TriageAction:
    return TriageAction(
        category="spam",
        priority="low",
        department="support",
        reply="",
    )


# ─────────────────────────────────────────────
# Environment lifecycle tests
# ─────────────────────────────────────────────
class TestEnvironmentLifecycle:

    def test_reset_returns_valid_observation(self):
        env = EmailTriageEnvironment(task_level="easy")
        obs = env.reset()
        assert obs.email_id != "none"
        assert len(obs.subject) > 0
        assert obs.task_level == "easy"
        assert obs.done is False
        assert 0.0 <= obs.score <= 1.0

    def test_reset_with_seed_is_reproducible(self):
        env1 = EmailTriageEnvironment(task_level="easy")
        env2 = EmailTriageEnvironment(task_level="easy")
        obs1 = env1.reset(seed=42)
        obs2 = env2.reset(seed=42)
        assert obs1.email_id == obs2.email_id

    def test_state_returns_state_object(self):
        env = EmailTriageEnvironment(task_level="easy")
        env.reset()
        state = env.state
        assert state.episode_id is not None
        assert state.step_count == 0

    def test_step_increments_step_count(self):
        env = EmailTriageEnvironment(task_level="easy")
        obs = env.reset()
        action = TriageAction(category="spam", priority="low", department="support", reply="")
        env.step(action)
        assert env.state.step_count == 1

    def test_episode_ends_after_all_emails(self):
        for level in ["easy", "medium", "hard"]:
            env = EmailTriageEnvironment(task_level=level)
            obs = env.reset()
            action = wrong_action()
            done = False
            steps = 0
            while not done:
                result = env.step(action)
                done = result.done
                steps += 1
                assert steps < 20, "Episode did not terminate"
            assert done is True

    def test_reward_is_in_valid_range(self):
        env = EmailTriageEnvironment(task_level="easy")
        obs = env.reset()
        while not obs.done:
            action = TriageAction(category="billing", priority="medium",
                                  department="finance", reply="")
            obs = env.step(action)
            assert -1.0 <= obs.reward <= 1.0


# ─────────────────────────────────────────────
# Grader tests
# ─────────────────────────────────────────────
class TestGraders:

    def test_easy_perfect_score(self):
        email = EASY_EMAILS[1]  # billing email
        score, _ = grade_easy(email, email.gt_category, email.gt_priority, email.gt_department)
        assert score == 1.0

    def test_easy_all_wrong_score(self):
        email = EASY_EMAILS[0]  # spam email
        score, _ = grade_easy(email, "technical", "urgent", "engineering")
        assert score == 0.0

    def test_easy_partial_score(self):
        email = EASY_EMAILS[1]  # billing/high/finance
        score, _ = grade_easy(email, "billing", "low", "support")
        assert 0.0 < score < 1.0
        assert score == pytest.approx(0.40)  # only category correct

    def test_medium_no_reply_penalised(self):
        email = MEDIUM_EMAILS[0]
        score, feedback = grade_medium(
            email, email.gt_category, email.gt_priority, email.gt_department, ""
        )
        assert "too short" in feedback or "missing" in feedback
        assert score <= 0.5

    def test_medium_good_reply_scores_high(self):
        email = MEDIUM_EMAILS[0]
        reply = "Dear customer, we sincerely apologize for the double charge. We will investigate and refund within 24-48 hours."
        score, _ = grade_medium(
            email, email.gt_category, email.gt_priority, email.gt_department, reply
        )
        assert score >= 0.7

    def test_hard_short_reply_penalised(self):
        email = HARD_EMAILS[0]
        score, feedback = grade_hard(
            email, email.gt_category, email.gt_priority, email.gt_department, "ok"
        )
        # Classification correct = 0.40 max; reply too short adds nothing
        assert score <= 0.42  # no reply bonus beyond classification

    def test_hard_full_reply_scores_high(self):
        email = HARD_EMAILS[0]
        reply = (
            "Dear customer, we sincerely apologize for the unacceptable experience with "
            "account ACC-00421. I am personally escalating this to our senior finance manager "
            "right now. We will resolve this within 24 hours and a manager will contact you "
            "directly. All previous case numbers have been reviewed."
        )
        score, _ = grade_hard(
            email, email.gt_category, email.gt_priority, email.gt_department, reply
        )
        assert score >= 0.6

    def test_scores_always_between_0_and_1(self):
        for email in EASY_EMAILS + MEDIUM_EMAILS + HARD_EMAILS:
            if email.task_level == "easy":
                s, _ = grade_easy(email, "spam", "low", "support")
            elif email.task_level == "medium":
                s, _ = grade_medium(email, "spam", "low", "support", "")
            else:
                s, _ = grade_hard(email, "spam", "low", "support", "")
            assert 0.0 <= s <= 1.0, f"Score {s} out of range for {email.email_id}"


# ─────────────────────────────────────────────
# Full episode integration tests
# ─────────────────────────────────────────────
class TestFullEpisode:

    def test_perfect_easy_episode_scores_1(self):
        env = EmailTriageEnvironment(task_level="easy")
        obs = env.reset(seed=0)
        total = 0.0
        steps = 0
        while not obs.done:
            # Find current email's ground truth from email_data
            from email_triage_rl_hackathon.server.email_data import ALL_EMAILS
            email = ALL_EMAILS.get(obs.email_id)
            assert email is not None, f"Unknown email_id: {obs.email_id}"
            action = perfect_action(email)
            obs = env.step(action)
            total += obs.reward or 0.0
            steps += 1
        assert steps > 0
        avg = total / steps
        assert avg >= 0.9, f"Perfect easy episode should score β‰₯0.9, got {avg:.2f}"