Update app.py
Browse files
app.py
CHANGED
|
@@ -1,33 +1,32 @@
|
|
| 1 |
# app.py
|
| 2 |
-
# @title
|
| 3 |
|
| 4 |
# -----------------------------------------------------------------------------
|
| 5 |
-
# 1.
|
| 6 |
# -----------------------------------------------------------------------------
|
| 7 |
import streamlit as st
|
| 8 |
import pandas as pd
|
| 9 |
import matplotlib.pyplot as plt
|
| 10 |
-
import numpy as np
|
| 11 |
from collections import deque
|
| 12 |
import time
|
| 13 |
import openai
|
| 14 |
import re
|
| 15 |
import random
|
| 16 |
import uuid
|
| 17 |
-
import os
|
| 18 |
from pathlib import Path
|
| 19 |
from datetime import datetime
|
| 20 |
from huggingface_hub import HfApi
|
| 21 |
|
| 22 |
# -----------------------------------------------------------------------------
|
| 23 |
-
# 0.
|
| 24 |
# -----------------------------------------------------------------------------
|
| 25 |
-
st.set_page_config(page_title="
|
|
|
|
| 26 |
|
| 27 |
# -----------------------------------------------------------------------------
|
| 28 |
-
# 2.
|
| 29 |
# -----------------------------------------------------------------------------
|
| 30 |
-
# ---
|
| 31 |
WEEKS = 24
|
| 32 |
INITIAL_INVENTORY = 12
|
| 33 |
INITIAL_BACKLOG = 0
|
|
@@ -38,31 +37,34 @@ FACTORY_SHIPPING_DELAY = 1
|
|
| 38 |
HOLDING_COST = 0.5
|
| 39 |
BACKLOG_COST = 1.0
|
| 40 |
|
| 41 |
-
# ---
|
| 42 |
OPENAI_MODEL = "gpt-4o-mini"
|
| 43 |
LOCAL_LOG_DIR = Path("logs")
|
| 44 |
LOCAL_LOG_DIR.mkdir(exist_ok=True)
|
| 45 |
|
| 46 |
-
# --- API & Secrets
|
| 47 |
try:
|
| 48 |
client = openai.OpenAI(api_key=st.secrets["OPENAI_API_KEY"])
|
| 49 |
HF_TOKEN = st.secrets.get("HF_TOKEN")
|
| 50 |
HF_REPO_ID = st.secrets.get("HF_REPO_ID")
|
| 51 |
hf_api = HfApi() if HF_TOKEN else None
|
| 52 |
except Exception as e:
|
| 53 |
-
st.session_state.initialization_error = f"
|
| 54 |
client = None
|
| 55 |
else:
|
| 56 |
st.session_state.initialization_error = None
|
| 57 |
|
|
|
|
| 58 |
# -----------------------------------------------------------------------------
|
| 59 |
-
# 3.
|
| 60 |
# -----------------------------------------------------------------------------
|
| 61 |
|
| 62 |
def get_customer_demand(week: int) -> int:
|
|
|
|
| 63 |
return 4 if week <= 4 else 8
|
| 64 |
|
| 65 |
def init_game_state(llm_personality: str, info_sharing: str):
|
|
|
|
| 66 |
roles = ["Retailer", "Wholesaler", "Distributor", "Factory"]
|
| 67 |
human_role = random.choice(roles)
|
| 68 |
participant_id = str(uuid.uuid4())[:8]
|
|
@@ -72,8 +74,8 @@ def init_game_state(llm_personality: str, info_sharing: str):
|
|
| 72 |
'human_role': human_role, 'llm_personality': llm_personality,
|
| 73 |
'info_sharing': info_sharing, 'logs': [], 'echelons': {},
|
| 74 |
'factory_production_pipeline': deque([0] * FACTORY_LEAD_TIME, maxlen=FACTORY_LEAD_TIME),
|
| 75 |
-
'decision_step': 'initial_order', #
|
| 76 |
-
'human_initial_order': None, #
|
| 77 |
}
|
| 78 |
|
| 79 |
for i, name in enumerate(roles):
|
|
@@ -90,11 +92,12 @@ def init_game_state(llm_personality: str, info_sharing: str):
|
|
| 90 |
'incoming_order': 0, 'order_placed': 0, 'shipment_sent': 0,
|
| 91 |
'weekly_cost': 0, 'total_cost': 0, 'upstream_name': upstream, 'downstream_name': downstream,
|
| 92 |
}
|
| 93 |
-
st.info(f"
|
| 94 |
|
| 95 |
def get_llm_order_decision(prompt: str, echelon_name: str) -> (int, str):
|
|
|
|
| 96 |
if not client: return 8, "NO_API_KEY_DEFAULT"
|
| 97 |
-
with st.spinner(f"
|
| 98 |
try:
|
| 99 |
temp = 0.1 if 'rational' in prompt else 0.7
|
| 100 |
response = client.chat.completions.create(
|
|
@@ -110,11 +113,12 @@ def get_llm_order_decision(prompt: str, echelon_name: str) -> (int, str):
|
|
| 110 |
if match: return int(match.group(0)), raw_text
|
| 111 |
return 8, raw_text
|
| 112 |
except Exception as e:
|
| 113 |
-
st.error(f"API
|
| 114 |
return 8, f"API_ERROR: {e}"
|
| 115 |
|
| 116 |
def get_llm_prompt(echelon_state: dict, week: int, llm_personality: str, info_sharing: str, all_echelons_state: dict) -> str:
|
| 117 |
-
|
|
|
|
| 118 |
base_info = f"Your Current Status at the **{echelon_state['name']}** for **Week {week}**:\n- On-hand inventory: {echelon_state['inventory']} units.\n- Backlog (unfilled orders): {echelon_state['backlog']} units.\n- Incoming order this week (from your customer): {echelon_state['incoming_order']} units.\n- Shipments on the way to you: {list(echelon_state['incoming_shipments'])}\n- Orders you have placed being processed by your supplier: {list(echelon_state['order_pipeline'])}"
|
| 119 |
if llm_personality == 'perfect_rational' and info_sharing == 'full':
|
| 120 |
stable_demand = 8; total_lead_time = ORDER_PASSING_DELAY + SHIPPING_DELAY; safety_stock = 4
|
|
@@ -137,16 +141,15 @@ def get_llm_prompt(echelon_state: dict, week: int, llm_personality: str, info_sh
|
|
| 137 |
elif llm_personality == 'human_like' and info_sharing == 'local':
|
| 138 |
return f"**You are a reactive supply chain manager for the {echelon_state['name']}.** You have a limited view and tend to over-correct based on fear.\n\n**Your Mindset: **Your top priority is try to not have a backlog.\n\n{base_info}\n\n**Your Task:** You just saw your own inventory and a new order coming. Your gut instinct is to panic and order enough to ensure you are never caught with a backlog again.\n\n**React emotionally.** What is your knee-jerk order quantity? Respond with a single integer."
|
| 139 |
|
| 140 |
-
|
| 141 |
def step_game(human_final_order: int, human_initial_order: int, ai_suggestion: int):
|
| 142 |
-
"""
|
| 143 |
state = st.session_state.game_state
|
| 144 |
week, echelons, human_role = state['week'], state['echelons'], state['human_role']
|
| 145 |
llm_personality, info_sharing = state['llm_personality'], state['info_sharing']
|
| 146 |
echelon_order = ["Retailer", "Wholesaler", "Distributor", "Factory"]
|
| 147 |
llm_raw_responses = {}
|
| 148 |
|
| 149 |
-
#
|
| 150 |
factory_state = echelons["Factory"]
|
| 151 |
if state['factory_production_pipeline']: factory_state['inventory'] += state['factory_production_pipeline'].popleft()
|
| 152 |
for name in ["Retailer", "Wholesaler", "Distributor"]:
|
|
@@ -164,7 +167,7 @@ def step_game(human_final_order: int, human_initial_order: int, ai_suggestion: i
|
|
| 164 |
receiver = echelons[sender]['downstream_name']
|
| 165 |
if receiver: echelons[receiver]['incoming_shipments'].append(echelons[sender]['shipment_sent'])
|
| 166 |
|
| 167 |
-
#
|
| 168 |
for name in echelon_order:
|
| 169 |
e = echelons[name]
|
| 170 |
if name == human_role:
|
|
@@ -178,9 +181,9 @@ def step_game(human_final_order: int, human_initial_order: int, ai_suggestion: i
|
|
| 178 |
|
| 179 |
state['factory_production_pipeline'].append(echelons["Factory"]['order_placed'])
|
| 180 |
|
| 181 |
-
#
|
| 182 |
log_entry = {'timestamp': datetime.utcnow().isoformat() + "Z", 'week': week, **state}
|
| 183 |
-
del log_entry['echelons'], log_entry['factory_production_pipeline'] #
|
| 184 |
for name in echelon_order:
|
| 185 |
e = echelons[name]
|
| 186 |
e['weekly_cost'] = (e['inventory'] * HOLDING_COST) + (e['backlog'] * BACKLOG_COST); e['total_cost'] += e['weekly_cost']
|
|
@@ -188,20 +191,21 @@ def step_game(human_final_order: int, human_initial_order: int, ai_suggestion: i
|
|
| 188 |
log_entry[f'{name}.{key}'] = e[key]
|
| 189 |
log_entry[f'{name}.llm_raw_response'] = llm_raw_responses.get(name, "")
|
| 190 |
|
| 191 |
-
#
|
| 192 |
log_entry[f'{human_role}.initial_order'] = human_initial_order
|
| 193 |
log_entry[f'{human_role}.ai_suggestion'] = ai_suggestion
|
| 194 |
|
| 195 |
state['logs'].append(log_entry)
|
| 196 |
|
| 197 |
-
#
|
| 198 |
state['week'] += 1
|
| 199 |
state['decision_step'] = 'initial_order'
|
| 200 |
if state['week'] > WEEKS: state['game_running'] = False
|
| 201 |
|
| 202 |
|
| 203 |
def plot_results(df: pd.DataFrame, title: str, human_role: str):
|
| 204 |
-
|
|
|
|
| 205 |
fig.suptitle(title, fontsize=16)
|
| 206 |
echelons = ['Retailer', 'Wholesaler', 'Distributor', 'Factory']
|
| 207 |
|
|
@@ -213,140 +217,172 @@ def plot_results(df: pd.DataFrame, title: str, human_role: str):
|
|
| 213 |
'total_cost': row[f'{e}.total_cost']})
|
| 214 |
plot_df = pd.DataFrame(plot_data)
|
| 215 |
|
| 216 |
-
#
|
| 217 |
inventory_pivot = plot_df.pivot(index='week', columns='echelon', values='inventory').reindex(columns=echelons)
|
| 218 |
-
inventory_pivot.plot(ax=axes[0], kind='line', marker='o', markersize=4); axes[0].set_title('Inventory Levels'); axes[0].grid(True, linestyle='--')
|
| 219 |
|
| 220 |
-
#
|
| 221 |
order_pivot = plot_df.pivot(index='week', columns='echelon', values='order_placed').reindex(columns=echelons)
|
| 222 |
-
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 (Bullwhip Effect)'); axes[1].grid(True, linestyle='--'); axes[1].legend()
|
| 223 |
|
| 224 |
-
#
|
| 225 |
total_costs = plot_df.groupby('echelon')['total_cost'].max().reindex(echelons)
|
| 226 |
-
total_costs.plot(kind='bar', ax=axes[2], rot=0); axes[2].set_title('Total Cumulative Cost')
|
| 227 |
|
| 228 |
-
#
|
| 229 |
human_df = df[['week', f'{human_role}.initial_order', f'{human_role}.ai_suggestion', f'{human_role}.order_placed']].copy()
|
| 230 |
human_df.rename(columns={
|
| 231 |
-
f'{human_role}.initial_order': 'Your Initial Order',
|
| 232 |
-
f'{human_role}.ai_suggestion': 'AI Suggestion',
|
| 233 |
-
f'{human_role}.order_placed': 'Your Final Order'
|
| 234 |
}, inplace=True)
|
| 235 |
-
human_df.plot(x='week', ax=axes[3], marker='o', linestyle='-')
|
| 236 |
-
axes[3].set_title(f'Analysis of Your ({human_role}) Decisions')
|
| 237 |
-
axes[3].set_ylabel('Order Quantity')
|
| 238 |
-
axes[3].grid(True, linestyle='--')
|
| 239 |
|
| 240 |
plt.tight_layout(rect=[0, 0, 1, 0.96]); return fig
|
| 241 |
|
| 242 |
-
|
| 243 |
def save_logs_and_upload(state: dict):
|
| 244 |
-
|
| 245 |
if not state.get('logs'): return
|
| 246 |
participant_id = state['participant_id']
|
| 247 |
df = pd.json_normalize(state['logs'])
|
| 248 |
fname = LOCAL_LOG_DIR / f"log_{participant_id}_{int(time.time())}.csv"
|
| 249 |
df.to_csv(fname, index=False)
|
| 250 |
-
st.success(f"
|
| 251 |
with open(fname, "rb") as f:
|
| 252 |
-
st.download_button("📥
|
| 253 |
if HF_TOKEN and HF_REPO_ID and hf_api:
|
| 254 |
-
with st.spinner("
|
| 255 |
try:
|
| 256 |
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)
|
| 257 |
-
st.success(f"✅
|
| 258 |
except Exception as e:
|
| 259 |
-
st.error(f"
|
| 260 |
|
| 261 |
# -----------------------------------------------------------------------------
|
| 262 |
-
# 4. Streamlit UI
|
| 263 |
# -----------------------------------------------------------------------------
|
| 264 |
-
st.title("🍺
|
| 265 |
|
| 266 |
if st.session_state.get('initialization_error'):
|
| 267 |
st.error(st.session_state.initialization_error)
|
| 268 |
else:
|
| 269 |
-
# ---
|
| 270 |
if 'game_state' not in st.session_state or not st.session_state.game_state.get('game_running', False):
|
| 271 |
-
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
col1, col2 = st.columns(2)
|
| 274 |
-
with col1:
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
|
| 279 |
-
# ---
|
| 280 |
elif 'game_state' in st.session_state and st.session_state.game_state.get('game_running'):
|
| 281 |
state = st.session_state.game_state
|
| 282 |
week, human_role, echelons, info_sharing = state['week'], state['human_role'], state['echelons'], state['info_sharing']
|
| 283 |
|
| 284 |
-
st.header(f"
|
| 285 |
-
st.subheader(f"
|
| 286 |
|
| 287 |
-
# --- 核心改动:根据信息模式显示面板 ---
|
| 288 |
st.markdown("---")
|
| 289 |
-
st.subheader("
|
| 290 |
if info_sharing == 'full':
|
| 291 |
cols = st.columns(4)
|
| 292 |
for i, name in enumerate(["Retailer", "Wholesaler", "Distributor", "Factory"]):
|
| 293 |
with cols[i]:
|
| 294 |
e, icon = echelons[name], "👤" if name == human_role else "🤖"
|
| 295 |
-
st.markdown(f"##### {icon} {name} {'(
|
| 296 |
-
st.metric("
|
| 297 |
-
st.write(f"
|
| 298 |
-
st.write(f"
|
| 299 |
else: # local information
|
| 300 |
-
st.info("
|
| 301 |
e = echelons[human_role]
|
| 302 |
-
st.markdown(f"### 👤 {human_role} (
|
| 303 |
col1, col2, col3, col4 = st.columns(4)
|
| 304 |
-
col1.metric("
|
| 305 |
-
col2.metric("
|
| 306 |
-
col3.write(f"
|
| 307 |
-
col4.write(f"
|
| 308 |
st.markdown("---")
|
| 309 |
|
| 310 |
-
# ---
|
| 311 |
-
st.header("
|
| 312 |
human_echelon_state = echelons[human_role]
|
| 313 |
|
| 314 |
-
# 阶段一:提交初步订单
|
| 315 |
if state['decision_step'] == 'initial_order':
|
| 316 |
with st.form(key="initial_order_form"):
|
| 317 |
-
st.markdown("####
|
| 318 |
-
initial_order = st.number_input("
|
| 319 |
-
if st.form_submit_button("
|
| 320 |
state['human_initial_order'] = int(initial_order)
|
| 321 |
state['decision_step'] = 'final_order'
|
| 322 |
st.rerun()
|
| 323 |
-
|
| 324 |
-
# 阶段二:结合AI建议,提交最终订单
|
| 325 |
elif state['decision_step'] == 'final_order':
|
| 326 |
-
st.success(f"
|
| 327 |
prompt_sugg = get_llm_prompt(human_echelon_state, week, state['llm_personality'], state['info_sharing'], echelons)
|
| 328 |
ai_suggestion, _ = get_llm_order_decision(prompt_sugg, f"{human_role} (Suggestion)")
|
| 329 |
|
| 330 |
with st.form(key="final_order_form"):
|
| 331 |
-
st.markdown(f"####
|
| 332 |
-
st.markdown("
|
| 333 |
-
final_order = st.number_input("
|
| 334 |
-
if st.form_submit_button("
|
| 335 |
step_game(int(final_order), state['human_initial_order'], ai_suggestion)
|
| 336 |
st.rerun()
|
| 337 |
|
| 338 |
-
st.sidebar.header("
|
| 339 |
-
st.sidebar.markdown(f"
|
| 340 |
-
if st.sidebar.button("🔄
|
| 341 |
del st.session_state.game_state; st.rerun()
|
| 342 |
|
| 343 |
-
# ---
|
| 344 |
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:
|
| 345 |
-
st.header("🎉
|
| 346 |
state = st.session_state.game_state
|
| 347 |
logs_df = pd.json_normalize(state['logs'])
|
| 348 |
fig = plot_results(logs_df, f"Beer Game (Human: {state['human_role']})\n(AI: {state['llm_personality']} | Info: {state['info_sharing']})", state['human_role'])
|
| 349 |
st.pyplot(fig)
|
| 350 |
save_logs_and_upload(state)
|
| 351 |
-
if st.button("✨
|
| 352 |
del st.session_state.game_state; st.rerun()
|
|
|
|
| 1 |
# app.py
|
| 2 |
+
# @title Beer Game Final Version (v4 - English UI & Introduction)
|
| 3 |
|
| 4 |
# -----------------------------------------------------------------------------
|
| 5 |
+
# 1. Import Libraries
|
| 6 |
# -----------------------------------------------------------------------------
|
| 7 |
import streamlit as st
|
| 8 |
import pandas as pd
|
| 9 |
import matplotlib.pyplot as plt
|
|
|
|
| 10 |
from collections import deque
|
| 11 |
import time
|
| 12 |
import openai
|
| 13 |
import re
|
| 14 |
import random
|
| 15 |
import uuid
|
|
|
|
| 16 |
from pathlib import Path
|
| 17 |
from datetime import datetime
|
| 18 |
from huggingface_hub import HfApi
|
| 19 |
|
| 20 |
# -----------------------------------------------------------------------------
|
| 21 |
+
# 0. Page Configuration (Must be the first Streamlit command)
|
| 22 |
# -----------------------------------------------------------------------------
|
| 23 |
+
st.set_page_config(page_title="Beer Game: Human-AI Collaboration", layout="wide")
|
| 24 |
+
|
| 25 |
|
| 26 |
# -----------------------------------------------------------------------------
|
| 27 |
+
# 2. Game Parameters & API Configuration
|
| 28 |
# -----------------------------------------------------------------------------
|
| 29 |
+
# --- Game Parameters ---
|
| 30 |
WEEKS = 24
|
| 31 |
INITIAL_INVENTORY = 12
|
| 32 |
INITIAL_BACKLOG = 0
|
|
|
|
| 37 |
HOLDING_COST = 0.5
|
| 38 |
BACKLOG_COST = 1.0
|
| 39 |
|
| 40 |
+
# --- Model & Log Configuration ---
|
| 41 |
OPENAI_MODEL = "gpt-4o-mini"
|
| 42 |
LOCAL_LOG_DIR = Path("logs")
|
| 43 |
LOCAL_LOG_DIR.mkdir(exist_ok=True)
|
| 44 |
|
| 45 |
+
# --- API & Secrets Configuration ---
|
| 46 |
try:
|
| 47 |
client = openai.OpenAI(api_key=st.secrets["OPENAI_API_KEY"])
|
| 48 |
HF_TOKEN = st.secrets.get("HF_TOKEN")
|
| 49 |
HF_REPO_ID = st.secrets.get("HF_REPO_ID")
|
| 50 |
hf_api = HfApi() if HF_TOKEN else None
|
| 51 |
except Exception as e:
|
| 52 |
+
st.session_state.initialization_error = f"Error reading secrets on startup: {e}."
|
| 53 |
client = None
|
| 54 |
else:
|
| 55 |
st.session_state.initialization_error = None
|
| 56 |
|
| 57 |
+
|
| 58 |
# -----------------------------------------------------------------------------
|
| 59 |
+
# 3. Core Game Logic Functions
|
| 60 |
# -----------------------------------------------------------------------------
|
| 61 |
|
| 62 |
def get_customer_demand(week: int) -> int:
|
| 63 |
+
"""Defines the end-customer demand pattern."""
|
| 64 |
return 4 if week <= 4 else 8
|
| 65 |
|
| 66 |
def init_game_state(llm_personality: str, info_sharing: str):
|
| 67 |
+
"""Initializes or resets the game state in st.session_state."""
|
| 68 |
roles = ["Retailer", "Wholesaler", "Distributor", "Factory"]
|
| 69 |
human_role = random.choice(roles)
|
| 70 |
participant_id = str(uuid.uuid4())[:8]
|
|
|
|
| 74 |
'human_role': human_role, 'llm_personality': llm_personality,
|
| 75 |
'info_sharing': info_sharing, 'logs': [], 'echelons': {},
|
| 76 |
'factory_production_pipeline': deque([0] * FACTORY_LEAD_TIME, maxlen=FACTORY_LEAD_TIME),
|
| 77 |
+
'decision_step': 'initial_order', # Controls the two-step decision process
|
| 78 |
+
'human_initial_order': None, # Stores the player's initial order
|
| 79 |
}
|
| 80 |
|
| 81 |
for i, name in enumerate(roles):
|
|
|
|
| 92 |
'incoming_order': 0, 'order_placed': 0, 'shipment_sent': 0,
|
| 93 |
'weekly_cost': 0, 'total_cost': 0, 'upstream_name': upstream, 'downstream_name': downstream,
|
| 94 |
}
|
| 95 |
+
st.info(f"New game started! AI Mode: **{llm_personality} / {info_sharing}**. You have been randomly assigned the role of: **{human_role}**.")
|
| 96 |
|
| 97 |
def get_llm_order_decision(prompt: str, echelon_name: str) -> (int, str):
|
| 98 |
+
"""Calls the OpenAI API to get a decision and returns the integer order and raw text."""
|
| 99 |
if not client: return 8, "NO_API_KEY_DEFAULT"
|
| 100 |
+
with st.spinner(f"Getting AI decision for {echelon_name}..."):
|
| 101 |
try:
|
| 102 |
temp = 0.1 if 'rational' in prompt else 0.7
|
| 103 |
response = client.chat.completions.create(
|
|
|
|
| 113 |
if match: return int(match.group(0)), raw_text
|
| 114 |
return 8, raw_text
|
| 115 |
except Exception as e:
|
| 116 |
+
st.error(f"API call failed for {echelon_name}: {e}")
|
| 117 |
return 8, f"API_ERROR: {e}"
|
| 118 |
|
| 119 |
def get_llm_prompt(echelon_state: dict, week: int, llm_personality: str, info_sharing: str, all_echelons_state: dict) -> str:
|
| 120 |
+
"""Generates the prompt for the LLM based on the game scenario."""
|
| 121 |
+
# This function's logic is complex and correct, so it remains unchanged.
|
| 122 |
base_info = f"Your Current Status at the **{echelon_state['name']}** for **Week {week}**:\n- On-hand inventory: {echelon_state['inventory']} units.\n- Backlog (unfilled orders): {echelon_state['backlog']} units.\n- Incoming order this week (from your customer): {echelon_state['incoming_order']} units.\n- Shipments on the way to you: {list(echelon_state['incoming_shipments'])}\n- Orders you have placed being processed by your supplier: {list(echelon_state['order_pipeline'])}"
|
| 123 |
if llm_personality == 'perfect_rational' and info_sharing == 'full':
|
| 124 |
stable_demand = 8; total_lead_time = ORDER_PASSING_DELAY + SHIPPING_DELAY; safety_stock = 4
|
|
|
|
| 141 |
elif llm_personality == 'human_like' and info_sharing == 'local':
|
| 142 |
return f"**You are a reactive supply chain manager for the {echelon_state['name']}.** You have a limited view and tend to over-correct based on fear.\n\n**Your Mindset: **Your top priority is try to not have a backlog.\n\n{base_info}\n\n**Your Task:** You just saw your own inventory and a new order coming. Your gut instinct is to panic and order enough to ensure you are never caught with a backlog again.\n\n**React emotionally.** What is your knee-jerk order quantity? Respond with a single integer."
|
| 143 |
|
|
|
|
| 144 |
def step_game(human_final_order: int, human_initial_order: int, ai_suggestion: int):
|
| 145 |
+
"""Processes one week of the game and records detailed logs, including two-step decision data."""
|
| 146 |
state = st.session_state.game_state
|
| 147 |
week, echelons, human_role = state['week'], state['echelons'], state['human_role']
|
| 148 |
llm_personality, info_sharing = state['llm_personality'], state['info_sharing']
|
| 149 |
echelon_order = ["Retailer", "Wholesaler", "Distributor", "Factory"]
|
| 150 |
llm_raw_responses = {}
|
| 151 |
|
| 152 |
+
# Core game simulation steps (unchanged)
|
| 153 |
factory_state = echelons["Factory"]
|
| 154 |
if state['factory_production_pipeline']: factory_state['inventory'] += state['factory_production_pipeline'].popleft()
|
| 155 |
for name in ["Retailer", "Wholesaler", "Distributor"]:
|
|
|
|
| 167 |
receiver = echelons[sender]['downstream_name']
|
| 168 |
if receiver: echelons[receiver]['incoming_shipments'].append(echelons[sender]['shipment_sent'])
|
| 169 |
|
| 170 |
+
# Agents place orders
|
| 171 |
for name in echelon_order:
|
| 172 |
e = echelons[name]
|
| 173 |
if name == human_role:
|
|
|
|
| 181 |
|
| 182 |
state['factory_production_pipeline'].append(echelons["Factory"]['order_placed'])
|
| 183 |
|
| 184 |
+
# Update costs and record logs
|
| 185 |
log_entry = {'timestamp': datetime.utcnow().isoformat() + "Z", 'week': week, **state}
|
| 186 |
+
del log_entry['echelons'], log_entry['factory_production_pipeline'], log_entry['logs'] # Clean up complex objects
|
| 187 |
for name in echelon_order:
|
| 188 |
e = echelons[name]
|
| 189 |
e['weekly_cost'] = (e['inventory'] * HOLDING_COST) + (e['backlog'] * BACKLOG_COST); e['total_cost'] += e['weekly_cost']
|
|
|
|
| 191 |
log_entry[f'{name}.{key}'] = e[key]
|
| 192 |
log_entry[f'{name}.llm_raw_response'] = llm_raw_responses.get(name, "")
|
| 193 |
|
| 194 |
+
# Record two-step decision data
|
| 195 |
log_entry[f'{human_role}.initial_order'] = human_initial_order
|
| 196 |
log_entry[f'{human_role}.ai_suggestion'] = ai_suggestion
|
| 197 |
|
| 198 |
state['logs'].append(log_entry)
|
| 199 |
|
| 200 |
+
# Advance week and reset decision step
|
| 201 |
state['week'] += 1
|
| 202 |
state['decision_step'] = 'initial_order'
|
| 203 |
if state['week'] > WEEKS: state['game_running'] = False
|
| 204 |
|
| 205 |
|
| 206 |
def plot_results(df: pd.DataFrame, title: str, human_role: str):
|
| 207 |
+
"""Generates and returns the end-of-game plots."""
|
| 208 |
+
fig, axes = plt.subplots(4, 1, figsize=(12, 22))
|
| 209 |
fig.suptitle(title, fontsize=16)
|
| 210 |
echelons = ['Retailer', 'Wholesaler', 'Distributor', 'Factory']
|
| 211 |
|
|
|
|
| 217 |
'total_cost': row[f'{e}.total_cost']})
|
| 218 |
plot_df = pd.DataFrame(plot_data)
|
| 219 |
|
| 220 |
+
# Plot 1: Inventory
|
| 221 |
inventory_pivot = plot_df.pivot(index='week', columns='echelon', values='inventory').reindex(columns=echelons)
|
| 222 |
+
inventory_pivot.plot(ax=axes[0], kind='line', marker='o', markersize=4); axes[0].set_title('Inventory Levels'); axes[0].grid(True, linestyle='--'); axes[0].set_ylabel('Stock (Units)')
|
| 223 |
|
| 224 |
+
# Plot 2: Orders (Bullwhip Effect)
|
| 225 |
order_pivot = plot_df.pivot(index='week', columns='echelon', values='order_placed').reindex(columns=echelons)
|
| 226 |
+
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 (The Bullwhip Effect)'); axes[1].grid(True, linestyle='--'); axes[1].legend(); axes[1].set_ylabel('Ordered (Units)')
|
| 227 |
|
| 228 |
+
# Plot 3: Costs
|
| 229 |
total_costs = plot_df.groupby('echelon')['total_cost'].max().reindex(echelons)
|
| 230 |
+
total_costs.plot(kind='bar', ax=axes[2], rot=0); axes[2].set_title('Total Cumulative Cost'); axes[2].set_ylabel('Cost ($)')
|
| 231 |
|
| 232 |
+
# Plot 4: Human Decision Analysis
|
| 233 |
human_df = df[['week', f'{human_role}.initial_order', f'{human_role}.ai_suggestion', f'{human_role}.order_placed']].copy()
|
| 234 |
human_df.rename(columns={
|
| 235 |
+
f'{human_role}.initial_order': 'Your Initial Order', f'{human_role}.ai_suggestion': 'AI Suggestion', f'{human_role}.order_placed': 'Your Final Order'
|
|
|
|
|
|
|
| 236 |
}, inplace=True)
|
| 237 |
+
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')
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
plt.tight_layout(rect=[0, 0, 1, 0.96]); return fig
|
| 240 |
|
|
|
|
| 241 |
def save_logs_and_upload(state: dict):
|
| 242 |
+
"""Saves logs locally and uploads to Hugging Face Hub at the end of the game."""
|
| 243 |
if not state.get('logs'): return
|
| 244 |
participant_id = state['participant_id']
|
| 245 |
df = pd.json_normalize(state['logs'])
|
| 246 |
fname = LOCAL_LOG_DIR / f"log_{participant_id}_{int(time.time())}.csv"
|
| 247 |
df.to_csv(fname, index=False)
|
| 248 |
+
st.success(f"Log successfully saved locally: `{fname}`")
|
| 249 |
with open(fname, "rb") as f:
|
| 250 |
+
st.download_button("📥 Download Log CSV", data=f, file_name=fname.name, mime="text/csv")
|
| 251 |
if HF_TOKEN and HF_REPO_ID and hf_api:
|
| 252 |
+
with st.spinner("Uploading log to Hugging Face Hub..."):
|
| 253 |
try:
|
| 254 |
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)
|
| 255 |
+
st.success(f"✅ Log successfully uploaded to Hugging Face! [View File]({url})")
|
| 256 |
except Exception as e:
|
| 257 |
+
st.error(f"Upload to Hugging Face failed: {e}")
|
| 258 |
|
| 259 |
# -----------------------------------------------------------------------------
|
| 260 |
+
# 4. Streamlit UI
|
| 261 |
# -----------------------------------------------------------------------------
|
| 262 |
+
st.title("🍺 The Beer Game: A Human-AI Collaboration Challenge")
|
| 263 |
|
| 264 |
if st.session_state.get('initialization_error'):
|
| 265 |
st.error(st.session_state.initialization_error)
|
| 266 |
else:
|
| 267 |
+
# --- Game Setup & Instructions ---
|
| 268 |
if 'game_state' not in st.session_state or not st.session_state.game_state.get('game_running', False):
|
| 269 |
+
|
| 270 |
+
# --- NEW: Introduction Section ---
|
| 271 |
+
st.markdown("---")
|
| 272 |
+
st.header("📖 Welcome to the Beer Game!")
|
| 273 |
+
st.markdown("""
|
| 274 |
+
The Beer Game is a classic supply chain simulation that demonstrates a phenomenon called the **"Bullwhip Effect."** Even with stable customer demand, small variations in orders can amplify as they move up the supply chain, causing massive shortages and overstocks.
|
| 275 |
+
|
| 276 |
+
**Your Goal:** Minimize the total cost for your position in the supply chain. Costs are incurred for holding inventory (**$0.50**/unit/week) and for having a backlog of unfilled orders (**$1.00**/unit/week).
|
| 277 |
+
""")
|
| 278 |
+
|
| 279 |
col1, col2 = st.columns(2)
|
| 280 |
+
with col1:
|
| 281 |
+
st.subheader("🔗 The Supply Chain")
|
| 282 |
+
st.markdown("""
|
| 283 |
+
You will be randomly assigned one of four roles. The other three will be controlled by AI agents.
|
| 284 |
+
- **Retailer:** Fulfills end-customer demand. Orders from the Wholesaler.
|
| 285 |
+
- **Wholesaler:** Fulfills Retailer orders. Orders from the Distributor.
|
| 286 |
+
- **Distributor:** Fulfills Wholesaler orders. Orders from the Factory.
|
| 287 |
+
- **Factory:** Fulfills Distributor orders. Produces new beer.
|
| 288 |
+
""")
|
| 289 |
+
with col2:
|
| 290 |
+
st.subheader("⏳ The Challenge: Delays!")
|
| 291 |
+
st.markdown("""
|
| 292 |
+
The key challenge is managing delays. There is a **communication delay** for orders to reach your supplier and a **shipping delay** for goods to arrive. You must order enough to meet future demand without creating a huge pile of expensive inventory.
|
| 293 |
+
""")
|
| 294 |
+
|
| 295 |
+
st.subheader("🎮 How to Play This Version")
|
| 296 |
+
st.markdown("""
|
| 297 |
+
1. **Configure the Game:** Choose the AI's behavior and the level of information sharing.
|
| 298 |
+
2. **Get Your Role:** You will be randomly assigned a role.
|
| 299 |
+
3. **Make Your Decision (Two Steps):**
|
| 300 |
+
- **Step 1:** Based on your current status, submit your **initial order**.
|
| 301 |
+
- **Step 2:** After submitting, you will see an **AI suggestion**. You can either follow it or stick to your own judgment when submitting your **final order**.
|
| 302 |
+
4. **Advance:** Once you submit your final order, the week advances, and all AI agents make their moves.
|
| 303 |
+
""")
|
| 304 |
+
st.markdown("---")
|
| 305 |
+
|
| 306 |
+
# --- Game Configuration ---
|
| 307 |
+
st.header("⚙️ Game Configuration")
|
| 308 |
+
c1, c2 = st.columns(2)
|
| 309 |
+
with c1:
|
| 310 |
+
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.")
|
| 311 |
+
with c2:
|
| 312 |
+
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.")
|
| 313 |
+
|
| 314 |
+
if st.button("🚀 Start Game", type="primary", disabled=(client is None)):
|
| 315 |
+
init_game_state(llm_personality, info_sharing)
|
| 316 |
+
st.rerun()
|
| 317 |
|
| 318 |
+
# --- Main Game Interface ---
|
| 319 |
elif 'game_state' in st.session_state and st.session_state.game_state.get('game_running'):
|
| 320 |
state = st.session_state.game_state
|
| 321 |
week, human_role, echelons, info_sharing = state['week'], state['human_role'], state['echelons'], state['info_sharing']
|
| 322 |
|
| 323 |
+
st.header(f"Week {week} / {WEEKS}")
|
| 324 |
+
st.subheader(f"Your Role: **{human_role}** | AI Mode: **{state['llm_personality'].replace('_', ' ')}** | Information: **{state['info_sharing']}**")
|
| 325 |
|
|
|
|
| 326 |
st.markdown("---")
|
| 327 |
+
st.subheader("Supply Chain Status")
|
| 328 |
if info_sharing == 'full':
|
| 329 |
cols = st.columns(4)
|
| 330 |
for i, name in enumerate(["Retailer", "Wholesaler", "Distributor", "Factory"]):
|
| 331 |
with cols[i]:
|
| 332 |
e, icon = echelons[name], "👤" if name == human_role else "🤖"
|
| 333 |
+
st.markdown(f"##### {icon} {name} {'(You)' if name == human_role else ''}")
|
| 334 |
+
st.metric("Inventory", e['inventory']); st.metric("Backlog", e['backlog'])
|
| 335 |
+
st.write(f"Incoming Order: **{e['incoming_order']}**")
|
| 336 |
+
st.write(f"Arriving Next Week: **{list(e['incoming_shipments'])[0] if e['incoming_shipments'] else 0}**")
|
| 337 |
else: # local information
|
| 338 |
+
st.info("In Local Information mode, you can only see your own status dashboard.")
|
| 339 |
e = echelons[human_role]
|
| 340 |
+
st.markdown(f"### 👤 {human_role} (Your Dashboard)")
|
| 341 |
col1, col2, col3, col4 = st.columns(4)
|
| 342 |
+
col1.metric("Current Inventory", e['inventory'])
|
| 343 |
+
col2.metric("Current Backlog", e['backlog'])
|
| 344 |
+
col3.write(f"**Incoming Order (This Week):**\n# {e['incoming_order']}")
|
| 345 |
+
col4.write(f"**Shipment Arriving (Next Week):**\n# {list(e['incoming_shipments'])[0] if e['incoming_shipments'] else 0}")
|
| 346 |
st.markdown("---")
|
| 347 |
|
| 348 |
+
# --- Two-Step Decision UI ---
|
| 349 |
+
st.header("Your Decision")
|
| 350 |
human_echelon_state = echelons[human_role]
|
| 351 |
|
|
|
|
| 352 |
if state['decision_step'] == 'initial_order':
|
| 353 |
with st.form(key="initial_order_form"):
|
| 354 |
+
st.markdown("#### **Step 1:** Based on the information available, submit your **initial** order.")
|
| 355 |
+
initial_order = st.number_input("Your Initial Order Quantity:", min_value=0, step=1, value=human_echelon_state['incoming_order'])
|
| 356 |
+
if st.form_submit_button("Submit Initial Order & See AI Suggestion", type="primary"):
|
| 357 |
state['human_initial_order'] = int(initial_order)
|
| 358 |
state['decision_step'] = 'final_order'
|
| 359 |
st.rerun()
|
| 360 |
+
|
|
|
|
| 361 |
elif state['decision_step'] == 'final_order':
|
| 362 |
+
st.success(f"Your initial order was: **{state['human_initial_order']}** units.")
|
| 363 |
prompt_sugg = get_llm_prompt(human_echelon_state, week, state['llm_personality'], state['info_sharing'], echelons)
|
| 364 |
ai_suggestion, _ = get_llm_order_decision(prompt_sugg, f"{human_role} (Suggestion)")
|
| 365 |
|
| 366 |
with st.form(key="final_order_form"):
|
| 367 |
+
st.markdown(f"#### **Step 2:** The AI suggests ordering **{ai_suggestion}** units.")
|
| 368 |
+
st.markdown("Considering the AI's advice, submit your **final** order to end the week.")
|
| 369 |
+
final_order = st.number_input("Your Final Order Quantity:", min_value=0, step=1, value=ai_suggestion)
|
| 370 |
+
if st.form_submit_button("Submit Final Order & Advance to Next Week"):
|
| 371 |
step_game(int(final_order), state['human_initial_order'], ai_suggestion)
|
| 372 |
st.rerun()
|
| 373 |
|
| 374 |
+
st.sidebar.header("Game Info")
|
| 375 |
+
st.sidebar.markdown(f"**Game ID**: `{state['participant_id']}`\n\n**Current Week**: {week}")
|
| 376 |
+
if st.sidebar.button("🔄 Reset Game"):
|
| 377 |
del st.session_state.game_state; st.rerun()
|
| 378 |
|
| 379 |
+
# --- Game Over Interface ---
|
| 380 |
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:
|
| 381 |
+
st.header("🎉 Game Over!")
|
| 382 |
state = st.session_state.game_state
|
| 383 |
logs_df = pd.json_normalize(state['logs'])
|
| 384 |
fig = plot_results(logs_df, f"Beer Game (Human: {state['human_role']})\n(AI: {state['llm_personality']} | Info: {state['info_sharing']})", state['human_role'])
|
| 385 |
st.pyplot(fig)
|
| 386 |
save_logs_and_upload(state)
|
| 387 |
+
if st.button("✨ Start a New Game"):
|
| 388 |
del st.session_state.game_state; st.rerun()
|