|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
st.set_page_config(page_title="Beer Game: Human-AI Collaboration", layout="wide") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
WEEKS = 24 |
|
|
INITIAL_INVENTORY = 12 |
|
|
INITIAL_BACKLOG = 0 |
|
|
ORDER_PASSING_DELAY = 1 |
|
|
SHIPPING_DELAY = 2 |
|
|
FACTORY_LEAD_TIME = 1 |
|
|
FACTORY_SHIPPING_DELAY = 1 |
|
|
HOLDING_COST = 0.5 |
|
|
BACKLOG_COST = 1.0 |
|
|
|
|
|
|
|
|
OPENAI_MODEL = "gpt-4o-mini" |
|
|
LOCAL_LOG_DIR = Path("logs") |
|
|
LOCAL_LOG_DIR.mkdir(exist_ok=True) |
|
|
IMAGE_PATH = "beer_game_diagram.png" |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_customer_demand(week: int) -> int: |
|
|
return 4 if week <= 4 else 8 |
|
|
|
|
|
def init_game_state(locus_of_chaos: str, info_sharing: str): |
|
|
""" |
|
|
Initializes the game state based on the Locus of Chaos and Information Sharing conditions. |
|
|
The human role is fixed as 'Distributor'. |
|
|
""" |
|
|
roles = ["Retailer", "Wholesaler", "Distributor", "Factory"] |
|
|
human_role = "Distributor" |
|
|
participant_id = str(uuid.uuid4())[:8] |
|
|
|
|
|
|
|
|
if locus_of_chaos == 'Downstream Chaos': |
|
|
personalities = { |
|
|
"Retailer": "human_like", |
|
|
"Wholesaler": "human_like", |
|
|
"Distributor": "HUMAN_PLAYER", |
|
|
"Factory": "perfect_rational" |
|
|
} |
|
|
else: |
|
|
personalities = { |
|
|
"Retailer": "perfect_rational", |
|
|
"Wholesaler": "perfect_rational", |
|
|
"Distributor": "HUMAN_PLAYER", |
|
|
"Factory": "human_like" |
|
|
} |
|
|
|
|
|
|
|
|
st.session_state.game_state = { |
|
|
'game_running': True, 'participant_id': participant_id, 'week': 1, |
|
|
'human_role': human_role, |
|
|
'locus_of_chaos': locus_of_chaos, |
|
|
'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: 0 for name in roles} |
|
|
|
|
|
} |
|
|
|
|
|
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 |
|
|
st.session_state.game_state['echelons'][name] = { |
|
|
'name': name, |
|
|
'personality': personalities[name], |
|
|
'inventory': INITIAL_INVENTORY, 'backlog': INITIAL_BACKLOG, |
|
|
'incoming_shipments': deque([0] * shipping_weeks, maxlen=shipping_weeks), |
|
|
'incoming_order': 0, 'order_placed': 0, 'shipment_sent': 0, |
|
|
'weekly_cost': 0, 'total_cost': 0, 'upstream_name': upstream, 'downstream_name': downstream, |
|
|
} |
|
|
st.info(f"New game started! AI Mode: **{locus_of_chaos} / {info_sharing}**. You are playing as the: **{human_role}**.") |
|
|
|
|
|
def get_llm_order_decision(prompt: str, echelon_name: str) -> (int, str): |
|
|
|
|
|
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 4. Raw Response: '{raw_text}'") |
|
|
return 4, raw_text |
|
|
except Exception as e: |
|
|
st.error(f"API call failed for {echelon_name}: {e}. Defaulting to 4.") |
|
|
return 4, f"API_ERROR: {e}" |
|
|
|
|
|
def get_llm_prompt(echelon_state_decision_point: dict, week: int, llm_personality: str, info_sharing: str, all_echelons_state_decision_point: dict) -> str: |
|
|
""" |
|
|
Generates the prompt for a specific AI agent based on its *individual* personality. |
|
|
NO CHANGE WAS NEEDED in this function's logic, as it correctly routes |
|
|
based on the llm_personality string it receives. |
|
|
""" |
|
|
e_state = echelon_state_decision_point |
|
|
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" |
|
|
base_info += f"- Your Production Pipeline (completing next week onwards): {list(st.session_state.game_state['factory_production_pipeline'])}" |
|
|
else: |
|
|
task_word = "order quantity" |
|
|
base_info += f"- Shipments In Transit To You (arriving next week onwards): {list(e_state['incoming_shipments'])}" |
|
|
|
|
|
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 |
|
|
if e_state['name'] == 'Factory': |
|
|
inventory_position = (e_state['inventory'] - e_state['backlog'] + sum(st.session_state.game_state['factory_production_pipeline'])) |
|
|
inv_pos_components = f"(Inv={e_state['inventory']} - Backlog={e_state['backlog']} + InProd={sum(st.session_state.game_state['factory_production_pipeline'])})" |
|
|
else: |
|
|
order_in_transit_to_supplier = st.session_state.game_state['last_week_orders'].get(e_state['name'], 0) |
|
|
inventory_position = (e_state['inventory'] - e_state['backlog'] + sum(e_state['incoming_shipments']) + order_in_transit_to_supplier) |
|
|
inv_pos_components = f"(Inv={e_state['inventory']} - Backlog={e_state['backlog']} + InTransitShip={sum(e_state['incoming_shipments'])} + OrderToSupplier={order_in_transit_to_supplier})" |
|
|
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'] |
|
|
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: |
|
|
order_in_transit_to_supplier = st.session_state.game_state['last_week_orders'].get(e_state['name'], 0) |
|
|
supply_line = sum(e_state['incoming_shipments']) + order_in_transit_to_supplier |
|
|
supply_line_desc = "Supply Line (In Transit Shipments + Order To Supplier)" |
|
|
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 decision.\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." |
|
|
|
|
|
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" |
|
|
for name, other_e_state in all_echelons_state_decision_point.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. |
|
|
""" |
|
|
|
|
|
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'] |
|
|
|
|
|
info_sharing = state['info_sharing'] |
|
|
echelon_order = ["Retailer", "Wholesaler", "Distributor", "Factory"] |
|
|
|
|
|
llm_raw_responses = {} |
|
|
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} |
|
|
inventory_after_arrival = {} |
|
|
|
|
|
factory_state = echelons["Factory"] |
|
|
produced_units = 0 |
|
|
if state['factory_production_pipeline']: |
|
|
produced_units = state['factory_production_pipeline'].popleft() |
|
|
arrived_this_week["Factory"] = produced_units |
|
|
inventory_after_arrival["Factory"] = factory_state['inventory'] + produced_units |
|
|
|
|
|
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 |
|
|
|
|
|
total_backlog_before_shipping = {} |
|
|
for name in echelon_order: |
|
|
incoming_order_for_this_week = 0 |
|
|
if name == "Retailer": incoming_order_for_this_week = get_customer_demand(week) |
|
|
else: |
|
|
downstream_name = echelons[name]['downstream_name'] |
|
|
if downstream_name: incoming_order_for_this_week = state['last_week_orders'].get(downstream_name, 0) |
|
|
echelons[name]['incoming_order'] = incoming_order_for_this_week |
|
|
total_backlog_before_shipping[name] = echelons[name]['backlog'] + incoming_order_for_this_week |
|
|
|
|
|
decision_point_states = {} |
|
|
for name in echelon_order: |
|
|
decision_point_states[name] = { |
|
|
'name': name, 'inventory': inventory_after_arrival[name], |
|
|
'backlog': total_backlog_before_shipping[name], 'incoming_order': echelons[name]['incoming_order'], |
|
|
'incoming_shipments': echelons[name]['incoming_shipments'].copy() if name != "Factory" else deque(), |
|
|
} |
|
|
|
|
|
current_week_orders = {} |
|
|
for name in echelon_order: |
|
|
e = echelons[name]; prompt_state = decision_point_states[name] |
|
|
|
|
|
if name == human_role: |
|
|
order_amount, raw_resp = human_final_order, "HUMAN_FINAL_INPUT" |
|
|
else: |
|
|
|
|
|
e_personality = e['personality'] |
|
|
prompt = get_llm_prompt(prompt_state, week, e_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); current_week_orders[name] = e['order_placed'] |
|
|
|
|
|
state['factory_production_pipeline'].append(echelons["Factory"]['order_placed']) |
|
|
units_shipped = {name: 0 for name in echelon_order} |
|
|
|
|
|
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); units_shipped[name] = e['shipment_sent'] |
|
|
e['inventory'] = available_inv - e['shipment_sent']; e['backlog'] = demand_to_meet - e['shipment_sent'] |
|
|
|
|
|
if units_shipped["Factory"] > 0: echelons['Distributor']['incoming_shipments'].append(units_shipped["Factory"]) |
|
|
if units_shipped['Distributor'] > 0: echelons['Wholesaler']['incoming_shipments'].append(units_shipped['Distributor']) |
|
|
if units_shipped['Wholesaler'] > 0: echelons['Retailer']['incoming_shipments'].append(units_shipped['Wholesaler']) |
|
|
|
|
|
|
|
|
log_entry = {'timestamp': datetime.utcnow().isoformat() + "Z", 'week': week, **state} |
|
|
|
|
|
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]; e['weekly_cost'] = (e['inventory'] * HOLDING_COST) + (e['backlog'] * BACKLOG_COST); e['total_cost'] += e['weekly_cost'] |
|
|
for key in ['inventory', 'backlog', 'incoming_order', 'order_placed', 'shipment_sent', 'weekly_cost', 'total_cost']: |
|
|
log_entry[f'{name}.{key}'] = e[key] |
|
|
|
|
|
log_entry[f'{name}.personality'] = e['personality'] |
|
|
log_entry[f'{name}.llm_raw_response'] = llm_raw_responses.get(name, "") |
|
|
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] |
|
|
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_entry[f'{human_role}.initial_order'] = human_initial_order; log_entry[f'{human_role}.ai_suggestion'] = ai_suggestion |
|
|
|
|
|
state['logs'].append(log_entry) |
|
|
state['week'] += 1; state['decision_step'] = 'initial_order'; state['last_week_orders'] = current_week_orders |
|
|
|
|
|
if state['week'] > WEEKS: state['game_running'] = False |
|
|
|
|
|
def plot_results(df: pd.DataFrame, title: str, human_role: str): |
|
|
|
|
|
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: |
|
|
plot_data.append({'week': row.get('week', 0), 'echelon': e, |
|
|
'inventory': row.get(f'{e}.inventory', 0), '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)') |
|
|
|
|
|
total_costs = plot_df.loc[plot_df.groupby('echelon')['week'].idxmax()] |
|
|
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 ($)') |
|
|
|
|
|
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] |
|
|
try: |
|
|
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: 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: raise ValueError("No human decision data columns found.") |
|
|
except (KeyError, ValueError) as plot_err: |
|
|
axes[3].set_title(f'Analysis of Your ({human_role}) Decisions - Error Plotting Data'); axes[3].text(0.5, 0.5, f"Error: {plot_err}", ha='center', va='center'); 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): |
|
|
|
|
|
if not state.get('logs'): return |
|
|
participant_id = state['participant_id'] |
|
|
try: |
|
|
df = pd.json_normalize(state['logs']) |
|
|
fname = LOCAL_LOG_DIR / f"log_{participant_id}_{int(time.time())}.csv" |
|
|
for col in df.select_dtypes(include=['object']).columns: 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_upload: st.error(f"Upload to Hugging Face failed: {e_upload}") |
|
|
except Exception as e_save: st.error(f"Error processing or saving log data: {e_save}") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
if 'game_state' not in st.session_state or not st.session_state.game_state.get('game_running', False): |
|
|
|
|
|
st.header("⚙️ Game Configuration") |
|
|
c1, c2 = st.columns(2) |
|
|
with c1: |
|
|
|
|
|
locus_of_chaos = st.selectbox( |
|
|
"AI Team Composition (Locus of Chaos)", |
|
|
('Downstream Chaos', 'Upstream Chaos'), |
|
|
format_func=lambda x: x.replace('_', ' ').title(), |
|
|
help=( |
|
|
"**Downstream Chaos:** Your customers (Retailer, Wholesaler) are 'Human-like' (chaotic). Your supplier (Factory) is 'Rational'.\n\n" |
|
|
"**Upstream Chaos:** Your customers (Retailer, Wholesaler) are 'Rational' (stable). Your supplier (Factory) is 'Human-like'." |
|
|
) |
|
|
) |
|
|
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(locus_of_chaos, info_sharing) |
|
|
st.rerun() |
|
|
|
|
|
|
|
|
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'] |
|
|
echelon_order = ["Retailer", "Wholesaler", "Distributor", "Factory"] |
|
|
|
|
|
st.header(f"Week {week} / {WEEKS}") |
|
|
|
|
|
st.subheader(f"Your Role: **{human_role}** | AI Mode: **{state['locus_of_chaos']}** | Information: **{state['info_sharing']}**") |
|
|
st.markdown("---") |
|
|
|
|
|
st.subheader("Supply Chain Status (Start of Week State)") |
|
|
|
|
|
if info_sharing == 'full': |
|
|
cols = st.columns(4) |
|
|
for i, name in enumerate(echelon_order): |
|
|
with cols[i]: |
|
|
e = echelons[name] |
|
|
icon = "👤" if name == human_role else "🤖" |
|
|
|
|
|
if name == human_role: |
|
|
st.markdown(f"##### **<span style='border: 1px solid #FF4B4B; padding: 2px 5px; border-radius: 3px;'>{icon} {name} (You)</span>**", unsafe_allow_html=True) |
|
|
else: |
|
|
st.markdown(f"##### {icon} {name}") |
|
|
|
|
|
personality_label = e['personality'].replace('_', ' ').title() |
|
|
st.caption(f"AI Type: **{personality_label}**") |
|
|
|
|
|
|
|
|
st.metric("Inventory (Opening)", e['inventory']) |
|
|
st.metric("Backlog (Opening)", e['backlog']) |
|
|
|
|
|
current_incoming_order = 0 |
|
|
if name == "Retailer": |
|
|
current_incoming_order = get_customer_demand(week) |
|
|
else: |
|
|
downstream_name = e['downstream_name'] |
|
|
if downstream_name: |
|
|
current_incoming_order = state['last_week_orders'].get(downstream_name, 0) |
|
|
|
|
|
st.write(f"Incoming Order (This Week): **{current_incoming_order}**") |
|
|
|
|
|
if name == "Factory": |
|
|
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: |
|
|
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"### 👤 **<span style='color:#FF4B4B;'>{human_role} (Your Dashboard - Start of Week State)</span>**", unsafe_allow_html=True) |
|
|
col1, col2, col3, col4 = st.columns(4) |
|
|
|
|
|
col1.metric("Inventory (Opening)", e['inventory']) |
|
|
col2.metric("Backlog (Opening)", e['backlog']) |
|
|
|
|
|
current_incoming_order = 0 |
|
|
downstream_name = e['downstream_name'] |
|
|
if downstream_name: |
|
|
current_incoming_order = state['last_week_orders'].get(downstream_name, 0) |
|
|
|
|
|
col3.write(f"**Incoming Order (This Week):**\n# {current_incoming_order}") |
|
|
col4.write(f"**Shipment Arriving (Next Week):**\n# {list(e['incoming_shipments'])[0] if e['incoming_shipments'] else 0}") |
|
|
|
|
|
st.markdown("---") |
|
|
st.header("Your Decision (Step 4)") |
|
|
|
|
|
|
|
|
all_decision_point_states = {} |
|
|
for name in echelon_order: |
|
|
e_curr = echelons[name] |
|
|
arrived = 0 |
|
|
if name == "Factory": |
|
|
if state['factory_production_pipeline']: arrived = list(state['factory_production_pipeline'])[0] |
|
|
else: |
|
|
if e_curr['incoming_shipments']: arrived = list(e_curr['incoming_shipments'])[0] |
|
|
|
|
|
inv_after_arrival = e_curr['inventory'] + arrived |
|
|
|
|
|
inc_order_this_week = 0 |
|
|
if name == "Retailer": inc_order_this_week = get_customer_demand(week) |
|
|
else: |
|
|
ds_name = e_curr['downstream_name'] |
|
|
if ds_name: inc_order_this_week = state['last_week_orders'].get(ds_name, 0) |
|
|
|
|
|
backlog_after_new_order = e_curr['backlog'] + inc_order_this_week |
|
|
|
|
|
all_decision_point_states[name] = { |
|
|
'name': name, 'inventory': inv_after_arrival, 'backlog': backlog_after_new_order, |
|
|
'incoming_order': inc_order_this_week, |
|
|
'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.") |
|
|
initial_order = st.number_input("Your Initial Order Quantity:", min_value=0, step=1) |
|
|
if st.form_submit_button("Submit Initial Order & See AI Suggestion", type="primary"): |
|
|
state['human_initial_order'] = int(initial_order) if initial_order is not None else 0 |
|
|
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.") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if state['locus_of_chaos'] == 'Downstream Chaos': |
|
|
suggestion_ai_personality = 'human_like' |
|
|
else: |
|
|
suggestion_ai_personality = 'perfect_rational' |
|
|
|
|
|
|
|
|
prompt_sugg = get_llm_prompt(human_echelon_state_for_prompt, week, suggestion_ai_personality, state['info_sharing'], all_decision_point_states) |
|
|
ai_suggestion, _ = get_llm_order_decision(prompt_sugg, f"{human_role} (Suggestion)") |
|
|
|
|
|
with st.form(key="final_order_form"): |
|
|
st.markdown(f"#### **Step 4b:** An AI {suggestion_ai_personality.replace('_', ' ')} assistant 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.get('final_order_input', 0) |
|
|
final_order_value = int(final_order_value) if final_order_value is not None else 0 |
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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 Mode: {state['locus_of_chaos']} | 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() |