File size: 12,964 Bytes
d5338b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51615a4
 
 
d5338b4
 
 
 
 
 
51615a4
 
 
 
 
 
 
 
 
 
 
 
 
 
d5338b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51615a4
d5338b4
 
 
 
 
 
 
 
 
51615a4
d5338b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51615a4
d5338b4
 
 
 
 
 
 
 
 
 
51615a4
d5338b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51615a4
d5338b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
"""

Inference Script for OptiChain-Env

===================================

MANDATORY HACKATHON CONFIGURATION

- Reads API_BASE_URL, MODEL_NAME, HF_TOKEN from environment.

- Uses OpenAI Python client for all LLM calls.

- Emits structured [START], [STEP], [END] logs for automated scoring.

"""

import os
import logging
from dotenv import load_dotenv
from openai import OpenAI
from env.core import SupplyChainEnv, EXPEDITE_SURCHARGE
from env.schemas import SupplyChainAction, SupplyChainObservation, PurchaseOrder

logger = logging.getLogger(__name__)

# Load environment variables for local testing
load_dotenv()

# =================================================================
# MANDATORY HACKATHON CONFIGURATION
# Set these in your .env or HF Space secrets before running.
# API_BASE_URL  β€” LLM endpoint (OpenAI-compatible)
# MODEL_NAME    β€” model identifier
# HF_TOKEN      β€” your Hugging Face / API key
# =================================================================
API_BASE_URL = os.environ.get("API_BASE_URL") or "http://localhost:11434/v1"
API_KEY      = os.environ.get("HF_TOKEN") or os.environ.get("API_KEY") or "ollama"
MODEL_NAME   = os.environ.get("MODEL_NAME") or "llama3.1:8b"

# Inference hyper-parameters
ANALYST_TEMPERATURE  = 0.1   # slight randomness for strategic reasoning
EXECUTOR_TEMPERATURE = 0.0   # deterministic JSON formatting
MAX_TOKENS           = 512   # cap token usage per call

# Lazy client β€” created on first LLM call so the server can boot
# before API secrets are injected (e.g. HF Space cold start).
_client: OpenAI | None = None


def _get_client() -> OpenAI:
    """Return a cached OpenAI client, re-reading env vars on first call."""
    global _client
    if _client is None:
        _client = OpenAI(
            base_url=os.environ.get("API_BASE_URL") or API_BASE_URL,
            api_key=os.environ.get("HF_TOKEN") or os.environ.get("API_KEY") or API_KEY,
        )
    return _client
# =================================================================


def get_agent_action(obs: SupplyChainObservation) -> tuple[SupplyChainAction, str]:
    """

    Multi-agent pipeline: Analyst reasons about the market, Executor formats the JSON.

    Returns: (SupplyChainAction, reasoning_string)

    """
    # =========================================================
    # 1. EXTRACT RAW TELEMETRY FOR THE LLM (No Python Math)
    # =========================================================
    wh = obs.warehouse_status[0]
    current_stock = wh.current_stock
    incoming_shipments = sum(wh.incoming_shipments.values())
    total_inventory_pos = current_stock + incoming_shipments
    days_remaining = obs.total_days - obs.current_day
    
    sales_yesterday = wh.sales_yesterday
    lost_yesterday = wh.lost_sales_yesterday

    # Format the pipeline so the LLM knows exactly when stock arrives
    pipeline_str = ", ".join([f"{qty} units in {days} days" for days, qty in wh.incoming_shipments.items() if qty > 0])
    if not pipeline_str:
        pipeline_str = "No incoming shipments."

    # =========================================================
    # πŸ€– AGENT 1: THE ANALYST (Strategic Decision Maker)
    # =========================================================
    analyst_prompt = (
        "You are an Elite Supply Chain Optimizer. Your performance is graded on 'Inventory Efficiency' (Newsvendor Logic).\n\n"
        
        "=== THE GOLDEN RULES FOR A 1.0 SCORE ===\n"
        "1. OVERAGE IS FAILURE: Ending any day with unsold stock kills your score. Aim for JIT (Just-In-Time) delivery.\n"
        "2. UNDERAGE IS FAILURE: Missing a customer sale kills your score. Maintain a minimal safety buffer.\n"
        "3. PIPELINE MATH: Your 'Total Inventory Position' = Current Stock + All units in Pipeline.\n"
        "4. TARGET FORMULA: Aim for a Total Inventory Position = (Predicted Daily Demand) * (Lead Time + 1).\n\n"

        "=== UNIT ECONOMICS & LEAD TIMES ===\n"
        "- Standard: $800 cost | 2-day lead time (4 days during crisis).\n"
        "- Expedited: $900 cost | 1-day lead time.\n"
        "- Holding Cost: $2/unit/day | Stockout Penalty: $100/unit.\n\n"

        "=== STRATEGIC MANDATES ===\n"
        "- PREDICTIVE BUFFER: Analyze 'Yesterday's Performance'. If demand was 12, assume today is 12. Add a +2 unit safety buffer only.\n"
        "- BUDGET CHECK: You MUST multiply (Order Quantity * Unit Cost). This result MUST be less than your current Cash Balance.\n"
        "- CRISIS ADAPTATION: During a shipping crisis (4-day delay), use Expedited (1-day) to stay lean and responsive.\n"
        "- HORIZON AWARENESS: The simulation ends on Day 30. Any stock arriving after Day 30 is a total financial loss and results in a 0.0 efficiency score. "
        "Calculate 'Days Remaining' vs 'Lead Time' to decide when to stop ordering. Your goal is to have EXACTLY zero stock on Day 30.\n\n"

        "=== REQUIRED OUTPUT FORMAT ===\n"
        "Begin with a 'Step-by-Step Math' paragraph: Calculate Predicted Demand, Current Inventory Position, and identify if an order will arrive before Day 30.\n"
        "End your response with these EXACT tags:\n"
        "ORDER_QUANTITY: [number]\n"
        "EXPEDITE: [true/false]"
    )
    
    # Build order feedback line so the LLM knows if its last order was rejected
    if obs.last_order_rejected > 0:
        order_feedback = (
            f"LAST ORDER: REJECTED {obs.last_order_rejected} units (insufficient funds). "
            f"Only {obs.last_order_accepted} units were accepted. Reduce your order size!"
        )
    elif obs.last_order_accepted > 0:
        order_feedback = f"LAST ORDER: Accepted {obs.last_order_accepted} units."
    else:
        order_feedback = "LAST ORDER: No order placed."

    analyst_context = (
        f"=== CURRENT STATUS ===\n"
        f"DAY: {obs.current_day} of {obs.total_days} ({days_remaining} days remaining)\n"
        f"MARKET SIGNAL: {obs.market_trend_signal}\n"
        f"YESTERDAY'S PERFORMANCE: Sold {sales_yesterday}, Missed {lost_yesterday} sales.\n"
        f"{order_feedback}\n"
        f"CASH BALANCE: ${obs.cash_balance:.2f}\n"
        f"CURRENT STOCK: {current_stock} units\n"
        f"PIPELINE: {pipeline_str}\n"
        f"TOTAL INVENTORY POSITION: {total_inventory_pos} units\n\n"
        "Write your reasoning and final decision:"
    )

    try:
        resp = _get_client().chat.completions.create(
            model=MODEL_NAME,
            messages=[
                {"role": "system", "content": analyst_prompt},
                {"role": "user",   "content": analyst_context},
            ],
            temperature=ANALYST_TEMPERATURE,
            max_tokens=MAX_TOKENS,
            timeout=30,
        )
        strategic_plan = resp.choices[0].message.content or ""
    except Exception as exc:
        logger.error("Analyst agent failed: %s", exc, exc_info=True)
        strategic_plan = f"Analyst unavailable ({exc}).\nORDER_QUANTITY: 0\nEXPEDITE: false"

    # =========================================================
    # πŸ€– AGENT 2: THE EXECUTOR (Strict JSON Formatter)
    # =========================================================
    executor_prompt = (
        "You are a strict Data Parsing API. Read the Analyst's plan, locate the 'ORDER_QUANTITY' and 'EXPEDITE' values, "
        "and output ONLY valid JSON using this exact schema:\n"
        "{\n"
        '  "orders": [\n'
        '    {"product_id": "SKU-LAPTOP", "quantity": <INT>, "expedite_shipping": <BOOL>}\n'
        "  ]\n"
        "}\n"
        "If ORDER_QUANTITY is 0, output: {\"orders\": []}\n"
        "Do not output markdown blocks or any other text."
    )

    try:
        resp = _get_client().chat.completions.create(
            model=MODEL_NAME,
            messages=[
                {"role": "system", "content": executor_prompt},
                {"role": "user",   "content": f"ANALYST PLAN:\n{strategic_plan}"},
            ],
            response_format={"type": "json_object"},
            temperature=EXECUTOR_TEMPERATURE,
            max_tokens=MAX_TOKENS,
            timeout=30,
        )
        action = SupplyChainAction.model_validate_json(resp.choices[0].message.content or "{}")
    except Exception as exc:
        logger.error("Executor agent failed: %s", exc, exc_info=True)
        action = SupplyChainAction(orders=[])

    # =================================================================
    # PYTHON GUARDRAILS β€” clip LLM output to what's affordable AND useful
    # Three caps applied in order:
    #   1. Burn-down: don't order if inventory already covers remaining demand
    #   2. Demand cap: never hold more than remaining_days Γ— max_daily_demand
    #   3. Affordability: never exceed cash balance
    #   4. Lead-time: don't order if delivery arrives after episode ends
    # =================================================================
    is_spike  = "Black Friday" in obs.market_trend_signal
    is_crisis = "crisis" in obs.market_trend_signal.lower() or "delay" in obs.market_trend_signal.lower()

    # Estimate max daily demand from the task context
    max_daily_demand = 45 if is_spike else (20 if is_crisis else 12)
    lead_time = 1 if is_crisis else 2

    max_useful = max(0, days_remaining * max_daily_demand - total_inventory_pos)

    clipped_orders = []
    remaining_cash = obs.cash_balance
    for order in action.orders:
        unit_cost = 900 if order.expedite_shipping else 800
        max_affordable = int(remaining_cash // unit_cost) if remaining_cash > 0 else 0

        qty = order.quantity
        qty = min(qty, max_useful)          # demand cap
        qty = min(qty, max_affordable)      # affordability cap

        # Lead-time gate: no point ordering if it arrives after Day 30
        if days_remaining <= lead_time:
            qty = 0

        if qty > 0:
            remaining_cash -= qty * unit_cost
            max_useful -= qty               # reduce remaining headroom
            clipped_orders.append(PurchaseOrder(
                product_id=order.product_id,
                quantity=qty,
                expedite_shipping=order.expedite_shipping,
            ))
    action = SupplyChainAction(orders=clipped_orders)

    return action, strategic_plan or ""


BENCHMARK = "optichain-inventory-v1"
SUCCESS_THRESHOLD = 0.1  # grader score >= 0.5 counts as success


def _log_start(task: str, env_name: str, model: str) -> None:
    print(f"[START] task={task} env={env_name} model={model}", flush=True)


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


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


def main():
    """

    Full CLI evaluation loop.

    Emits [START], [STEP], [END] structured logs required by the hackathon scorer.

    """
    env = SupplyChainEnv()
    tasks = ["task_01_easy", "task_02_medium", "task_03_hard"]

    for task_id in tasks:
        _log_start(task=task_id, env_name=BENCHMARK, model=MODEL_NAME)

        obs = env.reset(task_id=task_id)
        step = 0
        rewards: list[float] = []
        error: str | None = None

        try:
            while not obs.done:
                step += 1
                action, _ = get_agent_action(obs)

                # Compact action string: e.g. "SKU-LAPTOP x10 std"
                if action.orders:
                    o = action.orders[0]
                    mode = "exp" if o.expedite_shipping else "std"
                    action_str = f"{o.product_id}x{o.quantity}{mode}"
                else:
                    action_str = "no_order"

                obs = env.step(action)
                rewards.append(obs.reward)

                _log_step(
                    step=step,
                    action_str=action_str,
                    reward=obs.reward,
                    done=obs.done,
                    error=error,
                )
        except Exception as exc:
            error = str(exc)
            logger.error("Episode error on %s: %s", task_id, exc, exc_info=True)

        score = env.get_grader_score()
        success = score >= SUCCESS_THRESHOLD
        _log_end(success=success, steps=step, score=score, rewards=rewards)


if __name__ == "__main__":
    main()