Spaces:
Build error
Build error
Commit ·
9753ee2
0
Parent(s):
Production-ready: Clean code with Groq API integration, LoRA model support, and FastAPI app
Browse files- .dockerignore +51 -0
- .gitattributes +2 -0
- .gitignore +13 -0
- Dockerfile +33 -0
- README.md +82 -0
- app.py +230 -0
- environment.py +602 -0
- frontend/app.js +224 -0
- frontend/index.html +141 -0
- frontend/style.css +686 -0
- inference.py +1022 -0
- ml_policy.py +276 -0
- openenv.yaml +42 -0
- pyproject.toml +18 -0
- requirements.txt +11 -0
- server/app.py +16 -0
- 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 |
+

|
| 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 |
+

|
| 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("&", "&")
|
| 54 |
+
.replaceAll("<", "<")
|
| 55 |
+
.replaceAll(">", ">")
|
| 56 |
+
.replaceAll('"', """)
|
| 57 |
+
.replaceAll("'", "'");
|
| 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)))
|