File size: 6,749 Bytes
3752981
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d378e5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3752981
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
043d9e1
 
 
 
 
 
 
 
 
 
 
 
3752981
043d9e1
3752981
 
043d9e1
3752981
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d378e5d
 
 
 
 
 
 
 
 
 
 
 
 
 
3752981
 
 
 
 
 
 
 
 
 
 
 
 
 
8241eb5
3752981
8241eb5
 
 
 
 
 
3752981
 
 
8241eb5
 
d378e5d
 
8241eb5
d378e5d
3752981
d378e5d
 
8241eb5
d378e5d
3752981
 
8241eb5
 
 
 
 
3752981
e3dfee6
8241eb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3752981
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
from __future__ import annotations

from models import HelpdeskTicketAction, HelpdeskTicketRecord

ISSUE_TYPE_SIMILARITY = {
    ("billing_license", "service_request"): 0.4,
    ("service_request", "billing_license"): 0.4,
    ("application_support", "identity_access"): 0.5,
    ("identity_access", "application_support"): 0.5,
    ("application_support", "feature_request"): 0.35,
    ("feature_request", "application_support"): 0.35,
    ("onboarding", "identity_access"): 0.4,
    ("identity_access", "onboarding"): 0.4,
    ("general_inquiry", "feature_request"): 0.3,
    ("feature_request", "general_inquiry"): 0.3,
    ("general_inquiry", "service_request"): 0.25,
    ("service_request", "general_inquiry"): 0.25,
    ("spam_phishing", "security_compliance"): 0.4,
    ("security_compliance", "spam_phishing"): 0.4,
    ("security_compliance", "billing_license"): 0.2,
    ("billing_license", "security_compliance"): 0.2,
}

ASSIGNMENT_GROUP_SIMILARITY = {
    ("procurement", "license_ops"): 0.55,
    ("license_ops", "procurement"): 0.55,
    ("service_desk", "onboarding_ops"): 0.5,
    ("onboarding_ops", "service_desk"): 0.5,
    ("application_team", "security_team"): 0.35,
    ("security_team", "application_team"): 0.35,
    ("service_desk", "application_team"): 0.25,
    ("application_team", "service_desk"): 0.25,
    ("service_desk", "security_team"): 0.2,
    ("security_team", "service_desk"): 0.2,
}

RESOLUTION_ACTION_SIMILARITY = {
    ("assign", "escalate"): 0.6,
    ("escalate", "assign"): 0.6,
    ("acknowledge", "fulfill"): 0.35,
    ("fulfill", "acknowledge"): 0.35,
    ("assign", "fulfill"): 0.25,
    ("fulfill", "assign"): 0.25,
    ("escalate", "fulfill"): 0.2,
    ("fulfill", "escalate"): 0.2,
    ("acknowledge", "assign"): 0.2,
    ("assign", "acknowledge"): 0.2,
}

PRIORITY_SCORES = {
    ("critical", "high"): 0.6,
    ("high", "critical"): 0.6,
    ("high", "medium"): 0.5,
    ("medium", "high"): 0.5,
    ("medium", "low"): 0.4,
    ("low", "medium"): 0.4,
    ("critical", "medium"): 0.3,
    ("medium", "critical"): 0.3,
    ("critical", "low"): 0.1,
    ("low", "critical"): 0.1,
    ("high", "low"): 0.2,
    ("low", "high"): 0.2,
}


TASK_WEIGHTS = {
    1: {
        "issue_type": 0.40,
        "priority": 0.20,
        "assignment_group": 0.20,
        "resolution_action": 0.20,
    },
    2: {
        "issue_type": 0.32,
        "priority": 0.20,
        "assignment_group": 0.24,
        "resolution_action": 0.24,
    },
    3: {
        "issue_type": 0.30,
        "priority": 0.20,
        "assignment_group": 0.25,
        "resolution_action": 0.25,
    },
}


def _normalized(value: str | None) -> str:
    return (value or "").strip().lower()


def _score_exact_or_similar(predicted: str | None, expected: str) -> float:
    pred = _normalized(predicted)
    exp = _normalized(expected)
    if not pred:
        return 0.0
    if pred == exp:
        return 1.0
    return ISSUE_TYPE_SIMILARITY.get((pred, exp), 0.0)


def _score_exact_or_table(
    predicted: str | None,
    expected: str,
    similarity_table: dict[tuple[str, str], float],
) -> float:
    pred = _normalized(predicted)
    exp = _normalized(expected)
    if not pred:
        return 0.0
    if pred == exp:
        return 1.0
    return similarity_table.get((pred, exp), 0.0)


def _score_priority(predicted: str | None, expected: str) -> float:
    pred = _normalized(predicted)
    exp = _normalized(expected)
    if not pred:
        return 0.0
    if pred == exp:
        return 1.0
    return PRIORITY_SCORES.get((pred, exp), 0.0)


def _score_exact(predicted: str | None, expected: str) -> float:
    return 1.0 if _normalized(predicted) == _normalized(expected) and predicted else 0.0


def _score_route(
    action: HelpdeskTicketAction,
    *,
    issue_type: str,
    priority: str,
    assignment_group: str,
    resolution_action: str,
    score_multiplier: float,
    task_id: int,
) -> tuple[float, dict[str, float]]:
    field_scores = {
        "issue_type": _score_exact_or_similar(action.issue_type, issue_type),
        "priority": _score_priority(action.priority, priority),
        "assignment_group": _score_exact_or_table(
            action.assignment_group,
            assignment_group,
            ASSIGNMENT_GROUP_SIMILARITY,
        ),
        "resolution_action": _score_exact_or_table(
            action.resolution_action,
            resolution_action,
            RESOLUTION_ACTION_SIMILARITY,
        ),
    }
    if score_multiplier != 1.0:
        field_scores = {
            field: round(score * score_multiplier, 4)
            for field, score in field_scores.items()
        }
    weights = TASK_WEIGHTS[task_id]
    raw_score = sum(field_scores[field] * weight for field, weight in weights.items())
    return raw_score, field_scores


def _alternate_route_available(ticket: HelpdeskTicketRecord) -> bool:
    return any(
        value is not None
        for value in (
            ticket.alternate_issue_type,
            ticket.alternate_priority,
            ticket.alternate_assignment_group,
            ticket.alternate_resolution_action,
        )
    ) and ticket.alternate_route_score_multiplier > 0.0


def grade_action(
    action: HelpdeskTicketAction,
    ticket: HelpdeskTicketRecord,
    task_id: int,
) -> tuple[float, dict[str, float]]:
    if task_id not in TASK_WEIGHTS:
        raise ValueError(f"Unsupported task_id: {task_id}")

    primary_score, primary_field_scores = _score_route(
        action,
        issue_type=ticket.issue_type,
        priority=ticket.priority,
        assignment_group=ticket.assignment_group,
        resolution_action=ticket.resolution_action,
        score_multiplier=1.0,
        task_id=task_id,
    )
    chosen_score = primary_score
    chosen_field_scores = primary_field_scores

    if _alternate_route_available(ticket):
        alternate_score, alternate_field_scores = _score_route(
            action,
            issue_type=ticket.alternate_issue_type or ticket.issue_type,
            priority=ticket.alternate_priority or ticket.priority,
            assignment_group=(
                ticket.alternate_assignment_group or ticket.assignment_group
            ),
            resolution_action=(
                ticket.alternate_resolution_action or ticket.resolution_action
            ),
            score_multiplier=ticket.alternate_route_score_multiplier,
            task_id=task_id,
        )
        if alternate_score > chosen_score:
            chosen_score = alternate_score
            chosen_field_scores = alternate_field_scores

    score = max(0.0, min(1.0, chosen_score))
    breakdown = {field: chosen_field_scores[field] for field in TASK_WEIGHTS[task_id]}
    return score, breakdown