Spaces:
Sleeping
Sleeping
| # app.py | |
| # @title Beer Game Final Version (v12 - v3 Base + Logic/UI Fix) | |
| # ----------------------------------------------------------------------------- | |
| # 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, hf_hub_download | |
| from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError | |
| import json | |
| import numpy as np | |
| # ----------------------------------------------------------------------------- | |
| # 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 # Handled by last_week_orders | |
| SHIPPING_DELAY = 2 # General shipping delay (R->W, W->D) | |
| FACTORY_LEAD_TIME = 1 | |
| # This is CORRECT for LT=3 (1 pass + 1 produce + 1 ship = 3 week total LT) | |
| FACTORY_SHIPPING_DELAY = 1 | |
| 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" | |
| LEADERBOARD_FILE = "leaderboard.json" | |
| # --- 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 | |
| # =============== MODIFIED Initialization (v4.21 logic + v4.23 bugfix) =============== | |
| def init_game_state(llm_personality: str, info_sharing: str, participant_id: str): | |
| roles = ["Retailer", "Wholesaler", "Distributor", "Factory"] | |
| human_role = "Distributor" # Role is fixed | |
| 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, | |
| 'current_ai_suggestion': None, # v4.23 Bugfix: 用于存储AI建议 | |
| 'last_week_orders': {name: 0 for name in roles} # v4.21 Logic: 初始化为0 | |
| } | |
| 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 # This is 1 | |
| elif name == "Factory": shipping_weeks = 0 | |
| else: shipping_weeks = SHIPPING_DELAY # This is 2 | |
| 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 for **{participant_id}**! AI Mode: **{llm_personality} / {info_sharing}**. You are 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 'perfectly 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}" | |
| # =============== PROMPT FUNCTION (v4 - FIXES FOR OSCILLATION AND HUMAN-LIKE) =============== | |
| def get_llm_prompt(echelon_state_decision_point: dict, week: int, llm_personality: str, info_sharing: str, all_echelons_state_decision_point: dict) -> str: | |
| # This function's logic is updated for "human_like" to follow a flawed Sterman heuristic. | |
| 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" | |
| current_stable_demand = get_customer_demand(week) # Use current week's demand | |
| 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'])}" | |
| # --- PERFECT RATIONAL (NORMATIVE) PROMPTS --- | |
| if llm_personality == 'perfect_rational' and info_sharing == 'full': | |
| stable_demand = current_stable_demand | |
| # 1. CALCULATE CORRECT LEAD TIME (UNCHANGED) | |
| if e_state['name'] == 'Factory': | |
| total_lead_time = FACTORY_LEAD_TIME # 1 | |
| elif e_state['name'] == 'Distributor': | |
| total_lead_time = ORDER_PASSING_DELAY + FACTORY_LEAD_TIME + FACTORY_SHIPPING_DELAY # 1+1+1 = 3 | |
| else: | |
| total_lead_time = ORDER_PASSING_DELAY + SHIPPING_DELAY # 1+2 = 3 | |
| safety_stock = 4 | |
| target_inventory_level = (stable_demand * total_lead_time) + safety_stock | |
| # 2. OSCILLATION FIX: Calculate CORRECT Inventory Position | |
| order_in_transit_to_supplier = st.session_state.game_state['last_week_orders'].get(e_state['name'], 0) # Order Delay (1 week) | |
| if e_state['name'] == 'Factory': | |
| # Factory pipeline: In Production (1 week) | |
| supply_line = sum(st.session_state.game_state['factory_production_pipeline']) | |
| inventory_position = (e_state['inventory'] - e_state['backlog'] + supply_line) | |
| inv_pos_components = f"(Inv={e_state['inventory']} - Backlog={e_state['backlog']} + InProd={supply_line})" | |
| elif e_state['name'] == 'Distributor': | |
| # Distributor pipeline: In Shipping (1 week) + In Production (1 week) + Order Delay (1 week) | |
| in_shipping = sum(e_state['incoming_shipments']) | |
| in_production = sum(st.session_state.game_state['factory_production_pipeline']) | |
| supply_line = in_shipping + in_production + order_in_transit_to_supplier | |
| inventory_position = (e_state['inventory'] - e_state['backlog'] + supply_line) | |
| inv_pos_components = f"(Inv={e_state['inventory']} - Backlog={e_state['backlog']} + InTransitShip={in_shipping} + InProd={in_production} + OrderToSupplier={order_in_transit_to_supplier})" | |
| else: # Retailer and Wholesaler | |
| # R/W pipeline: In Shipping (2 weeks) + Order Delay (1 week) | |
| in_shipping = sum(e_state['incoming_shipments']) | |
| supply_line = in_shipping + order_in_transit_to_supplier | |
| inventory_position = (e_state['inventory'] - e_state['backlog'] + supply_line) | |
| inv_pos_components = f"(Inv={e_state['inventory']} - Backlog={e_state['backlog']} + InTransitShip={in_shipping} + 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']) | |
| # 2. OSCILLATION FIX: Calculate CORRECT *Local* Supply Line | |
| if e_state['name'] == 'Factory': | |
| # Factory can see its full (local) pipeline | |
| supply_line = sum(st.session_state.game_state['factory_production_pipeline']) | |
| supply_line_desc = "In Production" | |
| elif e_state['name'] == 'Distributor': | |
| # Distributor can *only* see its shipping queue (1 week) | |
| # It CANNOT see the factory pipeline or its own order delay | |
| # This is a weak heuristic, but it's *locally* correct and won't oscillate. | |
| supply_line = sum(e_state['incoming_shipments']) | |
| supply_line_desc = "Supply Line (In Transit Shipments)" | |
| else: # Retailer and Wholesaler | |
| # R/W can see their full (local) pipeline: Shipping (2 weeks) | |
| supply_line = sum(e_state['incoming_shipments']) | |
| supply_line_desc = "Supply Line (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 (that you can see). 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." | |
| # --- HUMAN-LIKE (DESCRIPTIVE) PROMPTS --- | |
| else: | |
| DESIRED_INVENTORY = 12 # Matches initial inventory | |
| net_inventory = e_state['inventory'] - e_state['backlog'] | |
| stock_correction = DESIRED_INVENTORY - net_inventory | |
| # Get supply line info *just to show* the AI it's being ignored | |
| if e_state['name'] == 'Factory': | |
| supply_line = sum(st.session_state.game_state['factory_production_pipeline']) | |
| supply_line_desc = "In Production" | |
| else: | |
| # This is just for display, not calculation | |
| 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" | |
| if info_sharing == 'local': | |
| # 1. HUMAN-LIKE / LOCAL (Unchanged): Anchors on *local* incoming order | |
| anchor_demand = e_state['incoming_order'] | |
| panicky_order = max(0, int(anchor_demand + stock_correction)) | |
| panicky_order_calc = f"{anchor_demand} (Your Incoming Order) + {stock_correction} (Your Stock Correction)" | |
| return f""" | |
| **You are a reactive supply chain manager for the {e_state['name']}.** You have a limited (local) view. | |
| You tend to make **reactive, 'gut-instinct' decisions** (like the classic Sterman 1989 model) that cause the Bullwhip Effect. | |
| {base_info} | |
| **Your Flawed 'Human' Heuristic:** | |
| Your gut tells you to fix your entire inventory problem *right now*, and you're afraid of your backlog. | |
| A 'rational' player would account for their {supply_line_desc} (which is {supply_line} units), but you're too busy panicking to trust that. | |
| **Your 'Panic' Calculation (Ignoring the Supply Line):** | |
| 1. **Anchor on Demand:** You just got an order for **{anchor_demand}** units. You'll order *at least* that. | |
| 2. **Correct for Stock:** Your desired 'safe' inventory is {DESIRED_INVENTORY}. Your current net inventory is {net_inventory}. You need to order **{stock_correction}** more units to feel safe again. | |
| 3. **Ignore Supply Line:** You'll ignore the **{supply_line} units** already in your pipeline. | |
| **Final Panic Order:** (Your Incoming Order) + (Your Stock Correction) | |
| * Order = {panicky_order_calc} = **{panicky_order} units**. | |
| **Your Task:** Confirm this 'gut-instinct' {task_word}. Respond with a single integer. | |
| """ | |
| elif info_sharing == 'full': | |
| # 1. HUMAN-LIKE / FULL (FIX v6): Anchors on an *average* of local panic and global reality | |
| local_anchor = e_state['incoming_order'] | |
| global_anchor = current_stable_demand | |
| # The "conflicted" human anchor | |
| anchor_demand = int((local_anchor + global_anchor) / 2) | |
| panicky_order = max(0, int(anchor_demand + stock_correction)) | |
| panicky_order_calc = f"{anchor_demand} (Conflicted Anchor) + {stock_correction} (Your Stock Correction)" | |
| # Build the "Full Info" string just for context | |
| full_info_str = f"\n**Full Supply Chain Information (State Before Shipping):**\n- End-Customer Demand this week: {current_stable_demand} 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.** | |
| {base_info} | |
| {full_info_str} | |
| **A "Human-like" Flawed Decision:** | |
| You are judged by *your own* performance. You can see the stable End-Customer Demand is **{global_anchor}**, but you just received a panicky order for **{local_anchor}**. | |
| Your "gut-instinct" is to split the difference, anchoring on an average of the two. | |
| You still ignore your supply line, focusing only on your local stock. | |
| **Your 'Panic' Calculation (Ignoring Supply Line, Averaging Anchors):** | |
| 1. **Anchor on Conflict:** (Your Incoming Order + End-Customer Demand) / 2 | |
| * Anchor = ({local_anchor} + {global_anchor}) / 2 = **{anchor_demand}** units. | |
| 2. **Correct for *Your* Stock:** Your desired 'safe' inventory is {DESIRED_INVENTORY}. Your current net inventory is {net_inventory}. You need to order **{stock_correction}** more units. | |
| 3. **Ignore *Your* Supply Line:** You'll ignore the **{supply_line} units** in your own pipeline ({supply_line_desc}). | |
| **Final Panic Order:** (Conflicted Anchor) + (Your Stock Correction) | |
| * Order = {panicky_order_calc} = **{panicky_order} units**. | |
| **Your Task:** Confirm this 'gut-instinct', locally-focused {task_word}. Respond with a single integer. | |
| """ | |
| # ========================================================= | |
| # =============== STEP_GAME (v12) - Stable Logic + Correct Log Fix =============== | |
| def step_game(human_final_order: int, human_initial_order: int, ai_suggestion: int): | |
| # This is the correct logic from v4.17 | |
| 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 = {} | |
| # Capture opening state for logging | |
| opening_inventories = {name: e['inventory'] for name, e in echelons.items()} | |
| opening_backlogs = {name: e['backlog'] for name, e in echelons.items()} | |
| # --- LOG FIX (v12): Capture arrival data BEFORE mutation --- | |
| arrived_this_week = {name: 0 for name in echelon_order} | |
| # This dict will store the value shown on the UI for "next week" | |
| opening_arriving_next_week_UI_VALUE = {name: 0 for name in echelon_order} | |
| # Factory | |
| factory_q = state['factory_production_pipeline'] | |
| if factory_q: | |
| arrived_this_week["Factory"] = factory_q[0] # Read before pop | |
| # "Next Week" for Factory is the order it just received (Distributor's last week order) | |
| opening_arriving_next_week_UI_VALUE["Factory"] = state['last_week_orders'].get("Distributor", 0) | |
| # R, W, D | |
| for name in ["Retailer", "Wholesaler", "Distributor"]: | |
| shipment_q = echelons[name]['incoming_shipments'] | |
| if shipment_q: | |
| arrived_this_week[name] = shipment_q[0] # Read before pop | |
| # --- THIS IS THE REAL FIX V12 --- | |
| if name == 'Distributor': | |
| # "Next" for Distributor (maxlen=1) is the item that will arrive W+1 | |
| # At the start of W4, shipping_q = [4] (from W2). This item arrives W5. | |
| # So, "Arriving Next Week" (W5) IS shipment_q[0]. | |
| if shipment_q: | |
| opening_arriving_next_week_UI_VALUE[name] = shipment_q[0] | |
| elif name in ("Retailer", "Wholesaler"): | |
| # "Next" for R/W (maxlen=2) is the item that will arrive W+1 | |
| # At start of W4, shipping_q = [0, 4]. [0] arrives W5, [1] arrives W6. | |
| # "Arriving Next Week" (W5) IS shipment_q[0]. | |
| # (Wait, no, [0] arrives W4, [1] arrives W5) | |
| # (Let's re-trace R/W) | |
| # W2: D ships 4. R/W q.append(4) -> [0, 4] | |
| # W3: R/W popleft() -> 0 arrives. q = [4]. | |
| # W3: D ships 8. R/W q.append(8) -> [4, 8] | |
| # W4: R/W popleft() -> 4 arrives. q = [8]. | |
| # At start of W4, "Arriving Next Week" (W5) is q[0] = 8. | |
| if shipment_q: | |
| opening_arriving_next_week_UI_VALUE[name] = shipment_q[0] | |
| # --- END OF LOG FIX (v12) --- | |
| # Now, the *actual* state mutation (popping) | |
| inventory_after_arrival = {} | |
| factory_state = echelons["Factory"] | |
| produced_units = 0 | |
| if state['factory_production_pipeline']: | |
| produced_units = state['factory_production_pipeline'].popleft() | |
| inventory_after_arrival["Factory"] = factory_state['inventory'] + produced_units | |
| # --- LOGIC FIX (v12) --- | |
| for name in ["Retailer", "Wholesaler", "Distributor"]: | |
| # Use the value we captured *before* popping | |
| arrived_shipment = arrived_this_week[name] | |
| if echelons[name]['incoming_shipments']: | |
| echelons[name]['incoming_shipments'].popleft() # Now we pop | |
| inventory_after_arrival[name] = echelons[name]['inventory'] + arrived_shipment | |
| # --- END LOGIC FIX (v12) --- | |
| # (Rest of game logic) | |
| 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: | |
| 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']) | |
| 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']) | |
| # (Logging) | |
| 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'] | |
| if 'current_ai_suggestion' in log_entry: del log_entry['current_ai_suggestion'] | |
| 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}.llm_raw_response'] = llm_raw_responses.get(name, "") | |
| # --- LOG FIX (v12): Use captured values --- | |
| 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] # Use captured value | |
| if name != 'Factory': | |
| log_entry[f'{name}.arriving_next_week'] = opening_arriving_next_week_UI_VALUE[name] | |
| else: | |
| log_entry[f'{name}.production_completing_next_week'] = opening_arriving_next_week_UI_VALUE[name] | |
| # --- END OF LOG FIX (v12) --- | |
| 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 | |
| state['current_ai_suggestion'] = None # Clean up | |
| 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: | |
| 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 | |
| # =============== NEW: Leaderboard Functions (MODIFIED) =============== | |
| def load_leaderboard_data(): | |
| if not hf_api or not HF_REPO_ID: return {} | |
| try: | |
| local_path = hf_hub_download(repo_id=HF_REPO_ID, repo_type="dataset", filename=LEADERBOARD_FILE, token=HF_TOKEN, cache_dir=LOCAL_LOG_DIR / "hf_cache") | |
| with open(local_path, 'r', encoding='utf-8') as f: return json.load(f) | |
| except EntryNotFoundError: | |
| st.sidebar.info("Leaderboard file not found. A new one will be created.") | |
| return {} | |
| except Exception as e: | |
| st.sidebar.error(f"Could not load leaderboard: {e}") | |
| return {} | |
| def save_leaderboard_data(data): | |
| if not hf_api or not HF_REPO_ID or not HF_TOKEN: return | |
| try: | |
| local_path = LOCAL_LOG_DIR / LEADERBOARD_FILE | |
| with open(local_path, 'w', encoding='utf-8') as f: json.dump(data, f, indent=2, ensure_ascii=False) | |
| hf_api.upload_file(path_or_fileobj=str(local_path), path_in_repo=LEADERBOARD_FILE, repo_id=HF_REPO_ID, repo_type="dataset", token=HF_TOKEN, commit_message="Update leaderboard") | |
| st.sidebar.success("Leaderboard updated!") | |
| st.cache_data.clear() | |
| except Exception as e: | |
| st.sidebar.error(f"Failed to upload leaderboard: {e}") | |
| # ---------- MODIFIED FUNCTION (v2) ---------- | |
| def display_rankings(df, top_n=10): | |
| if df.empty: | |
| st.info("No completed games for this category yet. Be the first!") | |
| return | |
| # 为新旧数据列进行数值转换 | |
| df['distributor_cost'] = pd.to_numeric(df.get('total_cost'), errors='coerce') # 'total_cost' 是旧的 distributor_cost | |
| df['total_chain_cost'] = pd.to_numeric(df.get('total_chain_cost'), errors='coerce') | |
| df['order_std_dev'] = pd.to_numeric(df.get('order_std_dev'), errors='coerce') | |
| c1, c2, c3 = st.columns(3) | |
| # 排行榜 1: 总供应链成本 (新) | |
| with c1: | |
| st.subheader("🏆 Supply Chain Champions") | |
| st.caption(f"Top {top_n} - Lowest **Total Chain** Cost") | |
| # .dropna() 确保只对有该数据的条目进行排序 (兼容旧数据) | |
| champs_df = df.dropna(subset=['total_chain_cost']).sort_values('total_chain_cost', ascending=True).head(top_n).copy() | |
| if champs_df.empty: | |
| st.info("No data for this category yet.") | |
| else: | |
| champs_df['total_chain_cost'] = champs_df['total_chain_cost'].map('${:,.2f}'.format) | |
| champs_df.rename(columns={'id': 'Participant', 'total_chain_cost': 'Total Chain Cost'}, inplace=True) | |
| st.dataframe(champs_df[['Participant', 'Total Chain Cost']], use_container_width=True, hide_index=True) | |
| # 排行榜 2: 你的 (Distributor) 成本 (修改) | |
| with c2: | |
| st.subheader("👤 Distributor Champions") | |
| st.caption(f"Top {top_n} - Lowest **Your** (Distributor) Cost") | |
| dist_df = df.dropna(subset=['distributor_cost']).sort_values('distributor_cost', ascending=True).head(top_n).copy() | |
| if dist_df.empty: | |
| st.info("No data for this category yet.") | |
| else: | |
| dist_df['distributor_cost'] = dist_df['distributor_cost'].map('${:,.2f}'.format) | |
| dist_df.rename(columns={'id': 'Participant', 'distributor_cost': 'Your Cost'}, inplace=True) | |
| st.dataframe(dist_df[['Participant', 'Your Cost']], use_container_width=True, hide_index=True) | |
| # 排行榜 3: 订单平滑度 (不变) | |
| with c3: | |
| st.subheader("🧘 Mr. Smooth") | |
| st.caption(f"Top {top_n} - Lowest Order Variation (Std. Dev.)") | |
| smooth_df = df.dropna(subset=['order_std_dev']).sort_values('order_std_dev', ascending=True).head(top_n).copy() | |
| if smooth_df.empty: | |
| st.info("No data for this category yet.") | |
| else: | |
| smooth_df['order_std_dev'] = smooth_df['order_std_dev'].map('{:,.2f}'.format) | |
| smooth_df.rename(columns={'id': 'Participant', 'order_std_dev': 'Order Std. Dev.'}, inplace=True) | |
| st.dataframe(smooth_df[['Participant', 'Order Std. Dev.']], use_container_width=True, hide_index=True) | |
| def show_leaderboard_ui(): | |
| st.markdown("---") | |
| st.header("📊 The Bullwhip Leaderboard") | |
| st.caption("Leaderboard updates after you finish a game. Cached for 60 seconds.") | |
| leaderboard_data = load_leaderboard_data() | |
| if not leaderboard_data: | |
| st.info("No leaderboard data yet. Be the first to finish a game!") | |
| else: | |
| try: | |
| df = pd.DataFrame(leaderboard_data.values()) | |
| if 'id' not in df.columns and not df.empty: df['id'] = list(leaderboard_data.keys()) | |
| # 检查旧列是否存在即可 | |
| if 'total_cost' not in df.columns or 'order_std_dev' not in df.columns or 'setting' not in df.columns: | |
| st.error("Leaderboard data is corrupted or incomplete.") | |
| return | |
| groups = sorted(df.setting.unique()) | |
| tabs = st.tabs(["**Overall**"] + groups) | |
| with tabs[0]: display_rankings(df) | |
| for i, group_name in enumerate(groups): | |
| with tabs[i+1]: | |
| df_group = df[df.setting == group_name].copy() | |
| display_rankings(df_group) | |
| except Exception as e: | |
| st.error(f"Error displaying leaderboard: {e}") | |
| st.dataframe(leaderboard_data) | |
| # ---------- MODIFIED FUNCTION (v2) ---------- | |
| def save_logs_and_upload(state: dict): | |
| if not state.get('logs'): | |
| st.warning("No log data to save.") | |
| return | |
| participant_id = state['participant_id'] | |
| logs_df = None | |
| try: | |
| logs_df = pd.json_normalize(state['logs']) | |
| safe_participant_id = re.sub(r'[^a-zA-Z0-9_-]', '_', participant_id) | |
| fname = LOCAL_LOG_DIR / f"log_{safe_participant_id}_{int(time.time())}.csv" | |
| for col in logs_df.select_dtypes(include=['object']).columns: logs_df[col] = logs_df[col].astype(str) | |
| logs_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 CSV 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 CSV successfully uploaded! [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 CSV: {e_save}") | |
| return | |
| if logs_df is None: return | |
| st.subheader("Updating Leaderboard...") | |
| try: | |
| human_role = state['human_role'] | |
| # 1. 计算你的 (Distributor) 成本 | |
| distributor_cost = logs_df[f'{human_role}.total_cost'].iloc[-1] | |
| # 2. 计算总供应链成本 | |
| r_cost = logs_df['Retailer.total_cost'].iloc[-1] | |
| w_cost = logs_df['Wholesaler.total_cost'].iloc[-1] | |
| f_cost = logs_df['Factory.total_cost'].iloc[-1] | |
| total_chain_cost = r_cost + w_cost + distributor_cost + f_cost | |
| # 3. 计算订单标准差 | |
| order_std_dev = logs_df[f'{human_role}.order_placed'].std() | |
| setting_name = f"{state['llm_personality']} / {state['info_sharing']}" | |
| # 4. 创建新的条目 | |
| new_entry = { | |
| 'id': participant_id, | |
| 'setting': setting_name, | |
| 'total_cost': float(distributor_cost), # 'total_cost' 现在明确是 distributor_cost | |
| 'total_chain_cost': float(total_chain_cost), # 新增: 总成本 | |
| 'order_std_dev': float(order_std_dev) if pd.notna(order_std_dev) else 0.0 | |
| } | |
| leaderboard_data = load_leaderboard_data() | |
| leaderboard_data[participant_id] = new_entry | |
| save_leaderboard_data(leaderboard_data) | |
| except Exception as e_board: | |
| st.error(f"Error calculating or saving leaderboard score: {e_board}") | |
| # ============================================================================== | |
| # ----------------------------------------------------------------------------- | |
| # 4. Streamlit UI (Applying v4.22 + v4.23 fixes) | |
| # ----------------------------------------------------------------------------- | |
| 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("⚙️ Game Configuration") | |
| # =============== NEW: Participant ID Input =============== | |
| participant_id = st.text_input("Enter Your Name or Team ID:", key="participant_id_input", placeholder="e.g., Team A") | |
| # ======================================================= | |
| 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.") | |
| # =============== MODIFIED: Start Game Button =============== | |
| if st.button("🚀 Start Game", type="primary", disabled=(client is None)): | |
| if not participant_id: | |
| st.error("Please enter a Name or Team ID to start!") | |
| else: | |
| existing_data = load_leaderboard_data() | |
| if participant_id in existing_data: | |
| # 如果ID已存在,添加一个session_state标志,要求再次点击 | |
| if st.session_state.get('last_id_warning') == participant_id: | |
| # 这是第二次点击,确认覆盖 | |
| st.session_state.pop('last_id_warning', None) | |
| init_game_state(llm_personality, info_sharing, participant_id) | |
| st.rerun() | |
| else: | |
| st.session_state['last_id_warning'] = participant_id | |
| st.warning(f"ID '{participant_id}' already exists! Your score will be overwritten. Click 'Start Game' again to confirm.") | |
| else: | |
| # 新ID,直接开始 | |
| if 'last_id_warning' in st.session_state: | |
| del st.session_state['last_id_warning'] | |
| init_game_state(llm_personality, info_sharing, participant_id) | |
| st.rerun() | |
| # =========================================================== | |
| # =============== NEW: Show Leaderboard on Start Page =============== | |
| show_leaderboard_ui() | |
| # ================================================================= | |
| # --- 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'] | |
| echelon_order = ["Retailer", "Wholesaler", "Distributor", "Factory"] # Define here for UI | |
| st.header(f"Week {week} / {WEEKS}") | |
| st.subheader(f"Your Role: **{human_role}** ({state['participant_id']}) | AI Mode: **{state['llm_personality'].replace('_', ' ')}** | Information: **{state['info_sharing']}**") | |
| st.markdown("---") | |
| st.subheader("Supply Chain Status (Start of Week State)") | |
| # =============== MODIFIED UI LOGIC (v12) =============== | |
| 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}") | |
| 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 = state['last_week_orders'].get("Distributor", 0) | |
| st.write(f"Completing Next Week: **{prod_completing_next}**") | |
| else: | |
| arriving_next = 0 | |
| # --- UI FIX V12 --- | |
| q = e['incoming_shipments'] | |
| if name == 'Distributor': | |
| # "Next" for Distributor (maxlen=1) is q[0] | |
| if q: arriving_next = list(q)[0] | |
| elif name in ('Wholesaler', 'Retailer'): | |
| # "Next" for R/W (maxlen=2) is q[0] | |
| # No, it's q[1]. | |
| # W3: q=[4,8]. ArrivedThisWeek=4. ArrivingNextWeek=8 | |
| # We pop 4. q=[8]. | |
| # W4: q=[8]. ArrivedThisWeek=8. | |
| if len(q) > 1: | |
| arriving_next = list(q)[1] # Read W+2 | |
| # Let's retry R/W logic | |
| # W3: q=[4,8]. ArrivedThisWeek=4 (from [0]). ANW=8 (from [1]) | |
| # W4: D ships 16. q.popleft() (4). q.append(16). q=[8,16] | |
| # W4 start: ArrivedThisWeek=8 (from [0]). ANW=16 (from [1]) | |
| if len(q) > 1: | |
| arriving_next = list(q)[1] | |
| # Let's retry Distributor logic (maxlen=1) | |
| # W3: F ships 4. q.append(4). q=[4] | |
| # W4: ArrivedThisWeek=4 (from [0]). ANW=?? | |
| # W4: F ships 8. q.popleft() (4). q.append(8). q=[8] | |
| # W5: ArrivedThisWeek=8 (from [0]). | |
| # "Arriving Next Week" for Distributor (W+1) is ALWAYS list(q)[0] | |
| if q: arriving_next = list(q)[0] | |
| # --- RETHINK UI V12 --- | |
| # For R/W (maxlen=2), q[0] is W+1, q[1] is W+2 | |
| # For D (maxlen=1), q[0] is W+1 | |
| if name in ('Wholesaler', 'Retailer'): | |
| q = e['incoming_shipments'] | |
| if q: arriving_next = list(q)[0] # Read W+1 | |
| elif name == 'Distributor': | |
| q = e['incoming_shipments'] | |
| if q: arriving_next = list(q)[0] # Read W+1 | |
| # --- END RETHINK V12 --- | |
| st.write(f"Arriving Next Week: **{arriving_next}**") | |
| else: # Local Info Mode | |
| st.info("In Local Information mode, you can only see your own status dashboard.") | |
| e = echelons[human_role] # Distributor | |
| st.markdown(f"### 👤 **<span style='color:#FF4B4B;'>{human_role} (Your Dashboard - Start of Week State)</span>**", unsafe_allow_html=True) | |
| col1, col2, col3 = st.columns(3) | |
| with col1: | |
| st.metric("Inventory (Opening)", e['inventory']) | |
| st.metric("Backlog (Opening)", e['backlog']) | |
| with col2: | |
| current_incoming_order = 0 | |
| downstream_name = e['downstream_name'] # Wholesaler | |
| if downstream_name: | |
| current_incoming_order = state['last_week_orders'].get(downstream_name, 0) | |
| st.write(f"**Incoming Order (This Week):**\n# {current_incoming_order}") | |
| with col3: | |
| # --------------------- LOCAL UI FIX V12 --------------------- | |
| # "Arriving Next Week" for Distributor in LOCAL mode. | |
| # Read W+1 item from its own shipping queue | |
| arriving_next = 0 | |
| q = e['incoming_shipments'] | |
| if q: | |
| arriving_next = list(q)[0] | |
| st.write(f"**Shipment Arriving (Next Week):**\n# {arriving_next}") | |
| # ----------------------------------------------------------- | |
| # ======================================================= | |
| st.markdown("---") | |
| st.header("Your Decision") | |
| # 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] # This is END OF LAST WEEK state | |
| 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] | |
| 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) | |
| inv_after_arrival = e_curr['inventory'] + arrived | |
| backlog_after_new_order = e_curr['backlog'] + inc_order_this_week | |
| # This is the state used for the prompt, it's calculated BEFORE the pop | |
| 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 a:** 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, value=None) # Start blank | |
| 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' | |
| # --- NEW: Calculate and store suggestion ONCE --- | |
| 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)") | |
| state['current_ai_suggestion'] = ai_suggestion # Store it | |
| # ------------------------------------------------ | |
| st.rerun() | |
| elif state['decision_step'] == 'final_order': | |
| st.success(f"Your initial order was: **{state['human_initial_order']}** units.") | |
| # --- NEW: Read stored suggestion --- | |
| ai_suggestion = state.get('current_ai_suggestion', 4) # Read stored value | |
| # ----------------------------------- | |
| with st.form(key="final_order_form"): | |
| st.markdown(f"#### **Step b:** 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', value=None) # Start blank | |
| 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']) | |
| # FIX: Removed 'Arrived This Week' from log UI | |
| human_cols = { | |
| 'week': 'Week', f'{human_role}.opening_inventory': 'Opening Inv.', | |
| f'{human_role}.opening_backlog': 'Opening Backlog', | |
| 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', | |
| } | |
| # FIX: Removed 'Arrived This Week' from log UI | |
| ordered_display_cols_keys = [ | |
| 'week', f'{human_role}.opening_inventory', f'{human_role}.opening_backlog', | |
| 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 | |
| if 'current_ai_suggestion' in st.session_state.game_state: del st.session_state.game_state['current_ai_suggestion'] | |
| 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) # This now also updates the leaderboard | |
| except Exception as e: | |
| st.error(f"Error generating final report: {e}") | |
| show_leaderboard_ui() | |
| if st.button("✨ Start a New Game"): | |
| del st.session_state.game_state | |
| st.rerun() |