Lilli98's picture
Update app.py
7cd2ba9 verified
raw
history blame
43.9 kB
# app.py
# @title Beer Game Final Version (v4.15 - Fixed Unpacking Error)
# -----------------------------------------------------------------------------
# 1. Import Libraries
# -----------------------------------------------------------------------------
import streamlit as st
import pandas as pd
import matplotlib.pyplot as plt
from collections import deque
import time
import openai
import re
import random
import uuid
from pathlib import Path
from datetime import datetime
from huggingface_hub import HfApi
# -----------------------------------------------------------------------------
# 0. Page Configuration (Must be the first Streamlit command)
# -----------------------------------------------------------------------------
st.set_page_config(page_title="Beer Game: Human-AI Collaboration", layout="wide")
# -----------------------------------------------------------------------------
# 2. Game Parameters & API Configuration
# -----------------------------------------------------------------------------
# --- Game Parameters ---
WEEKS = 24
INITIAL_INVENTORY = 12
INITIAL_BACKLOG = 0
ORDER_PASSING_DELAY = 1
SHIPPING_DELAY = 2 # General shipping delay
FACTORY_LEAD_TIME = 1
FACTORY_SHIPPING_DELAY = 1 # Specific delay from Factory to Distributor
HOLDING_COST = 0.5
BACKLOG_COST = 1.0
# --- Model & Log Configuration ---
OPENAI_MODEL = "gpt-4o-mini"
LOCAL_LOG_DIR = Path("logs")
LOCAL_LOG_DIR.mkdir(exist_ok=True)
IMAGE_PATH = "beer_game_diagram.png" # Path to your uploaded image
# --- API & Secrets Configuration ---
try:
client = openai.OpenAI(api_key=st.secrets["OPENAI_API_KEY"])
HF_TOKEN = st.secrets.get("HF_TOKEN")
HF_REPO_ID = st.secrets.get("HF_REPO_ID")
hf_api = HfApi() if HF_TOKEN else None
except Exception as e:
st.session_state.initialization_error = f"Error reading secrets on startup: {e}."
client = None
else:
st.session_state.initialization_error = None
# -----------------------------------------------------------------------------
# 3. Core Game Logic Functions
# -----------------------------------------------------------------------------
def get_customer_demand(week: int) -> int:
return 4 if week <= 4 else 8
def init_game_state(llm_personality: str, info_sharing: str):
roles = ["Retailer", "Wholesaler", "Distributor", "Factory"]
human_role = "Distributor" # Role is fixed
participant_id = str(uuid.uuid4())[:8]
st.session_state.game_state = {
'game_running': True, 'participant_id': participant_id, 'week': 1,
'human_role': human_role, 'llm_personality': llm_personality,
'info_sharing': info_sharing, 'logs': [], 'echelons': {},
'factory_production_pipeline': deque([0] * FACTORY_LEAD_TIME, maxlen=FACTORY_LEAD_TIME),
'decision_step': 'initial_order',
'human_initial_order': None,
'last_week_orders': {name: 4 for name in roles} # Seed initial orders/production for week 1
}
for i, name in enumerate(roles):
upstream = roles[i + 1] if i + 1 < len(roles) else None
downstream = roles[i - 1] if i - 1 >= 0 else None
if name == "Distributor": shipping_weeks = FACTORY_SHIPPING_DELAY
elif name == "Factory": shipping_weeks = 0
else: shipping_weeks = SHIPPING_DELAY
# 'inventory' and 'backlog' now consistently represent END-OF-WEEK state
st.session_state.game_state['echelons'][name] = {
'name': name,
'inventory': INITIAL_INVENTORY, # End-of-week state (used as opening state for next week)
'backlog': INITIAL_BACKLOG, # End-of-week state (used as opening state for next week)
'incoming_shipments': deque([0] * shipping_weeks, maxlen=shipping_weeks),
'incoming_order': 0, # Order received THIS week
'order_placed': 0, # Order placed THIS week
'shipment_sent': 0, # Shipment sent THIS week
'weekly_cost': 0, 'total_cost': 0, 'upstream_name': upstream, 'downstream_name': downstream,
}
st.info(f"New game started! AI Mode: **{llm_personality} / {info_sharing}**. You are playing as the: **{human_role}**.")
def get_llm_order_decision(prompt: str, echelon_name: str) -> (int, str):
# This function remains correct.
if not client: return 8, "NO_API_KEY_DEFAULT"
with st.spinner(f"Getting AI decision for {echelon_name}..."):
try:
temp = 0.1 if 'rational' in prompt else 0.7
response = client.chat.completions.create(
model=OPENAI_MODEL,
messages=[
{"role": "system", "content": "You are a supply chain manager playing the Beer Game. Your response must be only an integer number representing your order or production quantity and nothing else. For example: 8"},
{"role": "user", "content": prompt}
],
temperature=temp, max_tokens=10
)
raw_text = response.choices[0].message.content.strip()
match = re.search(r'\d+', raw_text)
if match: return int(match.group(0)), raw_text
st.warning(f"LLM for {echelon_name} did not return a valid number. Defaulting to 8. Raw Response: '{raw_text}'")
return 8, raw_text # Default if no number found
except Exception as e:
st.error(f"API call failed for {echelon_name}: {e}. Defaulting to 8.")
return 8, f"API_ERROR: {e}"
# =============== MODIFIED FUNCTION (Prompt uses state AFTER arrivals/orders) ===============
def get_llm_prompt(echelon_state_after_arrivals: dict, week: int, llm_personality: str, info_sharing: str, all_echelons_state_after_arrivals: dict) -> str:
"""Generates the prompt for the LLM based on the game scenario.
Uses the state AFTER arrivals and new orders are processed, as this is the decision point."""
e_state = echelon_state_after_arrivals # Use the passed-in state for prompts
base_info = f"Your Current Status at the **{e_state['name']}** for **Week {week}** (Before Shipping):\n- On-hand inventory: {e_state['inventory']} units.\n- Backlog (total unfilled orders): {e_state['backlog']} units.\n- Incoming order this week (just received): {e_state['incoming_order']} units.\n"
if e_state['name'] == 'Factory':
task_word = "production quantity"
# Factory needs access to the global pipeline state
base_info += f"- Production pipeline (completing in future weeks): {list(st.session_state.game_state['factory_production_pipeline'])}"
else:
task_word = "order quantity"
# Non-factory prompt needs its incoming shipments queue
base_info += f"- Shipments on the way to you (arriving next week and later): {list(e_state['incoming_shipments'])}"
# --- Perfect Rational ---
if llm_personality == 'perfect_rational' and info_sharing == 'full':
stable_demand = 8
if e_state['name'] == 'Factory': total_lead_time = FACTORY_LEAD_TIME
elif e_state['name'] == 'Distributor': total_lead_time = ORDER_PASSING_DELAY + FACTORY_LEAD_TIME + FACTORY_SHIPPING_DELAY
else: total_lead_time = ORDER_PASSING_DELAY + SHIPPING_DELAY
safety_stock = 4
target_inventory_level = (stable_demand * total_lead_time) + safety_stock
# Calculate Inventory Position based on state AFTER arrivals/orders
if e_state['name'] == 'Factory':
inv_pos_components = f"(Current Inv: {e_state['inventory']} - Current Backlog: {e_state['backlog']} + In_Production: {sum(st.session_state.game_state['factory_production_pipeline'])})"
inventory_position = (e_state['inventory'] - e_state['backlog'] + sum(st.session_state.game_state['factory_production_pipeline']))
else:
inv_pos_components = f"(Current Inv: {e_state['inventory']} - Current Backlog: {e_state['backlog']} + In_Transit: {sum(e_state['incoming_shipments'])})"
inventory_position = (e_state['inventory'] - e_state['backlog'] + sum(e_state['incoming_shipments']))
optimal_order = max(0, int(target_inventory_level - inventory_position))
return f"**You are a perfectly rational supply chain AI with full system visibility.**\nYour only goal is to maintain stability and minimize costs based on mathematical optimization.\n**System Analysis:**\n* **Known Stable End-Customer Demand:** {stable_demand} units/week.\n* **Your Current Total Inventory Position:** {inventory_position} units. {inv_pos_components}\n* **Optimal Target Inventory Level:** {target_inventory_level} units (Target for {total_lead_time} weeks lead time).\n* **Mathematically Optimal {task_word.title()}:** The optimal decision is **{optimal_order} units**.\n**Your Task:** Confirm this optimal {task_word}. Respond with a single integer."
elif llm_personality == 'perfect_rational' and info_sharing == 'local':
safety_stock = 4; anchor_demand = e_state['incoming_order']
# Use state AFTER arrivals/orders for inventory correction calculation
inventory_correction = safety_stock - (e_state['inventory'] - e_state['backlog'])
if e_state['name'] == 'Factory':
supply_line = sum(st.session_state.game_state['factory_production_pipeline'])
supply_line_desc = "In Production"
else:
supply_line = sum(e_state['incoming_shipments'])
supply_line_desc = "In Transit Shipments"
calculated_order = anchor_demand + inventory_correction - supply_line
rational_local_order = max(0, int(calculated_order))
return f"**You are a perfectly rational supply chain AI with ONLY LOCAL information.**\nYou must use a logical heuristic to make a stable decision. A proven method is \"Anchoring and Adjustment\".\n\n{base_info}\n\n**Rational Calculation (Anchoring & Adjustment):**\n1. **Anchor on Demand:** Your best guess for future demand is your last incoming order: **{anchor_demand} units**.\n2. **Adjust for Inventory:** You want to hold a safety stock of {safety_stock} units. Your current stock (before shipping) is {e_state['inventory'] - e_state['backlog']}. You need to order an extra **{inventory_correction} units** to correct this.\n3. **Account for {supply_line_desc}:** You already have **{supply_line} units** being processed. These should be subtracted from your new order.\n\n**Final Calculation:**\n* Decision = (Anchor Demand) + (Inventory Adjustment) - ({supply_line_desc})\n* Decision = {anchor_demand} + {inventory_correction} - {supply_line} = **{rational_local_order} units**.\n**Your Task:** Confirm this locally rational {task_word}. Respond with a single integer."
# --- Human-like ---
elif llm_personality == 'human_like' and info_sharing == 'full':
full_info_str = f"\n**Full Supply Chain Information (State Before Shipping):**\n- End-Customer Demand this week: {get_customer_demand(week)} units.\n"
# Display other players' state AFTER arrivals/orders
for name, other_e_state in all_echelons_state_after_arrivals.items():
if name != e_state['name']: full_info_str += f"- {name}: Inv={other_e_state['inventory']}, Backlog={other_e_state['backlog']}\n"
return f"""
**You are a supply chain manager ({e_state['name']}) with full system visibility.**
You can see everyone's current inventory and backlog before shipping, and the real customer demand.
{base_info}
{full_info_str}
**Your Task:** Your primary responsibility is to meet the demand from your direct customer (your `Incoming order this week`: **{e_state['incoming_order']}** units), which contributes to your total current backlog of {e_state['backlog']}.
While you can see the stable end-customer demand ({get_customer_demand(week)} units), your priority is to fulfill the order you just received and manage your inventory/backlog.
You are still human and might get anxious about your own stock levels.
What {task_word} should you decide on this week? Respond with a single integer.
"""
elif llm_personality == 'human_like' and info_sharing == 'local':
return f"""
**You are a reactive supply chain manager for the {e_state['name']}.** You have a limited view and tend to over-correct based on fear.
Your top priority is to NOT have a backlog.
{base_info}
**Your Task:** You just received an incoming order for **{e_state['incoming_order']}** units, adding to your total backlog.
Your gut instinct is to panic and {task_word.split(' ')[0]} enough to ensure you are never caught with a backlog again, considering your current inventory.
**React emotionally.** What is your knee-jerk {task_word}? Respond with a single integer.
"""
# ==============================================================================
# =============== CORRECTED step_game FUNCTION ===============
def step_game(human_final_order: int, human_initial_order: int, ai_suggestion: int):
state = st.session_state.game_state
week, echelons, human_role = state['week'], state['echelons'], state['human_role']
llm_personality, info_sharing = state['llm_personality'], state['info_sharing']
echelon_order = ["Retailer", "Wholesaler", "Distributor", "Factory"] # Defined here
llm_raw_responses = {}
# Store state at the very beginning of the week for logging opening balances
# These are the inventory/backlog values from the END of the previous week
opening_inventories = {name: e['inventory'] for name, e in echelons.items()}
opening_backlogs = {name: e['backlog'] for name, e in echelons.items()}
arrived_this_week = {name: 0 for name in echelon_order} # Track arrivals for logging
# --- Game Simulation Steps ---
# Step 1a: Factory Production completes
factory_state = echelons["Factory"]
produced_units = 0
if state['factory_production_pipeline']:
produced_units = state['factory_production_pipeline'].popleft()
# Temporarily store, don't update main state yet
inventory_after_production = factory_state['inventory'] + produced_units
arrived_this_week["Factory"] = produced_units
else:
inventory_after_production = factory_state['inventory']
# Step 1b: Shipments arrive at downstream echelons
inventory_after_arrival = {} # Store intermediate state
for name in ["Retailer", "Wholesaler", "Distributor"]:
arrived_shipment = 0
if echelons[name]['incoming_shipments']:
arrived_shipment = echelons[name]['incoming_shipments'].popleft()
arrived_this_week[name] = arrived_shipment
inventory_after_arrival[name] = echelons[name]['inventory'] + arrived_shipment
inventory_after_arrival["Factory"] = inventory_after_production # Add factory's state
# Step 2: Orders arrive from downstream partners (using LAST week's placed order)
total_backlog_before_shipping = {} # Store intermediate state
for name in echelon_order:
if name == "Retailer":
echelons[name]['incoming_order'] = get_customer_demand(week)
else:
downstream_name = echelons[name]['downstream_name']
order_from_downstream = 0
if downstream_name:
order_from_downstream = state['last_week_orders'].get(downstream_name, 0)
echelons[name]['incoming_order'] = order_from_downstream
# Calculate the total backlog BEFORE shipping
total_backlog_before_shipping[name] = echelons[name]['backlog'] + echelons[name]['incoming_order']
# --- Create State Snapshot for AI/Human Decision Point ---
# This reflects the state AFTER arrivals and new orders, BEFORE shipping
decision_point_states = {}
for name in echelon_order:
# Need to create a copy, including deque if needed for prompt
decision_point_states[name] = {
'name': name,
'inventory': inventory_after_arrival[name], # Inventory available
'backlog': total_backlog_before_shipping[name], # Total demand to meet
'incoming_order': echelons[name]['incoming_order'], # Order received this week
# Pass the current state of queues for prompt generation
'incoming_shipments': echelons[name]['incoming_shipments'].copy() if name != "Factory" else deque(),
}
# --- Step 4: Agent Decisions (Place Orders / Schedule Production) ---
# Agents make decisions based on the decision_point_states
current_week_orders = {}
for name in echelon_order:
e = echelons[name] # Get the main state dict to store results
prompt_state = decision_point_states[name] # Use the snapshot for the prompt
if name == human_role:
order_amount, raw_resp = human_final_order, "HUMAN_FINAL_INPUT"
else:
prompt = get_llm_prompt(prompt_state, week, llm_personality, info_sharing, decision_point_states)
order_amount, raw_resp = get_llm_order_decision(prompt, name)
llm_raw_responses[name] = raw_resp
e['order_placed'] = max(0, order_amount) # Store the decision in the main state dict
current_week_orders[name] = e['order_placed']
# Factory schedules production based on its 'order_placed' decision
state['factory_production_pipeline'].append(echelons["Factory"]['order_placed'])
# --- Step 3: Fulfill orders (Ship Beer) ---
# Now perform the shipping based on the inventory_after_arrival and total_backlog_before_shipping
for name in echelon_order:
e = echelons[name]
demand_to_meet = total_backlog_before_shipping[name]
available_inv = inventory_after_arrival[name]
e['shipment_sent'] = min(available_inv, demand_to_meet)
# Update the main state dict's inventory and backlog to reflect END OF WEEK state
e['inventory'] = available_inv - e['shipment_sent']
e['backlog'] = demand_to_meet - e['shipment_sent']
# Step 3b: Place shipped items into the *end* of the downstream partner's incoming shipment queue
for sender_name in ["Factory", "Distributor", "Wholesaler"]:
sender = echelons[sender_name]
receiver_name = sender['downstream_name']
if receiver_name:
echelons[receiver_name]['incoming_shipments'].append(sender['shipment_sent'])
# --- Calculate Costs & Log (End of Week) ---
log_entry = {'timestamp': datetime.utcnow().isoformat() + "Z", 'week': week, **state}
# These fields are nested in echelons, no need to log them top-level
del log_entry['echelons'], log_entry['factory_production_pipeline'], log_entry['logs'], log_entry['last_week_orders']
for name in echelon_order:
e = echelons[name]
# Costs are based on the END OF WEEK state
e['weekly_cost'] = (e['inventory'] * HOLDING_COST) + (e['backlog'] * BACKLOG_COST)
e['total_cost'] += e['weekly_cost']
# Log end-of-week internal state and decisions/events of the week
log_entry[f'{name}.inventory'] = e['inventory'] # End of week inventory
log_entry[f'{name}.backlog'] = e['backlog'] # End of week backlog
log_entry[f'{name}.incoming_order'] = e['incoming_order'] # Order received this week
log_entry[f'{name}.order_placed'] = e['order_placed'] # Decision made this week
log_entry[f'{name}.shipment_sent'] = e['shipment_sent'] # Shipped this week
log_entry[f'{name}.weekly_cost'] = e['weekly_cost'] # Cost for this week
log_entry[f'{name}.total_cost'] = e['total_cost'] # Cumulative cost
log_entry[f'{name}.llm_raw_response'] = llm_raw_responses.get(name, "")
# Log opening balances for the week
log_entry[f'{name}.opening_inventory'] = opening_inventories[name]
log_entry[f'{name}.opening_backlog'] = opening_backlogs[name]
log_entry[f'{name}.arrived_this_week'] = arrived_this_week[name]
# Log prediction for next week's arrival/completion (based on queues AFTER this week's processing)
if name != 'Factory':
log_entry[f'{name}.arriving_next_week'] = list(e['incoming_shipments'])[0] if e['incoming_shipments'] else 0
else:
log_entry[f'{name}.production_completing_next_week'] = list(state['factory_production_pipeline'])[0] if state['factory_production_pipeline'] else 0
# Log human-specific decisions
log_entry[f'{human_role}.initial_order'] = human_initial_order
log_entry[f'{human_role}.ai_suggestion'] = ai_suggestion
state['logs'].append(log_entry)
# --- Advance Week ---
state['week'] += 1
state['decision_step'] = 'initial_order'
state['last_week_orders'] = current_week_orders # Store current decisions for next week's Step 2
if state['week'] > WEEKS: state['game_running'] = False
# ==============================================================================
def plot_results(df: pd.DataFrame, title: str, human_role: str):
# This function remains correct.
fig, axes = plt.subplots(4, 1, figsize=(12, 22))
fig.suptitle(title, fontsize=16)
echelons = ['Retailer', 'Wholesaler', 'Distributor', 'Factory']
plot_data = []
for _, row in df.iterrows():
for e in echelons:
# Safely access keys, provide default if missing (e.g., first few weeks)
plot_data.append({'week': row.get('week', 0), 'echelon': e,
'inventory': row.get(f'{e}.inventory', 0), # Use end-of-week inventory for plots
'order_placed': row.get(f'{e}.order_placed', 0),
'total_cost': row.get(f'{e}.total_cost', 0)})
plot_df = pd.DataFrame(plot_data)
inventory_pivot = plot_df.pivot(index='week', columns='echelon', values='inventory').reindex(columns=echelons)
inventory_pivot.plot(ax=axes[0], kind='line', marker='o', markersize=4)
axes[0].set_title('Inventory Levels (End of Week)')
axes[0].grid(True, linestyle='--')
axes[0].set_ylabel('Stock (Units)')
order_pivot = plot_df.pivot(index='week', columns='echelon', values='order_placed').reindex(columns=echelons)
order_pivot.plot(ax=axes[1], style='--')
axes[1].plot(range(1, WEEKS + 1), [get_customer_demand(w) for w in range(1, WEEKS + 1)], label='Customer Demand', color='black', lw=2.5)
axes[1].set_title('Order Quantities / Production Decisions')
axes[1].grid(True, linestyle='--')
axes[1].legend()
axes[1].set_ylabel('Ordered/Produced (Units)')
# Ensure total_cost calculation handles potential missing data gracefully
total_costs = plot_df.loc[plot_df.groupby('echelon')['week'].idxmax()] # Get row with max week for each echelon
total_costs = total_costs.set_index('echelon')['total_cost'].reindex(echelons, fill_value=0)
total_costs.plot(kind='bar', ax=axes[2], rot=0)
axes[2].set_title('Total Cumulative Cost')
axes[2].set_ylabel('Cost ($)')
# Safely access human decision columns
human_cols = [f'{human_role}.initial_order', f'{human_role}.ai_suggestion', f'{human_role}.order_placed']
human_df_cols = ['week'] + [col for col in human_cols if col in df.columns]
human_df = df[human_df_cols].copy()
human_df.rename(columns={
f'{human_role}.initial_order': 'Your Initial Order',
f'{human_role}.ai_suggestion': 'AI Suggestion',
f'{human_role}.order_placed': 'Your Final Order'
}, inplace=True)
if len(human_df.columns) > 1: # Check if there's data to plot
human_df.plot(x='week', ax=axes[3], marker='o', linestyle='-')
axes[3].set_title(f'Analysis of Your ({human_role}) Decisions')
axes[3].set_ylabel('Order Quantity')
axes[3].grid(True, linestyle='--')
axes[3].set_xlabel('Week')
else:
axes[3].set_title(f'Analysis of Your ({human_role}) Decisions - No Data')
axes[3].grid(True, linestyle='--')
axes[3].set_xlabel('Week')
plt.tight_layout(rect=[0, 0, 1, 0.96])
return fig
def save_logs_and_upload(state: dict):
# This function remains correct.
if not state.get('logs'): return
participant_id = state['participant_id']
df = pd.json_normalize(state['logs'])
fname = LOCAL_LOG_DIR / f"log_{participant_id}_{int(time.time())}.csv"
for col in df.columns:
if df[col].dtype == 'object': df[col] = df[col].astype(str)
df.to_csv(fname, index=False)
st.success(f"Log successfully saved locally: `{fname}`")
with open(fname, "rb") as f:
st.download_button("📥 Download Log CSV", data=f, file_name=fname.name, mime="text/csv")
if HF_TOKEN and HF_REPO_ID and hf_api:
with st.spinner("Uploading log to Hugging Face Hub..."):
try:
url = hf_api.upload_file(
path_or_fileobj=str(fname),
path_in_repo=f"logs/{fname.name}",
repo_id=HF_REPO_ID,
repo_type="dataset",
token=HF_TOKEN
)
st.success(f"✅ Log successfully uploaded to Hugging Face! [View File]({url})")
except Exception as e:
st.error(f"Upload to Hugging Face failed: {e}")
# -----------------------------------------------------------------------------
# 4. Streamlit UI
# -----------------------------------------------------------------------------
st.title("🍺 The Beer Game: A Human-AI Collaboration Challenge")
if st.session_state.get('initialization_error'):
st.error(st.session_state.initialization_error)
else:
# --- Game Setup & Instructions ---
if 'game_state' not in st.session_state or not st.session_state.game_state.get('game_running', False):
st.markdown("---")
st.header("📖 Welcome to the Beer Game!")
st.markdown("This is a simulation of a supply chain. You will play against 3 AI agents. **You do not need any prior knowledge to play.** Please read these instructions carefully.")
st.subheader("1. Your Goal: Minimize Costs")
st.success("**Your single, most important goal is to: Minimize the total cost for your position in the supply chain.**")
st.markdown("You get costs from two things every week:")
st.markdown(f"""
- **Holding Inventory:** **${HOLDING_COST:,.2f} per unit per week.** (Cost applies to inventory left *after* shipping)
- **Backlog (Unfilled Orders):** **${BACKLOG_COST:,.2f} per unit per week.** (Cost applies to orders you couldn't fill *after* shipping)
""")
with st.expander("Click to see a cost calculation example"):
st.markdown(f"""
Imagine at the **end** of Week 5, *after* you shipped beer to the Wholesaler, your final state is:
- Inventory: 10 units
- Backlog: 0 units
Your cost for Week 5 would be calculated *at this point*:
- `(10 units of Inventory * ${HOLDING_COST:,.2f})` = $5.00
- `(0 units of Backlog * ${BACKLOG_COST:,.2f})` = $0.00
- **Total Weekly Cost:** = **$5.00**
This cost is added to your cumulative total.
""")
st.subheader("2. Your Role: The Distributor")
st.markdown("""
You will always play as the **Distributor**. The other 3 roles are played by AI.
- **Retailer (AI):** Sells to the final customer.
- **Wholesaler (AI):** Sells to the Retailer.
- **Distributor (You):** You sell to the Wholesaler.
- **Factory (AI):** You order from the Factory.
""")
try: st.image(IMAGE_PATH, caption="You are the Distributor. You get orders from the Wholesaler and place orders to the Factory.")
except FileNotFoundError: st.warning("Image file not found.")
st.subheader("3. The Core Challenge: Delays!")
st.warning(f"It takes **{ORDER_PASSING_DELAY + FACTORY_LEAD_TIME + FACTORY_SHIPPING_DELAY} weeks** for an order you place to arrive in your inventory.")
with st.expander("Click to see a detailed example of the 3-week delay"):
st.markdown(f"""
* **Week 10 (You):** You place an order for **50**.
* **Week 11 (System):** Your order arrives at the Factory (**{ORDER_PASSING_DELAY}w Order Delay**). Factory AI decides to produce 50.
* **Week 12 (System):** Factory finishes producing 50 (**{FACTORY_LEAD_TIME}w Production Delay**) & ships it.
* **Week 13 (System):** The 50 units arrive at your warehouse (**{FACTORY_SHIPPING_DELAY}w Shipping Delay**).
**Conclusion:** Think 3 weeks ahead! Your order in Week 10 arrives at the start of Week 13.
""")
st.subheader("4. Understanding Inventory & Backlog")
st.markdown("""
Managing your inventory and backlog is key to minimizing costs. Here's how they work:
* **Effective "Orders to Fill":** Each week, the total demand you need to satisfy is your `Incoming Order` for the week PLUS any `Backlog` carried over from the previous week.
* **If you DON'T have enough inventory:**
* You ship **all** the inventory you have.
* The remaining unfilled "Orders to Fill" becomes your **new Backlog** for next week.
* **Backlog is cumulative!** If you start Week 10 with a backlog of 5, get an order for 8 (total needed = 13), receive 10 units, and ship those 10 units, your new backlog for Week 11 is `13 - 10 = 3`.
* **If you DO have enough inventory:**
* You ship all the "Orders to Fill".
* Your Backlog becomes 0.
* The remaining inventory is carried over to next week (and incurs holding costs).
""")
st.subheader("5. The Bullwhip Effect (What to Avoid)")
st.markdown("""
The "Bullwhip Effect" happens when small changes in customer demand cause **amplified**, chaotic swings in orders further up the supply chain (like you and the Factory). This often leads to cycles of **panic ordering** (ordering too much when out of stock) followed by **massive inventory pile-ups** (when late orders arrive). This cycle is very expensive. Try to order smoothly.
""")
st.subheader("6. How Each Week Works & Understanding Your Dashboard")
st.markdown(f"""
Your main job is simple: place one order each week based on the dashboard presented to you.
**A) At the start of every week, BEFORE your turn:**
* **(Step 1) Shipments Arrive:** Beer you ordered {ORDER_PASSING_DELAY + FACTORY_LEAD_TIME + FACTORY_SHIPPING_DELAY} weeks ago arrives.
* **(Step 2) New Orders Arrive:** You receive a new order from the Wholesaler (their order from *last* week).
* **(Step 3) You Ship Beer (Automatically):** The system ships beer *immediately* based on your inventory *after* Step 1 and the total demand *after* Step 2.
**B) Your Dashboard (What You See for Your Turn):**
The dashboard shows your status **at the start of the week, BEFORE Steps 1, 2, and 3 happen**:
* `Inventory (Opening)`: Your stock **at the beginning of the week**. This is the inventory carried over from the end of last week.
* `Backlog (Opening)`: Unfilled orders **carried over from the end of last week**.
* `Incoming Order (This Week)`: The specific order quantity that **will arrive** from the Wholesaler *during* this week (Step 2). Use this for your planning.
* `Arriving Next Week`: The quantity scheduled to arrive at the start of the **next week**. Use this for your planning.
* `Your Total Cumulative Cost`: Sum of all weekly costs up to the **end of last week**.
* `Cost Last Week`: The specific cost incurred just **last week**.
**C) Your Decision (Step 4 - Two Parts):**
Now, looking at the dashboard (showing the start-of-week state) and considering the incoming order and future arrivals, you decide how much to order:
* **(Step 4a - Initial Order):** Submit your first estimate.
* **(Step 4b - Final Order):** See the AI's suggestion, then submit your final decision. This order will arrive in 3 weeks.
Submitting your final order ends the week. The system then calculates your `Weekly Cost` based on your inventory/backlog *after* Step 3 shipping, logs everything, and advances to the next week.
""")
st.markdown("---")
st.header("⚙️ Game Configuration")
c1, c2 = st.columns(2)
with c1:
llm_personality = st.selectbox("AI Agent 'Personality'", ('human_like', 'perfect_rational'), format_func=lambda x: x.replace('_', ' ').title(), help="**Human-like:** Tends to react emotionally, potentially over-ordering. **Perfect Rational:** Uses a mathematical heuristic to make stable, logical decisions.")
with c2:
info_sharing = st.selectbox("Information Sharing Level", ('local', 'full'), format_func=lambda x: x.title(), help="**Local:** You and the AI agents can only see your own inventory and incoming orders. **Full:** Everyone can see the entire supply chain's status and the true end-customer demand.")
if st.button("🚀 Start Game", type="primary", disabled=(client is None)):
init_game_state(llm_personality, info_sharing)
st.rerun()
# --- Main Game Interface ---
elif 'game_state' in st.session_state and st.session_state.game_state.get('game_running'):
state = st.session_state.game_state
week, human_role, echelons, info_sharing = state['week'], state['human_role'], state['echelons'], state['info_sharing']
# Define echelon order for display in the UI
echelon_order = ["Retailer", "Wholesaler", "Distributor", "Factory"]
st.header(f"Week {week} / {WEEKS}")
st.subheader(f"Your Role: **{human_role}** | AI Mode: **{state['llm_personality'].replace('_', ' ')}** | Information: **{state['info_sharing']}**")
st.markdown("---")
st.subheader("Supply Chain Status (Start of Week State)") # Clarified Timing
if info_sharing == 'full':
cols = st.columns(4)
for i, name in enumerate(echelon_order): # Use the defined echelon_order
with cols[i]:
e, icon = echelons[name], "👤" if name == human_role else "🤖"
st.markdown(f"##### {icon} {name} {'(You)' if name == human_role else ''}")
# Display the END OF LAST WEEK state (which is OPENING state for this week)
st.metric("Inventory (Opening)", e['inventory'])
st.metric("Backlog (Opening)", e['backlog'])
# Display cumulative cost and last week's cost for the human player
if name == human_role:
st.metric("Total Cost (Cumulative)", f"${e['total_cost']:,.2f}")
# Display last week's cost if available (week > 1)
last_week_cost = state['logs'][-1][f"{human_role}.weekly_cost"] if week > 1 and state['logs'] else 0
st.metric("Cost Last Week", f"${last_week_cost:,.2f}")
# Display info about THIS week's events
st.write(f"Incoming Order (This Week): **{e['incoming_order']}**") # Order arriving in Step 2
if name == "Factory":
# Production completing NEXT week
prod_completing_next = list(state['factory_production_pipeline'])[0] if state['factory_production_pipeline'] else 0
st.write(f"Completing Next Week: **{prod_completing_next}**")
else:
# Shipment arriving NEXT week
arriving_next = list(e['incoming_shipments'])[0] if e['incoming_shipments'] else 0
st.write(f"Arriving Next Week: **{arriving_next}**")
else:
st.info("In Local Information mode, you can only see your own status dashboard.")
e = echelons[human_role]
st.markdown(f"### 👤 {human_role} (Your Dashboard - Start of Week State)")
col1, col2, col3, col4 = st.columns(4)
# Display OPENING state
col1.metric("Inventory (Opening)", e['inventory'])
col2.metric("Backlog (Opening)", e['backlog'])
# Display info about THIS week's events / NEXT week's arrivals
col3.write(f"**Incoming Order (This Week):**\n# {e['incoming_order']}")
col4.write(f"**Shipment Arriving (Next Week):**\n# {list(e['incoming_shipments'])[0] if e['incoming_shipments'] else 0}")
st.metric("Your Total Cumulative Cost", f"${e['total_cost']:,.2f}")
last_week_cost = state['logs'][-1][f"{human_role}.weekly_cost"] if week > 1 and state['logs'] else 0
st.metric("Cost Last Week", f"${last_week_cost:,.2f}")
st.markdown("---")
st.header("Your Decision (Step 4)")
# Prepare the state snapshot for the AI prompt (State AFTER arrivals/orders, BEFORE shipping)
all_decision_point_states = {}
for name in echelon_order:
e_curr = echelons[name]
arrived = 0
# Peek at what *will* arrive this week (Step 1)
if name == "Factory":
# Peek at production pipeline
if state['factory_production_pipeline']: arrived = list(state['factory_production_pipeline'])[0]
else:
# Peek at incoming shipments
if e_curr['incoming_shipments']: arrived = list(e_curr['incoming_shipments'])[0]
all_decision_point_states[name] = {
'name': name,
'inventory': e_curr['inventory'] + arrived, # Opening Inv + Arriving This Week
'backlog': e_curr['backlog'] + e_curr['incoming_order'], # Opening Backlog + Incoming Order This Week
'incoming_order': e_curr['incoming_order'],
'incoming_shipments': e_curr['incoming_shipments'].copy() if name != "Factory" else deque()
}
human_echelon_state_for_prompt = all_decision_point_states[human_role]
if state['decision_step'] == 'initial_order':
with st.form(key="initial_order_form"):
st.markdown("#### **Step 4a:** Based on the dashboard, submit your **initial** order to the Factory.")
# Default initial order based on incoming order, minimum 4
default_initial = max(4, echelons[human_role]['incoming_order'])
initial_order = st.number_input("Your Initial Order Quantity:", min_value=0, step=1, value=default_initial)
if st.form_submit_button("Submit Initial Order & See AI Suggestion", type="primary"):
state['human_initial_order'] = int(initial_order)
state['decision_step'] = 'final_order'
st.rerun()
elif state['decision_step'] == 'final_order':
st.success(f"Your initial order was: **{state['human_initial_order']}** units.")
# Use the correctly timed state for the prompt
prompt_sugg = get_llm_prompt(human_echelon_state_for_prompt, week, state['llm_personality'], state['info_sharing'], all_decision_point_states)
ai_suggestion, _ = get_llm_order_decision(prompt_sugg, f"{human_role} (Suggestion)")
if 'final_order_input' not in st.session_state:
st.session_state.final_order_input = ai_suggestion
with st.form(key="final_order_form"):
st.markdown(f"#### **Step 4b:** The AI suggests ordering **{ai_suggestion}** units.")
st.markdown("Considering the AI's advice, submit your **final** order to end the week. (This order will arrive in 3 weeks).")
st.number_input("Your Final Order Quantity:", min_value=0, step=1, key='final_order_input')
if st.form_submit_button("Submit Final Order & Advance to Next Week"):
final_order_value = st.session_state.final_order_input
step_game(final_order_value, state['human_initial_order'], ai_suggestion)
if 'final_order_input' in st.session_state: del st.session_state.final_order_input
st.rerun()
st.markdown("---")
with st.expander("📖 Your Weekly Decision Log", expanded=False):
if not state.get('logs'):
st.write("Your weekly history will be displayed here after you complete the first week.")
else:
try:
history_df = pd.json_normalize(state['logs'])
human_cols = {
'week': 'Week', f'{human_role}.opening_inventory': 'Opening Inv.',
f'{human_role}.opening_backlog': 'Opening Backlog', f'{human_role}.arrived_this_week': 'Arrived This Week',
f'{human_role}.incoming_order': 'Incoming Order', f'{human_role}.initial_order': 'Your Initial Order',
f'{human_role}.ai_suggestion': 'AI Suggestion', f'{human_role}.order_placed': 'Your Final Order',
f'{human_role}.arriving_next_week': 'Arriving Next Week', f'{human_role}.weekly_cost': 'Weekly Cost',
}
ordered_display_cols_keys = [
'week', f'{human_role}.opening_inventory', f'{human_role}.opening_backlog',
f'{human_role}.arrived_this_week', f'{human_role}.incoming_order',
f'{human_role}.initial_order', f'{human_role}.ai_suggestion', f'{human_role}.order_placed',
f'{human_role}.arriving_next_week', f'{human_role}.weekly_cost'
]
final_cols_to_display = [col for col in ordered_display_cols_keys if col in history_df.columns]
if not final_cols_to_display:
st.write("No data columns available to display.")
else:
display_df = history_df[final_cols_to_display].rename(columns=human_cols)
if 'Weekly Cost' in display_df.columns:
display_df['Weekly Cost'] = display_df['Weekly Cost'].apply(lambda x: f"${x:,.2f}" if isinstance(x, (int, float)) else "")
st.dataframe(display_df.sort_values(by="Week", ascending=False), hide_index=True, use_container_width=True)
except Exception as e:
st.error(f"Error displaying weekly log: {e}")
try: st.sidebar.image(IMAGE_PATH, caption="Supply Chain Reference")
except FileNotFoundError: st.sidebar.warning("Image file not found.")
st.sidebar.header("Game Info")
st.sidebar.markdown(f"**Game ID**: `{state['participant_id']}`\n\n**Current Week**: {week}")
if st.sidebar.button("🔄 Reset Game"):
if 'final_order_input' in st.session_state: del st.session_state.final_order_input
del st.session_state.game_state
st.rerun()
# --- Game Over Interface ---
if 'game_state' in st.session_state and not st.session_state.game_state.get('game_running', False) and st.session_state.game_state['week'] > WEEKS:
st.header("🎉 Game Over!")
state = st.session_state.game_state
try:
logs_df = pd.json_normalize(state['logs'])
fig = plot_results(
logs_df,
f"Beer Game (Human: {state['human_role']})\n(AI: {state['llm_personality']} | Info: {state['info_sharing']})",
state['human_role']
)
st.pyplot(fig)
save_logs_and_upload(state)
except Exception as e:
st.error(f"Error generating final report: {e}")
if st.button("✨ Start a New Game"):
del st.session_state.game_state
st.rerun()