File size: 13,101 Bytes
510ab6f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf60b3f
 
510ab6f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
"""
FlexTime β€” Baseline Inference Script
======================================
Two baseline agents:
  1. GreedyBaseline  β€” rule-based, no API key required (DEFAULT)
  2. LLMBaseline     β€” OpenAI API client, reads OPENAI_API_KEY from env

Usage (CLI):
  python -m scripts.baseline                        # greedy, all 3 tasks
  python -m scripts.baseline --llm                  # LLM agent
  python -m scripts.baseline --task task_hard       # single task
  python -m scripts.baseline --seed 0               # different seed

Called by POST /baseline endpoint in app/main.py.
Produces reproducible scores: seed=42 always gives same result.
"""

from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
import time
from datetime import datetime, timezone
from typing import Dict, List, Optional

# Ensure project root is importable when run as script or module
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _ROOT not in sys.path:
    sys.path.insert(0, _ROOT)

from server.engine import FlexTimeEnv, TASK_CONFIGS
from server.models import Action


# ──────────────────────────────────────────────────────────────
#  GREEDY BASELINE AGENT
#  Priority heuristic: skill match β†’ availability β†’ not over hours
#  β†’ fewest assigned hours (fairness) β†’ preferred shift match
# ──────────────────────────────────────────────────────────────

class GreedyAgent:
    """
    Rule-based greedy agent. No API key required.
    Deterministic given the same seed β€” guarantees reproducible scores.
    """

    name = "GreedyBaseline"

    def act(self, obs_dict: Dict) -> Dict:
        unassigned = obs_dict.get("unassigned_shifts", [])
        if not unassigned:
            return {"action_type": "noop"}

        shifts   = {s["id"]: s for s in obs_dict["shifts"]}
        employees = obs_dict["employees"]

        # Track already-assigned (day, period) slots per employee to detect overlaps
        emp_slots: Dict[str, set] = {e["id"]: set() for e in employees}
        for s in obs_dict["shifts"]:
            eid = s.get("assigned_employee_id")
            if eid:
                emp_slots.setdefault(eid, set()).add((s["day"], s["period"]))

        for shift_id in unassigned:
            shf = shifts.get(shift_id)
            if not shf:
                continue

            skill    = shf["required_skill"]
            day      = shf["day"]
            period   = shf["period"]
            duration = shf["duration_hours"]

            candidates = []
            for emp in employees:
                eid = emp["id"]
                # Hard: skill match
                if skill not in emp["skills"]:
                    continue
                # Hard: availability
                if not emp["availability"][day]:
                    continue
                # Hard: max hours
                if emp["assigned_hours"] + duration > emp["max_hours_per_week"]:
                    continue
                # Hard: no overlap on same (day, period)
                if (day, period) in emp_slots.get(eid, set()):
                    continue

                fairness_score = -emp["assigned_hours"]          # fewer hours β†’ better
                pref_bonus     = 0.5 if emp.get("preferred_shift") == period else 0.0
                candidates.append((fairness_score + pref_bonus, eid))

            if candidates:
                candidates.sort(reverse=True)
                return {
                    "action_type": "assign",
                    "employee_id": candidates[0][1],
                    "shift_id": shift_id,
                }

        # No valid assignment found for any unassigned shift β†’ noop
        return {"action_type": "noop"}


# ──────────────────────────────────────────────────────────────
#  LLM BASELINE AGENT  (OpenAI API client)
# ──────────────────────────────────────────────────────────────

class LLMAgent:
    """
    LLM-based agent using the OpenAI API client.
    Credentials read from OPENAI_API_KEY environment variable.
    Falls back to GreedyAgent if API key not set or call fails.
    """

    SYSTEM_PROMPT = """You are an expert workforce scheduling agent.
Your job: assign employees to shifts optimally.

RULES (must follow):
- Employee skills must include the shift's required_skill
- Employee must be available on the shift's day (availability[day] == 1)
- Employee cannot exceed max_hours_per_week
- No two shifts for the same employee on the same (day, period)

You receive the current schedule state as JSON.
Respond with ONLY a valid JSON action object β€” no explanation, no markdown.

Valid formats:
  {"action_type": "assign", "employee_id": "emp001", "shift_id": "shf042"}
  {"action_type": "noop"}
"""

    def __init__(self, model: str = "gpt-4o-mini"):
        from openai import OpenAI  # raises ImportError if not installed
        api_key = os.environ.get("OPENAI_API_KEY")
        if not api_key:
            raise ValueError(
                "OPENAI_API_KEY environment variable not set. "
                "Export it before running with --llm."
            )
        self.client = OpenAI(api_key=api_key)
        self.model  = model
        self.name   = f"LLM ({model})"
        self._greedy_fallback = GreedyAgent()

    def act(self, obs_dict: Dict) -> Dict:
        # Trim observation to fit context window
        slim = {
            "unassigned_shifts": obs_dict["unassigned_shifts"][:8],
            "employees": [
                {k: e[k] for k in
                 ("id","name","skills","availability","assigned_hours","max_hours_per_week","preferred_shift")}
                for e in obs_dict["employees"]
            ],
            "shifts": [
                {k: s[k] for k in ("id","day","period","required_skill","duration_hours")}
                for s in obs_dict["shifts"]
                if s["id"] in obs_dict["unassigned_shifts"][:8]
            ],
        }
        try:
            resp = self.client.chat.completions.create(
                model=self.model,
                messages=[
                    {"role": "system",  "content": self.SYSTEM_PROMPT},
                    {"role": "user",    "content": json.dumps(slim)},
                ],
                max_tokens=80,
                temperature=0.0,
            )
            raw = resp.choices[0].message.content.strip()
            raw = raw.replace("```json","").replace("```","").strip()
            return json.loads(raw)
        except Exception as exc:
            print(f"[LLMAgent] API error ({exc}), falling back to greedy.")
            return self._greedy_fallback.act(obs_dict)


# ──────────────────────────────────────────────────────────────
#  EPISODE RUNNER
# ──────────────────────────────────────────────────────────────

def run_episode(env: FlexTimeEnv, agent, task_id: str, seed: int = 42) -> Dict:
    """Run one full episode. Returns grader result + episode stats."""
    obs      = env.reset(task_id=task_id, seed=seed)
    obs_dict = obs.model_dump()

    total_reward = 0.0
    steps        = 0
    noop_streak  = 0

    while not obs_dict.get("done", False):
        action_dict = agent.act(obs_dict)
        action      = Action(**action_dict)
        result      = env.step(action)
        obs_dict    = result.observation.model_dump()
        total_reward += result.reward.total
        steps        += 1

        if action.action_type == "noop":
            noop_streak += 1
            if noop_streak >= 5:
                break          # agent is stuck, stop wasting steps
        else:
            noop_streak = 0

    grade = env.grade()
    grade["episode_reward"] = round(total_reward, 4)
    grade["steps_used"]     = steps
    grade["task_id"]        = task_id
    return grade


# ──────────────────────────────────────────────────────────────
#  ASYNC RUNNER β€” called by POST /baseline
# ──────────────────────────────────────────────────────────────

async def run_baseline(use_llm: bool = False) -> Dict:
    """
    Run baseline agent on all 3 tasks.
    Called by POST /baseline endpoint in app/main.py.
    Returns reproducible results with seed=42.
    """
    if use_llm:
        try:
            agent = LLMAgent()
        except (ImportError, ValueError) as exc:
            print(f"[run_baseline] LLM unavailable ({exc}), using GreedyBaseline.")
            agent = GreedyAgent()
    else:
        agent = GreedyAgent()

    env     = FlexTimeEnv()
    results = []

    for task_id in TASK_CONFIGS:
        t0     = time.time()
        result = run_episode(env, agent, task_id, seed=42)
        result["elapsed_seconds"] = round(time.time() - t0, 3)
        results.append(result)
        print(f"[Baseline] {task_id}: score={result['score']:.4f}  "
              f"steps={result['steps_used']}  elapsed={result['elapsed_seconds']}s")

    mean_score = round(sum(r["score"] for r in results) / len(results), 4)

    return {
        "model":      agent.name if hasattr(agent, "name") else "GreedyBaseline",
        "seed":       42,
        "results":    results,
        "mean_score": mean_score,
        "timestamp":  datetime.now(timezone.utc).isoformat(),
    }


# ──────────────────────────────────────────────────────────────
#  CLI ENTRY POINT
#  python -m scripts.baseline [--llm] [--task TASK] [--seed N]
# ──────────────────────────────────────────────────────────────

async def _cli_async():
    parser = argparse.ArgumentParser(
        description="FlexTime Baseline Inference β€” reproducible scores on all 3 tasks"
    )
    parser.add_argument("--llm",   action="store_true", help="Use OpenAI LLM agent")
    parser.add_argument("--model", default="gpt-4o-mini", help="OpenAI model (with --llm)")
    parser.add_argument("--task",  default=None, choices=list(TASK_CONFIGS),
                        help="Run a single task only")
    parser.add_argument("--seed",  type=int, default=42, help="Random seed (default 42)")
    args = parser.parse_args()

    # Build agent
    if args.llm:
        try:
            agent = LLMAgent(model=args.model)
        except (ImportError, ValueError) as e:
            print(f"[ERROR] {e}")
            sys.exit(1)
    else:
        agent = GreedyAgent()

    env          = FlexTimeEnv()
    tasks_to_run = [args.task] if args.task else list(TASK_CONFIGS)
    results      = []

    print(f"\n{'='*62}")
    print(f"  FlexTime Baseline  |  Agent: {agent.name}  |  Seed: {args.seed}")
    print(f"{'='*62}\n")

    for task_id in tasks_to_run:
        cfg = TASK_CONFIGS[task_id]
        print(f"  [{task_id}]  {cfg['name']}  ({cfg['difficulty']})")
        t0     = time.time()
        result = run_episode(env, agent, task_id, seed=args.seed)
        elapsed = round(time.time() - t0, 3)

        status = "βœ… PASS" if result["passed"] else "❌ FAIL"
        print(f"    Score:      {result['score']:.4f}  {status}  "
              f"(target β‰₯ {cfg['target_score']})")
        print(f"    Steps:      {result['steps_used']} / {cfg['max_steps']}")
        print(f"    Ep Reward:  {result['episode_reward']:.4f}")
        print(f"    Elapsed:    {elapsed}s")
        print(f"    Breakdown:")
        for k, v in result["breakdown"].items():
            bar = "β–ˆ" * int(v * 20)
            print(f"      {k:<26} {v:.4f}  {bar}")
        print()
        result["elapsed_seconds"] = elapsed
        results.append(result)

    mean = round(sum(r["score"] for r in results) / len(results), 4)
    print(f"{'='*62}")
    print(f"  Mean Score: {mean:.4f}")
    print(f"{'='*62}\n")

    output = {
        "model":      agent.name,
        "seed":       args.seed,
        "results":    results,
        "mean_score": mean,
        "timestamp":  datetime.now(timezone.utc).isoformat(),
    }
    print(json.dumps(output, indent=2))


def main():
    asyncio.run(_cli_async())


if __name__ == "__main__":
    main()