email-triage-env / src /models.py
Janesh's picture
Upload folder using huggingface_hub
16d0c12 verified
Raw
History Blame Contribute Delete
7.94 kB
"""
Pydantic models for the Email Triage OpenEnv environment.
Defines the core data structures: Email, EmailTriageObservation, EmailTriageAction,
Reward, and StepRecord. Action and Observation extend the OpenEnv SDK base types
for full compatibility with create_app() and the OpenEnv validation toolchain.
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Dict, List, Literal, Optional
from openenv.core.env_server.types import (
Action as OpenEnvAction,
Observation as OpenEnvObservation,
)
from pydantic import BaseModel, Field, model_validator
logger = logging.getLogger(__name__)
# ── Constants ────────────────────────────────────────────────────────────────
AVAILABLE_FOLDERS: List[str] = [
"inbox", "work", "finance", "meetings", "spam", "archive",
]
ACTION_TYPES: List[str] = ["move", "delete", "snooze", "flag", "mark_spam"]
MAX_STEPS_PER_EPISODE: int = 50
# ── Email ────────────────────────────────────────────────────────────────────
class Email(BaseModel):
"""A single email in the agent's inbox."""
id: int = Field(..., ge=0, description="Unique email identifier")
subject: str = Field(..., min_length=1, max_length=200)
sender: str = Field(..., min_length=3)
sender_domain: str = Field(..., min_length=1)
timestamp: datetime
body_preview: str = Field("", max_length=500)
priority_flag: bool = False
has_attachment: bool = False
is_vip_sender: bool = False
category_hint: Optional[str] = None
ground_truth_folder: str = Field(
"inbox",
description="Correct folder for grading (not exposed to agent)",
)
is_ambiguous: bool = False
acceptable_folders: List[str] = Field(default_factory=list)
def to_agent_view(self) -> Dict[str, Any]:
"""Return a dict without ground-truth fields (safe to show the agent)."""
return self.model_dump(
exclude={"ground_truth_folder", "is_ambiguous", "acceptable_folders"}
)
# ── Observation (extends OpenEnv SDK) ────────────────────────────────────────
class EmailTriageObservation(OpenEnvObservation):
"""What the agent sees at each step.
Extends openenv.core.env_server.types.Observation which provides
done, reward, and metadata fields automatically.
"""
inbox_emails: List[Dict[str, Any]] = Field(
default_factory=list, description="Emails currently in the inbox"
)
available_folders: List[str] = Field(
default_factory=lambda: list(AVAILABLE_FOLDERS),
description="Folders the agent can move emails into",
)
current_step: int = Field(0, ge=0)
max_steps: int = Field(MAX_STEPS_PER_EPISODE, ge=1)
episode_num: int = Field(1, ge=1)
info: Dict[str, Any] = Field(default_factory=dict)
# ── Action (extends OpenEnv SDK) ─────────────────────────────────────────────
class EmailTriageAction(OpenEnvAction):
"""An action the agent takes on an email.
Extends openenv.core.env_server.types.Action which provides
a metadata field automatically.
Supported action types:
- move: Move email to a target folder (requires target_folder).
- delete: Permanently delete email.
- snooze: Temporarily hide email.
- flag: Mark email as important.
- mark_spam: Move email to spam.
"""
action_type: Literal["move", "delete", "snooze", "flag", "mark_spam"] = Field(
..., description="Type of action to perform"
)
email_id: int = Field(..., ge=0, description="ID of the target email")
target_folder: Optional[str] = Field(
None, description="Destination folder (required for 'move')"
)
reason: Optional[str] = Field(
None, max_length=300, description="Optional explanation for the action"
)
@model_validator(mode="after")
def validate_move_has_folder(self) -> "EmailTriageAction":
if self.action_type == "move" and not self.target_folder:
raise ValueError("target_folder is required for 'move' action")
if (
self.target_folder
and self.target_folder not in AVAILABLE_FOLDERS
):
raise ValueError(
f"target_folder must be one of {AVAILABLE_FOLDERS}, "
f"got '{self.target_folder}'"
)
return self
# ── Internal Observation (used by core environment.py) ───────────────────────
class Observation(BaseModel):
"""Internal observation model used by EmailTriageEnv (no SDK dependency)."""
inbox_emails: List[Email] = Field(default_factory=list)
available_folders: List[str] = Field(
default_factory=lambda: list(AVAILABLE_FOLDERS),
)
current_step: int = Field(0, ge=0)
max_steps: int = Field(MAX_STEPS_PER_EPISODE, ge=1)
episode_num: int = Field(1, ge=1)
done: bool = False
info: Dict[str, Any] = Field(default_factory=dict)
def to_agent_view(self) -> Dict[str, Any]:
"""Serialize for the agent, stripping ground-truth fields from emails."""
data = self.model_dump()
data["inbox_emails"] = [e.to_agent_view() for e in self.inbox_emails]
return data
# ── Legacy Action alias (used by internal modules) ───────────────────────────
# Internal modules (reward_shaper, grader, inference) use this simpler model
# that doesn't depend on the SDK for easier testing.
class Action(BaseModel):
"""Standalone action model for internal use (graders, reward shaper)."""
action_type: Literal["move", "delete", "snooze", "flag", "mark_spam"] = Field(
..., description="Type of action to perform"
)
email_id: int = Field(..., ge=0, description="ID of the target email")
target_folder: Optional[str] = Field(None)
reason: Optional[str] = Field(None, max_length=300)
@model_validator(mode="after")
def validate_move_has_folder(self) -> "Action":
if self.action_type == "move" and not self.target_folder:
raise ValueError("target_folder is required for 'move' action")
if self.target_folder and self.target_folder not in AVAILABLE_FOLDERS:
raise ValueError(
f"target_folder must be one of {AVAILABLE_FOLDERS}, "
f"got '{self.target_folder}'"
)
return self
# ── Reward ───────────────────────────────────────────────────────────────────
class Reward(BaseModel):
"""Graded feedback returned after each step."""
value: float = Field(..., ge=0.0, le=1.0, description="Overall reward [0, 1]")
components: Dict[str, float] = Field(
default_factory=dict,
description="Breakdown of reward by component",
)
reason: str = Field("", description="Human-readable explanation")
is_terminal: bool = Field(
False, description="Whether this reward ends the episode"
)
# ── StepRecord ───────────────────────────────────────────────────────────────
class StepRecord(BaseModel):
"""A single step's data, stored for episode history and grading."""
step_num: int
action: Action
email: Email
reward: Reward
done: bool