| |
| |
|
|
| |
| |
| |
| 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(llm_personality: str, info_sharing: str): |
| roles = ["Retailer", "Wholesaler", "Distributor", "Factory"] |
| human_role = "Distributor" |
| 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} |
| } |
|
|
| 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, |
| '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: **{llm_personality} / {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 8. Raw Response: '{raw_text}'") |
| return 8, raw_text |
| except Exception as e: |
| st.error(f"API call failed for {echelon_name}: {e}. Defaulting to 8.") |
| return 8, f"API_ERROR: {e}" |
|
|
| |
| 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 |
| 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"- Production pipeline (completing in future weeks): {list(st.session_state.game_state['factory_production_pipeline'])}" |
| else: |
| task_word = "order quantity" |
| |
| base_info += f"- Shipments on the way to you (arriving next week and later): {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': |
| 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'] |
|
|
| |
| 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." |
|
|
| |
| 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_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. |
| """ |
|
|
| |
|
|
| |
| 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"] |
| 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} |
|
|
| |
|
|
| |
| factory_state = echelons["Factory"] |
| produced_units = 0 |
| if state['factory_production_pipeline']: |
| produced_units = state['factory_production_pipeline'].popleft() |
| |
| inventory_after_production = factory_state['inventory'] + produced_units |
| arrived_this_week["Factory"] = produced_units |
| else: |
| inventory_after_production = factory_state['inventory'] |
|
|
| |
| inventory_after_arrival = {} |
| 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 |
|
|
|
|
| |
| total_backlog_before_shipping = {} |
| 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 |
| |
| total_backlog_before_shipping[name] = echelons[name]['backlog'] + echelons[name]['incoming_order'] |
|
|
| |
| |
| 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: |
| 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) |
| current_week_orders[name] = e['order_placed'] |
|
|
| |
| state['factory_production_pipeline'].append(echelons["Factory"]['order_placed']) |
|
|
| |
| |
| 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) |
| |
| e['inventory'] = available_inv - e['shipment_sent'] |
| e['backlog'] = demand_to_meet - e['shipment_sent'] |
|
|
| |
| 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']) |
|
|
|
|
| |
| 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'] |
|
|
| |
| log_entry[f'{name}.inventory'] = e['inventory'] |
| log_entry[f'{name}.backlog'] = e['backlog'] |
| log_entry[f'{name}.incoming_order'] = e['incoming_order'] |
| log_entry[f'{name}.order_placed'] = e['order_placed'] |
| log_entry[f'{name}.shipment_sent'] = e['shipment_sent'] |
| log_entry[f'{name}.weekly_cost'] = e['weekly_cost'] |
| log_entry[f'{name}.total_cost'] = e['total_cost'] |
| 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] |
| 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: |
| 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): |
| |
| 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}") |
|
|
| |
| |
| |
| 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.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() |
|
|
| |
| 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['llm_personality'].replace('_', ' ')}** | 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, icon = echelons[name], "👤" if name == human_role else "🤖" |
| st.markdown(f"##### {icon} {name} {'(You)' if name == human_role else ''}") |
| |
| st.metric("Inventory (Opening)", e['inventory']) |
| st.metric("Backlog (Opening)", e['backlog']) |
|
|
| |
| if name == human_role: |
| st.metric("Total Cost (Cumulative)", 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.write(f"Incoming Order (This Week): **{e['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"### 👤 {human_role} (Your Dashboard - Start of Week State)") |
| col1, col2, col3, col4 = st.columns(4) |
| |
| col1.metric("Inventory (Opening)", e['inventory']) |
| col2.metric("Backlog (Opening)", e['backlog']) |
| |
| 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)") |
|
|
| |
| 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] |
|
|
| all_decision_point_states[name] = { |
| 'name': name, |
| 'inventory': e_curr['inventory'] + arrived, |
| 'backlog': e_curr['backlog'] + e_curr['incoming_order'], |
| '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 = 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.") |
| |
| 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() |
|
|
| |
| 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() |