email-copilot-env / env /data_loader.py
rogerdemello's picture
Fix CI formatting gates: ruff format + prettier
7daf5d0
Raw History Blame Contribute Delete
12 kB
from __future__ import annotations
import copy
import random
import threading
from pathlib import Path
from typing import Any
import yaml
_lock = threading.Lock()
_cache: dict[Path, tuple[int, int, Any]] = {}
# =============================================================================
# Synthetic Scenario Combinators
# =============================================================================
# Adversarial email wording patterns for subtle manipulation
ADVERSARIAL_PATTERNS: dict[str, list[dict[str, str]]] = {
"urgency_manipulation": [
{
"pattern": "Need it ASAP",
"replacement": "Need it by end of day",
"subtle_variant": "Need it when you get a chance",
},
{"pattern": "URGENT", "replacement": "Important", "subtle_variant": "Time-sensitive"},
{
"pattern": "Immediate attention required",
"replacement": "Please review when available",
"subtle_variant": "Kindly review at your convenience",
},
],
"social_proof": [
{
"pattern": "Everyone is waiting",
"replacement": "Several team members are ready",
"subtle_variant": "The team is prepared to proceed",
},
{
"pattern": "As discussed",
"replacement": "Following up on",
"subtle_variant": "Building on our previous conversation",
},
],
"artificial_deadline": [
{
"pattern": "Must respond today",
"replacement": "Please respond when possible",
"subtle_variant": "Response appreciated this week",
},
{
"pattern": "Deadline is tomorrow",
"replacement": "Target response: end of week",
"subtle_variant": "Ideally would appreciate response by Friday",
},
],
"guilt_trip": [
{
"pattern": "I've been waiting",
"replacement": "Following up",
"subtle_variant": "Checking in on this",
},
{
"pattern": "No one else can do this",
"replacement": "Your expertise would be valuable",
"subtle_variant": "Your input would be helpful",
},
],
"false_authority": [
{
"pattern": "The CEO wants",
"replacement": "Leadership is interested",
"subtle_variant": "Senior leadership has requested",
},
{
"pattern": "Legal requires",
"replacement": "Compliance recommends",
"subtle_variant": "We should consider",
},
],
}
# Conflicting deadline scenarios
CONFLICTING_DEADLINE_TEMPLATES: list[dict[str, Any]] = [
{
"description": "Two urgent items with overlapping deadlines",
"email_a": {"deadline_minutes": 45, "priority_hint": "high", "business_value": 0.9},
"email_b": {"deadline_minutes": 30, "priority_hint": "high", "business_value": 0.85},
},
{
"description": "Critical deadline vs important deadline",
"email_a": {"deadline_minutes": 60, "priority_hint": "high", "business_value": 0.98},
"email_b": {"deadline_minutes": 90, "priority_hint": "medium", "business_value": 0.7},
},
{
"description": "Multiple items due within same hour",
"emails": [
{"deadline_minutes": 45, "priority_hint": "high"},
{"deadline_minutes": 50, "priority_hint": "high"},
{"deadline_minutes": 55, "priority_hint": "medium"},
],
},
]
def apply_adversarial_pattern(
text: str,
pattern_type: str,
rng: random.Random,
apply_subtle: bool = False,
) -> str:
"""Apply adversarial wording pattern to text."""
patterns = ADVERSARIAL_PATTERNS.get(pattern_type, [])
if not patterns:
return text
entry = rng.choice(patterns)
if apply_subtle and entry.get("subtle_variant"):
replacement = entry["subtle_variant"]
else:
replacement = entry.get("replacement", entry["pattern"])
return text.replace(entry["pattern"], replacement)
def generate_conflicting_deadlines(
scenario: dict[str, Any],
difficulty: str,
rng: random.Random,
) -> list[dict[str, Any]]:
"""Generate conflicting deadline scenarios based on difficulty."""
if difficulty == "easy":
return []
conflicts = []
num_conflicts = 1 if difficulty == "medium" else rng.randint(1, 2)
for _ in range(num_conflicts):
template = rng.choice(CONFLICTING_DEADLINE_TEMPLATES)
conflict = {
"description": template["description"],
"type": "conflicting_deadline",
}
if "emails" in template:
conflict["emails"] = template["emails"]
else:
conflict["email_a"] = template["email_a"].copy()
conflict["email_b"] = template["email_b"].copy()
conflicts.append(conflict)
return conflicts
def generate_interruptions(
base_scenario: dict[str, Any],
difficulty: str,
rng: random.Random,
) -> list[dict[str, Any]]:
"""Generate random mid-episode interruptions based on difficulty."""
existing_interruptions = base_scenario.get("interruptions", [])
if difficulty == "easy":
# No additional interruptions for easy
return existing_interruptions
# Base interruption probability
base_prob = 0.3 if difficulty == "medium" else 0.5
# Generate additional interruptions
additional = []
max_additional = 0 if difficulty == "medium" else rng.randint(0, 2)
for _ in range(max_additional):
if rng.random() < base_prob:
trigger_range = [
rng.randint(30, 60) if difficulty == "medium" else rng.randint(15, 45),
rng.randint(60, 90) if difficulty == "medium" else rng.randint(45, 75),
]
additional.append(
{
"trigger_minute_range": trigger_range,
"email": {
"id": f"int_{rng.randint(1000, 9999)}",
"sender": rng.choice(
[
"colleague@company.com",
"manager@company.com",
"vendor@external.com",
"client@partner.com",
]
),
"sender_role": rng.choice(["internal", "internal", "vendor", "client"]),
"subject": rng.choice(
[
"Quick question",
"Follow-up on earlier item",
"Status check",
"Request for input",
]
),
"body": rng.choice(
[
"Can you take a quick look?",
"Need your thoughts when available.",
"Checking on next steps.",
"Would appreciate your input.",
]
),
"priority_hint": rng.choice(["low", "medium", "high"]),
"deadline_minutes": rng.choice([30, 60, 120, 240]),
"business_value": round(rng.uniform(0.4, 0.8), 2),
"risk_tag": rng.choice(["none", "ops", "finance"]),
},
}
)
return existing_interruptions + additional
def scale_difficulty(
base_scenario: dict[str, Any],
difficulty: str,
rng: random.Random,
) -> dict[str, Any]:
"""Scale scenario complexity based on difficulty level."""
scenario = copy.deepcopy(base_scenario)
if difficulty == "hard":
# Add adversarial patterns to existing emails
emails = scenario.get("emails", [])
for email in emails:
if rng.random() < 0.3: # 30% chance of adversarial pattern
pattern_type = rng.choice(list(ADVERSARIAL_PATTERNS.keys()))
# Apply to subject
if "subject" in email:
email["subject"] = apply_adversarial_pattern(
email["subject"], pattern_type, rng, apply_subtle=True
)
# Apply to body
if "body" in email:
email["body"] = apply_adversarial_pattern(
email["body"], pattern_type, rng, apply_subtle=True
)
# Reduce time budget slightly for hard
current_budget = scenario.get("time_budget", 180)
scenario["time_budget"] = int(current_budget * 0.85)
elif difficulty == "medium":
# Reduce time budget moderately
current_budget = scenario.get("time_budget", 180)
scenario["time_budget"] = int(current_budget * 0.92)
# Add conflicting deadlines for medium/hard
if difficulty in ("medium", "hard"):
conflicts = generate_conflicting_deadlines(scenario, difficulty, rng)
scenario["conflicting_deadlines"] = conflicts
# Generate interruptions
scenario["interruptions"] = generate_interruptions(scenario, difficulty, rng)
return scenario
def resolve_scenario_path(
base_task_id: str,
seed: int,
dir_path: Path,
) -> Path:
"""Resolve which scenario YAML to load for ``base_task_id``.
Default behaviour (the :class:`~env.config.Settings` ``scenario_variants``
flag is off) resolves exactly ``{base_task_id}.yaml`` -- byte-identical to
historical behaviour. When the flag is on, all ``{base_task_id}*.yaml`` files
(canonical + authored variants) are globbed, sorted deterministically, and
one is chosen via ``random.Random(seed)`` so the selection is reproducible.
"""
from .config import get_settings
canonical = dir_path / f"{base_task_id}.yaml"
if not get_settings().scenario_variants:
return canonical
candidates = sorted(p for p in dir_path.glob(f"{base_task_id}*.yaml") if p.is_file())
if not candidates:
return canonical
return random.Random(seed).choice(candidates)
def generate_synthetic_scenario(
base_task_id: str,
seed: int,
difficulty: str = "medium",
scenarios_dir: Path | None = None,
) -> dict[str, Any]:
"""Generate a synthetic scenario with difficulty scaling."""
rng = random.Random(seed)
dir_path = scenarios_dir or SCENARIOS_DIR
path = resolve_scenario_path(base_task_id, seed, dir_path)
if not path.exists():
raise ValueError(f"Missing scenario file: {dir_path / f'{base_task_id}.yaml'}")
base_scenario = load_yaml(path)
scaled = scale_difficulty(base_scenario, difficulty, rng)
return scaled
SCENARIOS_DIR = Path(__file__).resolve().parents[1] / "data" / "scenarios"
def load_yaml(path: Path) -> Any:
if not path.exists():
raise RuntimeError(f"Missing YAML file: {path}")
stat = path.stat()
mtime_ns = stat.st_mtime_ns
size = stat.st_size
with _lock:
cached = _cache.get(path)
if cached and cached[0] == mtime_ns and cached[1] == size:
return copy.deepcopy(cached[2])
with path.open("r", encoding="utf-8") as handle:
data = yaml.safe_load(handle) or {}
with _lock:
_cache[path] = (mtime_ns, size, data)
return copy.deepcopy(data)
def clear_yaml_cache() -> None:
with _lock:
_cache.clear()