CoolFace
Apppublic

TanujInsane/document-classification-env

sourceHugging Faceupdated 5mo agoView on Hugging Face
3likes
environment.py301 linesDownload Raw Back to root
1import gymnasium as gym2from gymnasium import spaces3import numpy as np4import time5import random6from tasks import TaskDataGenerator, get_task_config7from constants import CATEGORIES8 9 10class DocumentClassificationEnv(gym.Env):11    metadata = {12        "render_modes": ["human"],13        "name": "DocumentClassification-v1"14    }15    16    # Different difficulty levels = different number of categories17    # Easy = 5, Medium = 10, Hard = 22 (gets gnarly fast)18    # Category Maps are now centralized in constants.py19    CATEGORY_MAPS = CATEGORIES20    21    def __init__(self, task_difficulty="easy", seed=None):22        super().__init__()23        self.task_difficulty = task_difficulty24        if task_difficulty not in ["easy", "medium", "hard"]:25            raise ValueError(f"Pick easy, medium, or hard. Got: {task_difficulty}")26        if seed is not None:27            np.random.seed(seed)28            random.seed(seed)29        self.task_config = get_task_config(task_difficulty)30        self.num_categories = self.task_config["num_categories"]31        self.feature_dim = self.task_config["feature_dim"]32        self.action_space = spaces.Discrete(self.num_categories + 2) # +2 for Tools33        self.observation_space = spaces.Dict({34            "document_id": spaces.Text(max_length=20),35            "content": spaces.Text(max_length=10000),36            "word_count": spaces.Box(low=0, high=10000, shape=(1,), dtype=np.int32),37            "has_urgency_markers": spaces.MultiBinary(1),38            "features": spaces.Box(low=-1.0, high=1.0, shape=(self.feature_dim,), dtype=np.float32),39            "document_index": spaces.Box(low=0, high=10000, shape=(1,), dtype=np.int32),40            "total_documents": spaces.Box(low=0, high=10000, shape=(1,), dtype=np.int32),41            "metadata_response": spaces.Text(max_length=1000),42            "sla_remaining": spaces.Box(low=0, high=300, shape=(1,), dtype=np.float32),43        })44        self.data_generator = TaskDataGenerator(task_difficulty, seed)45        self.sla_seconds = self.task_config.get("sla_seconds", None)46        self.documents = None47        self.labels = None48        self.current_document_index = 049        self.episode_rewards = []50        self.correct = 051        self.total = 052        self.episode_times = []53        self.current_metadata_response = "Not requested. Use action to request customer history."54        self.doc_start_time = None   # Tracks time spent on current document55        self.sla_breaches = 0        # Count of SLA breaches56        self.start_time = None57        self.action_history = []58        self.reward_history = []59        self.processing_times = []60    61    def reset(self, seed=None, options=None):62        if seed is not None:63            np.random.seed(seed)64            random.seed(seed)65        self.documents, self.labels = self.data_generator.generate_task_data()66        self.current_document_index = 067        self.episode_rewards = []68        self.correct = 069        self.total = 070        self.episode_times = []71        self.action_history = []72        self.reward_history = []73        self.processing_times = []74        self.current_metadata_response = "Not requested. Use action to request customer history."75        self.doc_start_time = time.time()76        self.sla_breaches = 077        self.start_time = time.time()78        return self._obs(), {}79    80    def _compute_sla_penalty(self):81        """Compute SLA penalty based on time spent on current document.82        The longer the agent deliberates, the higher the penalty."""83        if self.sla_seconds is None or self.doc_start_time is None:84            return 0.085        elapsed = time.time() - self.doc_start_time86        if elapsed > self.sla_seconds:87            self.sla_breaches += 188            # Penalty scales with how much over SLA (capped at -0.3)89            overage_ratio = min((elapsed - self.sla_seconds) / self.sla_seconds, 1.0)90            return -0.3 * overage_ratio91        return 0.092 93    def step(self, action):94        t0 = time.time()95        96        # Enforce per-episode time limits97        limit_per_ep = self.task_config.get("time_limit_per_episode")98        if limit_per_ep is not None and self.start_time is not None:99            if time.time() - self.start_time > limit_per_ep:100                info = {"episode_complete": True, "time_limit_exceeded": True, "episode_accuracy": self.correct / self.total if self.total > 0 else 0.0}101                info["episode_summary"] = self._summary()102                return self._obs(), 0.0, True, False, info103        104        if self.current_document_index >= len(self.documents):105            return self._obs(), 0.0, True, False, {"episode_complete": True}106        107        true_label = self.labels[self.current_document_index]108        true_cat_str = self.CATEGORY_MAPS[self.task_difficulty][true_label]109        sla_penalty = self._compute_sla_penalty()110        111        # Tool: Request Metadata112        if action == self.num_categories:113            if random.random() < 0.15:114                self.current_metadata_response = "[SYSTEM: Database timeout. No history available.]"115            else:116                self.current_metadata_response = f"[SYSTEM: User history suggests high correlation with {true_cat_str.split('-')[0]}]"117            118            tool_reward = -0.05 + sla_penalty  # Tool cost + SLA pressure119            self.episode_rewards.append(tool_reward)120            self.episode_times.append(time.time() - t0)121            122            info = {123                "is_correct": False,124                "true_category": true_cat_str,125                "predicted_category": "TOOL_METADATA",126                "processing_time_ms": (time.time() - t0) * 1000,127                "episode_accuracy": self.correct / self.total if self.total > 0 else 0.0,128                "sla_penalty": sla_penalty,129            }130            return self._obs(), tool_reward, False, False, info131            132        # Tool: Escalate to Human133        elif action == self.num_categories + 1:134            self.current_metadata_response = "Not requested. Use action to request customer history."135            esc_reward = 0.0 + sla_penalty  # Escalation + SLA pressure136            self.episode_rewards.append(esc_reward)137            self.total += 1138            self.episode_times.append(time.time() - t0)139            140            self.current_document_index += 1141            self.doc_start_time = time.time()  # Reset SLA timer for next doc142            done = self.current_document_index >= len(self.documents)143            144            info = {145                "is_correct": False,146                "true_category": true_cat_str,147                "predicted_category": "ESCALATED",148                "processing_time_ms": (time.time() - t0) * 1000,149                "episode_accuracy": self.correct / self.total if self.total > 0 else 0.0,150                "sla_penalty": sla_penalty,151            }152            if done: info["episode_summary"] = self._summary()153            return self._obs(), esc_reward, done, False, info154            155        # Standard Classification156        self.current_metadata_response = "Not requested. Use action to request customer history."157        is_correct = action == true_label158        159        processing_time = time.time() - t0160        is_vip = self.documents[self.current_document_index].get("is_vip", False)161        error_penalty = -0.5 if is_vip else -0.4162        base_reward = 1.0 if is_correct else error_penalty163        164        # Speed Bonus: Scale reward if classification is correct and fast165        speed_bonus = 0.0166        if is_correct and self.task_difficulty in ["medium", "hard"]:167            # Bonus if under 1.0s (medium) or 0.5s (hard)168            threshold = 1.0 if self.task_difficulty == "medium" else 0.5169            if processing_time < threshold:170                speed_bonus = 0.2 * (1.0 - (processing_time / threshold))171        172        # Processing Cost: Fixed deduction for resource usage (Hard only)173        proc_cost = -0.01 if self.task_difficulty == "hard" else 0.0174        175        reward = base_reward + sla_penalty + speed_bonus + proc_cost176        177        self.episode_rewards.append(reward)178        self.action_history.append(action)179        self.reward_history.append(reward)180        self.processing_times.append(processing_time)181 182        if is_correct:183            self.correct += 1184        self.total += 1185        self.episode_times.append(processing_time)186        187        self.current_document_index += 1188        self.doc_start_time = time.time()  # Reset SLA timer for next doc189        done = self.current_document_index >= len(self.documents)190        obs = self._obs()191        192        try:193            pred_cat = self.CATEGORY_MAPS[self.task_difficulty][action]194        except KeyError:195            pred_cat = f"INVALID_ACTION_{action}"196            197        info = {198            "is_correct": is_correct,199            "true_category": true_cat_str,200            "predicted_category": pred_cat,201            "processing_time_ms": processing_time * 1000,202            "episode_accuracy": self.correct / self.total if self.total > 0 else 0.0,203            "sla_penalty": sla_penalty,204            "speed_bonus": speed_bonus,205            "proc_cost": proc_cost,206        }207        if done:208            info["episode_summary"] = self._summary()209        return obs, reward, done, False, info210    211    def state(self):212        """Return the full internal environment state with rich diagnostic history."""213        # Ground-truth label for the current document (hidden from agent)214        current_true_label = None215        current_true_category = None216        if self.documents and self.current_document_index < len(self.documents):217            true_label_idx = self.labels[self.current_document_index]218            current_true_label = int(true_label_idx)219            current_true_category = self.CATEGORY_MAPS[self.task_difficulty].get(current_true_label)220 221        return {222            # ── Progress ──────────────────────────────────────────────────223            "document_index": self.current_document_index,224            "total_documents": len(self.documents) if self.documents else 0,225            # ── Hidden ground truth (not in observation) ──────────────────226            "current_true_label": current_true_label,227            "current_true_category": current_true_category,228            # ── Accumulated metrics ───────────────────────────────────────229            "total_reward": sum(self.episode_rewards),230            "correct": self.correct,231            "total_classified": self.total,232            "accuracy": self.correct / self.total if self.total > 0 else 0.0,233            "avg_time_ms": float(np.mean(self.episode_times) * 1000) if self.episode_times else 0.0,234            "sla_breaches": self.sla_breaches,235            # ── History Traces (NEW) ──────────────────────────────────────236            "action_history": list(self.action_history),237            "reward_history": [float(r) for r in self.reward_history],238            "processing_times_ms": [float(t * 1000) for t in self.processing_times],239            # ── Environment config ────────────────────────────────────────240            "difficulty": self.task_difficulty,241            "num_categories": self.num_categories,242            "sla_seconds": self.sla_seconds,243        }244    245    def _get_sla_remaining(self):246        """Calculate SLA time remaining for current document."""247        if self.sla_seconds is None or self.doc_start_time is None:248            return 999.0  # No SLA pressure249        elapsed = time.time() - self.doc_start_time250        return max(0.0, self.sla_seconds - elapsed)251 252    def _obs(self):253        sla_remaining = self._get_sla_remaining()254        if self.current_document_index >= len(self.documents):255            return {256                "document_id": "",257                "content": "",258                "word_count": np.array([0], dtype=np.int32),259                "has_urgency_markers": np.array([0], dtype=np.int8),260                "features": np.zeros(self.feature_dim, dtype=np.float32),261                "document_index": np.array([self.current_document_index], dtype=np.int32),262                "total_documents": np.array([len(self.documents)], dtype=np.int32),263                "metadata_response": self.current_metadata_response,264                "sla_remaining": np.array([0.0], dtype=np.float32),265            }266        doc = self.documents[self.current_document_index]267        return {268            "document_id": doc["id"],269            "content": doc["content"],270            "word_count": np.array([doc["word_count"]], dtype=np.int32),271            "has_urgency_markers": np.array([int(doc["has_urgency_markers"])], dtype=np.int8),272            "features": np.array(doc["features"], dtype=np.float32),273            "document_index": np.array([self.current_document_index], dtype=np.int32),274            "total_documents": np.array([len(self.documents)], dtype=np.int32),275            "metadata_response": self.current_metadata_response,276            "sla_remaining": np.array([sla_remaining], dtype=np.float32),277        }278    279    def _summary(self):280        total_reward = sum(self.episode_rewards)281        avg_reward = total_reward / len(self.episode_rewards) if self.episode_rewards else 0.0282        acc = self.correct / self.total if self.total > 0 else 0.0283        avg_time = np.mean(self.episode_times) * 1000 if self.episode_times else 0.0284        elapsed = time.time() - self.start_time285        return {286            "total_reward": total_reward,287            "avg_reward": avg_reward,288            "accuracy": acc,289            "total_classified": self.total,290            "avg_time_ms": avg_time,291            "total_time_s": elapsed,292        }293    294    def render(self):295        pass296    297    def close(self):298        pass299 300 301