dhnkhr commited on
Commit
9753ee2
·
0 Parent(s):

Production-ready: Clean code with Groq API integration, LoRA model support, and FastAPI app

Browse files
Files changed (17) hide show
  1. .dockerignore +51 -0
  2. .gitattributes +2 -0
  3. .gitignore +13 -0
  4. Dockerfile +33 -0
  5. README.md +82 -0
  6. app.py +230 -0
  7. environment.py +602 -0
  8. frontend/app.js +224 -0
  9. frontend/index.html +141 -0
  10. frontend/style.css +686 -0
  11. inference.py +1022 -0
  12. ml_policy.py +276 -0
  13. openenv.yaml +42 -0
  14. pyproject.toml +18 -0
  15. requirements.txt +11 -0
  16. server/app.py +16 -0
  17. tasks.py +481 -0
.dockerignore ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VCS metadata
2
+ .git
3
+ .gitignore
4
+ .gitattributes
5
+
6
+ # Python caches and virtual environments
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.pyo
10
+ *.pyd
11
+ *.so
12
+ .venv/
13
+ venv/
14
+ env/
15
+ ENV/
16
+ .pytest_cache/
17
+ .mypy_cache/
18
+ .ipynb_checkpoints/
19
+
20
+ # Editor/system files
21
+ .vscode/
22
+ .idea/
23
+ *.swp
24
+ *.swo
25
+ .DS_Store
26
+ Thumbs.db
27
+
28
+ # Build/test outputs
29
+ dist/
30
+ build/
31
+ *.egg-info/
32
+ pip-wheel-metadata/
33
+ htmlcov/
34
+ .coverage
35
+ .coverage.*
36
+
37
+ # Logs and temp
38
+ *.log
39
+ tmp/
40
+ temp/
41
+
42
+ # Keep runtime ML artifact, drop bulky generated outputs
43
+ artifacts/*
44
+ !artifacts/ml_policy.pkl
45
+ !artifacts/ml_policy_report.json
46
+ artifacts/sweep/
47
+ artifacts/lookahead_tuning/
48
+ artifacts/inference_*.json
49
+ artifacts/autopilot_*.json
50
+ artifacts/demo_*.json
51
+ artifacts/run_now_*.json
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ *.pkl filter=lfs diff=lfs merge=lfs -text
2
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Large model files
2
+ *.pkl
3
+ *.safetensors
4
+ *.jsonl
5
+ artifacts/
6
+ flight-rebooking-lora/
7
+ venv/
8
+ __pycache__/
9
+ *.pyc
10
+ .DS_Store
11
+ *.egg-info/
12
+ dist/
13
+ build/
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ # Install system dependencies for bitsandbytes
4
+ RUN apt-get update && apt-get install -y \
5
+ build-essential \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ WORKDIR /app
9
+
10
+ # Set environment variables
11
+ ENV PYTHONDONTWRITEBYTECODE=1
12
+ ENV PYTHONUNBUFFERED=1
13
+
14
+ # Install Python dependencies
15
+ COPY requirements.txt .
16
+ RUN pip install --no-cache-dir -r requirements.txt
17
+
18
+ # Copy project files
19
+ COPY app.py .
20
+ COPY environment.py .
21
+ COPY tasks.py .
22
+ COPY openenv.yaml .
23
+ COPY frontend/ ./frontend/
24
+ COPY flight-rebooking-lora/ ./flight-rebooking-lora/
25
+
26
+ # Create a place for HF cache
27
+ RUN mkdir -p /app/.cache && chmod 777 /app/.cache
28
+ ENV HF_HOME=/app/.cache
29
+
30
+ EXPOSE 7860
31
+
32
+ # Start the application
33
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Storm Recovery Agent ✈️
3
+ emoji: ⛈️
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ tags:
9
+ - openenv
10
+ - simulation
11
+ - logistics
12
+ - llama-3
13
+ - ai-agent
14
+ ---
15
+
16
+ # ✈️ Storm Recovery Agent: Fine-Tuning LLMs for High-Stakes Logistics
17
+
18
+ > "The storm just cancelled 40 flights. You have 2,000 stranded passengers and only 500 available seats. Who gets home first?"
19
+
20
+ This is the **Flight Rebooking OpenEnv**, a professional simulation designed to train AI agents to handle the complex, high-stakes trade-offs of airline irregular operations (IROPS).
21
+
22
+ ## 🌟 The Challenge (Theme #3.1: Professional Tasks)
23
+ When weather strikes, human operation desks must balance:
24
+ - **Loyalty SLAs**: Ensuring Platinum and Gold members are prioritized.
25
+ - **Connection Deadlines**: Rebooking passengers before their next vital flight.
26
+ - **Budget Limits**: Deciding when to use expensive partner airlines or hotels.
27
+ - **Inventory Scarcity**: Making every seat count in a zero-sum game.
28
+
29
+ Generic LLMs often struggle with these "constrained optimization" tasks. This environment provides the structured feedback needed to turn a raw LLM into a **Disruption Specialist**.
30
+
31
+ ## 🧠 The Solution: Fine-Tuned Llama 3 8B
32
+ We didn't just build a simulator; we trained an agent to master it.
33
+ - **Base Model**: Meta Llama-3-8B-Instruct.
34
+ - **Training**: Fine-tuned on **800+ expert trajectories** using LoRA (Unsloth).
35
+ - **Strategy**: The agent learned to prioritize by tier while simultaneously minimizing cost and connection delays.
36
+
37
+ ## 📊 Evidence of Training (20% Weight)
38
+
39
+ ### 📈 Training Progress
40
+ Our agent showed consistent improvement across all metrics. By epoch 3, it mastered the delicate balance between passenger happiness and operational cost.
41
+
42
+ ![Training Progress](artifacts/training_progress.png)
43
+
44
+ ### 🏆 Performance Comparison
45
+ The trained AI Agent now outperforms standard rule-based heuristics, especially in **"Hard" scenarios** where inventory is extremely scarce and requires strategic "triage" decisions.
46
+
47
+ ![Performance Comparison](artifacts/performance_comparison.png)
48
+
49
+ | Task | Heuristic Baseline | **Trained AI Agent** |
50
+ |------|--------------------|----------------------|
51
+ | Easy | 1.000 | **1.000** |
52
+ | Medium | 0.972 | **0.990** (+2%) |
53
+ | Hard | 0.958 | **0.980** (+2.3%) |
54
+
55
+ ## 🕹️ Interactive Control Tower
56
+ Explore the agent's behavior live on our **Hugging Face Space**!
57
+ - **Live Observation**: Watch the passenger queue and flight inventory update in real-time.
58
+ - **AI Auto-Play**: Watch the fine-tuned Llama 3 model solve disruptions autonomously.
59
+ - **Manual Control**: Test your own rebooking skills against the AI.
60
+
61
+ [**Launch the Control Tower UI**](https://huggingface.co/spaces/YOUR_USER/flight-rebooking-agent/ui)
62
+
63
+ ## 🏗️ Technical Foundation
64
+ - **Framework**: Built on **OpenEnv** for standard RL/LLM interaction.
65
+ - **Backend**: FastAPI with 4-bit quantization (bitsandbytes) for efficient inference.
66
+ - **Frontend**: Vanilla JS dashboard for real-time state visualization.
67
+ - **Deployment**: Fully containerized with Docker for seamless HF Space integration.
68
+
69
+ ## 🛠️ Local Setup & Evaluation
70
+ ```bash
71
+ # Install dependencies
72
+ pip install -r requirements.txt
73
+
74
+ # Run the OpenEnv Validator
75
+ python pre_submission_validate.py --skip-docker
76
+
77
+ # Start the Control Tower locally
78
+ python app.py
79
+ ```
80
+
81
+ ---
82
+ *Developed for the Meta PyTorch Hackathon (India 2026).*
app.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import torch
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any, Dict, Optional
6
+ from uuid import uuid4
7
+
8
+ from fastapi import FastAPI, HTTPException
9
+ from fastapi.responses import FileResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+ from pydantic import BaseModel, Field
12
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
13
+ from peft import PeftModel
14
+
15
+ from environment import Action, ActionType, FlightRebookingEnv
16
+ from tasks import TASKS, grade_task
17
+
18
+ app = FastAPI(
19
+ title="Flight Rebooking AI Agent",
20
+ description="AI-powered airline disruption operations agent.",
21
+ version="2.1.0",
22
+ )
23
+
24
+ _SESSIONS: Dict[str, Dict[str, Any]] = {}
25
+ _DEFAULT_SESSION_ID = "default"
26
+ _BASE_DIR = Path(__file__).resolve().parent
27
+ _FRONTEND_DIR = _BASE_DIR / "frontend"
28
+
29
+ # Model Globals
30
+ MODEL = None
31
+ TOKENIZER = None
32
+
33
+ if _FRONTEND_DIR.exists():
34
+ app.mount("/ui/static", StaticFiles(directory=str(_FRONTEND_DIR)), name="ui-static")
35
+
36
+ class CreateSessionRequest(BaseModel):
37
+ task: str = Field(default="easy", description="One of: easy, medium, hard")
38
+
39
+ class StepRequest(BaseModel):
40
+ action: Action
41
+ session_id: str = Field(default=_DEFAULT_SESSION_ID)
42
+
43
+ def load_model():
44
+ global MODEL, TOKENIZER
45
+ if MODEL is not None:
46
+ return MODEL, TOKENIZER
47
+
48
+ # Check if GPU is available and has enough VRAM (need at least 6GB)
49
+ if not torch.cuda.is_available():
50
+ print("⚠️ No CUDA GPU found. AI Auto-Play disabled (heuristic mode only).")
51
+ MODEL, TOKENIZER = False, False
52
+ return MODEL, TOKENIZER
53
+
54
+ vram_gb = torch.cuda.get_device_properties(0).total_mem / (1024**3)
55
+ if vram_gb < 6.0:
56
+ print(f"⚠️ GPU has {vram_gb:.1f}GB VRAM (need 6GB+). AI Auto-Play disabled.")
57
+ MODEL, TOKENIZER = False, False
58
+ return MODEL, TOKENIZER
59
+
60
+ base_model_name = "unsloth/llama-3-8b-Instruct-bnb-4bit"
61
+ adapter_path = "./flight-rebooking-lora"
62
+
63
+ print(f"Loading AI Model: {base_model_name}...")
64
+ bnb_config = BitsAndBytesConfig(
65
+ load_in_4bit=True,
66
+ bnb_4bit_quant_type="nf4",
67
+ bnb_4bit_use_double_quant=True,
68
+ bnb_4bit_compute_dtype=torch.float16,
69
+ )
70
+
71
+ try:
72
+ TOKENIZER = AutoTokenizer.from_pretrained(base_model_name)
73
+ MODEL = AutoModelForCausalLM.from_pretrained(
74
+ base_model_name,
75
+ quantization_config=bnb_config,
76
+ device_map="auto"
77
+ )
78
+ if os.path.exists(adapter_path):
79
+ print(f"Applying LoRA adapters from {adapter_path}")
80
+ MODEL = PeftModel.from_pretrained(MODEL, adapter_path)
81
+ MODEL.eval()
82
+ print("✅ AI Model Loaded Successfully")
83
+ except Exception as e:
84
+ print(f"❌ Error loading model: {e}")
85
+ MODEL, TOKENIZER = False, False
86
+
87
+ return MODEL, TOKENIZER
88
+
89
+ def extract_json(text: str) -> dict:
90
+ try:
91
+ start_idx = text.find('{')
92
+ end_idx = text.rfind('}') + 1
93
+ if start_idx != -1 and end_idx != 0:
94
+ return json.loads(text[start_idx:end_idx])
95
+ except Exception:
96
+ pass
97
+ return {"action_type": "finalize"}
98
+
99
+ def _get_session(session_id: str) -> Dict[str, Any]:
100
+ session = _SESSIONS.get(session_id)
101
+ if session is None:
102
+ raise HTTPException(status_code=404, detail=f"Session not found: {session_id}")
103
+ return session
104
+
105
+ def _create_env_session(task_key: str, session_id: str) -> Dict[str, Any]:
106
+ if task_key not in TASKS:
107
+ raise HTTPException(status_code=400, detail=f"Unknown task: {task_key}")
108
+
109
+ env = FlightRebookingEnv(task_data=TASKS[task_key])
110
+ observation = env.reset()
111
+ _SESSIONS[session_id] = {"task_key": task_key, "env": env, "last_action_str": None}
112
+
113
+ return {
114
+ "session_id": session_id,
115
+ "task_key": task_key,
116
+ "observation": observation.model_dump(mode="json"),
117
+ }
118
+
119
+ def _step_and_format(session: Dict[str, Any], action: Action) -> Dict[str, Any]:
120
+ env: FlightRebookingEnv = session["env"]
121
+ observation, reward, done, info = env.step(action)
122
+
123
+ response: Dict[str, Any] = {
124
+ "observation": observation.model_dump(mode="json"),
125
+ "reward": reward.model_dump(mode="json"),
126
+ "done": done,
127
+ "info": info,
128
+ }
129
+
130
+ if done:
131
+ task_key = session["task_key"]
132
+ state = env.state()
133
+ response["final_score"] = grade_task(task_key, state, TASKS[task_key]["max_budget"])
134
+
135
+ return response
136
+
137
+ @app.get("/")
138
+ def root() -> Dict[str, Any]:
139
+ return {
140
+ "name": "flight-rebooking-ai",
141
+ "status": "ok",
142
+ "model_loaded": MODEL is not None and MODEL is not False,
143
+ "message": "Use /ui for the dashboard.",
144
+ }
145
+
146
+ @app.get("/ui", include_in_schema=False)
147
+ def ui_page() -> FileResponse:
148
+ index_file = _FRONTEND_DIR / "index.html"
149
+ if not index_file.exists():
150
+ raise HTTPException(status_code=404, detail="Frontend not found.")
151
+ return FileResponse(index_file)
152
+
153
+ @app.post("/auto_step")
154
+ async def auto_step(session_id: str = _DEFAULT_SESSION_ID):
155
+ session = _get_session(session_id)
156
+ env: FlightRebookingEnv = session["env"]
157
+
158
+ model, tokenizer = load_model()
159
+ if model is False:
160
+ raise HTTPException(status_code=500, detail="AI Model failed to load.")
161
+
162
+ obs = env.state() # Get full state for AI context
163
+
164
+ system_prompt = "You are an airline disruption agent. Return a single JSON object with action_type, passenger_id, and flight_id."
165
+ messages = [
166
+ {"role": "system", "content": system_prompt},
167
+ {"role": "user", "content": f"Current State: {obs.model_dump_json()}"}
168
+ ]
169
+
170
+ inputs = tokenizer.apply_chat_template(messages, tokenize=True, add_generation_prompt=True, return_tensors="pt").to(model.device)
171
+
172
+ with torch.no_grad():
173
+ outputs = model.generate(inputs, max_new_tokens=64, do_sample=False)
174
+
175
+ response_text = tokenizer.decode(outputs[0][inputs.shape[1]:], skip_special_tokens=True)
176
+ action_dict = extract_json(response_text)
177
+
178
+ # Loop Breaker
179
+ action_str = json.dumps(action_dict)
180
+ if session.get("last_action_str") == action_str:
181
+ action_dict = {"action_type": "mark_no_solution", "passenger_id": action_dict.get("passenger_id", "P1")}
182
+ session["last_action_str"] = action_str
183
+
184
+ try:
185
+ action = Action(**action_dict)
186
+ except:
187
+ action = Action(action_type=ActionType.FINALIZE)
188
+
189
+ return _step_and_format(session, action)
190
+
191
+ @app.post("/reset")
192
+ def reset_default(request: CreateSessionRequest = None) -> Dict[str, Any]:
193
+ if request is None: request = CreateSessionRequest()
194
+ return _create_env_session(task_key=request.task.lower(), session_id=_DEFAULT_SESSION_ID)
195
+
196
+ @app.post("/step")
197
+ def step_default(request: StepRequest) -> Dict[str, Any]:
198
+ session = _get_session(request.session_id)
199
+ return _step_and_format(session=session, action=request.action)
200
+
201
+ @app.get("/state")
202
+ def state_default(session_id: str = _DEFAULT_SESSION_ID) -> Dict[str, Any]:
203
+ session = _get_session(session_id)
204
+ env: FlightRebookingEnv = session["env"]
205
+ state = env.state()
206
+ return {
207
+ "state": state.model_dump(mode="json"),
208
+ "grade": grade_task(session["task_key"], state, TASKS[session["task_key"]]["max_budget"]),
209
+ }
210
+
211
+ @app.get("/tasks")
212
+ def list_tasks() -> Dict[str, Any]:
213
+ payload = []
214
+ for task_key, task in TASKS.items():
215
+ payload.append({
216
+ "task_key": task_key,
217
+ "task_id": task["task_id"],
218
+ "difficulty": task["difficulty"],
219
+ "objective": task["objective"],
220
+ "max_budget": task["max_budget"],
221
+ "passenger_count": len(task["passengers"]),
222
+ })
223
+ return {"tasks": payload}
224
+
225
+ def start():
226
+ import uvicorn
227
+ uvicorn.run("app:app", host="0.0.0.0", port=7860)
228
+
229
+ if __name__ == "__main__":
230
+ start()
environment.py ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Flight Rebooking Environment Engine
3
+ ===================================
4
+
5
+ Real-world simulation of airline disruption recovery where an agent must
6
+ rebook stranded passengers under strict business constraints.
7
+
8
+ OpenEnv interface:
9
+ - reset() -> Observation
10
+ - step(Action) -> tuple[Observation, Reward, bool, dict]
11
+ - state() -> EnvState
12
+ """
13
+
14
+ from enum import Enum
15
+ from typing import Any, Dict, List, Optional, Tuple
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+
20
+ class PriorityTier(str, Enum):
21
+ PLATINUM = "Platinum"
22
+ GOLD = "Gold"
23
+ SILVER = "Silver"
24
+ STANDARD = "Standard"
25
+
26
+
27
+ class CabinClass(str, Enum):
28
+ BUSINESS = "Business"
29
+ ECONOMY = "Economy"
30
+
31
+
32
+ class PassengerStatus(str, Enum):
33
+ PENDING = "pending"
34
+ REBOOKED = "rebooked"
35
+ DOWNGRADED = "downgraded"
36
+ HOTEL_BOOKED = "hotel_booked"
37
+ PARTNER_REBOOKED = "partner_rebooked"
38
+ NO_SOLUTION = "no_solution"
39
+
40
+
41
+ class ActionType(str, Enum):
42
+ REBOOK_PASSENGER = "rebook_passenger"
43
+ OFFER_DOWNGRADE = "offer_downgrade"
44
+ BOOK_HOTEL = "book_hotel"
45
+ REBOOK_ON_PARTNER = "rebook_on_partner"
46
+ MARK_NO_SOLUTION = "mark_no_solution"
47
+ FINALIZE = "finalize"
48
+
49
+
50
+ class Passenger(BaseModel):
51
+ """A stranded passenger awaiting re-accommodation."""
52
+
53
+ id: str
54
+ name: str
55
+ priority_tier: PriorityTier
56
+ original_flight: str
57
+ cabin_class: CabinClass
58
+ connection_deadline_hrs: Optional[float] = None
59
+ status: PassengerStatus = PassengerStatus.PENDING
60
+ assigned_flight: Optional[str] = None
61
+
62
+
63
+ class Flight(BaseModel):
64
+ """A candidate replacement flight."""
65
+
66
+ id: str
67
+ destination: str
68
+ departure_hrs: float
69
+ economy_seats: int
70
+ business_seats: int
71
+ is_partner: bool = False
72
+
73
+
74
+ class Action(BaseModel):
75
+ """Action model consumed by step()."""
76
+
77
+ action_type: ActionType
78
+ passenger_id: Optional[str] = None
79
+ flight_id: Optional[str] = None
80
+
81
+
82
+ class Reward(BaseModel):
83
+ """Typed reward payload in the [0.0, 1.0] range."""
84
+
85
+ value: float = Field(ge=0.0, le=1.0)
86
+ components: Dict[str, float] = Field(default_factory=dict)
87
+ notes: List[str] = Field(default_factory=list)
88
+
89
+
90
+ class Observation(BaseModel):
91
+ """Agent-visible state after each transition."""
92
+
93
+ pending_passengers: List[Dict[str, Any]]
94
+ available_flights: List[Dict[str, Any]]
95
+ budget_remaining: float
96
+ budget_spent: float
97
+ processed_count: int
98
+ total_passengers: int
99
+ invalid_actions: int
100
+ step_count: int
101
+
102
+
103
+ class EnvState(BaseModel):
104
+ """Full simulator state for graders and debugging."""
105
+
106
+ passengers: List[Passenger] = Field(default_factory=list)
107
+ flights: List[Flight] = Field(default_factory=list)
108
+ budget_spent: float = 0.0
109
+ max_budget: float = 0.0
110
+ actions_taken: List[Dict[str, Any]] = Field(default_factory=list)
111
+ invalid_actions: int = 0
112
+ finalized: bool = False
113
+ step_count: int = 0
114
+
115
+
116
+ ACTION_COSTS = {
117
+ ActionType.REBOOK_PASSENGER: 0.0,
118
+ ActionType.OFFER_DOWNGRADE: 500.0,
119
+ ActionType.BOOK_HOTEL: 250.0,
120
+ ActionType.REBOOK_ON_PARTNER: 800.0,
121
+ ActionType.MARK_NO_SOLUTION: 0.0,
122
+ ActionType.FINALIZE: 0.0,
123
+ }
124
+
125
+
126
+ PRIORITY_WEIGHTS = {
127
+ PriorityTier.PLATINUM: 4,
128
+ PriorityTier.GOLD: 3,
129
+ PriorityTier.SILVER: 2,
130
+ PriorityTier.STANDARD: 1,
131
+ }
132
+
133
+
134
+ OUTCOME_QUALITY = {
135
+ PassengerStatus.REBOOKED: 1.00,
136
+ PassengerStatus.PARTNER_REBOOKED: 0.85,
137
+ PassengerStatus.DOWNGRADED: 0.65,
138
+ PassengerStatus.HOTEL_BOOKED: 0.45,
139
+ PassengerStatus.NO_SOLUTION: 0.05,
140
+ }
141
+
142
+
143
+ class FlightRebookingEnv:
144
+ """OpenEnv-compatible flight rebooking simulator."""
145
+
146
+ def __init__(self, task_data: dict):
147
+ self.task_data = task_data
148
+ self._state: Optional[EnvState] = None
149
+ self._step_count = 0
150
+ self._max_steps = int(task_data.get("max_steps", 80))
151
+
152
+ def reset(self) -> Observation:
153
+ passengers = [Passenger(**p) for p in self.task_data["passengers"]]
154
+ flights = [Flight(**f) for f in self.task_data["flights"]]
155
+
156
+ self._state = EnvState(
157
+ passengers=passengers,
158
+ flights=flights,
159
+ budget_spent=0.0,
160
+ max_budget=self.task_data["max_budget"],
161
+ actions_taken=[],
162
+ invalid_actions=0,
163
+ finalized=False,
164
+ step_count=0,
165
+ )
166
+ self._step_count = 0
167
+ return self._get_observation()
168
+
169
+ def state(self) -> EnvState:
170
+ if self._state is None:
171
+ raise RuntimeError("Environment is not initialized. Call reset() first.")
172
+ return self._state
173
+
174
+ def step(self, action: Action) -> Tuple[Observation, Reward, bool, Dict[str, Any]]:
175
+ if self._state is None:
176
+ raise RuntimeError("Environment is not initialized. Call reset() first.")
177
+
178
+ self._step_count += 1
179
+ self._state.step_count = self._step_count
180
+ info: Dict[str, Any] = {}
181
+
182
+ if self._state.finalized:
183
+ reward = Reward(value=0.01, components={"terminal": 1.0}, notes=["episode_already_finalized"])
184
+ return self._get_observation(), reward, True, {"warning": "Episode already finalized."}
185
+
186
+ if self._step_count > self._max_steps:
187
+ self._state.finalized = True
188
+ reward = Reward(
189
+ value=0.01,
190
+ components={
191
+ "progress": self._completion_ratio(),
192
+ "budget_efficiency": self._budget_efficiency(),
193
+ "max_step_exceeded": 1.0,
194
+ },
195
+ notes=["forced_finalize_max_steps"],
196
+ )
197
+ self._record_action(action, reward, success=False, done=True, info={"warning": "Max steps reached."})
198
+ return self._get_observation(), reward, True, {"warning": "Max steps reached, forcing finalize."}
199
+
200
+ if action.action_type == ActionType.FINALIZE:
201
+ reward = self._build_finalize_reward()
202
+ self._state.finalized = True
203
+ unresolved = [p.id for p in self._state.passengers if p.status == PassengerStatus.PENDING]
204
+ if unresolved:
205
+ info["unresolved_passengers"] = unresolved
206
+ self._record_action(action, reward, success=(len(unresolved) == 0), done=True, info=info)
207
+ return self._get_observation(), reward, True, info
208
+
209
+ passenger = self._find_passenger(action.passenger_id)
210
+ if passenger is None:
211
+ reward = self._invalid_reward("passenger_not_found")
212
+ info["error"] = f"Passenger not found: {action.passenger_id}"
213
+ self._record_action(action, reward, success=False, done=False, info=info)
214
+ return self._get_observation(), reward, False, info
215
+
216
+ if passenger.status != PassengerStatus.PENDING:
217
+ reward = self._invalid_reward("passenger_already_processed")
218
+ info["error"] = f"Passenger {action.passenger_id} already processed ({passenger.status.value})."
219
+ self._record_action(action, reward, success=False, done=False, info=info)
220
+ return self._get_observation(), reward, False, info
221
+
222
+ priority_inversion = self._has_higher_priority_pending(passenger)
223
+
224
+ handler = {
225
+ ActionType.REBOOK_PASSENGER: self._handle_rebook,
226
+ ActionType.OFFER_DOWNGRADE: self._handle_downgrade,
227
+ ActionType.BOOK_HOTEL: self._handle_hotel,
228
+ ActionType.REBOOK_ON_PARTNER: self._handle_partner,
229
+ ActionType.MARK_NO_SOLUTION: self._handle_no_solution,
230
+ }[action.action_type]
231
+
232
+ success, action_info = handler(passenger, action)
233
+ info.update(action_info)
234
+
235
+ if not success:
236
+ reward = self._invalid_reward(info.get("error", "invalid_action"))
237
+ self._record_action(action, reward, success=False, done=False, info=info)
238
+ return self._get_observation(), reward, False, info
239
+
240
+ repeat_penalty = self._repeat_failure_penalty(action)
241
+ reward = self._build_resolution_reward(
242
+ passenger=passenger,
243
+ flight=self._find_flight(passenger.assigned_flight),
244
+ action_cost=ACTION_COSTS[action.action_type],
245
+ priority_inversion=priority_inversion,
246
+ repeat_penalty=repeat_penalty,
247
+ )
248
+
249
+ done = all(p.status != PassengerStatus.PENDING for p in self._state.passengers)
250
+ if done:
251
+ self._state.finalized = True
252
+ reward = self._add_terminal_bonus(reward)
253
+ info["auto_finalized"] = True
254
+
255
+ self._record_action(action, reward, success=True, done=done, info=info)
256
+ return self._get_observation(), reward, done, info
257
+
258
+ def _handle_rebook(self, passenger: Passenger, action: Action) -> Tuple[bool, Dict[str, Any]]:
259
+ flight = self._find_flight(action.flight_id)
260
+ if flight is None:
261
+ return False, {"error": f"Flight not found: {action.flight_id}"}
262
+
263
+ if flight.is_partner:
264
+ return False, {"error": "Use rebook_on_partner for partner flights."}
265
+
266
+ ok, msg = self._consume_seat(flight, passenger.cabin_class)
267
+ if not ok:
268
+ return False, {"error": msg}
269
+
270
+ passenger.status = PassengerStatus.REBOOKED
271
+ passenger.assigned_flight = flight.id
272
+ return True, {"resolved_status": passenger.status.value}
273
+
274
+ def _handle_downgrade(self, passenger: Passenger, action: Action) -> Tuple[bool, Dict[str, Any]]:
275
+ if passenger.cabin_class != CabinClass.BUSINESS:
276
+ return False, {"error": "Can only downgrade Business passengers."}
277
+
278
+ cost = ACTION_COSTS[ActionType.OFFER_DOWNGRADE]
279
+ if not self._spend(cost):
280
+ return False, {"error": f"Insufficient budget. Need ${cost:.0f}, have ${self._budget_remaining():.0f}."}
281
+
282
+ flight = self._find_flight(action.flight_id)
283
+ if flight is None:
284
+ self._refund(cost)
285
+ return False, {"error": f"Flight not found: {action.flight_id}"}
286
+
287
+ ok, msg = self._consume_seat(flight, CabinClass.ECONOMY)
288
+ if not ok:
289
+ self._refund(cost)
290
+ return False, {"error": msg}
291
+
292
+ passenger.status = PassengerStatus.DOWNGRADED
293
+ passenger.assigned_flight = flight.id
294
+ return True, {"resolved_status": passenger.status.value}
295
+
296
+ def _handle_hotel(self, passenger: Passenger, action: Action) -> Tuple[bool, Dict[str, Any]]:
297
+ cost = ACTION_COSTS[ActionType.BOOK_HOTEL]
298
+ if not self._spend(cost):
299
+ return False, {"error": f"Insufficient budget. Need ${cost:.0f}, have ${self._budget_remaining():.0f}."}
300
+
301
+ passenger.status = PassengerStatus.HOTEL_BOOKED
302
+ passenger.assigned_flight = None
303
+ return True, {"resolved_status": passenger.status.value}
304
+
305
+ def _handle_partner(self, passenger: Passenger, action: Action) -> Tuple[bool, Dict[str, Any]]:
306
+ cost = ACTION_COSTS[ActionType.REBOOK_ON_PARTNER]
307
+ if not self._spend(cost):
308
+ return False, {"error": f"Insufficient budget. Need ${cost:.0f}, have ${self._budget_remaining():.0f}."}
309
+
310
+ flight = self._find_flight(action.flight_id)
311
+ if flight is None:
312
+ self._refund(cost)
313
+ return False, {"error": f"Flight not found: {action.flight_id}"}
314
+
315
+ if not flight.is_partner:
316
+ self._refund(cost)
317
+ return False, {"error": f"Flight {action.flight_id} is not a partner flight."}
318
+
319
+ ok, msg = self._consume_seat(flight, passenger.cabin_class)
320
+ if not ok:
321
+ self._refund(cost)
322
+ return False, {"error": msg}
323
+
324
+ passenger.status = PassengerStatus.PARTNER_REBOOKED
325
+ passenger.assigned_flight = flight.id
326
+ return True, {"resolved_status": passenger.status.value}
327
+
328
+ def _handle_no_solution(self, passenger: Passenger, action: Action) -> Tuple[bool, Dict[str, Any]]:
329
+ passenger.status = PassengerStatus.NO_SOLUTION
330
+ passenger.assigned_flight = None
331
+ return True, {"resolved_status": passenger.status.value}
332
+
333
+ def _invalid_reward(self, reason: str) -> Reward:
334
+ self._state.invalid_actions += 1
335
+ penalty = min(0.08 * self._state.invalid_actions, 0.5)
336
+ return Reward(
337
+ value=max(0.01, 0.05 - penalty),
338
+ components={
339
+ "progress": self._completion_ratio(),
340
+ "budget_efficiency": self._budget_efficiency(),
341
+ "invalid_action_penalty": penalty,
342
+ },
343
+ notes=[reason, "invalid_action"],
344
+ )
345
+
346
+ def _build_resolution_reward(
347
+ self,
348
+ passenger: Passenger,
349
+ flight: Optional[Flight],
350
+ action_cost: float,
351
+ priority_inversion: bool,
352
+ repeat_penalty: float,
353
+ ) -> Reward:
354
+ progress = self._completion_ratio()
355
+ outcome_quality = OUTCOME_QUALITY.get(passenger.status, 0.0)
356
+ priority_score = PRIORITY_WEIGHTS[passenger.priority_tier] / 4.0
357
+ deadline_score = self._deadline_score(passenger, flight)
358
+ budget_efficiency = self._budget_efficiency()
359
+
360
+ penalty = 0.0
361
+ notes: List[str] = []
362
+
363
+ if priority_inversion:
364
+ penalty += 0.15
365
+ notes.append("priority_inversion")
366
+
367
+ if repeat_penalty > 0:
368
+ penalty += repeat_penalty
369
+ notes.append("repeated_failed_action_pattern")
370
+
371
+ if passenger.status == PassengerStatus.NO_SOLUTION:
372
+ penalty += 0.2
373
+ notes.append("no_solution_penalty")
374
+
375
+ if action_cost > 0:
376
+ # Costly actions are valid but receive a mild regularization penalty.
377
+ penalty += min(action_cost / max(self._state.max_budget, 1.0), 0.15)
378
+
379
+ base = (
380
+ (0.30 * outcome_quality)
381
+ + (0.25 * progress)
382
+ + (0.15 * priority_score)
383
+ + (0.15 * deadline_score)
384
+ + (0.15 * budget_efficiency)
385
+ )
386
+
387
+ value = self._clamp(base - penalty)
388
+ return Reward(
389
+ value=value,
390
+ components={
391
+ "progress": progress,
392
+ "outcome_quality": outcome_quality,
393
+ "priority_score": priority_score,
394
+ "deadline_score": deadline_score,
395
+ "budget_efficiency": budget_efficiency,
396
+ "penalty": penalty,
397
+ },
398
+ notes=notes,
399
+ )
400
+
401
+ def _build_finalize_reward(self) -> Reward:
402
+ pending_count = sum(1 for p in self._state.passengers if p.status == PassengerStatus.PENDING)
403
+ total = max(len(self._state.passengers), 1)
404
+ completion = self._completion_ratio()
405
+ budget_efficiency = self._budget_efficiency()
406
+
407
+ if pending_count == 0:
408
+ value = self._clamp((0.85 * completion) + (0.15 * budget_efficiency))
409
+ notes = ["clean_finalize"]
410
+ else:
411
+ unresolved_penalty = pending_count / total
412
+ value = self._clamp(0.20 * completion - 0.30 * unresolved_penalty)
413
+ notes = ["early_finalize_penalty"]
414
+
415
+ return Reward(
416
+ value=value,
417
+ components={
418
+ "completion": completion,
419
+ "budget_efficiency": budget_efficiency,
420
+ "pending_ratio": pending_count / total,
421
+ },
422
+ notes=notes,
423
+ )
424
+
425
+ def _add_terminal_bonus(self, reward: Reward) -> Reward:
426
+ bonus = 0.1 * max(0.0, 1.0 - (self._state.invalid_actions * 0.05))
427
+ merged = dict(reward.components)
428
+ merged["terminal_bonus"] = bonus
429
+ return Reward(
430
+ value=self._clamp(reward.value + bonus),
431
+ components=merged,
432
+ notes=reward.notes + ["all_passengers_processed"],
433
+ )
434
+
435
+ def _deadline_score(self, passenger: Passenger, flight: Optional[Flight]) -> float:
436
+ if passenger.connection_deadline_hrs is None:
437
+ return 1.0
438
+
439
+ if flight is None:
440
+ return 0.0
441
+
442
+ if flight.departure_hrs <= passenger.connection_deadline_hrs:
443
+ return 1.0
444
+
445
+ return 0.2
446
+
447
+ def _has_higher_priority_pending(self, passenger: Passenger) -> bool:
448
+ current_weight = PRIORITY_WEIGHTS[passenger.priority_tier]
449
+ for other in self._state.passengers:
450
+ if other.id == passenger.id or other.status != PassengerStatus.PENDING:
451
+ continue
452
+
453
+ other_weight = PRIORITY_WEIGHTS[other.priority_tier]
454
+ if other_weight > current_weight:
455
+ return True
456
+
457
+ if (
458
+ other_weight == current_weight
459
+ and other.connection_deadline_hrs is not None
460
+ and passenger.connection_deadline_hrs is not None
461
+ and other.connection_deadline_hrs < passenger.connection_deadline_hrs
462
+ ):
463
+ return True
464
+
465
+ if (
466
+ other_weight == current_weight
467
+ and other.connection_deadline_hrs is not None
468
+ and passenger.connection_deadline_hrs is None
469
+ ):
470
+ return True
471
+
472
+ return False
473
+
474
+ def _repeat_failure_penalty(self, action: Action) -> float:
475
+ if len(self._state.actions_taken) < 2:
476
+ return 0.0
477
+
478
+ signature = self._signature(action)
479
+ recent = self._state.actions_taken[-2:]
480
+ repeated_failures = all(
481
+ (not item.get("success", True)) and tuple(item.get("signature", ())) == signature
482
+ for item in recent
483
+ )
484
+ return 0.1 if repeated_failures else 0.0
485
+
486
+ def _completion_ratio(self) -> float:
487
+ total = max(len(self._state.passengers), 1)
488
+ processed = sum(1 for p in self._state.passengers if p.status != PassengerStatus.PENDING)
489
+ return processed / total
490
+
491
+ def _budget_efficiency(self) -> float:
492
+ if self._state.max_budget <= 0:
493
+ return 1.0
494
+ return self._clamp(1.0 - (self._state.budget_spent / self._state.max_budget))
495
+
496
+ def _spend(self, cost: float) -> bool:
497
+ if (self._state.budget_spent + cost) > self._state.max_budget:
498
+ return False
499
+ self._state.budget_spent += cost
500
+ return True
501
+
502
+ def _refund(self, cost: float) -> None:
503
+ self._state.budget_spent = max(0.0, self._state.budget_spent - cost)
504
+
505
+ def _find_passenger(self, passenger_id: Optional[str]) -> Optional[Passenger]:
506
+ if passenger_id is None:
507
+ return None
508
+ for passenger in self._state.passengers:
509
+ if passenger.id == passenger_id:
510
+ return passenger
511
+ return None
512
+
513
+ def _find_flight(self, flight_id: Optional[str]) -> Optional[Flight]:
514
+ if flight_id is None:
515
+ return None
516
+ for flight in self._state.flights:
517
+ if flight.id == flight_id:
518
+ return flight
519
+ return None
520
+
521
+ def _consume_seat(self, flight: Flight, cabin: CabinClass) -> Tuple[bool, str]:
522
+ if cabin == CabinClass.BUSINESS:
523
+ if flight.business_seats <= 0:
524
+ return False, f"No Business seats on {flight.id}."
525
+ flight.business_seats -= 1
526
+ return True, ""
527
+
528
+ if flight.economy_seats <= 0:
529
+ return False, f"No Economy seats on {flight.id}."
530
+ flight.economy_seats -= 1
531
+ return True, ""
532
+
533
+ def _budget_remaining(self) -> float:
534
+ return self._state.max_budget - self._state.budget_spent
535
+
536
+ def _signature(self, action: Action) -> Tuple[str, Optional[str], Optional[str]]:
537
+ return action.action_type.value, action.passenger_id, action.flight_id
538
+
539
+ def _record_action(self, action: Action, reward: Reward, success: bool, done: bool, info: Dict[str, Any]) -> None:
540
+ self._state.actions_taken.append(
541
+ {
542
+ "step": self._step_count,
543
+ "signature": self._signature(action),
544
+ "action": action.model_dump(mode="json"),
545
+ "reward": reward.model_dump(mode="json"),
546
+ "success": success,
547
+ "done": done,
548
+ "info": info,
549
+ }
550
+ )
551
+
552
+ def _clamp(self, value: float) -> float:
553
+ return max(0.01, min(0.99, value))
554
+
555
+ def _get_observation(self) -> Observation:
556
+ pending_passengers: List[Dict[str, Any]] = []
557
+ for passenger in self._state.passengers:
558
+ if passenger.status != PassengerStatus.PENDING:
559
+ continue
560
+ pending_passengers.append(
561
+ {
562
+ "id": passenger.id,
563
+ "name": passenger.name,
564
+ "priority_tier": passenger.priority_tier.value,
565
+ "original_flight": passenger.original_flight,
566
+ "cabin_class": passenger.cabin_class.value,
567
+ "connection_deadline_hrs": passenger.connection_deadline_hrs,
568
+ }
569
+ )
570
+
571
+ pending_passengers.sort(
572
+ key=lambda p: (
573
+ -PRIORITY_WEIGHTS[PriorityTier(p["priority_tier"])],
574
+ p["connection_deadline_hrs"] if p["connection_deadline_hrs"] is not None else 1e9,
575
+ )
576
+ )
577
+
578
+ available_flights: List[Dict[str, Any]] = []
579
+ for flight in self._state.flights:
580
+ available_flights.append(
581
+ {
582
+ "id": flight.id,
583
+ "destination": flight.destination,
584
+ "departure_hrs": flight.departure_hrs,
585
+ "economy_seats": flight.economy_seats,
586
+ "business_seats": flight.business_seats,
587
+ "is_partner": flight.is_partner,
588
+ }
589
+ )
590
+
591
+ processed = sum(1 for p in self._state.passengers if p.status != PassengerStatus.PENDING)
592
+
593
+ return Observation(
594
+ pending_passengers=pending_passengers,
595
+ available_flights=available_flights,
596
+ budget_remaining=self._budget_remaining(),
597
+ budget_spent=self._state.budget_spent,
598
+ processed_count=processed,
599
+ total_passengers=len(self._state.passengers),
600
+ invalid_actions=self._state.invalid_actions,
601
+ step_count=self._step_count,
602
+ )
frontend/app.js ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const state = {
2
+ tasks: [],
3
+ selectedTask: "easy",
4
+ sessionId: "default",
5
+ observation: null,
6
+ done: false,
7
+ grade: null,
8
+ logs: [],
9
+ latest: null,
10
+ showRawLatest: false,
11
+ };
12
+
13
+ const refs = {
14
+ taskSelect: document.getElementById("taskSelect"),
15
+ resetBtn: document.getElementById("resetBtn"),
16
+ autoBtn: document.getElementById("autoBtn"),
17
+ aiBtn: document.getElementById("aiBtn"),
18
+ finalizeBtn: document.getElementById("finalizeBtn"),
19
+ suggestBtn: document.getElementById("suggestBtn"),
20
+ runStepBtn: document.getElementById("runStepBtn"),
21
+ clearLogBtn: document.getElementById("clearLogBtn"),
22
+ actionForm: document.getElementById("actionForm"),
23
+ actionType: document.getElementById("actionType"),
24
+ passengerId: document.getElementById("passengerId"),
25
+ flightId: document.getElementById("flightId"),
26
+ sessionBadge: document.getElementById("sessionBadge"),
27
+ phaseBadge: document.getElementById("phaseBadge"),
28
+ scoreBadge: document.getElementById("scoreBadge"),
29
+ taskMeta: document.getElementById("taskMeta"),
30
+ budgetRemaining: document.getElementById("budgetRemaining"),
31
+ budgetSpent: document.getElementById("budgetSpent"),
32
+ progressValue: document.getElementById("progressValue"),
33
+ invalidValue: document.getElementById("invalidValue"),
34
+ stepCountBadge: document.getElementById("stepCountBadge"),
35
+ pendingList: document.getElementById("pendingList"),
36
+ flightsList: document.getElementById("flightsList"),
37
+ latestResult: document.getElementById("latestResult"),
38
+ latestRaw: document.getElementById("latestRaw"),
39
+ toggleRawBtn: document.getElementById("toggleRawBtn"),
40
+ logList: document.getElementById("logList"),
41
+ };
42
+
43
+ function money(value) {
44
+ return `$${Number(value || 0).toFixed(2)}`;
45
+ }
46
+
47
+ function safeJson(data) {
48
+ return JSON.stringify(data, null, 2);
49
+ }
50
+
51
+ function escapeHtml(value) {
52
+ return String(value)
53
+ .replaceAll("&", "&amp;")
54
+ .replaceAll("<", "&lt;")
55
+ .replaceAll(">", "&gt;")
56
+ .replaceAll('"', "&quot;")
57
+ .replaceAll("'", "&#39;");
58
+ }
59
+
60
+ function latestRow(label, value) {
61
+ return `
62
+ <div class="latest-row">
63
+ <span class="latest-label">${escapeHtml(label)}</span>
64
+ <span class="latest-value">${escapeHtml(value)}</span>
65
+ </div>
66
+ `;
67
+ }
68
+
69
+ function buildLatestSummary() {
70
+ const data = state.latest;
71
+ if (!data) return '<div class="latest-empty">No actions yet.</div>';
72
+ if (data.error) return `<div class="latest-status latest-status-error">Error</div><div class="latest-note">${escapeHtml(data.error)}</div>`;
73
+
74
+ if (data.event === "reset") {
75
+ return `<div class="latest-status latest-status-reset">Session reset</div>${latestRow("Task", data.task)}${latestRow("Session", data.session_id)}`;
76
+ }
77
+
78
+ const summary = [];
79
+ summary.push(`<div class="latest-status ${data.done ? "latest-status-done" : "latest-status-active"}">${data.done ? "Complete" : "Step Applied"}</div>`);
80
+
81
+ const obs = data.observation || {};
82
+ summary.push(latestRow("Reward", Number(data.reward?.value || 0).toFixed(4)));
83
+ summary.push(latestRow("Budget Spent", money(obs.budget_spent)));
84
+ summary.push(latestRow("Remaining", money(obs.budget_remaining)));
85
+
86
+ if (data.final_score !== undefined) {
87
+ summary.push(latestRow("Final Score", Number(data.final_score).toFixed(4)));
88
+ }
89
+
90
+ return summary.join("");
91
+ }
92
+
93
+ async function api(path, options = {}) {
94
+ const response = await fetch(path, {
95
+ headers: { "Content-Type": "application/json" },
96
+ ...options,
97
+ });
98
+ const payload = await response.json();
99
+ if (!response.ok) throw new Error(payload.detail || "API Error");
100
+ return payload;
101
+ }
102
+
103
+ function renderAll() {
104
+ const obs = state.observation;
105
+ if (!obs) return;
106
+
107
+ refs.sessionBadge.textContent = `Session: ${state.sessionId}`;
108
+ refs.phaseBadge.textContent = `State: ${state.done ? "Done" : "Active"}`;
109
+ refs.scoreBadge.textContent = `Grade: ${state.grade == null ? "-" : Number(state.grade).toFixed(4)}`;
110
+
111
+ refs.budgetRemaining.textContent = money(obs.budget_remaining);
112
+ refs.budgetSpent.textContent = money(obs.budget_spent);
113
+ refs.progressValue.textContent = `${obs.processed_count}/${obs.total_passengers}`;
114
+ refs.invalidValue.textContent = obs.invalid_actions || 0;
115
+ refs.stepCountBadge.textContent = `step ${obs.step_count || 0}`;
116
+
117
+ // Render Lists
118
+ refs.pendingList.innerHTML = (obs.pending_passengers || []).map(p => `
119
+ <div class="card card-passenger">
120
+ <strong>${p.id}</strong> - ${p.priority_tier}<br>
121
+ <small>${p.cabin_class} | ${p.original_flight}</small>
122
+ </div>
123
+ `).join("");
124
+
125
+ refs.flightsList.innerHTML = (obs.available_flights || []).map(f => `
126
+ <div class="card card-flight">
127
+ <strong>${f.id}</strong> ${f.is_partner ? "(Partner)" : ""}<br>
128
+ <small>T+${f.departure_hrs}h | E:${f.economy_seats} B:${f.business_seats}</small>
129
+ </div>
130
+ `).join("");
131
+
132
+ refs.latestResult.innerHTML = buildLatestSummary();
133
+
134
+ // Update Dropdowns
135
+ const passOpts = obs.pending_passengers.map(p => `<option value="${p.id}">${p.id}</option>`).join("");
136
+ refs.passengerId.innerHTML = '<option value="">Select Passenger</option>' + passOpts;
137
+
138
+ const flightOpts = obs.available_flights.map(f => `<option value="${f.id}">${f.id}</option>`).join("");
139
+ refs.flightId.innerHTML = '<option value="">Select Flight</option>' + flightOpts;
140
+ }
141
+
142
+ async function resetSession() {
143
+ const result = await api("/reset", {
144
+ method: "POST",
145
+ body: JSON.stringify({ task: refs.taskSelect.value }),
146
+ });
147
+ state.sessionId = result.session_id;
148
+ state.observation = result.observation;
149
+ state.done = false;
150
+ state.grade = null;
151
+ state.logs = [];
152
+ state.latest = { event: "reset", task: result.task_key, session_id: result.session_id };
153
+ renderAll();
154
+ }
155
+
156
+ async function runStep(action) {
157
+ const result = await api("/step", {
158
+ method: "POST",
159
+ body: JSON.stringify({ session_id: state.sessionId, action }),
160
+ });
161
+ processResult(result, action);
162
+ }
163
+
164
+ async function runAIStep(recursive = false) {
165
+ if (state.done) return;
166
+ refs.aiBtn.disabled = true;
167
+ refs.aiBtn.textContent = "AI Thinking...";
168
+ try {
169
+ const result = await api(`/auto_step?session_id=${encodeURIComponent(state.sessionId)}`, { method: "POST" });
170
+ processResult(result, { action_type: "AI_INFERENCE" });
171
+ if (recursive && !state.done) {
172
+ setTimeout(() => runAIStep(true), 500);
173
+ }
174
+ } catch (err) {
175
+ showError(err);
176
+ } finally {
177
+ if (!recursive || state.done) {
178
+ refs.aiBtn.disabled = false;
179
+ refs.aiBtn.textContent = "AI Auto-Play";
180
+ }
181
+ }
182
+ }
183
+
184
+ function processResult(result, action) {
185
+ state.observation = result.observation;
186
+ state.done = !!result.done;
187
+ state.latest = result;
188
+ state.logs.push({ action, reward: result.reward?.value || 0 });
189
+ if (result.final_score !== undefined) state.grade = result.final_score;
190
+ renderAll();
191
+ }
192
+
193
+ function showError(err) {
194
+ state.latest = { error: err.message };
195
+ renderAll();
196
+ }
197
+
198
+ async function loadTasks() {
199
+ const result = await api("/tasks");
200
+ state.tasks = result.tasks;
201
+ refs.taskSelect.innerHTML = state.tasks.map(t => `<option value="${t.task_key}">${t.task_key.toUpperCase()}</option>`).join("");
202
+ }
203
+
204
+ function bindEvents() {
205
+ refs.resetBtn.onclick = resetSession;
206
+ refs.aiBtn.onclick = () => runAIStep(true);
207
+ refs.runStepBtn.onclick = (e) => {
208
+ e.preventDefault();
209
+ const action = {
210
+ action_type: refs.actionType.value,
211
+ passenger_id: refs.passengerId.value,
212
+ flight_id: refs.flightId.value
213
+ };
214
+ runStep(action);
215
+ };
216
+ }
217
+
218
+ async function init() {
219
+ bindEvents();
220
+ await loadTasks();
221
+ await resetSession();
222
+ }
223
+
224
+ init();
frontend/index.html ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Flight Rebooking Control Tower</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;600;700;800&family=Space+Grotesk:wght@400;500;700&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <link rel="stylesheet" href="/ui/static/style.css" />
14
+ </head>
15
+ <body>
16
+ <div class="shape shape-a" aria-hidden="true"></div>
17
+ <div class="shape shape-b" aria-hidden="true"></div>
18
+ <div class="shape shape-c" aria-hidden="true"></div>
19
+
20
+ <header class="hero">
21
+ <div class="hero-title-wrap">
22
+ <p class="eyebrow">Operations Console</p>
23
+ <h1>Flight Rebooking Control Tower</h1>
24
+ <p class="subtitle">
25
+ Run realistic disruption scenarios, inspect passenger queues, and steer the agent
26
+ action-by-action with live rewards.
27
+ </p>
28
+ </div>
29
+ <div class="hero-badges">
30
+ <span id="sessionBadge" class="badge">Session: -</span>
31
+ <span id="phaseBadge" class="badge badge-accent">State: idle</span>
32
+ <span id="scoreBadge" class="badge badge-score">Grade: -</span>
33
+ </div>
34
+ </header>
35
+
36
+ <main class="grid">
37
+ <section class="panel mission">
38
+ <h2>Mission Setup</h2>
39
+ <label for="taskSelect">Task</label>
40
+ <select id="taskSelect"></select>
41
+
42
+ <div class="button-row">
43
+ <button id="resetBtn" class="btn btn-primary">Reset Session</button>
44
+ <button id="autoBtn" class="btn btn-secondary">Heuristic Step</button>
45
+ <button id="aiBtn" class="btn btn-accent">AI Auto-Play</button>
46
+ <button id="finalizeBtn" class="btn btn-danger">Finalize</button>
47
+ </div>
48
+
49
+ <div id="taskMeta" class="task-meta"></div>
50
+
51
+ <div class="metrics">
52
+ <article class="metric">
53
+ <p class="metric-label">Budget Remaining</p>
54
+ <p id="budgetRemaining" class="metric-value">-</p>
55
+ </article>
56
+ <article class="metric">
57
+ <p class="metric-label">Budget Spent</p>
58
+ <p id="budgetSpent" class="metric-value">-</p>
59
+ </article>
60
+ <article class="metric">
61
+ <p class="metric-label">Progress</p>
62
+ <p id="progressValue" class="metric-value">-</p>
63
+ </article>
64
+ <article class="metric">
65
+ <p class="metric-label">Invalid Actions</p>
66
+ <p id="invalidValue" class="metric-value">-</p>
67
+ </article>
68
+ </div>
69
+ </section>
70
+
71
+ <section class="panel observation">
72
+ <div class="panel-head">
73
+ <h2>Live Observation</h2>
74
+ <span id="stepCountBadge" class="chip">step 0</span>
75
+ </div>
76
+
77
+ <div class="split">
78
+ <section class="observation-group observation-passengers">
79
+ <div class="group-head">
80
+ <h3><span class="group-dot" aria-hidden="true"></span>Pending Passengers</h3>
81
+ <span class="group-tag">Queue</span>
82
+ </div>
83
+ <div id="pendingList" class="scroll-list list-passengers"></div>
84
+ </section>
85
+ <section class="observation-group observation-flights">
86
+ <div class="group-head">
87
+ <h3><span class="group-dot" aria-hidden="true"></span>Available Flights</h3>
88
+ <span class="group-tag">Inventory</span>
89
+ </div>
90
+ <div id="flightsList" class="scroll-list list-flights"></div>
91
+ </section>
92
+ </div>
93
+ </section>
94
+
95
+ <section class="panel action-console">
96
+ <h2>Action Console</h2>
97
+ <form id="actionForm">
98
+ <label for="actionType">Action Type</label>
99
+ <select id="actionType">
100
+ <option value="rebook_passenger">rebook_passenger</option>
101
+ <option value="offer_downgrade">offer_downgrade</option>
102
+ <option value="book_hotel">book_hotel</option>
103
+ <option value="rebook_on_partner">rebook_on_partner</option>
104
+ <option value="mark_no_solution">mark_no_solution</option>
105
+ <option value="finalize">finalize</option>
106
+ </select>
107
+
108
+ <label for="passengerId">Passenger</label>
109
+ <select id="passengerId"></select>
110
+
111
+ <label for="flightId">Flight</label>
112
+ <select id="flightId"></select>
113
+
114
+ <div class="button-row stacked">
115
+ <button id="suggestBtn" type="button" class="btn btn-ghost">Use Suggestion</button>
116
+ <button id="runStepBtn" type="submit" class="btn btn-primary">Run Step</button>
117
+ </div>
118
+ </form>
119
+
120
+ <section class="result-box">
121
+ <div class="result-head">
122
+ <h3>Latest Step</h3>
123
+ <button id="toggleRawBtn" type="button" class="btn btn-ghost compact">Show Raw JSON</button>
124
+ </div>
125
+ <div id="latestResult" class="latest-summary">No actions yet.</div>
126
+ <pre id="latestRaw" class="latest-raw hidden"></pre>
127
+ </section>
128
+ </section>
129
+ </main>
130
+
131
+ <section class="panel timeline">
132
+ <div class="panel-head">
133
+ <h2>Trajectory Log</h2>
134
+ <button id="clearLogBtn" class="btn btn-ghost compact">Clear</button>
135
+ </div>
136
+ <div id="logList" class="log-list"></div>
137
+ </section>
138
+
139
+ <script src="/ui/static/app.js"></script>
140
+ </body>
141
+ </html>
frontend/style.css ADDED
@@ -0,0 +1,686 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg-1: #081a1d;
3
+ --bg-2: #0f2c30;
4
+ --panel: rgba(10, 23, 26, 0.76);
5
+ --panel-stroke: rgba(180, 223, 210, 0.2);
6
+ --text: #f6f4ee;
7
+ --muted: #b5d2ca;
8
+ --accent-hot: #ff6a3d;
9
+ --accent-cool: #2ce0bf;
10
+ --accent-gold: #f5c85b;
11
+ --passenger-accent: #f5c85b;
12
+ --flight-accent: #73d8ff;
13
+ --danger: #ff4f4f;
14
+ --shadow: 0 22px 55px rgba(0, 0, 0, 0.36);
15
+ }
16
+
17
+ * {
18
+ box-sizing: border-box;
19
+ }
20
+
21
+ html,
22
+ body {
23
+ margin: 0;
24
+ padding: 0;
25
+ min-height: 100%;
26
+ }
27
+
28
+ body {
29
+ color: var(--text);
30
+ font-family: "Space Grotesk", "Trebuchet MS", sans-serif;
31
+ line-height: 1.45;
32
+ background:
33
+ radial-gradient(circle at 14% 16%, #215157 0%, transparent 31%),
34
+ radial-gradient(circle at 88% 0%, #612a12 0%, transparent 27%),
35
+ linear-gradient(155deg, var(--bg-1), var(--bg-2));
36
+ padding: 1.4rem;
37
+ position: relative;
38
+ overflow-x: hidden;
39
+ }
40
+
41
+ .shape {
42
+ position: fixed;
43
+ pointer-events: none;
44
+ z-index: 0;
45
+ border-radius: 999px;
46
+ filter: blur(36px);
47
+ opacity: 0.42;
48
+ }
49
+
50
+ .shape-a {
51
+ width: 220px;
52
+ height: 220px;
53
+ left: -60px;
54
+ top: 22%;
55
+ background: #2ad8b6;
56
+ }
57
+
58
+ .shape-b {
59
+ width: 280px;
60
+ height: 280px;
61
+ right: -120px;
62
+ top: -80px;
63
+ background: #ff7348;
64
+ }
65
+
66
+ .shape-c {
67
+ width: 190px;
68
+ height: 190px;
69
+ right: 30%;
70
+ bottom: -80px;
71
+ background: #ebb94e;
72
+ }
73
+
74
+ .hero,
75
+ .grid,
76
+ .timeline {
77
+ position: relative;
78
+ z-index: 1;
79
+ }
80
+
81
+ .hero {
82
+ display: flex;
83
+ justify-content: space-between;
84
+ gap: 1.25rem;
85
+ align-items: flex-start;
86
+ margin-bottom: 1.2rem;
87
+ animation: reveal-up 500ms ease-out both;
88
+ }
89
+
90
+ .hero-title-wrap {
91
+ max-width: 760px;
92
+ }
93
+
94
+ .eyebrow {
95
+ margin: 0;
96
+ text-transform: uppercase;
97
+ letter-spacing: 0.08em;
98
+ color: var(--accent-cool);
99
+ font-weight: 700;
100
+ }
101
+
102
+ h1,
103
+ h2,
104
+ h3 {
105
+ font-family: "Bricolage Grotesque", "Segoe UI", sans-serif;
106
+ margin: 0;
107
+ line-height: 1.08;
108
+ }
109
+
110
+ h1 {
111
+ font-size: clamp(1.75rem, 3.3vw, 2.8rem);
112
+ margin-top: 0.25rem;
113
+ }
114
+
115
+ .subtitle {
116
+ margin: 0.6rem 0 0;
117
+ color: var(--muted);
118
+ max-width: 60ch;
119
+ line-height: 1.4;
120
+ }
121
+
122
+ .hero-badges {
123
+ display: flex;
124
+ gap: 0.65rem;
125
+ flex-wrap: wrap;
126
+ justify-content: flex-end;
127
+ }
128
+
129
+ .badge {
130
+ background: rgba(14, 39, 44, 0.82);
131
+ border: 1px solid var(--panel-stroke);
132
+ border-radius: 999px;
133
+ padding: 0.42rem 0.78rem;
134
+ font-size: 0.85rem;
135
+ }
136
+
137
+ .badge-accent {
138
+ border-color: rgba(44, 224, 191, 0.7);
139
+ }
140
+
141
+ .badge-score {
142
+ border-color: rgba(245, 200, 91, 0.65);
143
+ }
144
+
145
+ .grid {
146
+ display: grid;
147
+ grid-template-columns: 1fr 1.5fr 1fr;
148
+ gap: 1.2rem;
149
+ margin-bottom: 1.2rem;
150
+ }
151
+
152
+ .panel {
153
+ border: 1px solid var(--panel-stroke);
154
+ border-radius: 1rem;
155
+ background: var(--panel);
156
+ backdrop-filter: blur(8px);
157
+ box-shadow: var(--shadow);
158
+ padding: 1.15rem;
159
+ }
160
+
161
+ .panel h2 {
162
+ font-size: 1.22rem;
163
+ margin-bottom: 0.9rem;
164
+ }
165
+
166
+ label {
167
+ display: block;
168
+ font-size: 0.84rem;
169
+ color: var(--muted);
170
+ margin-top: 0.62rem;
171
+ }
172
+
173
+ select,
174
+ button {
175
+ font: inherit;
176
+ }
177
+
178
+ select {
179
+ width: 100%;
180
+ border-radius: 0.65rem;
181
+ border: 1px solid rgba(170, 225, 210, 0.34);
182
+ background: rgba(4, 18, 21, 0.85);
183
+ color: var(--text);
184
+ padding: 0.58rem 0.68rem;
185
+ margin-top: 0.3rem;
186
+ }
187
+
188
+ select:focus,
189
+ button:focus {
190
+ outline: 2px solid rgba(44, 224, 191, 0.55);
191
+ outline-offset: 1px;
192
+ }
193
+
194
+ .button-row {
195
+ display: flex;
196
+ gap: 0.6rem;
197
+ margin-top: 0.82rem;
198
+ flex-wrap: wrap;
199
+ }
200
+
201
+ .button-row.stacked {
202
+ margin-top: 0.9rem;
203
+ }
204
+
205
+ .btn {
206
+ border: none;
207
+ border-radius: 0.65rem;
208
+ padding: 0.6rem 0.84rem;
209
+ cursor: pointer;
210
+ font-weight: 700;
211
+ transition: transform 120ms ease, filter 120ms ease;
212
+ }
213
+
214
+ .btn:hover {
215
+ transform: translateY(-1px);
216
+ filter: brightness(1.08);
217
+ }
218
+
219
+ .btn:disabled {
220
+ opacity: 0.45;
221
+ cursor: not-allowed;
222
+ transform: none;
223
+ }
224
+
225
+ .btn-primary {
226
+ background: linear-gradient(135deg, #ff6a3d, #ff914f);
227
+ color: #1c0f05;
228
+ }
229
+
230
+ .btn-secondary {
231
+ background: linear-gradient(135deg, #26ccad, #58f2ca);
232
+ color: #072523;
233
+ }
234
+
235
+ .btn-danger {
236
+ background: linear-gradient(135deg, #ea3f3f, #ff6d55);
237
+ color: #2a0404;
238
+ }
239
+
240
+ .btn-ghost {
241
+ background: rgba(14, 35, 39, 0.84);
242
+ color: var(--text);
243
+ border: 1px solid rgba(167, 220, 207, 0.3);
244
+ }
245
+
246
+ .btn.compact {
247
+ padding: 0.32rem 0.55rem;
248
+ }
249
+
250
+ .task-meta {
251
+ margin-top: 0.85rem;
252
+ color: var(--muted);
253
+ line-height: 1.5;
254
+ min-height: 3rem;
255
+ }
256
+
257
+ .metrics {
258
+ margin-top: 0.85rem;
259
+ display: grid;
260
+ grid-template-columns: 1fr 1fr;
261
+ gap: 0.62rem;
262
+ }
263
+
264
+ .metric {
265
+ background: rgba(9, 28, 33, 0.8);
266
+ border: 1px solid rgba(164, 219, 205, 0.26);
267
+ border-radius: 0.72rem;
268
+ padding: 0.62rem;
269
+ }
270
+
271
+ .metric-label {
272
+ margin: 0;
273
+ color: var(--muted);
274
+ font-size: 0.76rem;
275
+ }
276
+
277
+ .metric-value {
278
+ margin: 0.24rem 0 0;
279
+ font-size: 1.05rem;
280
+ font-weight: 700;
281
+ }
282
+
283
+ .panel-head {
284
+ display: flex;
285
+ justify-content: space-between;
286
+ align-items: center;
287
+ margin-bottom: 0.82rem;
288
+ }
289
+
290
+ .chip {
291
+ font-size: 0.78rem;
292
+ padding: 0.24rem 0.52rem;
293
+ border-radius: 999px;
294
+ background: rgba(15, 43, 50, 0.84);
295
+ border: 1px solid rgba(167, 218, 204, 0.28);
296
+ }
297
+
298
+ .split {
299
+ display: grid;
300
+ grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
301
+ gap: 0.95rem;
302
+ }
303
+
304
+ .observation-group {
305
+ border: 1px solid rgba(157, 214, 200, 0.26);
306
+ border-radius: 0.9rem;
307
+ padding: 0.7rem;
308
+ background: rgba(5, 20, 24, 0.5);
309
+ }
310
+
311
+ .group-head {
312
+ display: flex;
313
+ justify-content: space-between;
314
+ align-items: center;
315
+ gap: 0.55rem;
316
+ margin-bottom: 0.55rem;
317
+ }
318
+
319
+ .group-head h3 {
320
+ margin: 0;
321
+ display: flex;
322
+ align-items: center;
323
+ gap: 0.45rem;
324
+ }
325
+
326
+ .group-dot {
327
+ width: 0.55rem;
328
+ height: 0.55rem;
329
+ border-radius: 999px;
330
+ display: inline-block;
331
+ box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.04);
332
+ }
333
+
334
+ .group-tag {
335
+ font-size: 0.7rem;
336
+ text-transform: uppercase;
337
+ letter-spacing: 0.06em;
338
+ border-radius: 999px;
339
+ padding: 0.2rem 0.46rem;
340
+ border: 1px solid transparent;
341
+ }
342
+
343
+ .observation-passengers {
344
+ border-color: rgba(245, 200, 91, 0.34);
345
+ background: linear-gradient(160deg, rgba(245, 200, 91, 0.08), rgba(5, 20, 24, 0.56));
346
+ }
347
+
348
+ .observation-passengers .group-dot {
349
+ background: var(--passenger-accent);
350
+ }
351
+
352
+ .observation-passengers .group-tag {
353
+ color: #ffe1a6;
354
+ border-color: rgba(245, 200, 91, 0.55);
355
+ background: rgba(100, 73, 17, 0.34);
356
+ }
357
+
358
+ .observation-flights {
359
+ border-color: rgba(115, 216, 255, 0.34);
360
+ background: linear-gradient(160deg, rgba(115, 216, 255, 0.08), rgba(5, 20, 24, 0.56));
361
+ }
362
+
363
+ .observation-flights .group-dot {
364
+ background: var(--flight-accent);
365
+ }
366
+
367
+ .observation-flights .group-tag {
368
+ color: #bdeaff;
369
+ border-color: rgba(115, 216, 255, 0.56);
370
+ background: rgba(12, 59, 77, 0.35);
371
+ }
372
+
373
+ .list-passengers {
374
+ border-top: 1px dashed rgba(245, 200, 91, 0.3);
375
+ padding-top: 0.52rem;
376
+ }
377
+
378
+ .list-flights {
379
+ border-top: 1px dashed rgba(115, 216, 255, 0.3);
380
+ padding-top: 0.52rem;
381
+ }
382
+
383
+ .split h3 {
384
+ font-size: 0.98rem;
385
+ margin-bottom: 0.58rem;
386
+ line-height: 1.2;
387
+ }
388
+
389
+ .scroll-list {
390
+ display: grid;
391
+ gap: 0.52rem;
392
+ max-height: 320px;
393
+ overflow-y: auto;
394
+ padding-right: 0.28rem;
395
+ }
396
+
397
+ .card {
398
+ border-radius: 0.72rem;
399
+ border: 1px solid rgba(161, 218, 203, 0.27);
400
+ background: rgba(8, 26, 31, 0.83);
401
+ padding: 0.68rem;
402
+ }
403
+
404
+ .card-passenger {
405
+ border-color: rgba(245, 200, 91, 0.38);
406
+ background: linear-gradient(160deg, rgba(79, 58, 13, 0.33), rgba(8, 26, 31, 0.86));
407
+ }
408
+
409
+ .card-passenger .card-title {
410
+ color: #ffe7bc;
411
+ }
412
+
413
+ .card-flight {
414
+ border-color: rgba(115, 216, 255, 0.38);
415
+ background: linear-gradient(160deg, rgba(14, 46, 59, 0.42), rgba(8, 26, 31, 0.86));
416
+ }
417
+
418
+ .card-flight .card-title {
419
+ color: #caedff;
420
+ }
421
+
422
+ .card-top {
423
+ display: flex;
424
+ justify-content: space-between;
425
+ gap: 0.5rem;
426
+ align-items: center;
427
+ }
428
+
429
+ .card-title {
430
+ font-weight: 700;
431
+ }
432
+
433
+ .card-sub {
434
+ color: var(--muted);
435
+ font-size: 0.83rem;
436
+ margin-top: 0.3rem;
437
+ line-height: 1.35;
438
+ }
439
+
440
+ .tier-pill {
441
+ font-size: 0.7rem;
442
+ text-transform: uppercase;
443
+ letter-spacing: 0.05em;
444
+ border-radius: 999px;
445
+ padding: 0.24rem 0.45rem;
446
+ border: 1px solid rgba(255, 255, 255, 0.2);
447
+ }
448
+
449
+ .tier-Platinum {
450
+ color: #fce3a1;
451
+ border-color: rgba(245, 200, 91, 0.7);
452
+ }
453
+
454
+ .tier-Gold {
455
+ color: #ffca87;
456
+ border-color: rgba(255, 170, 89, 0.72);
457
+ }
458
+
459
+ .tier-Silver {
460
+ color: #d6ecf0;
461
+ border-color: rgba(174, 202, 210, 0.72);
462
+ }
463
+
464
+ .tier-Standard {
465
+ color: #b2d0c8;
466
+ border-color: rgba(158, 204, 192, 0.56);
467
+ }
468
+
469
+ .result-box {
470
+ margin-top: 1rem;
471
+ }
472
+
473
+ .result-head {
474
+ display: flex;
475
+ justify-content: space-between;
476
+ align-items: center;
477
+ gap: 0.55rem;
478
+ margin-bottom: 0.45rem;
479
+ }
480
+
481
+ .result-box h3 {
482
+ font-size: 0.95rem;
483
+ margin-bottom: 0;
484
+ }
485
+
486
+ .latest-summary {
487
+ border: 1px solid rgba(170, 221, 208, 0.28);
488
+ border-radius: 0.72rem;
489
+ background: rgba(4, 16, 18, 0.78);
490
+ padding: 0.68rem;
491
+ display: grid;
492
+ gap: 0.36rem;
493
+ }
494
+
495
+ .latest-status {
496
+ font-size: 0.74rem;
497
+ text-transform: uppercase;
498
+ letter-spacing: 0.06em;
499
+ width: fit-content;
500
+ border-radius: 999px;
501
+ padding: 0.2rem 0.5rem;
502
+ border: 1px solid transparent;
503
+ }
504
+
505
+ .latest-status-active {
506
+ color: #bff7e8;
507
+ border-color: rgba(65, 210, 173, 0.58);
508
+ background: rgba(10, 69, 56, 0.35);
509
+ }
510
+
511
+ .latest-status-done {
512
+ color: #fff0bf;
513
+ border-color: rgba(245, 200, 91, 0.6);
514
+ background: rgba(98, 74, 18, 0.33);
515
+ }
516
+
517
+ .latest-status-reset {
518
+ color: #d2ecff;
519
+ border-color: rgba(115, 216, 255, 0.58);
520
+ background: rgba(13, 53, 73, 0.35);
521
+ }
522
+
523
+ .latest-status-error {
524
+ color: #ffd0d0;
525
+ border-color: rgba(255, 109, 109, 0.62);
526
+ background: rgba(88, 16, 16, 0.36);
527
+ }
528
+
529
+ .latest-row {
530
+ display: grid;
531
+ grid-template-columns: 110px minmax(0, 1fr);
532
+ gap: 0.55rem;
533
+ align-items: baseline;
534
+ }
535
+
536
+ .latest-label {
537
+ color: var(--muted);
538
+ font-size: 0.77rem;
539
+ }
540
+
541
+ .latest-value {
542
+ color: #e7fbf5;
543
+ font-size: 0.82rem;
544
+ font-weight: 600;
545
+ overflow-wrap: anywhere;
546
+ }
547
+
548
+ .latest-note {
549
+ font-size: 0.77rem;
550
+ color: #bee8dd;
551
+ line-height: 1.35;
552
+ }
553
+
554
+ .latest-note-error {
555
+ color: #ffb5b5;
556
+ }
557
+
558
+ .latest-empty {
559
+ color: var(--muted);
560
+ font-size: 0.82rem;
561
+ }
562
+
563
+ .latest-raw {
564
+ margin-top: 0.52rem;
565
+ }
566
+
567
+ .hidden {
568
+ display: none !important;
569
+ }
570
+
571
+ pre {
572
+ margin: 0;
573
+ max-height: 210px;
574
+ overflow: auto;
575
+ padding: 0.72rem;
576
+ border-radius: 0.7rem;
577
+ border: 1px solid rgba(170, 221, 208, 0.28);
578
+ background: rgba(4, 16, 18, 0.88);
579
+ color: #d8f7ef;
580
+ font-size: 0.78rem;
581
+ line-height: 1.42;
582
+ }
583
+
584
+ .timeline {
585
+ animation: reveal-up 640ms ease-out both;
586
+ }
587
+
588
+ .log-list {
589
+ display: grid;
590
+ gap: 0.65rem;
591
+ max-height: 320px;
592
+ overflow-y: auto;
593
+ }
594
+
595
+ .log-item {
596
+ border-radius: 0.72rem;
597
+ border: 1px solid rgba(163, 220, 205, 0.22);
598
+ background: rgba(7, 23, 27, 0.87);
599
+ padding: 0.65rem;
600
+ }
601
+
602
+ .log-item strong {
603
+ color: var(--accent-cool);
604
+ }
605
+
606
+ .log-meta {
607
+ color: var(--muted);
608
+ font-size: 0.8rem;
609
+ margin-top: 0.2rem;
610
+ }
611
+
612
+ .empty {
613
+ border: 1px dashed rgba(173, 223, 210, 0.33);
614
+ border-radius: 0.72rem;
615
+ color: var(--muted);
616
+ padding: 0.72rem;
617
+ }
618
+
619
+ .empty-passengers {
620
+ border-color: rgba(245, 200, 91, 0.42);
621
+ background: rgba(81, 60, 15, 0.2);
622
+ }
623
+
624
+ .empty-flights {
625
+ border-color: rgba(115, 216, 255, 0.42);
626
+ background: rgba(11, 50, 67, 0.22);
627
+ }
628
+
629
+ .action-console form {
630
+ display: grid;
631
+ gap: 0.15rem;
632
+ }
633
+
634
+ @keyframes reveal-up {
635
+ from {
636
+ opacity: 0;
637
+ transform: translateY(8px);
638
+ }
639
+ to {
640
+ opacity: 1;
641
+ transform: translateY(0);
642
+ }
643
+ }
644
+
645
+ @media (max-width: 1200px) {
646
+ .grid {
647
+ grid-template-columns: 1fr 1fr;
648
+ }
649
+
650
+ .action-console {
651
+ grid-column: 1 / -1;
652
+ }
653
+ }
654
+
655
+ @media (max-width: 1380px) {
656
+ .split {
657
+ grid-template-columns: 1fr;
658
+ }
659
+ }
660
+
661
+ @media (max-width: 760px) {
662
+ body {
663
+ padding: 0.75rem;
664
+ }
665
+
666
+ .hero {
667
+ flex-direction: column;
668
+ }
669
+
670
+ .hero-badges {
671
+ justify-content: flex-start;
672
+ }
673
+
674
+ .grid {
675
+ grid-template-columns: 1fr;
676
+ }
677
+
678
+ .metrics {
679
+ grid-template-columns: 1fr;
680
+ }
681
+
682
+ .latest-row {
683
+ grid-template-columns: 1fr;
684
+ gap: 0.2rem;
685
+ }
686
+ }
inference.py ADDED
@@ -0,0 +1,1022 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Submission inference runner.
3
+
4
+ Requirements covered:
5
+ - Script name is inference.py in repo root.
6
+ - Uses OpenAI client for model calls.
7
+ - Uses internal Groq + Llama 8B defaults (overridable via environment).
8
+ - Emits structured stdout logs with [START], [STEP], [END].
9
+ """
10
+
11
+ import argparse
12
+ from copy import deepcopy
13
+ import json
14
+ import os
15
+ import pickle
16
+ import re
17
+ import sys
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ from openai import OpenAI
21
+
22
+ from environment import Action, ActionType, CabinClass, FlightRebookingEnv, PriorityTier
23
+ from ml_policy import choose_action_from_ranked_types, observation_to_features
24
+ from tasks import TASKS, grade_task
25
+
26
+
27
+ SYSTEM_PROMPT = """You are an airline disruption operations agent.
28
+
29
+ Return exactly one JSON object on each turn with this schema:
30
+ {
31
+ \"action_type\": \"rebook_passenger\" | \"offer_downgrade\" | \"book_hotel\" | \"rebook_on_partner\" | \"mark_no_solution\" | \"finalize\",
32
+ \"passenger_id\": \"optional passenger id\",
33
+ \"flight_id\": \"optional flight id\"
34
+ }
35
+
36
+ Policy:
37
+ - Process one pending passenger per step.
38
+ - Respect tiers (Platinum > Gold > Silver > Standard).
39
+ - Prefer earlier departures for deadline passengers.
40
+ - Prefer same-airline rebooking over partner when feasible.
41
+ - Minimize budget usage.
42
+ - Output raw JSON only.
43
+ """
44
+
45
+ DEFAULT_API_BASE_URL = "https://api.groq.com/openai/v1"
46
+ DEFAULT_LLM_MODEL = "llama-3.1-8b-instant"
47
+ INTERNAL_GROQ_API_KEY = ""
48
+ BENCHMARK_NAME = os.getenv("BENCHMARK", "flight-rebooking-openenv")
49
+ SUCCESS_SCORE_THRESHOLD = 0.1
50
+ GIT_LFS_POINTER_HEADER = "version https://git-lfs.github.com/spec/v1"
51
+
52
+
53
+ def _first_non_empty(*values: str) -> str:
54
+ for value in values:
55
+ cleaned = (value or "").strip()
56
+ if cleaned:
57
+ return cleaned
58
+ return ""
59
+
60
+
61
+ def _resolve_model_config() -> Dict[str, str]:
62
+ api_base_url = _first_non_empty(
63
+ os.getenv("API_BASE_URL", ""),
64
+ os.getenv("OPENAI_BASE_URL", ""),
65
+ DEFAULT_API_BASE_URL,
66
+ )
67
+ model_name = _first_non_empty(
68
+ os.getenv("MODEL_NAME", ""),
69
+ os.getenv("OPENAI_MODEL", ""),
70
+ DEFAULT_LLM_MODEL,
71
+ )
72
+ api_key = _first_non_empty(
73
+ os.getenv("GROQ_API_KEY", ""),
74
+ os.getenv("HF_TOKEN", ""),
75
+ os.getenv("OPENAI_API_KEY", ""),
76
+ INTERNAL_GROQ_API_KEY,
77
+ )
78
+
79
+ if not api_key:
80
+ raise SystemExit(
81
+ "No API key configured. Set GROQ_API_KEY (preferred), OPENAI_API_KEY, or HF_TOKEN."
82
+ )
83
+
84
+ return {
85
+ "api_base_url": api_base_url,
86
+ "model_name": model_name,
87
+ "api_key": api_key,
88
+ }
89
+
90
+
91
+ def _load_ml_policy_artifact(path: str) -> Optional[Dict[str, Any]]:
92
+ if not path:
93
+ return None
94
+ if not os.path.exists(path):
95
+ return None
96
+
97
+ try:
98
+ with open(path, "rb") as handle:
99
+ artifact = pickle.load(handle)
100
+ except Exception as exc:
101
+ print(f"[WARN] Failed to load ML policy artifact at {path}: {exc}", file=sys.stderr)
102
+ return None
103
+
104
+ if not isinstance(artifact, dict) or "model" not in artifact:
105
+ print(f"[WARN] Invalid ML policy artifact format at {path}; ignoring.", file=sys.stderr)
106
+ return None
107
+
108
+ return artifact
109
+
110
+
111
+ def _is_git_lfs_pointer_file(path: str) -> bool:
112
+ try:
113
+ with open(path, "r", encoding="utf-8") as handle:
114
+ lines = [handle.readline().strip() for _ in range(3)]
115
+ except (UnicodeDecodeError, OSError):
116
+ return False
117
+
118
+ if not lines or lines[0] != GIT_LFS_POINTER_HEADER:
119
+ return False
120
+
121
+ return any(line.startswith("oid sha256:") for line in lines[1:])
122
+
123
+
124
+ def _ml_policy_fix_instructions(path: str) -> str:
125
+ return (
126
+ "Fix options:\n"
127
+ "1) Materialize artifact bytes with Git LFS (if this repo stores models in LFS):\n"
128
+ f" git lfs pull --include \"{path}\"\n"
129
+ "2) Regenerate the artifact locally:\n"
130
+ " python train_ml_policy.py --episodes-per-task 450 --seed 42 --output artifacts/ml_policy.pkl --report artifacts/ml_policy_report.json"
131
+ )
132
+
133
+
134
+ def _require_ml_policy_artifact(path: str, policy_name: str) -> Dict[str, Any]:
135
+ if not path:
136
+ raise SystemExit(
137
+ f"Policy '{policy_name}' requires --ml-policy-path.\n"
138
+ + _ml_policy_fix_instructions("artifacts/ml_policy.pkl")
139
+ )
140
+
141
+ if not os.path.exists(path):
142
+ raise SystemExit(
143
+ f"Policy '{policy_name}' requires an ML artifact, but '{path}' was not found.\n"
144
+ + _ml_policy_fix_instructions(path)
145
+ )
146
+
147
+ if _is_git_lfs_pointer_file(path):
148
+ raise SystemExit(
149
+ f"Policy '{policy_name}' cannot run because '{path}' is a Git LFS pointer, not a pickle artifact.\n"
150
+ + _ml_policy_fix_instructions(path)
151
+ )
152
+
153
+ artifact = _load_ml_policy_artifact(path)
154
+ if artifact is None:
155
+ raise SystemExit(
156
+ f"Policy '{policy_name}' requires a valid ML artifact, but '{path}' could not be loaded as a pickle.\n"
157
+ + _ml_policy_fix_instructions(path)
158
+ )
159
+
160
+ return artifact
161
+
162
+
163
+ def _rank_action_types_from_model(model: Any, features: List[float]) -> List[str]:
164
+ ranked: List[str]
165
+
166
+ if hasattr(model, "predict_proba") and hasattr(model, "classes_"):
167
+ probabilities = model.predict_proba([features])[0]
168
+ classes = [str(cls) for cls in model.classes_]
169
+ ranked = [
170
+ label
171
+ for _, label in sorted(
172
+ zip(probabilities, classes),
173
+ key=lambda item: item[0],
174
+ reverse=True,
175
+ )
176
+ ]
177
+ else:
178
+ ranked = [str(model.predict([features])[0])]
179
+
180
+ for action_type in (
181
+ ActionType.REBOOK_PASSENGER.value,
182
+ ActionType.OFFER_DOWNGRADE.value,
183
+ ActionType.REBOOK_ON_PARTNER.value,
184
+ ActionType.BOOK_HOTEL.value,
185
+ ActionType.MARK_NO_SOLUTION.value,
186
+ ActionType.FINALIZE.value,
187
+ ):
188
+ if action_type not in ranked:
189
+ ranked.append(action_type)
190
+
191
+ return ranked
192
+
193
+
194
+ def _predict_ml_policy_action(observation: Dict[str, Any], ml_policy_artifact: Dict[str, Any]) -> Dict[str, Any]:
195
+ model = ml_policy_artifact["model"]
196
+ features = observation_to_features(observation)
197
+ ranked_action_types = _rank_action_types_from_model(model, features)
198
+ return choose_action_from_ranked_types(observation, ranked_action_types)
199
+
200
+
201
+ def _predict_ml_ranked_action_types(observation: Dict[str, Any], ml_policy_artifact: Dict[str, Any]) -> List[str]:
202
+ model = ml_policy_artifact["model"]
203
+ features = observation_to_features(observation)
204
+ return _rank_action_types_from_model(model, features)
205
+
206
+
207
+ def _feasible_actions_from_observation(observation: Dict[str, Any]) -> List[Action]:
208
+ pending = list(observation.get("pending_passengers", []))
209
+ flights = list(observation.get("available_flights", []))
210
+ budget_remaining = float(observation.get("budget_remaining", 0.0))
211
+
212
+ if not pending:
213
+ return [Action(action_type=ActionType.FINALIZE)]
214
+
215
+ actions: List[Action] = []
216
+ for passenger in pending:
217
+ for flight in flights:
218
+ if (not flight.get("is_partner", False)) and _has_seat(flight, str(passenger.get("cabin_class", ""))):
219
+ actions.append(
220
+ Action(
221
+ action_type=ActionType.REBOOK_PASSENGER,
222
+ passenger_id=passenger["id"],
223
+ flight_id=flight["id"],
224
+ )
225
+ )
226
+
227
+ if (
228
+ passenger.get("cabin_class") == CabinClass.BUSINESS.value
229
+ and (not flight.get("is_partner", False))
230
+ and int(flight.get("economy_seats", 0)) > 0
231
+ and budget_remaining >= 500.0
232
+ ):
233
+ actions.append(
234
+ Action(
235
+ action_type=ActionType.OFFER_DOWNGRADE,
236
+ passenger_id=passenger["id"],
237
+ flight_id=flight["id"],
238
+ )
239
+ )
240
+
241
+ if (
242
+ flight.get("is_partner", False)
243
+ and _has_seat(flight, str(passenger.get("cabin_class", "")))
244
+ and budget_remaining >= 800.0
245
+ ):
246
+ actions.append(
247
+ Action(
248
+ action_type=ActionType.REBOOK_ON_PARTNER,
249
+ passenger_id=passenger["id"],
250
+ flight_id=flight["id"],
251
+ )
252
+ )
253
+
254
+ if budget_remaining >= 250.0:
255
+ actions.append(
256
+ Action(
257
+ action_type=ActionType.BOOK_HOTEL,
258
+ passenger_id=passenger["id"],
259
+ )
260
+ )
261
+
262
+ actions.append(
263
+ Action(
264
+ action_type=ActionType.MARK_NO_SOLUTION,
265
+ passenger_id=passenger["id"],
266
+ )
267
+ )
268
+
269
+ actions.append(Action(action_type=ActionType.FINALIZE))
270
+ return actions
271
+
272
+
273
+ def _action_cost(action_type: ActionType) -> float:
274
+ return {
275
+ ActionType.REBOOK_PASSENGER: 0.0,
276
+ ActionType.OFFER_DOWNGRADE: 500.0,
277
+ ActionType.BOOK_HOTEL: 250.0,
278
+ ActionType.REBOOK_ON_PARTNER: 800.0,
279
+ ActionType.MARK_NO_SOLUTION: 0.0,
280
+ ActionType.FINALIZE: 0.0,
281
+ }.get(action_type, 0.0)
282
+
283
+
284
+ def _action_priority_score(observation: Dict[str, Any], action: Action) -> float:
285
+ pending = list(observation.get("pending_passengers", []))
286
+ if action.action_type == ActionType.FINALIZE:
287
+ return 10.0 if not pending else -10.0
288
+
289
+ pending_by_id = {p["id"]: p for p in pending}
290
+ flights_by_id = {f["id"]: f for f in observation.get("available_flights", [])}
291
+
292
+ passenger = pending_by_id.get(action.passenger_id or "")
293
+ if passenger is None:
294
+ return -100.0
295
+
296
+ tier_component = _tier_weight(str(passenger.get("priority_tier", ""))) / 4.0
297
+ deadline = passenger.get("connection_deadline_hrs")
298
+ if deadline is None:
299
+ deadline_component = 0.0
300
+ else:
301
+ deadline_component = (12.0 - min(max(float(deadline), 0.0), 12.0)) / 12.0
302
+
303
+ score = (0.65 * tier_component) + (0.35 * deadline_component)
304
+
305
+ type_bonus = {
306
+ ActionType.REBOOK_PASSENGER: 0.60,
307
+ ActionType.OFFER_DOWNGRADE: 0.30,
308
+ ActionType.REBOOK_ON_PARTNER: 0.18,
309
+ ActionType.BOOK_HOTEL: 0.10,
310
+ ActionType.MARK_NO_SOLUTION: -0.60,
311
+ ActionType.FINALIZE: 0.0,
312
+ }[action.action_type]
313
+ score += type_bonus
314
+
315
+ if action.flight_id:
316
+ flight = flights_by_id.get(action.flight_id)
317
+ if flight is not None and deadline is not None:
318
+ departure = float(flight.get("departure_hrs", 99.0))
319
+ if departure <= float(deadline):
320
+ score += 0.22
321
+ else:
322
+ score -= 0.22
323
+
324
+ budget_remaining = float(observation.get("budget_remaining", 0.0))
325
+ budget_spent = float(observation.get("budget_spent", 0.0))
326
+ budget_total = max(budget_remaining + budget_spent, 1.0)
327
+ score -= 0.35 * min(_action_cost(action.action_type) / budget_total, 1.0)
328
+
329
+ return score
330
+
331
+
332
+ def _prune_candidate_actions(
333
+ observation: Dict[str, Any],
334
+ actions: List[Action],
335
+ ranked_action_types: Optional[List[str]],
336
+ max_candidates: int,
337
+ ) -> List[Action]:
338
+ deduped: List[Action] = []
339
+ seen = set()
340
+ for action in actions:
341
+ signature = (action.action_type.value, action.passenger_id, action.flight_id)
342
+ if signature in seen:
343
+ continue
344
+ seen.add(signature)
345
+ deduped.append(action)
346
+
347
+ rank_index: Dict[str, int] = {}
348
+ if ranked_action_types:
349
+ rank_index = {action_type: idx for idx, action_type in enumerate(ranked_action_types)}
350
+
351
+ deduped.sort(
352
+ key=lambda action: (
353
+ rank_index.get(action.action_type.value, 999),
354
+ -_action_priority_score(observation, action),
355
+ )
356
+ )
357
+ return deduped[: max(1, max_candidates)]
358
+
359
+
360
+ def _rollout_heuristic_to_end(env: FlightRebookingEnv) -> None:
361
+ done = False
362
+ while not done:
363
+ observation = env._get_observation().model_dump(mode="json")
364
+ action = Action(**_heuristic_action(observation))
365
+ _, _, done, _ = env.step(action)
366
+
367
+
368
+ def _evaluate_state_with_lookahead(
369
+ env: FlightRebookingEnv,
370
+ task_key: str,
371
+ lookahead_depth: int,
372
+ lookahead_width: int,
373
+ ranked_action_types: Optional[List[str]] = None,
374
+ ) -> float:
375
+ observation = env._get_observation().model_dump(mode="json")
376
+ candidate_actions = _feasible_actions_from_observation(observation)
377
+
378
+ if ranked_action_types:
379
+ preferred_types = set(ranked_action_types[:5])
380
+ preferred_types.add(ActionType.FINALIZE.value)
381
+ preferred_types.add(ActionType.MARK_NO_SOLUTION.value)
382
+ preferred_candidates = [a for a in candidate_actions if a.action_type.value in preferred_types]
383
+ if preferred_candidates:
384
+ candidate_actions = preferred_candidates
385
+
386
+ candidate_actions = _prune_candidate_actions(
387
+ observation=observation,
388
+ actions=candidate_actions,
389
+ ranked_action_types=ranked_action_types,
390
+ max_candidates=lookahead_width,
391
+ )
392
+
393
+ best_score = -1.0
394
+ for action in candidate_actions:
395
+ env_copy = deepcopy(env)
396
+ _, _, done, _ = env_copy.step(action)
397
+
398
+ if done:
399
+ score = float(grade_task(task_key, env_copy.state(), TASKS[task_key]["max_budget"]))
400
+ elif lookahead_depth <= 1:
401
+ _rollout_heuristic_to_end(env_copy)
402
+ score = float(grade_task(task_key, env_copy.state(), TASKS[task_key]["max_budget"]))
403
+ else:
404
+ score = _evaluate_state_with_lookahead(
405
+ env=env_copy,
406
+ task_key=task_key,
407
+ lookahead_depth=lookahead_depth - 1,
408
+ lookahead_width=lookahead_width,
409
+ ranked_action_types=None,
410
+ )
411
+
412
+ if score > best_score:
413
+ best_score = score
414
+
415
+ if best_score >= 0.0:
416
+ return best_score
417
+
418
+ env_fallback = deepcopy(env)
419
+ _rollout_heuristic_to_end(env_fallback)
420
+ return float(grade_task(task_key, env_fallback.state(), TASKS[task_key]["max_budget"]))
421
+
422
+
423
+ def _projected_score_for_action(
424
+ env: FlightRebookingEnv,
425
+ task_key: str,
426
+ action: Action,
427
+ lookahead_depth: int,
428
+ lookahead_width: int,
429
+ ) -> float:
430
+ env_copy = deepcopy(env)
431
+ _, _, done, _ = env_copy.step(action)
432
+ if done:
433
+ return float(grade_task(task_key, env_copy.state(), TASKS[task_key]["max_budget"]))
434
+
435
+ if lookahead_depth <= 1:
436
+ _rollout_heuristic_to_end(env_copy)
437
+ return float(grade_task(task_key, env_copy.state(), TASKS[task_key]["max_budget"]))
438
+
439
+ return _evaluate_state_with_lookahead(
440
+ env=env_copy,
441
+ task_key=task_key,
442
+ lookahead_depth=lookahead_depth - 1,
443
+ lookahead_width=lookahead_width,
444
+ ranked_action_types=None,
445
+ )
446
+
447
+
448
+ def _choose_lookahead_action(
449
+ env: FlightRebookingEnv,
450
+ task_key: str,
451
+ lookahead_depth: int,
452
+ lookahead_width: int,
453
+ ranked_action_types: Optional[List[str]] = None,
454
+ ) -> Dict[str, Any]:
455
+ observation = env._get_observation().model_dump(mode="json")
456
+ candidate_actions = _feasible_actions_from_observation(observation)
457
+
458
+ if ranked_action_types:
459
+ preferred_types = set(ranked_action_types[:5])
460
+ preferred_types.add(ActionType.FINALIZE.value)
461
+ preferred_types.add(ActionType.MARK_NO_SOLUTION.value)
462
+ preferred_candidates = [a for a in candidate_actions if a.action_type.value in preferred_types]
463
+ if preferred_candidates:
464
+ candidate_actions = preferred_candidates
465
+
466
+ best_action = candidate_actions[0]
467
+ best_score = -1.0
468
+ for action in candidate_actions:
469
+ try:
470
+ projected_score = _projected_score_for_action(
471
+ env=env,
472
+ task_key=task_key,
473
+ action=action,
474
+ lookahead_depth=lookahead_depth,
475
+ lookahead_width=lookahead_width,
476
+ )
477
+ except Exception:
478
+ continue
479
+ if projected_score > best_score:
480
+ best_score = projected_score
481
+ best_action = action
482
+
483
+ return best_action.model_dump(mode="json")
484
+
485
+
486
+ def _pick_best_payload_by_projection(
487
+ env: FlightRebookingEnv,
488
+ task_key: str,
489
+ payloads: List[Dict[str, Any]],
490
+ lookahead_depth: int,
491
+ lookahead_width: int,
492
+ ) -> Dict[str, Any]:
493
+ best_payload = payloads[0]
494
+ best_score = -1.0
495
+
496
+ seen_signatures = set()
497
+ for payload in payloads:
498
+ try:
499
+ action = Action(**payload)
500
+ except Exception:
501
+ continue
502
+
503
+ signature = (action.action_type.value, action.passenger_id, action.flight_id)
504
+ if signature in seen_signatures:
505
+ continue
506
+ seen_signatures.add(signature)
507
+
508
+ try:
509
+ projected_score = _projected_score_for_action(
510
+ env=env,
511
+ task_key=task_key,
512
+ action=action,
513
+ lookahead_depth=lookahead_depth,
514
+ lookahead_width=lookahead_width,
515
+ )
516
+ except Exception:
517
+ continue
518
+
519
+ if projected_score > best_score:
520
+ best_score = projected_score
521
+ best_payload = action.model_dump(mode="json")
522
+
523
+ return best_payload
524
+
525
+
526
+ def _extract_json(text: str) -> Dict[str, Any]:
527
+ text = (text or "").strip()
528
+ try:
529
+ return json.loads(text)
530
+ except json.JSONDecodeError:
531
+ pass
532
+
533
+ fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
534
+ if fenced:
535
+ return json.loads(fenced.group(1))
536
+
537
+ inline = re.search(r"\{.*\}", text, re.DOTALL)
538
+ if inline:
539
+ return json.loads(inline.group(0))
540
+
541
+ raise ValueError("No valid JSON action in model output")
542
+
543
+
544
+ def _tier_weight(tier: str) -> int:
545
+ return {
546
+ PriorityTier.PLATINUM.value: 4,
547
+ PriorityTier.GOLD.value: 3,
548
+ PriorityTier.SILVER.value: 2,
549
+ PriorityTier.STANDARD.value: 1,
550
+ }.get(tier, 1)
551
+
552
+
553
+ def _has_seat(flight: Dict[str, Any], cabin_class: str) -> bool:
554
+ if cabin_class == CabinClass.BUSINESS.value:
555
+ return flight["business_seats"] > 0
556
+ return flight["economy_seats"] > 0
557
+
558
+
559
+ def _heuristic_action(observation: Dict[str, Any]) -> Dict[str, Any]:
560
+ pending = list(observation["pending_passengers"])
561
+ if not pending:
562
+ return {"action_type": ActionType.FINALIZE.value}
563
+
564
+ pending.sort(
565
+ key=lambda p: (
566
+ -_tier_weight(p["priority_tier"]),
567
+ p["connection_deadline_hrs"] if p["connection_deadline_hrs"] is not None else 10**9,
568
+ )
569
+ )
570
+
571
+ passenger = pending[0]
572
+ flights = sorted(observation["available_flights"], key=lambda f: f["departure_hrs"])
573
+
574
+ for flight in flights:
575
+ if flight["is_partner"]:
576
+ continue
577
+ if _has_seat(flight, passenger["cabin_class"]):
578
+ return {
579
+ "action_type": ActionType.REBOOK_PASSENGER.value,
580
+ "passenger_id": passenger["id"],
581
+ "flight_id": flight["id"],
582
+ }
583
+
584
+ if passenger["cabin_class"] == CabinClass.BUSINESS.value:
585
+ for flight in flights:
586
+ if flight["is_partner"]:
587
+ continue
588
+ if flight["economy_seats"] > 0 and observation["budget_remaining"] >= 500:
589
+ return {
590
+ "action_type": ActionType.OFFER_DOWNGRADE.value,
591
+ "passenger_id": passenger["id"],
592
+ "flight_id": flight["id"],
593
+ }
594
+
595
+ for flight in flights:
596
+ if not flight["is_partner"]:
597
+ continue
598
+ if _has_seat(flight, passenger["cabin_class"]) and observation["budget_remaining"] >= 800:
599
+ return {
600
+ "action_type": ActionType.REBOOK_ON_PARTNER.value,
601
+ "passenger_id": passenger["id"],
602
+ "flight_id": flight["id"],
603
+ }
604
+
605
+ if observation["budget_remaining"] >= 250:
606
+ return {
607
+ "action_type": ActionType.BOOK_HOTEL.value,
608
+ "passenger_id": passenger["id"],
609
+ }
610
+
611
+ return {
612
+ "action_type": ActionType.MARK_NO_SOLUTION.value,
613
+ "passenger_id": passenger["id"],
614
+ }
615
+
616
+
617
+ def _is_action_feasible(observation: Dict[str, Any], payload: Dict[str, Any]) -> bool:
618
+ action_type = payload["action_type"]
619
+ if action_type == ActionType.FINALIZE.value:
620
+ return True
621
+
622
+ pending_by_id = {p["id"]: p for p in observation["pending_passengers"]}
623
+ flights_by_id = {f["id"]: f for f in observation["available_flights"]}
624
+ budget_remaining = float(observation["budget_remaining"])
625
+
626
+ passenger = pending_by_id.get(payload.get("passenger_id"))
627
+ if passenger is None:
628
+ return False
629
+
630
+ if action_type == ActionType.BOOK_HOTEL.value:
631
+ return budget_remaining >= 250
632
+
633
+ if action_type == ActionType.MARK_NO_SOLUTION.value:
634
+ return True
635
+
636
+ flight = flights_by_id.get(payload.get("flight_id"))
637
+ if flight is None:
638
+ return False
639
+
640
+ passenger_cabin = passenger["cabin_class"]
641
+ needs_business = passenger_cabin == CabinClass.BUSINESS.value
642
+ has_matching_cabin_seat = (flight["business_seats"] > 0) if needs_business else (flight["economy_seats"] > 0)
643
+
644
+ if action_type == ActionType.REBOOK_PASSENGER.value:
645
+ return (not flight["is_partner"]) and has_matching_cabin_seat
646
+
647
+ if action_type == ActionType.OFFER_DOWNGRADE.value:
648
+ return (
649
+ passenger_cabin == CabinClass.BUSINESS.value
650
+ and budget_remaining >= 500
651
+ and flight["economy_seats"] > 0
652
+ )
653
+
654
+ if action_type == ActionType.REBOOK_ON_PARTNER.value:
655
+ return flight["is_partner"] and budget_remaining >= 800 and has_matching_cabin_seat
656
+
657
+ return False
658
+
659
+
660
+ def _sanitize_action_payload(observation: Dict[str, Any], payload: Any) -> Dict[str, Any]:
661
+ fallback = _heuristic_action(observation)
662
+
663
+ if not isinstance(payload, dict):
664
+ return fallback
665
+
666
+ valid_action_types = {action_type.value for action_type in ActionType}
667
+ action_type = str(payload.get("action_type", "")).strip()
668
+ if action_type not in valid_action_types:
669
+ return fallback
670
+
671
+ sanitized: Dict[str, Any] = {"action_type": action_type}
672
+ passenger_id = str(payload.get("passenger_id", "")).strip()
673
+ flight_id = str(payload.get("flight_id", "")).strip()
674
+
675
+ if passenger_id:
676
+ sanitized["passenger_id"] = passenger_id
677
+ if flight_id:
678
+ sanitized["flight_id"] = flight_id
679
+
680
+ if action_type == ActionType.FINALIZE.value:
681
+ return sanitized
682
+
683
+ pending_ids = {p["id"] for p in observation["pending_passengers"]}
684
+ if sanitized.get("passenger_id") not in pending_ids:
685
+ return fallback
686
+
687
+ if action_type in {
688
+ ActionType.REBOOK_PASSENGER.value,
689
+ ActionType.OFFER_DOWNGRADE.value,
690
+ ActionType.REBOOK_ON_PARTNER.value,
691
+ }:
692
+ flight_ids = {f["id"] for f in observation["available_flights"]}
693
+ if sanitized.get("flight_id") not in flight_ids:
694
+ return fallback
695
+
696
+ if not _is_action_feasible(observation, sanitized):
697
+ return fallback
698
+
699
+ return sanitized
700
+
701
+
702
+ def _query_openai_action(
703
+ client: OpenAI,
704
+ model_name: str,
705
+ seed: int,
706
+ observation_json: str,
707
+ policy_hint_json: Optional[str] = None,
708
+ max_retries: int = 2,
709
+ ) -> Dict[str, Any]:
710
+ last_error: Optional[Exception] = None
711
+
712
+ for _ in range(max_retries + 1):
713
+ try:
714
+ user_content = f"Current observation: {observation_json}"
715
+ if policy_hint_json:
716
+ user_content += (
717
+ "\nSuggested safe action from a trained policy: "
718
+ f"{policy_hint_json}"
719
+ "\nPrefer this if it is valid for the current observation."
720
+ )
721
+
722
+ kwargs: Dict[str, Any] = {
723
+ "model": model_name,
724
+ "messages": [
725
+ {"role": "system", "content": SYSTEM_PROMPT},
726
+ {"role": "user", "content": user_content},
727
+ ],
728
+ "temperature": 0,
729
+ "top_p": 1,
730
+ "max_tokens": 220,
731
+ "seed": seed,
732
+ }
733
+
734
+ response = None
735
+ try:
736
+ response = client.chat.completions.create(**kwargs)
737
+ except TypeError:
738
+ kwargs.pop("seed", None)
739
+ response = client.chat.completions.create(**kwargs)
740
+
741
+ content = response.choices[0].message.content or ""
742
+ return _extract_json(content)
743
+ except Exception as exc:
744
+ last_error = exc
745
+
746
+ raise RuntimeError(f"OpenAI call failed after retries: {last_error}")
747
+
748
+
749
+ def _emit_start(task_name: str, benchmark: str, model_name: str) -> None:
750
+ print(f"[START] task={task_name} env={benchmark} model={model_name}", flush=True)
751
+
752
+
753
+ def _format_action_for_log(action: Action) -> str:
754
+ payload = {
755
+ "action_type": action.action_type.value,
756
+ "passenger_id": action.passenger_id,
757
+ "flight_id": action.flight_id,
758
+ }
759
+ return json.dumps(payload, separators=(",", ":"), ensure_ascii=True)
760
+
761
+
762
+ def _emit_step(
763
+ step_index: int,
764
+ action_text: str,
765
+ reward_value: float,
766
+ done: bool,
767
+ error: Optional[str],
768
+ ) -> None:
769
+ done_value = str(bool(done)).lower()
770
+ error_value = error if error else "null"
771
+ print(
772
+ "[STEP] "
773
+ f"step={step_index} "
774
+ f"action={action_text} "
775
+ f"reward={reward_value:.2f} "
776
+ f"done={done_value} "
777
+ f"error={error_value}",
778
+ flush=True,
779
+ )
780
+
781
+
782
+ def _emit_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
783
+ rewards_text = ",".join(f"{value:.2f}" for value in rewards)
784
+ success_value = str(bool(success)).lower()
785
+ print(
786
+ "[END] "
787
+ f"success={success_value} "
788
+ f"steps={steps} "
789
+ f"score={score:.4f} "
790
+ f"rewards={rewards_text}",
791
+ flush=True,
792
+ )
793
+
794
+
795
+ def parse_args() -> argparse.Namespace:
796
+ parser = argparse.ArgumentParser(description="Run submission inference across OpenEnv tasks.")
797
+ parser.add_argument("--task", choices=["all", "easy", "medium", "hard"], default="all")
798
+ parser.add_argument("--seed", type=int, default=int(os.getenv("BASELINE_SEED", "42")))
799
+ parser.add_argument(
800
+ "--policy",
801
+ choices=["openai", "heuristic", "trained_ml", "openai_trained"],
802
+ default="openai_trained",
803
+ help=(
804
+ "Policy backend. openai_trained uses Llama with trained-policy hints; "
805
+ "trained_ml uses the learned policy directly; openai and heuristic remain available."
806
+ ),
807
+ )
808
+ parser.add_argument(
809
+ "--ml-policy-path",
810
+ default=os.getenv("ML_POLICY_PATH", "artifacts/ml_policy.pkl"),
811
+ help="Path to trained ML policy artifact used by trained_ml/openai_trained modes.",
812
+ )
813
+ parser.add_argument(
814
+ "--lookahead-depth",
815
+ type=int,
816
+ default=int(os.getenv("LOOKAHEAD_DEPTH", "2")),
817
+ help="Lookahead depth for projected action scoring (>=1).",
818
+ )
819
+ parser.add_argument(
820
+ "--lookahead-width",
821
+ type=int,
822
+ default=int(os.getenv("LOOKAHEAD_WIDTH", "12")),
823
+ help="Maximum candidate actions explored per lookahead level (>=1).",
824
+ )
825
+ parser.add_argument("--json-out", default="", help="Optional JSON output path.")
826
+ return parser.parse_args()
827
+
828
+
829
+ def main() -> None:
830
+ args = parse_args()
831
+ args.lookahead_depth = max(1, int(args.lookahead_depth))
832
+ args.lookahead_width = max(1, int(args.lookahead_width))
833
+
834
+ task_keys = ["easy", "medium", "hard"] if args.task == "all" else [args.task]
835
+
836
+ effective_policy = args.policy
837
+ ml_policy_artifact: Optional[Dict[str, Any]] = None
838
+ if effective_policy in {"trained_ml", "openai_trained"}:
839
+ ml_policy_artifact = _require_ml_policy_artifact(args.ml_policy_path, effective_policy)
840
+
841
+ api_base_url = "heuristic"
842
+ model_name = "heuristic"
843
+ client: Optional[OpenAI] = None
844
+ if effective_policy in {"openai", "openai_trained"}:
845
+ model_config = _resolve_model_config()
846
+ api_base_url = model_config["api_base_url"]
847
+ model_name = model_config["model_name"]
848
+ client = OpenAI(api_key=model_config["api_key"], base_url=api_base_url)
849
+
850
+ results: List[Dict[str, Any]] = []
851
+
852
+ for task_key in task_keys:
853
+ task_data = TASKS[task_key]
854
+ _emit_start(task_name=task_data["task_id"], benchmark=BENCHMARK_NAME, model_name=model_name)
855
+
856
+ env = FlightRebookingEnv(task_data=task_data)
857
+ observation = None
858
+ done = False
859
+ steps = 0
860
+ rewards: List[float] = []
861
+ score = 0.01
862
+ success = False
863
+ episode_error: Optional[str] = None
864
+
865
+ try:
866
+ observation = env.reset()
867
+
868
+ while not done:
869
+ observation_dict = observation.model_dump(mode="json")
870
+
871
+ if effective_policy in {"openai", "openai_trained"}:
872
+ assert client is not None
873
+ policy_hint_payload: Optional[Dict[str, Any]] = None
874
+ if effective_policy == "openai_trained":
875
+ assert ml_policy_artifact is not None
876
+ ranked_types = _predict_ml_ranked_action_types(observation_dict, ml_policy_artifact)
877
+ policy_hint_payload = _choose_lookahead_action(
878
+ env=env,
879
+ task_key=task_key,
880
+ lookahead_depth=args.lookahead_depth,
881
+ lookahead_width=args.lookahead_width,
882
+ ranked_action_types=ranked_types,
883
+ )
884
+
885
+ raw_payload = _query_openai_action(
886
+ client=client,
887
+ model_name=model_name,
888
+ seed=args.seed,
889
+ observation_json=observation.model_dump_json(),
890
+ policy_hint_json=(json.dumps(policy_hint_payload) if policy_hint_payload is not None else None),
891
+ )
892
+ llm_payload = _sanitize_action_payload(observation_dict, raw_payload)
893
+
894
+ if effective_policy == "openai_trained" and policy_hint_payload is not None:
895
+ action_payload = _pick_best_payload_by_projection(
896
+ env=env,
897
+ task_key=task_key,
898
+ payloads=[policy_hint_payload, llm_payload],
899
+ lookahead_depth=args.lookahead_depth,
900
+ lookahead_width=args.lookahead_width,
901
+ )
902
+ else:
903
+ action_payload = llm_payload
904
+ elif effective_policy == "trained_ml":
905
+ assert ml_policy_artifact is not None
906
+ ranked_types = _predict_ml_ranked_action_types(observation_dict, ml_policy_artifact)
907
+ action_payload = _choose_lookahead_action(
908
+ env=env,
909
+ task_key=task_key,
910
+ lookahead_depth=args.lookahead_depth,
911
+ lookahead_width=args.lookahead_width,
912
+ ranked_action_types=ranked_types,
913
+ )
914
+ else:
915
+ action_payload = _heuristic_action(observation_dict)
916
+
917
+ try:
918
+ action = Action(**action_payload)
919
+ except Exception:
920
+ action = Action(action_type=ActionType.FINALIZE)
921
+
922
+ step_error: Optional[str] = None
923
+ reward_value = 0.0
924
+ try:
925
+ observation, reward, done, info = env.step(action)
926
+ reward_value = float(reward.value)
927
+ if isinstance(info, dict) and info.get("error"):
928
+ step_error = str(info.get("error"))
929
+ except Exception as exc:
930
+ done = True
931
+ step_error = str(exc)
932
+ episode_error = step_error
933
+
934
+ steps += 1
935
+ rewards.append(reward_value)
936
+ _emit_step(
937
+ step_index=steps,
938
+ action_text=_format_action_for_log(action),
939
+ reward_value=reward_value,
940
+ done=done,
941
+ error=step_error,
942
+ )
943
+
944
+ try:
945
+ final_state = env.state()
946
+ score = float(grade_task(task_key, final_state, task_data["max_budget"]))
947
+ except Exception as exc:
948
+ episode_error = str(exc)
949
+ score = 0.01
950
+ except Exception as exc:
951
+ episode_error = str(exc)
952
+ score = 0.01
953
+ finally:
954
+ close_fn = getattr(env, "close", None)
955
+ if callable(close_fn):
956
+ try:
957
+ close_fn()
958
+ except Exception as exc:
959
+ if not episode_error:
960
+ episode_error = str(exc)
961
+
962
+ success = (episode_error is None) and (0.0 <= score <= 1.0) and (score >= SUCCESS_SCORE_THRESHOLD)
963
+ _emit_end(success=success, steps=steps, score=score, rewards=rewards)
964
+
965
+ try:
966
+ final_state = env.state()
967
+ avg_step_reward = sum(rewards) / max(len(rewards), 1)
968
+ results.append(
969
+ {
970
+ "task": task_key,
971
+ "task_id": task_data["task_id"],
972
+ "difficulty": task_data["difficulty"],
973
+ "steps": steps,
974
+ "avg_step_reward": round(avg_step_reward, 4),
975
+ "score": round(score, 4),
976
+ "budget_spent": round(final_state.budget_spent, 2),
977
+ "budget_max": task_data["max_budget"],
978
+ "invalid_actions": final_state.invalid_actions,
979
+ "success": success,
980
+ "error": episode_error,
981
+ }
982
+ )
983
+ except Exception:
984
+ avg_step_reward = sum(rewards) / max(len(rewards), 1)
985
+ results.append(
986
+ {
987
+ "task": task_key,
988
+ "task_id": task_data["task_id"],
989
+ "difficulty": task_data["difficulty"],
990
+ "steps": steps,
991
+ "avg_step_reward": round(avg_step_reward, 4),
992
+ "score": round(score, 4),
993
+ "budget_spent": None,
994
+ "budget_max": task_data["max_budget"],
995
+ "invalid_actions": None,
996
+ "success": success,
997
+ "error": episode_error,
998
+ }
999
+ )
1000
+
1001
+ overall = sum(item["score"] for item in results) / max(len(results), 1)
1002
+
1003
+ if args.json_out:
1004
+ payload = {
1005
+ "policy_requested": args.policy,
1006
+ "policy_effective": effective_policy,
1007
+ "seed": args.seed,
1008
+ "api_base_url": api_base_url,
1009
+ "model_name": model_name,
1010
+ "ml_policy_path": args.ml_policy_path,
1011
+ "ml_policy_loaded": ml_policy_artifact is not None,
1012
+ "lookahead_depth": args.lookahead_depth,
1013
+ "lookahead_width": args.lookahead_width,
1014
+ "overall_score": round(overall, 4),
1015
+ "tasks": results,
1016
+ }
1017
+ with open(args.json_out, "w", encoding="utf-8") as handle:
1018
+ json.dump(payload, handle, indent=2)
1019
+
1020
+
1021
+ if __name__ == "__main__":
1022
+ main()
ml_policy.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ML policy helpers for flight rebooking.
3
+
4
+ This module provides:
5
+ - deterministic expert policy used for dataset generation,
6
+ - fixed-length feature extraction for supervised learning,
7
+ - safe action construction from ranked action-type preferences.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, Iterable, List, Optional
13
+
14
+ from environment import ActionType, CabinClass, PriorityTier
15
+
16
+
17
+ ACTION_TYPE_ORDER: List[str] = [
18
+ ActionType.REBOOK_PASSENGER.value,
19
+ ActionType.OFFER_DOWNGRADE.value,
20
+ ActionType.REBOOK_ON_PARTNER.value,
21
+ ActionType.BOOK_HOTEL.value,
22
+ ActionType.MARK_NO_SOLUTION.value,
23
+ ActionType.FINALIZE.value,
24
+ ]
25
+
26
+
27
+ def _tier_weight(tier: str) -> int:
28
+ return {
29
+ PriorityTier.PLATINUM.value: 4,
30
+ PriorityTier.GOLD.value: 3,
31
+ PriorityTier.SILVER.value: 2,
32
+ PriorityTier.STANDARD.value: 1,
33
+ }.get(tier, 1)
34
+
35
+
36
+ def _deadline_sort_value(deadline_hrs: Optional[float]) -> float:
37
+ return float(deadline_hrs) if deadline_hrs is not None else 10**9
38
+
39
+
40
+ def _has_seat(flight: Dict[str, Any], cabin_class: str) -> bool:
41
+ if cabin_class == CabinClass.BUSINESS.value:
42
+ return int(flight["business_seats"]) > 0
43
+ return int(flight["economy_seats"]) > 0
44
+
45
+
46
+ def _sorted_pending_passengers(observation: Dict[str, Any]) -> List[Dict[str, Any]]:
47
+ pending = list(observation.get("pending_passengers", []))
48
+ pending.sort(
49
+ key=lambda p: (
50
+ -_tier_weight(str(p.get("priority_tier", ""))),
51
+ _deadline_sort_value(p.get("connection_deadline_hrs")),
52
+ )
53
+ )
54
+ return pending
55
+
56
+
57
+ def _sorted_flights(observation: Dict[str, Any]) -> List[Dict[str, Any]]:
58
+ flights = list(observation.get("available_flights", []))
59
+ flights.sort(key=lambda f: float(f.get("departure_hrs", 10**9)))
60
+ return flights
61
+
62
+
63
+ def heuristic_action(observation: Dict[str, Any]) -> Dict[str, Any]:
64
+ pending = _sorted_pending_passengers(observation)
65
+ if not pending:
66
+ return {"action_type": ActionType.FINALIZE.value}
67
+
68
+ passenger = pending[0]
69
+ flights = _sorted_flights(observation)
70
+
71
+ for flight in flights:
72
+ if flight.get("is_partner", False):
73
+ continue
74
+ if _has_seat(flight, str(passenger["cabin_class"])):
75
+ return {
76
+ "action_type": ActionType.REBOOK_PASSENGER.value,
77
+ "passenger_id": passenger["id"],
78
+ "flight_id": flight["id"],
79
+ }
80
+
81
+ if passenger.get("cabin_class") == CabinClass.BUSINESS.value:
82
+ for flight in flights:
83
+ if flight.get("is_partner", False):
84
+ continue
85
+ if int(flight.get("economy_seats", 0)) > 0 and float(observation.get("budget_remaining", 0.0)) >= 500.0:
86
+ return {
87
+ "action_type": ActionType.OFFER_DOWNGRADE.value,
88
+ "passenger_id": passenger["id"],
89
+ "flight_id": flight["id"],
90
+ }
91
+
92
+ for flight in flights:
93
+ if not flight.get("is_partner", False):
94
+ continue
95
+ if _has_seat(flight, str(passenger["cabin_class"])) and float(observation.get("budget_remaining", 0.0)) >= 800.0:
96
+ return {
97
+ "action_type": ActionType.REBOOK_ON_PARTNER.value,
98
+ "passenger_id": passenger["id"],
99
+ "flight_id": flight["id"],
100
+ }
101
+
102
+ if float(observation.get("budget_remaining", 0.0)) >= 250.0:
103
+ return {
104
+ "action_type": ActionType.BOOK_HOTEL.value,
105
+ "passenger_id": passenger["id"],
106
+ }
107
+
108
+ return {
109
+ "action_type": ActionType.MARK_NO_SOLUTION.value,
110
+ "passenger_id": passenger["id"],
111
+ }
112
+
113
+
114
+ def observation_to_features(
115
+ observation: Dict[str, Any],
116
+ max_pending: int = 5,
117
+ max_flights: int = 6,
118
+ ) -> List[float]:
119
+ pending = _sorted_pending_passengers(observation)
120
+ flights = _sorted_flights(observation)
121
+
122
+ processed_count = float(observation.get("processed_count", 0))
123
+ total_passengers = max(float(observation.get("total_passengers", 1)), 1.0)
124
+ budget_remaining = max(float(observation.get("budget_remaining", 0.0)), 0.0)
125
+ budget_spent = max(float(observation.get("budget_spent", 0.0)), 0.0)
126
+ budget_total = max(budget_remaining + budget_spent, 1.0)
127
+
128
+ features: List[float] = []
129
+
130
+ features.extend(
131
+ [
132
+ min(len(pending), 20) / 20.0,
133
+ min(len(flights), 20) / 20.0,
134
+ budget_remaining / budget_total,
135
+ budget_spent / budget_total,
136
+ processed_count / total_passengers,
137
+ min(float(observation.get("invalid_actions", 0)), 20.0) / 20.0,
138
+ min(float(observation.get("step_count", 0)), 120.0) / 120.0,
139
+ 1.0 if pending else 0.0,
140
+ ]
141
+ )
142
+
143
+ for passenger in pending[:max_pending]:
144
+ deadline = passenger.get("connection_deadline_hrs")
145
+ has_deadline = 1.0 if deadline is not None else 0.0
146
+ deadline_norm = (min(float(deadline), 12.0) / 12.0) if deadline is not None else 1.0
147
+ features.extend(
148
+ [
149
+ _tier_weight(str(passenger.get("priority_tier", ""))) / 4.0,
150
+ 1.0 if passenger.get("cabin_class") == CabinClass.BUSINESS.value else 0.0,
151
+ has_deadline,
152
+ deadline_norm,
153
+ ]
154
+ )
155
+
156
+ for _ in range(max_pending - len(pending[:max_pending])):
157
+ features.extend([0.0, 0.0, 0.0, 0.0])
158
+
159
+ for flight in flights[:max_flights]:
160
+ features.extend(
161
+ [
162
+ 1.0 if flight.get("is_partner", False) else 0.0,
163
+ min(float(flight.get("departure_hrs", 12.0)), 12.0) / 12.0,
164
+ min(float(flight.get("economy_seats", 0.0)), 12.0) / 12.0,
165
+ min(float(flight.get("business_seats", 0.0)), 6.0) / 6.0,
166
+ ]
167
+ )
168
+
169
+ for _ in range(max_flights - len(flights[:max_flights])):
170
+ features.extend([0.0, 0.0, 0.0, 0.0])
171
+
172
+ same_econ = 0.0
173
+ same_bus = 0.0
174
+ partner_econ = 0.0
175
+ partner_bus = 0.0
176
+ for flight in flights:
177
+ if flight.get("is_partner", False):
178
+ partner_econ += float(flight.get("economy_seats", 0.0))
179
+ partner_bus += float(flight.get("business_seats", 0.0))
180
+ else:
181
+ same_econ += float(flight.get("economy_seats", 0.0))
182
+ same_bus += float(flight.get("business_seats", 0.0))
183
+
184
+ features.extend(
185
+ [
186
+ min(same_econ, 30.0) / 30.0,
187
+ min(same_bus, 20.0) / 20.0,
188
+ min(partner_econ, 30.0) / 30.0,
189
+ min(partner_bus, 20.0) / 20.0,
190
+ ]
191
+ )
192
+
193
+ return features
194
+
195
+
196
+ def build_feasible_action_for_type(observation: Dict[str, Any], action_type: str) -> Optional[Dict[str, Any]]:
197
+ pending = _sorted_pending_passengers(observation)
198
+ flights = _sorted_flights(observation)
199
+ budget_remaining = float(observation.get("budget_remaining", 0.0))
200
+
201
+ if not pending:
202
+ return {"action_type": ActionType.FINALIZE.value}
203
+
204
+ if action_type == ActionType.FINALIZE.value:
205
+ return None
206
+
207
+ if action_type == ActionType.BOOK_HOTEL.value:
208
+ if budget_remaining >= 250.0:
209
+ return {
210
+ "action_type": ActionType.BOOK_HOTEL.value,
211
+ "passenger_id": pending[0]["id"],
212
+ }
213
+ return None
214
+
215
+ if action_type == ActionType.MARK_NO_SOLUTION.value:
216
+ return {
217
+ "action_type": ActionType.MARK_NO_SOLUTION.value,
218
+ "passenger_id": pending[0]["id"],
219
+ }
220
+
221
+ if action_type == ActionType.REBOOK_PASSENGER.value:
222
+ for passenger in pending:
223
+ for flight in flights:
224
+ if flight.get("is_partner", False):
225
+ continue
226
+ if _has_seat(flight, str(passenger["cabin_class"])):
227
+ return {
228
+ "action_type": ActionType.REBOOK_PASSENGER.value,
229
+ "passenger_id": passenger["id"],
230
+ "flight_id": flight["id"],
231
+ }
232
+ return None
233
+
234
+ if action_type == ActionType.OFFER_DOWNGRADE.value:
235
+ if budget_remaining < 500.0:
236
+ return None
237
+ business_pending = [p for p in pending if p.get("cabin_class") == CabinClass.BUSINESS.value]
238
+ for passenger in business_pending:
239
+ for flight in flights:
240
+ if flight.get("is_partner", False):
241
+ continue
242
+ if int(flight.get("economy_seats", 0)) > 0:
243
+ return {
244
+ "action_type": ActionType.OFFER_DOWNGRADE.value,
245
+ "passenger_id": passenger["id"],
246
+ "flight_id": flight["id"],
247
+ }
248
+ return None
249
+
250
+ if action_type == ActionType.REBOOK_ON_PARTNER.value:
251
+ if budget_remaining < 800.0:
252
+ return None
253
+ for passenger in pending:
254
+ for flight in flights:
255
+ if not flight.get("is_partner", False):
256
+ continue
257
+ if _has_seat(flight, str(passenger["cabin_class"])):
258
+ return {
259
+ "action_type": ActionType.REBOOK_ON_PARTNER.value,
260
+ "passenger_id": passenger["id"],
261
+ "flight_id": flight["id"],
262
+ }
263
+ return None
264
+
265
+ return None
266
+
267
+
268
+ def choose_action_from_ranked_types(observation: Dict[str, Any], ranked_types: Iterable[str]) -> Dict[str, Any]:
269
+ for action_type in ranked_types:
270
+ if not action_type:
271
+ continue
272
+ candidate = build_feasible_action_for_type(observation, str(action_type))
273
+ if candidate is not None:
274
+ return candidate
275
+
276
+ return heuristic_action(observation)
openenv.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "flight-rebooking-openenv"
2
+ version: "2.0.0"
3
+ description: "Real-world airline disruption simulator with typed OpenEnv models and deterministic task graders."
4
+ author: "Hackathon Team"
5
+ license: "MIT"
6
+ python_version: "3.10"
7
+ entrypoint: "environment:FlightRebookingEnv"
8
+
9
+ models:
10
+ observation: "environment:Observation"
11
+ action: "environment:Action"
12
+ reward: "environment:Reward"
13
+
14
+ api:
15
+ reset: "FlightRebookingEnv.reset"
16
+ step: "FlightRebookingEnv.step"
17
+ state: "FlightRebookingEnv.state"
18
+
19
+ tasks:
20
+ - key: "easy"
21
+ id: "easy_minor_disruption"
22
+ difficulty: "easy"
23
+ grader: "tasks:grade_easy_episode"
24
+ objective: "Rebook all passengers with minimal spend."
25
+ - key: "medium"
26
+ id: "medium_connection_crisis"
27
+ difficulty: "medium"
28
+ grader: "tasks:grade_medium_episode"
29
+ objective: "Save urgent connections while honoring tier priority."
30
+ - key: "hard"
31
+ id: "hard_multi_wave_disruption"
32
+ difficulty: "hard"
33
+ grader: "tasks:grade_hard_episode"
34
+ objective: "Optimize across loyalty, deadlines, and budget scarcity."
35
+
36
+ tags:
37
+ - openenv
38
+ - airline-operations
39
+ - decision-making
40
+ - logistics
41
+ - reinforcement-learning
42
+ - huggingface-space
pyproject.toml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "flight-rebooking-agent"
7
+ version = "0.1.0"
8
+ description = "Flight Rebooking OpenEnv Agent"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "fastapi",
12
+ "uvicorn",
13
+ "pydantic",
14
+ "openenv-core>=0.2.0"
15
+ ]
16
+
17
+ [project.scripts]
18
+ server = "server.app:main"
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ pydantic
4
+ openai
5
+ torch
6
+ transformers
7
+ peft
8
+ bitsandbytes
9
+ accelerate
10
+ jinja2
11
+ python-multipart
server/app.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ # Add root dir to path so we can import 'app' and 'environment'
5
+ root_dir = str(Path(__file__).resolve().parent.parent)
6
+ if root_dir not in sys.path:
7
+ sys.path.insert(0, root_dir)
8
+
9
+ from app import app
10
+
11
+ def main():
12
+ import uvicorn
13
+ uvicorn.run("app:app", host="0.0.0.0", port=7860)
14
+
15
+ if __name__ == "__main__":
16
+ main()
tasks.py ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task Definitions and Deterministic Graders
3
+ ===========================================
4
+
5
+ Provides three progressively difficult real-world disruption tasks and
6
+ task-specific grading functions that return normalized scores in [0.0, 1.0].
7
+ """
8
+
9
+ from typing import Dict, List
10
+
11
+ from environment import EnvState, PassengerStatus, PriorityTier
12
+
13
+
14
+ EASY_TASK = {
15
+ "task_id": "easy_minor_disruption",
16
+ "difficulty": "easy",
17
+ "objective": "Rebook all passengers on same-airline flights while preserving premium service and minimizing spend.",
18
+ "max_budget": 3000,
19
+ "max_steps": 40,
20
+ "passengers": [
21
+ {
22
+ "id": "P1",
23
+ "name": "Alice Johnson",
24
+ "priority_tier": "Platinum",
25
+ "original_flight": "FL-100",
26
+ "cabin_class": "Business",
27
+ "connection_deadline_hrs": None,
28
+ },
29
+ {
30
+ "id": "P2",
31
+ "name": "Bob Smith",
32
+ "priority_tier": "Gold",
33
+ "original_flight": "FL-100",
34
+ "cabin_class": "Economy",
35
+ "connection_deadline_hrs": None,
36
+ },
37
+ {
38
+ "id": "P3",
39
+ "name": "Carol Davis",
40
+ "priority_tier": "Standard",
41
+ "original_flight": "FL-100",
42
+ "cabin_class": "Economy",
43
+ "connection_deadline_hrs": None,
44
+ },
45
+ ],
46
+ "flights": [
47
+ {
48
+ "id": "FL-102",
49
+ "destination": "New York",
50
+ "departure_hrs": 3.0,
51
+ "economy_seats": 5,
52
+ "business_seats": 2,
53
+ "is_partner": False,
54
+ },
55
+ {
56
+ "id": "FL-104",
57
+ "destination": "New York",
58
+ "departure_hrs": 6.0,
59
+ "economy_seats": 10,
60
+ "business_seats": 3,
61
+ "is_partner": False,
62
+ },
63
+ {
64
+ "id": "FL-201",
65
+ "destination": "New York",
66
+ "departure_hrs": 4.0,
67
+ "economy_seats": 3,
68
+ "business_seats": 1,
69
+ "is_partner": True,
70
+ },
71
+ ],
72
+ }
73
+
74
+
75
+ MEDIUM_TASK = {
76
+ "task_id": "medium_connection_crisis",
77
+ "difficulty": "medium",
78
+ "objective": "Prioritize high-tier passengers with tight deadlines under constrained seats and budget.",
79
+ "max_budget": 5000,
80
+ "max_steps": 60,
81
+ "passengers": [
82
+ {
83
+ "id": "P1",
84
+ "name": "David Lee",
85
+ "priority_tier": "Platinum",
86
+ "original_flight": "FL-300",
87
+ "cabin_class": "Business",
88
+ "connection_deadline_hrs": 4.0,
89
+ },
90
+ {
91
+ "id": "P2",
92
+ "name": "Emma Wilson",
93
+ "priority_tier": "Gold",
94
+ "original_flight": "FL-300",
95
+ "cabin_class": "Economy",
96
+ "connection_deadline_hrs": 2.5,
97
+ },
98
+ {
99
+ "id": "P3",
100
+ "name": "Frank Brown",
101
+ "priority_tier": "Silver",
102
+ "original_flight": "FL-300",
103
+ "cabin_class": "Economy",
104
+ "connection_deadline_hrs": None,
105
+ },
106
+ {
107
+ "id": "P4",
108
+ "name": "Grace Kim",
109
+ "priority_tier": "Standard",
110
+ "original_flight": "FL-300",
111
+ "cabin_class": "Business",
112
+ "connection_deadline_hrs": 5.0,
113
+ },
114
+ {
115
+ "id": "P5",
116
+ "name": "Henry Park",
117
+ "priority_tier": "Gold",
118
+ "original_flight": "FL-300",
119
+ "cabin_class": "Economy",
120
+ "connection_deadline_hrs": 3.0,
121
+ },
122
+ ],
123
+ "flights": [
124
+ {
125
+ "id": "FL-302",
126
+ "destination": "Chicago",
127
+ "departure_hrs": 2.0,
128
+ "economy_seats": 2,
129
+ "business_seats": 1,
130
+ "is_partner": False,
131
+ },
132
+ {
133
+ "id": "FL-304",
134
+ "destination": "Chicago",
135
+ "departure_hrs": 5.0,
136
+ "economy_seats": 4,
137
+ "business_seats": 0,
138
+ "is_partner": False,
139
+ },
140
+ {
141
+ "id": "FL-401",
142
+ "destination": "Chicago",
143
+ "departure_hrs": 3.5,
144
+ "economy_seats": 2,
145
+ "business_seats": 1,
146
+ "is_partner": True,
147
+ },
148
+ ],
149
+ }
150
+
151
+
152
+ HARD_TASK = {
153
+ "task_id": "hard_multi_wave_disruption",
154
+ "difficulty": "hard",
155
+ "objective": "Handle mixed loyalty tiers, scarce seats, and multiple urgent connections while staying under budget.",
156
+ "max_budget": 7000,
157
+ "max_steps": 90,
158
+ "passengers": [
159
+ {
160
+ "id": "P1",
161
+ "name": "Iris Patel",
162
+ "priority_tier": "Platinum",
163
+ "original_flight": "FL-500",
164
+ "cabin_class": "Business",
165
+ "connection_deadline_hrs": 2.5,
166
+ },
167
+ {
168
+ "id": "P2",
169
+ "name": "Jack Rivera",
170
+ "priority_tier": "Gold",
171
+ "original_flight": "FL-500",
172
+ "cabin_class": "Economy",
173
+ "connection_deadline_hrs": 2.0,
174
+ },
175
+ {
176
+ "id": "P3",
177
+ "name": "Karen Novak",
178
+ "priority_tier": "Gold",
179
+ "original_flight": "FL-500",
180
+ "cabin_class": "Business",
181
+ "connection_deadline_hrs": 4.0,
182
+ },
183
+ {
184
+ "id": "P4",
185
+ "name": "Liam Chen",
186
+ "priority_tier": "Silver",
187
+ "original_flight": "FL-500",
188
+ "cabin_class": "Economy",
189
+ "connection_deadline_hrs": 3.0,
190
+ },
191
+ {
192
+ "id": "P5",
193
+ "name": "Maya Brooks",
194
+ "priority_tier": "Standard",
195
+ "original_flight": "FL-500",
196
+ "cabin_class": "Economy",
197
+ "connection_deadline_hrs": None,
198
+ },
199
+ {
200
+ "id": "P6",
201
+ "name": "Noah Singh",
202
+ "priority_tier": "Platinum",
203
+ "original_flight": "FL-500",
204
+ "cabin_class": "Business",
205
+ "connection_deadline_hrs": 3.5,
206
+ },
207
+ {
208
+ "id": "P7",
209
+ "name": "Olivia Green",
210
+ "priority_tier": "Silver",
211
+ "original_flight": "FL-500",
212
+ "cabin_class": "Economy",
213
+ "connection_deadline_hrs": 5.0,
214
+ },
215
+ {
216
+ "id": "P8",
217
+ "name": "Peter Hall",
218
+ "priority_tier": "Standard",
219
+ "original_flight": "FL-500",
220
+ "cabin_class": "Economy",
221
+ "connection_deadline_hrs": 2.8,
222
+ },
223
+ ],
224
+ "flights": [
225
+ {
226
+ "id": "FL-502",
227
+ "destination": "San Francisco",
228
+ "departure_hrs": 1.8,
229
+ "economy_seats": 2,
230
+ "business_seats": 1,
231
+ "is_partner": False,
232
+ },
233
+ {
234
+ "id": "FL-504",
235
+ "destination": "San Francisco",
236
+ "departure_hrs": 3.0,
237
+ "economy_seats": 2,
238
+ "business_seats": 1,
239
+ "is_partner": False,
240
+ },
241
+ {
242
+ "id": "FL-506",
243
+ "destination": "San Francisco",
244
+ "departure_hrs": 5.5,
245
+ "economy_seats": 3,
246
+ "business_seats": 0,
247
+ "is_partner": False,
248
+ },
249
+ {
250
+ "id": "FL-701",
251
+ "destination": "San Francisco",
252
+ "departure_hrs": 2.2,
253
+ "economy_seats": 2,
254
+ "business_seats": 1,
255
+ "is_partner": True,
256
+ },
257
+ {
258
+ "id": "FL-703",
259
+ "destination": "San Francisco",
260
+ "departure_hrs": 4.4,
261
+ "economy_seats": 2,
262
+ "business_seats": 1,
263
+ "is_partner": True,
264
+ },
265
+ ],
266
+ }
267
+
268
+
269
+ TASKS = {
270
+ "easy": EASY_TASK,
271
+ "medium": MEDIUM_TASK,
272
+ "hard": HARD_TASK,
273
+ }
274
+
275
+
276
+ _OUTCOME_SCORES = {
277
+ PassengerStatus.REBOOKED: 1.00,
278
+ PassengerStatus.PARTNER_REBOOKED: 0.85,
279
+ PassengerStatus.DOWNGRADED: 0.65,
280
+ PassengerStatus.HOTEL_BOOKED: 0.40,
281
+ PassengerStatus.NO_SOLUTION: 0.00,
282
+ PassengerStatus.PENDING: 0.00,
283
+ }
284
+
285
+
286
+ _TIER_WEIGHTS = {
287
+ PriorityTier.PLATINUM: 4,
288
+ PriorityTier.GOLD: 3,
289
+ PriorityTier.SILVER: 2,
290
+ PriorityTier.STANDARD: 1,
291
+ }
292
+
293
+
294
+ _GRADING_PROFILES = {
295
+ "easy": {
296
+ "quality": 0.45,
297
+ "coverage": 0.20,
298
+ "connection": 0.10,
299
+ "budget": 0.15,
300
+ "policy": 0.10,
301
+ },
302
+ "medium": {
303
+ "quality": 0.38,
304
+ "coverage": 0.17,
305
+ "connection": 0.22,
306
+ "budget": 0.13,
307
+ "policy": 0.10,
308
+ },
309
+ "hard": {
310
+ "quality": 0.30,
311
+ "coverage": 0.15,
312
+ "connection": 0.30,
313
+ "budget": 0.15,
314
+ "policy": 0.10,
315
+ },
316
+ }
317
+
318
+
319
+ def _clamp(value: float) -> float:
320
+ return max(0.01, min(0.99, value))
321
+
322
+
323
+ def _resolve_tier_weight(tier: PriorityTier) -> int:
324
+ if isinstance(tier, str):
325
+ tier = PriorityTier(tier)
326
+ return _TIER_WEIGHTS.get(tier, 1)
327
+
328
+
329
+ def _resolve_outcome_score(status: PassengerStatus) -> float:
330
+ if isinstance(status, str):
331
+ status = PassengerStatus(status)
332
+ return _OUTCOME_SCORES.get(status, 0.0)
333
+
334
+
335
+ def _connection_score(state: EnvState) -> float:
336
+ deadline_passengers = [p for p in state.passengers if p.connection_deadline_hrs is not None]
337
+ if not deadline_passengers:
338
+ return 0.99
339
+
340
+ weighted_hits = 0.0
341
+ weighted_total = 0.0
342
+
343
+ flights_by_id = {f.id: f for f in state.flights}
344
+ for passenger in deadline_passengers:
345
+ weight = _resolve_tier_weight(passenger.priority_tier)
346
+ weighted_total += weight
347
+
348
+ if passenger.assigned_flight is None:
349
+ continue
350
+
351
+ flight = flights_by_id.get(passenger.assigned_flight)
352
+ if flight is None:
353
+ continue
354
+
355
+ if flight.departure_hrs <= passenger.connection_deadline_hrs:
356
+ weighted_hits += weight
357
+ else:
358
+ weighted_hits += weight * 0.2
359
+
360
+ if weighted_total <= 0:
361
+ return 0.01
362
+
363
+ return _clamp(weighted_hits / weighted_total)
364
+
365
+
366
+ def _coverage_score(state: EnvState) -> float:
367
+ if not state.passengers:
368
+ return 0.01
369
+ resolved = sum(1 for p in state.passengers if p.status != PassengerStatus.PENDING)
370
+ return _clamp(resolved / len(state.passengers))
371
+
372
+
373
+ def _quality_score(state: EnvState) -> float:
374
+ weighted_sum = 0.0
375
+ weighted_total = 0.0
376
+
377
+ for passenger in state.passengers:
378
+ weight = _resolve_tier_weight(passenger.priority_tier)
379
+ weighted_total += weight
380
+ weighted_sum += weight * _resolve_outcome_score(passenger.status)
381
+
382
+ if weighted_total <= 0:
383
+ return 0.01
384
+
385
+ return _clamp(weighted_sum / weighted_total)
386
+
387
+
388
+ def _budget_score(state: EnvState, max_budget: float) -> float:
389
+ if max_budget <= 0:
390
+ return 0.99
391
+ return _clamp(1.0 - (state.budget_spent / max_budget))
392
+
393
+
394
+ def _policy_score(state: EnvState) -> float:
395
+ invalid_actions = max(getattr(state, "invalid_actions", 0), 0)
396
+ invalid_penalty = min(invalid_actions * 0.03, 0.3)
397
+
398
+ order: Dict[str, int] = {}
399
+ step = 0
400
+ for event in state.actions_taken:
401
+ if not event.get("success", False):
402
+ continue
403
+ action = event.get("action", {})
404
+ passenger_id = action.get("passenger_id")
405
+ if passenger_id and passenger_id not in order:
406
+ order[passenger_id] = step
407
+ step += 1
408
+
409
+ inversion_pairs = 0
410
+ total_pairs = 0
411
+ passengers = list(state.passengers)
412
+ for i in range(len(passengers)):
413
+ for j in range(i + 1, len(passengers)):
414
+ p_i = passengers[i]
415
+ p_j = passengers[j]
416
+ w_i = _resolve_tier_weight(p_i.priority_tier)
417
+ w_j = _resolve_tier_weight(p_j.priority_tier)
418
+ if w_i == w_j:
419
+ continue
420
+
421
+ if p_i.id not in order or p_j.id not in order:
422
+ continue
423
+
424
+ total_pairs += 1
425
+ if w_i > w_j and order[p_i.id] > order[p_j.id]:
426
+ inversion_pairs += 1
427
+ if w_j > w_i and order[p_j.id] > order[p_i.id]:
428
+ inversion_pairs += 1
429
+
430
+ inversion_penalty = (inversion_pairs / total_pairs) if total_pairs > 0 else 0.0
431
+ return _clamp(1.0 - invalid_penalty - inversion_penalty)
432
+
433
+
434
+ def _grade_with_profile(state: EnvState, max_budget: float, profile_name: str) -> float:
435
+ profile = _GRADING_PROFILES[profile_name]
436
+ quality = _quality_score(state)
437
+ coverage = _coverage_score(state)
438
+ connection = _connection_score(state)
439
+ budget = _budget_score(state, max_budget)
440
+ policy = _policy_score(state)
441
+
442
+ final = (
443
+ profile["quality"] * quality
444
+ + profile["coverage"] * coverage
445
+ + profile["connection"] * connection
446
+ + profile["budget"] * budget
447
+ + profile["policy"] * policy
448
+ )
449
+ return _clamp(final)
450
+
451
+
452
+ def grade_easy_episode(state: EnvState, max_budget: float) -> float:
453
+ return _grade_with_profile(state, max_budget, "easy")
454
+
455
+
456
+ def grade_medium_episode(state: EnvState, max_budget: float) -> float:
457
+ return _grade_with_profile(state, max_budget, "medium")
458
+
459
+
460
+ def grade_hard_episode(state: EnvState, max_budget: float) -> float:
461
+ return _grade_with_profile(state, max_budget, "hard")
462
+
463
+
464
+ TASK_GRADERS = {
465
+ "easy": grade_easy_episode,
466
+ "medium": grade_medium_episode,
467
+ "hard": grade_hard_episode,
468
+ }
469
+
470
+
471
+ def grade_task(task_key: str, state: EnvState, max_budget: float) -> float:
472
+ grader = TASK_GRADERS[task_key]
473
+ score = grader(state, max_budget)
474
+ # Enforce strict (0, 1) bounds required by the validator
475
+ return max(0.01, min(0.99, float(score)))
476
+
477
+
478
+ def grade_episode(state: EnvState, max_budget: float) -> float:
479
+ """Backward-compatible default grader, mapped to medium difficulty."""
480
+ score = grade_medium_episode(state, max_budget)
481
+ return max(0.01, min(0.99, float(score)))