Raje19112003/Invoice_Dispute_Resolution_Environment
0
1"""2Invoice Dispute Resolution Environment — Core Logic3Implements reset(), step(), and state() for the OpenEnv framework.4 5An AI agent receives a billing dispute scenario and must:6 1. Analyse the invoice, customer complaint, and company policy7 2. Choose the correct resolution (full refund, partial refund, reject, escalate, request_info)8 3. Draft a professional, policy-compliant customer response9 10DIFFICULTY LEVELS (3 Tasks):11 EASY → Obvious disputes (duplicate charges, clear service failures)12 MEDIUM → Ambiguous cases (wrong amount, customer history needed)13 HARD → Complex cases (unauthorized charges, policy edge cases)14 15Reward is shaped by:16 - Correctness of the decision (+0.5)17 - Policy compliance (+0.2)18 - Response quality (tone & clarity) (+0.2)19 - Efficiency (fewer steps = bonus) (+0.1)20 - Wrong decisions (-0.3)21 - Policy violations (-0.2)22"""23 24import random25import uuid26from datetime import datetime, timedelta27from typing import Optional, Literal28 29from models import DisputeAction, DisputeObservation, DisputeState30 31 32# ─────────────────────────────────────────────────────────────33# TASK DEFINITIONS - 3 difficulty levels34# ─────────────────────────────────────────────────────────────35 36# EASY: Obvious, clear-cut disputes37EASY_SCENARIOS = [38 {39 "dispute_type": "duplicate_charge",40 "invoice_amount": 99.99,41 "line_items": [42 {"item": "Pro Plan Subscription", "amount": 99.99},43 {"item": "Pro Plan Subscription", "amount": 99.99},44 ],45 "customer_message": (46 "I was charged twice for my Pro Plan this month. "47 "Please refund the duplicate charge of $99.99."48 ),49 "correct_decision": "full_refund",50 "expected_refund": 99.99,51 "difficulty": "easy",52 },53 {54 "dispute_type": "service_not_received",55 "invoice_amount": 59.99,56 "line_items": [57 {"item": "Premium Support (Monthly)", "amount": 59.99},58 ],59 "customer_message": (60 "I was billed for Premium Support but nobody responded to my tickets. "61 "This service was never provided."62 ),63 "correct_decision": "full_refund",64 "expected_refund": 59.99,65 "difficulty": "easy",66 },67]68 69# MEDIUM: Requires understanding customer history and policy70MEDIUM_SCENARIOS = [71 {72 "dispute_type": "wrong_amount",73 "invoice_amount": 249.99,74 "line_items": [75 {"item": "Enterprise Storage (500 GB)", "amount": 149.99},76 {"item": "API Add-on", "amount": 100.00},77 ],78 "customer_message": (79 "My invoice shows $249.99 but I was quoted $199.99 during sign-up. "80 "I have the email confirmation. This is incorrect billing."81 ),82 "correct_decision": "partial_refund",83 "expected_refund": 50.00,84 "difficulty": "medium",85 },86 {87 "dispute_type": "sla_breach",88 "invoice_amount": 199.99,89 "line_items": [90 {"item": "SLA Support Plan", "amount": 199.99},91 ],92 "customer_message": (93 "Your SLA guarantees 4-hour response time. I submitted a critical issue "94 "and got no response for 48 hours. This is a breach of contract."95 ),96 "correct_decision": "partial_refund",97 "expected_refund": 100.00,98 "difficulty": "medium",99 },100]101 102# HARD: Complex, ambiguous, requires policy knowledge103HARD_SCENARIOS = [104 {105 "dispute_type": "unauthorized_charge",106 "invoice_amount": 399.99,107 "line_items": [108 {"item": "Enterprise Upgrade", "amount": 399.99},109 ],110 "customer_message": (111 "I never authorised an upgrade to Enterprise. Someone on my team must "112 "have done this by mistake. Please reverse this charge."113 ),114 "correct_decision": "escalate",115 "expected_refund": None,116 "difficulty": "hard",117 },118 {119 "dispute_type": "policy_ambiguity",120 "invoice_amount": 1299.00,121 "line_items": [122 {"item": "Annual Enterprise Plan", "amount": 1299.00},123 ],124 "customer_message": (125 "Your pricing page showed $99/month for Enterprise, which is $1188/year. "126 "You charged $1299. Is this a mistake or a different plan?"127 ),128 "correct_decision": "escalate",129 "expected_refund": None,130 "difficulty": "hard",131 },132]133 134ALL_SCENARIOS = {135 "easy": EASY_SCENARIOS,136 "medium": MEDIUM_SCENARIOS,137 "hard": HARD_SCENARIOS,138}139 140CUSTOMER_TIERS = ["standard", "premium", "enterprise"]141 142POLICIES = {143 "standard": {"max_auto_refund": 100, "escalate_above": 300, "response_sla_hours": 48},144 "premium": {"max_auto_refund": 250, "escalate_above": 500, "response_sla_hours": 24},145 "enterprise": {"max_auto_refund": 500, "escalate_above": 1000, "response_sla_hours": 4},146}147 148 149# ─────────────────────────────────────────────────────────────150# ENVIRONMENT CLASS151# ─────────────────────────────────────────────────────────────152 153class InvoiceDisputeEnv:154 """155 OpenEnv-compatible Invoice Dispute Resolution Environment.156 Supports 3 difficulty levels: easy, medium, hard.157 """158 159 MAX_STEPS = 3160 161 def __init__(self, difficulty: Literal["easy", "medium", "hard"] = "medium"):162 self.difficulty = difficulty163 self._state: Optional[DisputeState] = None164 self._scenario = None165 166 # ── reset ─────────────────────────────────────────────────167 def reset(self) -> DisputeObservation:168 """Start a new dispute episode with a scenario from the selected difficulty."""169 scenario = random.choice(ALL_SCENARIOS[self.difficulty])170 tier = random.choice(CUSTOMER_TIERS)171 policy = POLICIES[tier]172 173 invoice_date = (datetime.today() - timedelta(days=random.randint(1, 14))).strftime("%Y-%m-%d")174 175 self._state = DisputeState(176 invoice_id=f"INV-{uuid.uuid4().hex[:8].upper()}",177 invoice_amount=scenario["invoice_amount"],178 invoice_date=invoice_date,179 line_items=scenario["line_items"],180 dispute_type=scenario["dispute_type"],181 customer_message=scenario["customer_message"],182 customer_tier=tier,183 customer_history={184 "total_orders": random.randint(1, 50),185 "disputes_filed": random.randint(0, 3),186 "churn_risk": random.choice(["low", "medium", "high"]),187 },188 policy=policy,189 step_count=0,190 max_steps=self.MAX_STEPS,191 is_done=False,192 total_reward=0.0,193 correct_decision=scenario["correct_decision"],194 )195 196 self._scenario = scenario197 198 return DisputeObservation(199 step_result="New dispute received. Analyse the invoice and customer complaint, then resolve.",200 reward=0.0,201 done=False,202 feedback="Episode started. No action taken yet.",203 customer_reaction=None,204 )205 206 # ── step ──────────────────────────────────────────────────207 def step(self, action: DisputeAction) -> DisputeObservation:208 """Process the agent's resolution decision and return reward + feedback."""209 if self._state is None:210 raise RuntimeError("Call reset() before step().")211 if self._state.is_done:212 raise RuntimeError("Episode already finished. Call reset() to start a new one.")213 214 self._state.step_count += 1215 reward = 0.0216 feedback_parts = []217 218 correct = self._scenario["correct_decision"]219 expected_refund = self._scenario.get("expected_refund")220 policy = self._state.policy221 222 # ── Decision correctness ───────────────────────────────223 if action.decision == correct:224 reward += 0.5225 feedback_parts.append("✅ Correct decision.")226 else:227 reward -= 0.3228 feedback_parts.append(f"❌ Wrong (expected {correct}, got {action.decision}).")229 230 # ── Policy compliance ──────────────────────────────────231 policy_ok = True232 if action.decision == "full_refund":233 if self._state.invoice_amount > policy["max_auto_refund"]:234 reward -= 0.2235 feedback_parts.append(236 f"⚠️ Refund ${self._state.invoice_amount} exceeds limit ${policy['max_auto_refund']}."237 )238 policy_ok = False239 240 if action.decision == "partial_refund":241 if action.refund_amount is None:242 reward -= 0.1243 feedback_parts.append("⚠️ partial_refund chosen but refund_amount missing.")244 policy_ok = False245 elif expected_refund and abs(action.refund_amount - expected_refund) < 10:246 reward += 0.1247 feedback_parts.append(f"✅ Refund ${action.refund_amount} within range.")248 249 if action.decision == "escalate" and self._state.invoice_amount < policy["escalate_above"]:250 reward -= 0.1251 feedback_parts.append(f"⚠️ Escalation unnecessary (${self._state.invoice_amount} < ${policy['escalate_above']}).")252 policy_ok = False253 254 if policy_ok and action.decision == correct:255 reward += 0.2256 feedback_parts.append("✅ Policy compliant.")257 258 # ── Response quality ───────────────────────────────────259 response_score = self._grade_response(action.response_text, action.decision)260 reward += response_score * 0.2261 if response_score >= 0.8:262 feedback_parts.append("✅ Professional response.")263 elif response_score >= 0.5:264 feedback_parts.append("ℹ️ Response acceptable but could be better.")265 else:266 feedback_parts.append("❌ Response too brief or unprofessional.")267 268 # ── Efficiency bonus ───────────────────────────────────269 if self._state.step_count == 1 and action.decision == correct:270 reward += 0.1271 feedback_parts.append("🚀 Efficiency bonus (1-step resolution).")272 273 # ── Termination ────────────────────────────────────────274 done = False275 if action.decision in ("full_refund", "partial_refund", "reject", "escalate"):276 done = True277 elif self._state.step_count >= self._state.max_steps:278 done = True279 reward -= 0.2280 feedback_parts.append("⏰ Max steps exceeded.")281 282 reward = max(-1.0, min(1.0, reward))283 284 self._state.is_done = done285 self._state.total_reward += reward286 287 if done:288 self._state.correct_decision = correct289 feedback_parts.append(f"\n📋 Ground truth: {correct}")290 291 customer_reaction = self._simulate_customer_reaction(action.decision, reward)292 293 return DisputeObservation(294 step_result=f"Step {self._state.step_count}: {action.decision}",295 reward=round(reward, 4),296 done=done,297 feedback=" | ".join(feedback_parts),298 customer_reaction=customer_reaction,299 )300 301 # ── state ─────────────────────────────────────────────────302 @property303 def state(self) -> DisputeState:304 if self._state is None:305 raise RuntimeError("Call reset() first.")306 return self._state307 308 # ── helpers ───────────────────────────────────────────────309 def _grade_response(self, text: str, decision: str) -> float:310 if not text or len(text) < 20:311 return 0.0312 score = 0.5313 text_lower = text.lower()314 empathy = ["apologise", "sorry", "understand", "inconvenience", "appreciate"]315 if any(w in text_lower for w in empathy):316 score += 0.15317 action_words = ["refund", "credit", "escalate", "investigate", "resolve"]318 if any(w in text_lower for w in action_words):319 score += 0.15320 if text == text.upper():321 score -= 0.2322 if len(text) > 60:323 score += 0.1324 if len(text) > 150:325 score += 0.1326 return min(1.0, max(0.0, score))327 328 def _simulate_customer_reaction(self, decision: str, reward: float) -> str:329 if reward >= 0.6:330 return "Thank you! I appreciate the quick resolution."331 elif reward >= 0.2:332 return "OK, I understand. Let's proceed."333 else:334 return "I'm disappointed with this response."335 