File size: 10,100 Bytes
753b5ee
 
 
6026955
 
 
 
 
753b5ee
 
 
 
 
 
6026955
 
 
753b5ee
 
 
 
 
 
 
 
 
 
 
 
 
6026955
 
 
 
1816098
 
 
6026955
 
753b5ee
6026955
 
 
 
753b5ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
caa07e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
753b5ee
 
 
f15c8b1
 
 
 
 
 
 
753b5ee
 
f15c8b1
753b5ee
 
f15c8b1
753b5ee
 
f15c8b1
753b5ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f15c8b1
753b5ee
 
 
 
 
f15c8b1
753b5ee
 
 
 
 
 
 
 
 
 
f15c8b1
 
753b5ee
 
 
f15c8b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
753b5ee
f15c8b1
 
82f2af5
 
 
f15c8b1
 
 
 
 
 
 
 
 
 
 
 
753b5ee
 
 
 
 
 
 
 
 
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
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
"""
Inference Script β€” Email Triage RL Environment
===================================
MANDATORY environment variables (injected by hackathon validator):
    API_BASE_URL        The LiteLLM proxy endpoint (REQUIRED)
    API_KEY             The proxy API key (REQUIRED)
    MODEL_NAME          The model identifier (default: gpt-4o-mini)
    TASK_LEVEL          easy | medium | hard (default: easy)

STDOUT FORMAT (required by hackathon evaluator):
    [START] task=<task_name> env=<benchmark> model=<model_name>
    [STEP]  step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>
    [END]   success=<true|false> steps=<n> score=<score> rewards=<r1,r2,...,rn>

Usage (local testing only):
    # Against local server:
    API_KEY=hf_xxx API_BASE_URL=https://router.huggingface.co/v1 python inference.py
"""

import asyncio
import json
import os
import textwrap
from typing import List, Optional

from openai import OpenAI

from email_triage_rl_hackathon import EmailTriageEnv, TriageAction

# ── Config ────────────────────────────────────────────────────────────────────
# These are injected by the hackathon validator. Read them at call time inside main()
# so they are resolved AFTER the validator sets them β€” never use a module-level default
# that could silently bypass the proxy.
MODEL_NAME       = os.environ.get("MODEL_NAME", "gpt-4o-mini")
# ENV_BASE_URL: validator injects this pointing to the running environment server.
# Fallback to the HF Space URL for local testing without ENV_BASE_URL set.
ENV_BASE_URL     = os.environ.get("ENV_BASE_URL", "https://Shivacode-rl-hackathon.hf.space")
LOCAL_IMAGE_NAME = os.environ.get("LOCAL_IMAGE_NAME", "")
TASK_LEVEL       = os.environ.get("TASK_LEVEL", "easy")   # easy | medium | hard
BENCHMARK        = "email_triage_rl_hackathon"
MAX_STEPS        = 15
TEMPERATURE      = 0.0
MAX_TOKENS       = 512
SUCCESS_SCORE_THRESHOLD = 0.5

# ── Prompts ───────────────────────────────────────────────────────────────────
SYSTEM_PROMPT = textwrap.dedent("""
    You are an expert email triage agent working for a company.
    For each email you receive, respond with a JSON object with EXACTLY these fields:
    {
      "category":   one of ["spam", "billing", "technical", "hr", "general"],
      "priority":   one of ["low", "medium", "high", "urgent"],
      "department": one of ["support", "finance", "engineering", "hr", "management"],
      "reply":      "your professional reply draft (empty string for easy tasks)"
    }

    Routing rules:
    - spam               β†’ category=spam,      priority=low,    department=support
    - payment/invoice    β†’ category=billing,   priority=high,   department=finance
    - bugs/login/API     β†’ category=technical, priority=urgent, department=engineering
    - leave/HR/complaint β†’ category=hr,        priority=urgent, department=hr
    - board/executive    β†’ category=hr,        priority=urgent, department=management
    - general/supplies   β†’ category=general,   priority=low,    department=support

    For medium/hard tasks: write a professional reply (minimum 2 sentences).
    For urgent issues: always acknowledge urgency and give a timeframe in the reply.
    Respond ONLY with the JSON object, no other text.
""").strip()


# ── Logging helpers (required stdout format) ──────────────────────────────────
def log_start(task: str, env: str, model: str) -> None:
    print(f"[START] task={task} env={env} model={model}", flush=True)


def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
    error_val = error if error else "null"
    done_val  = str(done).lower()
    print(
        f"[STEP] step={step} action={action} reward={reward:.2f} done={done_val} error={error_val}",
        flush=True,
    )


def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
    rewards_str = ",".join(f"{r:.2f}" for r in rewards)
    print(
        f"[END] success={str(success).lower()} steps={steps} score={score:.3f} rewards={rewards_str}",
        flush=True,
    )


# ── LLM call ─────────────────────────────────────────────────────────────────
def build_user_prompt(obs, step: int, history: List[str]) -> str:
    history_block = "\n".join(history[-3:]) if history else "None"
    return textwrap.dedent(f"""
        Step: {step}
        Task level: {obs.task_level}
        Emails remaining after this: {obs.emails_remaining}

        --- EMAIL ---
        From:    {obs.sender}
        Subject: {obs.subject}

        {obs.body}
        --- END EMAIL ---

        Previous steps:
        {history_block}

        {"Note: Write a professional reply (reply field required)." if obs.task_level in ("medium", "hard") else "Note: Reply field can be empty string for easy tasks."}
    """).strip()


def get_model_action(client: OpenAI, obs, step: int, history: List[str]) -> TriageAction:
    """Call the LLM and parse the JSON response into a TriageAction."""
    user_prompt = build_user_prompt(obs, step, history)
    completion = client.chat.completions.create(
        model=MODEL_NAME,
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user",   "content": user_prompt},
        ],
        temperature=TEMPERATURE,
        max_tokens=MAX_TOKENS,
        stream=False,
    )
    content = (completion.choices[0].message.content or "").strip()

    # Strip markdown code fences if present
    if content.startswith("```"):
        content = content.split("```")[1]
        if content.startswith("json"):
            content = content[4:]
    content = content.strip()

    data = json.loads(content)
    return TriageAction(**data)


# ── Main episode loop ─────────────────────────────────────────────────────────
async def run_episode(
    client: OpenAI,
    env: EmailTriageEnv,
    task_level: str,
) -> None:
    """Run one full episode for a given task level, emitting START/STEP/END logs."""
    history:     List[str]   = []
    rewards:     List[float] = []
    steps_taken: int         = 0
    score:       float       = 0.01
    success:     bool        = False

    log_start(task=task_level, env=BENCHMARK, model=MODEL_NAME)

    try:
        reset_result = await env.reset(seed=42, task_level=task_level)
        obs = reset_result.observation if hasattr(reset_result, "observation") else reset_result

        for step in range(1, MAX_STEPS + 1):
            if obs.done or obs.email_id == "none":
                break

            action = get_model_action(client, obs, step, history)

            action_str = (
                f"category={action.category},priority={action.priority},"
                f"department={action.department},"
                f"reply={repr(action.reply[:40]) if action.reply else repr('')}"
            )

            result = await env.step(action)

            reward      = result.reward or 0.01
            done        = result.done
            steps_taken = step
            obs         = result.observation

            rewards.append(reward)
            log_step(step=step, action=action_str, reward=reward, done=done, error=None)

            history.append(
                f"Step {step} [{obs.email_id}]: "
                f"cat={action.category} pri={action.priority} "
                f"β†’ reward={reward:.2f} | {obs.feedback[:60]}"
            )

            if done:
                break

        score   = sum(rewards) / len(rewards) if rewards else 0.01
        score   = min(max(score, 0.01), 0.99)   # strictly (0, 1) as required
        success = score >= SUCCESS_SCORE_THRESHOLD

    except Exception as exc:
        print(f"[DEBUG] Episode error ({task_level}): {exc}", flush=True)
        score = 0.01

    log_end(success=success, steps=steps_taken, score=score, rewards=rewards)


async def main() -> None:
    api_key      = os.environ.get("API_KEY") or os.environ.get("HF_TOKEN") or os.environ.get("OPENAI_API_KEY")
    api_base_url = os.environ.get("API_BASE_URL")

    if not api_key:
        raise EnvironmentError(
            "API_KEY is not set. The hackathon validator injects API_KEY automatically.\n"
            "For local testing: export API_KEY=hf_your_token_here"
        )
    if not api_base_url:
        api_base_url = "https://router.huggingface.co/v1"
        print(f"[DEBUG] API_BASE_URL not set β€” using fallback: {api_base_url}", flush=True)

    print(f"[DEBUG] API_BASE_URL={api_base_url}", flush=True)
    print(f"[DEBUG] MODEL_NAME={MODEL_NAME}", flush=True)

    client = OpenAI(api_key=api_key, base_url=api_base_url)

    # Always run all three task levels β€” the validator requires at least 3 graded tasks.
    # TASK_LEVEL env var is intentionally ignored here; it only affects the uvicorn server.
    task_levels = ["easy", "medium", "hard"]
    print(f"[DEBUG] Running task levels: {task_levels}", flush=True)

    if LOCAL_IMAGE_NAME:
        print(f"[DEBUG] Connecting via Docker image: {LOCAL_IMAGE_NAME}", flush=True)
        env = await EmailTriageEnv.from_docker_image(LOCAL_IMAGE_NAME)
    else:
        print(f"[DEBUG] Connecting to server: {ENV_BASE_URL}", flush=True)
        env = EmailTriageEnv(base_url=ENV_BASE_URL)

    try:
        for task_level in task_levels:
            await run_episode(client, env, task_level)
    finally:
        try:
            await env.close()
        except Exception as e:
            print(f"[DEBUG] env.close() error: {e}", flush=True)


if __name__ == "__main__":
    asyncio.run(main())