Lilli98 commited on
Commit
5e8ddb1
·
verified ·
1 Parent(s): 87169ce

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +283 -182
app.py CHANGED
@@ -1,5 +1,5 @@
1
  # app.py
2
- # @title Beer Game Final Version (v4.22 - Custom ID & Dynamic Leaderboard)
3
 
4
  # -----------------------------------------------------------------------------
5
  # 1. Import Libraries
@@ -15,10 +15,10 @@ import random
15
  import uuid
16
  from pathlib import Path
17
  from datetime import datetime
18
- from huggingface_hub import HfApi, hf_hub_download # 导入新工具
19
- from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError # 导入错误处理
20
- import json # 导入 JSON
21
- import numpy as np # 导入 Numpy 用于 std
22
 
23
  # -----------------------------------------------------------------------------
24
  # 0. Page Configuration (Must be the first Streamlit command)
@@ -32,10 +32,10 @@ st.set_page_config(page_title="Beer Game: Human-AI Collaboration", layout="wide"
32
  WEEKS = 24
33
  INITIAL_INVENTORY = 12
34
  INITIAL_BACKLOG = 0
35
- ORDER_PASSING_DELAY = 1 # Handled by last_week_orders
36
- SHIPPING_DELAY = 2 # General shipping delay (R->W, W->D)
37
- FACTORY_LEAD_TIME = 1
38
- FACTORY_SHIPPING_DELAY = 1 # Specific delay from Factory to Distributor
39
  HOLDING_COST = 0.5
40
  BACKLOG_COST = 1.0
41
 
@@ -44,7 +44,7 @@ OPENAI_MODEL = "gpt-4o-mini"
44
  LOCAL_LOG_DIR = Path("logs")
45
  LOCAL_LOG_DIR.mkdir(exist_ok=True)
46
  IMAGE_PATH = "beer_game_diagram.png"
47
- LEADERBOARD_FILE = "leaderboard.json" # 新增:排行榜文件名
48
 
49
  # --- API & Secrets Configuration ---
50
  try:
@@ -65,29 +65,42 @@ else:
65
  def get_customer_demand(week: int) -> int:
66
  return 4 if week <= 4 else 8
67
 
68
- # =============== MODIFIED Initialization (Accepts participant_id) ===============
69
  def init_game_state(llm_personality: str, info_sharing: str, participant_id: str):
70
  roles = ["Retailer", "Wholesaler", "Distributor", "Factory"]
71
- human_role = "Distributor" # Role is fixed
72
 
73
  st.session_state.game_state = {
74
- 'game_running': True,
75
- 'participant_id': participant_id, # 使用传入的ID
76
- 'week': 1,
77
  'human_role': human_role, 'llm_personality': llm_personality,
78
  'info_sharing': info_sharing, 'logs': [], 'echelons': {},
 
 
 
 
 
79
  'factory_production_pipeline': deque([0] * FACTORY_LEAD_TIME, maxlen=FACTORY_LEAD_TIME),
 
 
 
 
 
 
 
 
80
  'decision_step': 'initial_order',
81
  'human_initial_order': None,
82
- 'last_week_orders': {name: 0 for name in roles}
83
  }
84
 
85
  for i, name in enumerate(roles):
86
  upstream = roles[i + 1] if i + 1 < len(roles) else None
87
  downstream = roles[i - 1] if i - 1 >= 0 else None
 
88
  if name == "Distributor": shipping_weeks = FACTORY_SHIPPING_DELAY
89
- elif name == "Factory": shipping_weeks = 0
90
- else: shipping_weeks = SHIPPING_DELAY
 
91
  st.session_state.game_state['echelons'][name] = {
92
  'name': name, 'inventory': INITIAL_INVENTORY, 'backlog': INITIAL_BACKLOG,
93
  'incoming_shipments': deque([0] * shipping_weeks, maxlen=shipping_weeks),
@@ -115,50 +128,75 @@ def get_llm_order_decision(prompt: str, echelon_name: str) -> (int, str):
115
  match = re.search(r'\d+', raw_text)
116
  if match: return int(match.group(0)), raw_text
117
  st.warning(f"LLM for {echelon_name} did not return a valid number. Defaulting to 4. Raw Response: '{raw_text}'")
118
- return 4, raw_text # Default to 4
119
  except Exception as e:
120
  st.error(f"API call failed for {echelon_name}: {e}. Defaulting to 4.")
121
  return 4, f"API_ERROR: {e}"
122
 
 
123
  def get_llm_prompt(echelon_state_decision_point: dict, week: int, llm_personality: str, info_sharing: str, all_echelons_state_decision_point: dict) -> str:
124
- # This function's logic remains correct (from v4.17).
125
  e_state = echelon_state_decision_point
126
  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"
 
 
 
 
 
 
 
 
 
 
127
  if e_state['name'] == 'Factory':
128
  task_word = "production quantity"
129
- base_info += f"- Your Production Pipeline (completing next week onwards): {list(st.session_state.game_state['factory_production_pipeline'])}"
 
130
  else:
131
  task_word = "order quantity"
132
- base_info += f"- Shipments In Transit To You (arriving next week onwards): {list(e_state['incoming_shipments'])}"
 
 
 
133
  if llm_personality == 'perfect_rational' and info_sharing == 'full':
134
  stable_demand = 8
135
- if e_state['name'] == 'Factory': total_lead_time = FACTORY_LEAD_TIME
136
  elif e_state['name'] == 'Distributor': total_lead_time = ORDER_PASSING_DELAY + FACTORY_LEAD_TIME + FACTORY_SHIPPING_DELAY
137
  else: total_lead_time = ORDER_PASSING_DELAY + SHIPPING_DELAY
138
  safety_stock = 4
139
  target_inventory_level = (stable_demand * total_lead_time) + safety_stock
 
140
  if e_state['name'] == 'Factory':
141
- inventory_position = (e_state['inventory'] - e_state['backlog'] + sum(st.session_state.game_state['factory_production_pipeline']))
142
- inv_pos_components = f"(Inv={e_state['inventory']} - Backlog={e_state['backlog']} + InProd={sum(st.session_state.game_state['factory_production_pipeline'])})"
 
 
 
143
  else:
144
- order_in_transit_to_supplier = st.session_state.game_state['last_week_orders'].get(e_state['name'], 0)
145
- inventory_position = (e_state['inventory'] - e_state['backlog'] + sum(e_state['incoming_shipments']) + order_in_transit_to_supplier)
146
- inv_pos_components = f"(Inv={e_state['inventory']} - Backlog={e_state['backlog']} + InTransitShip={sum(e_state['incoming_shipments'])} + OrderToSupplier={order_in_transit_to_supplier})"
 
 
 
147
  optimal_order = max(0, int(target_inventory_level - inventory_position))
148
  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."
 
149
  elif llm_personality == 'perfect_rational' and info_sharing == 'local':
150
  safety_stock = 4; anchor_demand = e_state['incoming_order']
151
  inventory_correction = safety_stock - (e_state['inventory'] - e_state['backlog'])
 
152
  if e_state['name'] == 'Factory':
153
- supply_line = sum(st.session_state.game_state['factory_production_pipeline'])
154
- supply_line_desc = "In Production"
155
  else:
156
- order_in_transit_to_supplier = st.session_state.game_state['last_week_orders'].get(e_state['name'], 0)
157
- supply_line = sum(e_state['incoming_shipments']) + order_in_transit_to_supplier
158
- supply_line_desc = "Supply Line (In Transit Shipments + Order To Supplier)"
159
  calculated_order = anchor_demand + inventory_correction - supply_line
160
  rational_local_order = max(0, int(calculated_order))
161
  return f"**You are a perfectly rational supply chain AI with ONLY LOCAL information.**\nYou must use a logical heuristic to make a stable decision. A proven method is \"Anchoring and Adjustment\".\n\n{base_info}\n\n**Rational Calculation (Anchoring & Adjustment):**\n1. **Anchor on Demand:** Your best guess for future demand is your last incoming order: **{anchor_demand} units**.\n2. **Adjust for Inventory:** You want to hold a safety stock of {safety_stock} units. Your current stock (before shipping) is {e_state['inventory'] - e_state['backlog']}. You need to order an extra **{inventory_correction} units** to correct this.\n3. **Account for {supply_line_desc}:** You already have **{supply_line} units** being processed. These should be subtracted from your new decision.\n\n**Final Calculation:**\n* Decision = (Anchor Demand) + (Inventory Adjustment) - ({supply_line_desc})\n* Decision = {anchor_demand} + {inventory_correction} - {supply_line} = **{rational_local_order} units**.\n**Your Task:** Confirm this locally rational {task_word}. Respond with a single integer."
 
 
162
  elif llm_personality == 'human_like' and info_sharing == 'full':
163
  full_info_str = f"\n**Full Supply Chain Information (State Before Shipping):**\n- End-Customer Demand this week: {get_customer_demand(week)} units.\n"
164
  for name, other_e_state in all_echelons_state_decision_point.items():
@@ -182,77 +220,167 @@ def get_llm_prompt(echelon_state_decision_point: dict, week: int, llm_personalit
182
  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.
183
  **React emotionally.** What is your knee-jerk {task_word}? Respond with a single integer.
184
  """
 
185
 
 
186
  def step_game(human_final_order: int, human_initial_order: int, ai_suggestion: int):
187
- # This function's logic remains correct (from v4.17).
188
  state = st.session_state.game_state
189
  week, echelons, human_role = state['week'], state['echelons'], state['human_role']
190
  llm_personality, info_sharing = state['llm_personality'], state['info_sharing']
191
  echelon_order = ["Retailer", "Wholesaler", "Distributor", "Factory"]
192
  llm_raw_responses = {}
 
 
193
  opening_inventories = {name: e['inventory'] for name, e in echelons.items()}
194
  opening_backlogs = {name: e['backlog'] for name, e in echelons.items()}
195
  arrived_this_week = {name: 0 for name in echelon_order}
 
 
 
 
196
  inventory_after_arrival = {}
197
- factory_state = echelons["Factory"]
198
- produced_units = 0
199
- if state['factory_production_pipeline']:
200
- produced_units = state['factory_production_pipeline'].popleft()
201
- arrived_this_week["Factory"] = produced_units
202
- inventory_after_arrival["Factory"] = factory_state['inventory'] + produced_units
203
  for name in ["Retailer", "Wholesaler", "Distributor"]:
204
  arrived_shipment = 0
205
  if echelons[name]['incoming_shipments']:
206
  arrived_shipment = echelons[name]['incoming_shipments'].popleft()
207
  arrived_this_week[name] = arrived_shipment
208
  inventory_after_arrival[name] = echelons[name]['inventory'] + arrived_shipment
 
 
209
  total_backlog_before_shipping = {}
210
  for name in echelon_order:
211
  incoming_order_for_this_week = 0
212
- if name == "Retailer": incoming_order_for_this_week = get_customer_demand(week)
 
213
  else:
 
214
  downstream_name = echelons[name]['downstream_name']
215
- if downstream_name: incoming_order_for_this_week = state['last_week_orders'].get(downstream_name, 0)
 
 
 
 
 
 
216
  echelons[name]['incoming_order'] = incoming_order_for_this_week
 
 
 
 
 
 
 
 
 
 
217
  total_backlog_before_shipping[name] = echelons[name]['backlog'] + incoming_order_for_this_week
 
 
218
  decision_point_states = {}
219
  for name in echelon_order:
220
  decision_point_states[name] = {
221
- 'name': name, 'inventory': inventory_after_arrival[name],
222
- 'backlog': total_backlog_before_shipping[name], 'incoming_order': echelons[name]['incoming_order'],
 
 
223
  'incoming_shipments': echelons[name]['incoming_shipments'].copy() if name != "Factory" else deque(),
224
  }
225
- current_week_orders = {}
 
226
  for name in echelon_order:
227
- e = echelons[name]; prompt_state = decision_point_states[name]
228
- if name == human_role: order_amount, raw_resp = human_final_order, "HUMAN_FINAL_INPUT"
 
 
 
229
  else:
230
  prompt = get_llm_prompt(prompt_state, week, llm_personality, info_sharing, decision_point_states)
231
  order_amount, raw_resp = get_llm_order_decision(prompt, name)
232
- llm_raw_responses[name] = raw_resp; e['order_placed'] = max(0, order_amount); current_week_orders[name] = e['order_placed']
233
- state['factory_production_pipeline'].append(echelons["Factory"]['order_placed'])
 
 
 
 
 
 
 
 
 
 
234
  units_shipped = {name: 0 for name in echelon_order}
235
  for name in echelon_order:
236
- e = echelons[name]; demand_to_meet = total_backlog_before_shipping[name]; available_inv = inventory_after_arrival[name]
237
- e['shipment_sent'] = min(available_inv, demand_to_meet); units_shipped[name] = e['shipment_sent']
238
- e['inventory'] = available_inv - e['shipment_sent']; e['backlog'] = demand_to_meet - e['shipment_sent']
239
- if units_shipped["Factory"] > 0: echelons['Distributor']['incoming_shipments'].append(units_shipped["Factory"])
240
- if units_shipped['Distributor'] > 0: echelons['Wholesaler']['incoming_shipments'].append(units_shipped['Distributor'])
241
- if units_shipped['Wholesaler'] > 0: echelons['Retailer']['incoming_shipments'].append(units_shipped['Wholesaler'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  log_entry = {'timestamp': datetime.utcnow().isoformat() + "Z", 'week': week, **state}
243
- del log_entry['echelons'], log_entry['factory_production_pipeline'], log_entry['logs'], log_entry['last_week_orders']
 
 
 
 
 
244
  for name in echelon_order:
245
- e = echelons[name]; e['weekly_cost'] = (e['inventory'] * HOLDING_COST) + (e['backlog'] * BACKLOG_COST); e['total_cost'] += e['weekly_cost']
246
- for key in ['inventory', 'backlog', 'incoming_order', 'order_placed', 'shipment_sent', 'weekly_cost', 'total_cost']: log_entry[f'{name}.{key}'] = e[key]
247
- log_entry[f'{name}.llm_raw_response'] = llm_raw_responses.get(name, "")
 
 
 
 
 
248
  log_entry[f'{name}.opening_inventory'] = opening_inventories[name]; log_entry[f'{name}.opening_backlog'] = opening_backlogs[name]
249
  log_entry[f'{name}.arrived_this_week'] = arrived_this_week[name]
250
- if name != 'Factory': log_entry[f'{name}.arriving_next_week'] = list(e['incoming_shipments'])[0] if e['incoming_shipments'] else 0
251
- else: log_entry[f'{name}.production_completing_next_week'] = list(state['factory_production_pipeline'])[0] if state['factory_production_pipeline'] else 0
 
 
 
 
252
  log_entry[f'{human_role}.initial_order'] = human_initial_order; log_entry[f'{human_role}.ai_suggestion'] = ai_suggestion
253
  state['logs'].append(log_entry)
254
- state['week'] += 1; state['decision_step'] = 'initial_order'; state['last_week_orders'] = current_week_orders
 
 
 
 
255
  if state['week'] > WEEKS: state['game_running'] = False
 
 
256
 
257
  def plot_results(df: pd.DataFrame, title: str, human_role: str):
258
  # This function remains correct.
@@ -284,24 +412,13 @@ def plot_results(df: pd.DataFrame, title: str, human_role: str):
284
  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')
285
  plt.tight_layout(rect=[0, 0, 1, 0.96]); return fig
286
 
287
-
288
- # =============== NEW: Leaderboard Functions ===============
289
-
290
- @st.cache_data(ttl=60) # 缓存1分钟
291
  def load_leaderboard_data():
292
- """从Hugging Face Hub下载并加载排行榜数据。"""
293
- if not hf_api or not HF_REPO_ID:
294
- return {} # 没有HF连接,返回空
295
  try:
296
- local_path = hf_hub_download(
297
- repo_id=HF_REPO_ID,
298
- repo_type="dataset",
299
- filename=LEADERBOARD_FILE,
300
- token=HF_TOKEN,
301
- cache_dir=LOCAL_LOG_DIR / "hf_cache" # 明确指定缓存目录
302
- )
303
- with open(local_path, 'r', encoding='utf-8') as f:
304
- return json.load(f)
305
  except EntryNotFoundError:
306
  st.sidebar.info("Leaderboard file not found. A new one will be created.")
307
  return {}
@@ -310,43 +427,27 @@ def load_leaderboard_data():
310
  return {}
311
 
312
  def save_leaderboard_data(data):
313
- """将更新后的排行榜数据保存到Hugging Face Hub。"""
314
- if not hf_api or not HF_REPO_ID or not HF_TOKEN:
315
- st.sidebar.warning("Cannot save leaderboard. HF credentials missing.")
316
- return
317
  try:
318
  local_path = LOCAL_LOG_DIR / LEADERBOARD_FILE
319
- with open(local_path, 'w', encoding='utf-8') as f:
320
- json.dump(data, f, indent=2, ensure_ascii=False)
321
-
322
- hf_api.upload_file(
323
- path_or_fileobj=str(local_path),
324
- path_in_repo=LEADERBOARD_FILE,
325
- repo_id=HF_REPO_ID,
326
- repo_type="dataset",
327
- token=HF_TOKEN
328
- )
329
  st.sidebar.success("Leaderboard updated!")
330
- st.cache_data.clear() # 清除缓存
331
  except Exception as e:
332
  st.sidebar.error(f"Failed to upload leaderboard: {e}")
333
 
334
  def display_rankings(df, top_n=10):
335
- """在UI上显示三个排名的辅助函数。"""
336
  if df.empty:
337
  st.info("No completed games for this category yet. Be the first!")
338
  return
339
-
340
- # 数据清洗:确保成本和标准差是数字
341
  df['total_cost'] = pd.to_numeric(df['total_cost'], errors='coerce')
342
  df['order_std_dev'] = pd.to_numeric(df['order_std_dev'], errors='coerce')
343
  df = df.dropna(subset=['total_cost', 'order_std_dev'])
344
  if df.empty:
345
  st.info("No valid completed games for this category yet.")
346
  return
347
-
348
  c1, c2, c3 = st.columns(3)
349
-
350
  with c1:
351
  st.subheader("🏆 Supply Chain Champions")
352
  st.caption(f"Top {top_n} - Lowest Total Cost")
@@ -354,7 +455,6 @@ def display_rankings(df, top_n=10):
354
  champs_df['total_cost'] = champs_df['total_cost'].map('${:,.2f}'.format)
355
  champs_df.rename(columns={'id': 'Participant', 'total_cost': 'Total Cost'}, inplace=True)
356
  st.dataframe(champs_df[['Participant', 'Total Cost']], use_container_width=True, hide_index=True)
357
-
358
  with c2:
359
  st.subheader("👑 Bullwhip Kings")
360
  st.caption(f"Top {top_n} - Highest Total Cost")
@@ -362,7 +462,6 @@ def display_rankings(df, top_n=10):
362
  kings_df['total_cost'] = kings_df['total_cost'].map('${:,.2f}'.format)
363
  kings_df.rename(columns={'id': 'Participant', 'total_cost': 'Total Cost'}, inplace=True)
364
  st.dataframe(kings_df[['Participant', 'Total Cost']], use_container_width=True, hide_index=True)
365
-
366
  with c3:
367
  st.subheader("🧘 Mr. Smooth")
368
  st.caption(f"Top {top_n} - Lowest Order Variation (Std. Dev.)")
@@ -372,61 +471,44 @@ def display_rankings(df, top_n=10):
372
  st.dataframe(smooth_df[['Participant', 'Order Std. Dev.']], use_container_width=True, hide_index=True)
373
 
374
  def show_leaderboard_ui():
375
- """加载数据并显示完整的排行榜UI。"""
376
  st.markdown("---")
377
  st.header("📊 The Bullwhip Leaderboard")
378
  st.caption("Leaderboard updates after you finish a game. Cached for 60 seconds.")
379
-
380
  leaderboard_data = load_leaderboard_data()
381
  if not leaderboard_data:
382
  st.info("No leaderboard data yet. Be the first to finish a game!")
383
  else:
384
  try:
385
  df = pd.DataFrame(leaderboard_data.values())
386
- if 'id' not in df.columns: # 确保id列存在
387
- df['id'] = list(leaderboard_data.keys())
388
-
389
  if 'total_cost' not in df.columns or 'order_std_dev' not in df.columns or 'setting' not in df.columns:
390
  st.error("Leaderboard data is corrupted or incomplete.")
391
  return
392
-
393
  groups = sorted(df.setting.unique())
394
  tabs = st.tabs(["**Overall**"] + groups)
395
-
396
- with tabs[0]: # Overall
397
- display_rankings(df)
398
-
399
  for i, group_name in enumerate(groups):
400
  with tabs[i+1]:
401
  df_group = df[df.setting == group_name].copy()
402
  display_rankings(df_group)
403
  except Exception as e:
404
  st.error(f"Error displaying leaderboard: {e}")
405
- st.dataframe(leaderboard_data) # 原始数据以供调试
406
- # ==============================================================================
407
 
408
-
409
- # =============== MODIFIED Function (Updates Leaderboard) ===============
410
  def save_logs_and_upload(state: dict):
411
  if not state.get('logs'):
412
  st.warning("No log data to save.")
413
  return
414
-
415
- participant_id = state['participant_id'] # 这是您输入的自定义ID
416
  logs_df = None
417
-
418
- # 1. Save individual log CSV
419
  try:
420
  logs_df = pd.json_normalize(state['logs'])
421
- # 确保文件名安全
422
  safe_participant_id = re.sub(r'[^a-zA-Z0-9_-]', '_', participant_id)
423
  fname = LOCAL_LOG_DIR / f"log_{safe_participant_id}_{int(time.time())}.csv"
424
-
425
  for col in logs_df.select_dtypes(include=['object']).columns: logs_df[col] = logs_df[col].astype(str)
426
  logs_df.to_csv(fname, index=False)
427
  st.success(f"Log successfully saved locally: `{fname}`")
428
  with open(fname, "rb") as f: st.download_button("📥 Download Log CSV", data=f, file_name=fname.name, mime="text/csv")
429
-
430
  if HF_TOKEN and HF_REPO_ID and hf_api:
431
  with st.spinner("Uploading log CSV to Hugging Face Hub..."):
432
  try:
@@ -435,40 +517,28 @@ def save_logs_and_upload(state: dict):
435
  except Exception as e_upload: st.error(f"Upload to Hugging Face failed: {e_upload}")
436
  except Exception as e_save:
437
  st.error(f"Error processing or saving log CSV: {e_save}")
438
- return # Don't proceed to leaderboard if CSV failed
439
-
440
- # 2. Update and upload leaderboard.json
441
- if logs_df is None: return # Ensure logs_df was created
442
-
443
  st.subheader("Updating Leaderboard...")
444
  try:
445
  human_role = state['human_role']
446
-
447
- # Calculate metrics
448
  total_cost = logs_df[f'{human_role}.total_cost'].iloc[-1]
449
  order_std_dev = logs_df[f'{human_role}.order_placed'].std()
450
  setting_name = f"{state['llm_personality']} / {state['info_sharing']}"
451
-
452
  new_entry = {
453
- 'id': participant_id, # 使用自定义ID
454
- 'setting': setting_name,
455
- 'total_cost': float(total_cost), # 确保是JSON兼容的float
456
- 'order_std_dev': float(order_std_dev) # 确保是JSON兼容的float
457
  }
458
-
459
- # Load, update, save
460
  leaderboard_data = load_leaderboard_data()
461
- # 使用ID作为键来允许覆盖/更新 (同名/同组名的人会更新成绩)
462
  leaderboard_data[participant_id] = new_entry
463
  save_leaderboard_data(leaderboard_data)
464
-
465
  except Exception as e_board:
466
  st.error(f"Error calculating or saving leaderboard score: {e_board}")
467
  # ==============================================================================
468
 
469
-
470
  # -----------------------------------------------------------------------------
471
- # 4. Streamlit UI (Adjusted for Custom ID and Leaderboard)
472
  # -----------------------------------------------------------------------------
473
  st.title("🍺 The Beer Game: A Human-AI Collaboration Challenge")
474
 
@@ -478,12 +548,12 @@ else:
478
  # --- Game Setup & Instructions ---
479
  if 'game_state' not in st.session_state or not st.session_state.game_state.get('game_running', False):
480
 
 
 
481
  st.markdown("---")
482
  st.header("⚙️ Game Configuration")
483
 
484
- # =============== NEW: Participant ID Input ===============
485
  participant_id = st.text_input("Enter Your Name or Team ID:", key="participant_id_input", placeholder="e.g., Team A")
486
- # =======================================================
487
 
488
  c1, c2 = st.columns(2)
489
  with c1:
@@ -491,46 +561,46 @@ else:
491
  with c2:
492
  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.")
493
 
494
- # =============== MODIFIED: Start Game Button ===============
495
  if st.button("🚀 Start Game", type="primary", disabled=(client is None)):
496
  if not participant_id:
497
  st.error("Please enter a Name or Team ID to start!")
498
- elif participant_id in load_leaderboard_data(): # 检查ID是否已存在
499
- st.warning(f"ID '{participant_id}' already exists! Your score will be overwritten. Click again to confirm.")
500
- # 这是一个简单的检查,更复杂的可能需要一个确认按钮
501
- # 为了课堂使用,我们先假设他们会自己协调
502
- # 或者让他们加个后缀,比如 "Team A - 2"
503
- # 为简单起见,我们允许覆盖
504
- init_game_state(llm_personality, info_sharing, participant_id)
505
- st.rerun()
506
  else:
507
- # Pass the participant_id to the init function
508
- init_game_state(llm_personality, info_sharing, participant_id)
509
- st.rerun()
510
- # ===========================================================
 
 
 
 
 
 
 
 
 
 
 
511
 
512
- # =============== NEW: Show Leaderboard on Start Page ===============
513
  show_leaderboard_ui()
514
- # =================================================================
515
 
516
  # --- Main Game Interface ---
517
  elif 'game_state' in st.session_state and st.session_state.game_state.get('game_running'):
518
  state = st.session_state.game_state
519
  week, human_role, echelons, info_sharing = state['week'], state['human_role'], state['echelons'], state['info_sharing']
520
- echelon_order = ["Retailer", "Wholesaler", "Distributor", "Factory"] # Define here for UI
521
 
522
 
523
  st.header(f"Week {week} / {WEEKS}")
524
- # 显示自定义ID
525
  st.subheader(f"Your Role: **{human_role}** ({state['participant_id']}) | AI Mode: **{state['llm_personality'].replace('_', ' ')}** | Information: **{state['info_sharing']}**")
526
  st.markdown("---")
527
- st.subheader("Supply Chain Status (Start of Week State)") # Clarified Timing
528
 
 
529
  if info_sharing == 'full':
530
  cols = st.columns(4)
531
- for i, name in enumerate(echelon_order): # Use the defined echelon_order
532
  with cols[i]:
533
- e = echelons[name] # Get the echelon state
534
  icon = "👤" if name == human_role else "🤖"
535
 
536
  if name == human_role:
@@ -540,17 +610,21 @@ else:
540
 
541
  st.metric("Inventory (Opening)", e['inventory'])
542
  st.metric("Backlog (Opening)", e['backlog'])
543
-
544
- # 移除成本显示
545
 
546
- # --- NEW: Added Arriving This Week ---
 
547
  current_incoming_order = 0
548
  if name == "Retailer":
549
  current_incoming_order = get_customer_demand(week)
550
  else:
 
551
  downstream_name = e['downstream_name']
552
- if downstream_name:
553
- current_incoming_order = state['last_week_orders'].get(downstream_name, 0)
 
 
 
 
554
  st.write(f"Incoming Order (This Week): **{current_incoming_order}**")
555
 
556
  if name == "Factory":
@@ -561,13 +635,30 @@ else:
561
  else:
562
  arriving_this_week = list(e['incoming_shipments'])[0] if e['incoming_shipments'] else 0
563
  st.write(f"Arriving This Week: **{arriving_this_week}**")
564
- arriving_next = list(e['incoming_shipments'])[1] if len(e['incoming_shipments']) > 1 else (list(e['incoming_shipments'])[0] if name in ('Wholesaler', 'Retailer') and len(e['incoming_shipments']) > 0 else 0) # 修正 2-week delay 的显示
565
- st.write(f"Arriving Next Week: **{arriving_next}**")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
 
567
  else: # Local Info Mode
568
  st.info("In Local Information mode, you can only see your own status dashboard.")
569
- e = echelons[human_role]
570
- st.markdown(f"### 👤 **<span style='color:#FF4B4B;'>{human_role} (Your Dashboard - Start of Week State)</span>**", unsafe_allow_html=True) # Highlight self
571
 
572
  col1, col2, col3 = st.columns(3)
573
  with col1:
@@ -575,10 +666,10 @@ else:
575
  st.metric("Backlog (Opening)", e['backlog'])
576
 
577
  with col2:
 
578
  current_incoming_order = 0
579
- downstream_name = e['downstream_name'] # Wholesaler
580
- if downstream_name:
581
- current_incoming_order = state['last_week_orders'].get(downstream_name, 0)
582
  st.write(f"**Incoming Order (This Week):**\n# {current_incoming_order}")
583
 
584
  with col3:
@@ -587,16 +678,18 @@ else:
587
  st.write(f"**Shipment Arriving (This Week):**\n# {arriving_this_week}")
588
 
589
  # Arriving NEXT week (Peek at the next item in the 1-week delay queue)
590
- arriving_next = list(e['incoming_shipments'])[1] if len(e['incoming_shipments']) > 1 else 0
591
  st.write(f"**Shipment Arriving (Next Week):**\n# {arriving_next}")
592
 
 
 
593
  st.markdown("---")
594
  st.header("Your Decision (Step 4)")
595
 
596
  # Prepare the state snapshot for the AI prompt (State AFTER arrivals/orders, BEFORE shipping)
597
  all_decision_point_states = {}
598
  for name in echelon_order:
599
- e_curr = echelons[name] # This is END OF LAST WEEK state
600
  arrived = 0
601
  if name == "Factory":
602
  if state['factory_production_pipeline']: arrived = list(state['factory_production_pipeline'])[0]
@@ -607,7 +700,11 @@ else:
607
  if name == "Retailer": inc_order_this_week = get_customer_demand(week)
608
  else:
609
  ds_name = e_curr['downstream_name']
610
- if ds_name: inc_order_this_week = state['last_week_orders'].get(ds_name, 0)
 
 
 
 
611
 
612
  inv_after_arrival = e_curr['inventory'] + arrived
613
  backlog_after_new_order = e_curr['backlog'] + inc_order_this_week
@@ -623,21 +720,26 @@ else:
623
  if state['decision_step'] == 'initial_order':
624
  with st.form(key="initial_order_form"):
625
  st.markdown("#### **Step 4a:** Based on the dashboard, submit your **initial** order to the Factory.")
626
- initial_order = st.number_input("Your Initial Order Quantity:", min_value=0, step=1) # No 'value'
627
  if st.form_submit_button("Submit Initial Order & See AI Suggestion", type="primary"):
628
  state['human_initial_order'] = int(initial_order) if initial_order is not None else 0
629
  state['decision_step'] = 'final_order'
 
 
 
 
 
630
  st.rerun()
631
 
632
  elif state['decision_step'] == 'final_order':
633
  st.success(f"Your initial order was: **{state['human_initial_order']}** units.")
634
- prompt_sugg = get_llm_prompt(human_echelon_state_for_prompt, week, state['llm_personality'], state['info_sharing'], all_decision_point_states)
635
- ai_suggestion, _ = get_llm_order_decision(prompt_sugg, f"{human_role} (Suggestion)")
636
 
637
  with st.form(key="final_order_form"):
638
  st.markdown(f"#### **Step 4b:** The AI suggests ordering **{ai_suggestion}** units.")
639
  st.markdown("Considering the AI's advice, submit your **final** order to end the week. (This order will arrive in 3 weeks).")
640
- st.number_input("Your Final Order Quantity:", min_value=0, step=1, key='final_order_input') # No 'value'
641
 
642
  if st.form_submit_button("Submit Final Order & Advance to Next Week"):
643
  final_order_value = st.session_state.get('final_order_input', 0)
@@ -687,6 +789,7 @@ else:
687
  st.sidebar.markdown(f"**Game ID**: `{state['participant_id']}`\n\n**Current Week**: {week}")
688
  if st.sidebar.button("🔄 Reset Game"):
689
  if 'final_order_input' in st.session_state: del st.session_state.final_order_input
 
690
  del st.session_state.game_state
691
  st.rerun()
692
 
@@ -706,9 +809,7 @@ else:
706
  except Exception as e:
707
  st.error(f"Error generating final report: {e}")
708
 
709
- # =============== NEW: Show Leaderboard on End Page ===============
710
  show_leaderboard_ui()
711
- # ===============================================================
712
 
713
  if st.button("✨ Start a New Game"):
714
  del st.session_state.game_state
 
1
  # app.py
2
+ # @title Beer Game Final Version (v4.24 - Corrected 3-Week Lead Time Logic & UI)
3
 
4
  # -----------------------------------------------------------------------------
5
  # 1. Import Libraries
 
15
  import uuid
16
  from pathlib import Path
17
  from datetime import datetime
18
+ from huggingface_hub import HfApi, hf_hub_download
19
+ from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError
20
+ import json
21
+ import numpy as np
22
 
23
  # -----------------------------------------------------------------------------
24
  # 0. Page Configuration (Must be the first Streamlit command)
 
32
  WEEKS = 24
33
  INITIAL_INVENTORY = 12
34
  INITIAL_BACKLOG = 0
35
+ ORDER_PASSING_DELAY = 1 # 订单传递延迟
36
+ SHIPPING_DELAY = 2 # 通用运输延迟 (R->W, W->D)
37
+ FACTORY_LEAD_TIME = 1 # 工厂生产延迟
38
+ FACTORY_SHIPPING_DELAY = 1 # 工厂到分销商的运输延迟
39
  HOLDING_COST = 0.5
40
  BACKLOG_COST = 1.0
41
 
 
44
  LOCAL_LOG_DIR = Path("logs")
45
  LOCAL_LOG_DIR.mkdir(exist_ok=True)
46
  IMAGE_PATH = "beer_game_diagram.png"
47
+ LEADERBOARD_FILE = "leaderboard.json"
48
 
49
  # --- API & Secrets Configuration ---
50
  try:
 
65
  def get_customer_demand(week: int) -> int:
66
  return 4 if week <= 4 else 8
67
 
68
+ # =============== MODIFIED Initialization (Corrected Queues) ===============
69
  def init_game_state(llm_personality: str, info_sharing: str, participant_id: str):
70
  roles = ["Retailer", "Wholesaler", "Distributor", "Factory"]
71
+ human_role = "Distributor"
72
 
73
  st.session_state.game_state = {
74
+ 'game_running': True, 'participant_id': participant_id, 'week': 1,
 
 
75
  'human_role': human_role, 'llm_personality': llm_personality,
76
  'info_sharing': info_sharing, 'logs': [], 'echelons': {},
77
+
78
+ # 管道现在必须模拟总延迟
79
+ # Factory: 1 (订单) + 1 (生产) = 2 周延迟
80
+ # 我们需要一个队列来处理订单 (1周),一个队列处理生产 (1周)
81
+ 'factory_order_pipeline': deque([0] * ORDER_PASSING_DELAY, maxlen=ORDER_PASSING_DELAY),
82
  'factory_production_pipeline': deque([0] * FACTORY_LEAD_TIME, maxlen=FACTORY_LEAD_TIME),
83
+
84
+ # Distributor -> Factory: 1(订单) + 1(生产) + 1(运输) = 3 周
85
+ # Wholesaler -> Distributor: 1(订单) + 2(运输) = 3 周
86
+ # Retailer -> Wholesaler: 1(订单) + 2(运输) = 3 周
87
+ 'distributor_order_pipeline': deque([0] * ORDER_PASSING_DELAY, maxlen=ORDER_PASSING_DELAY),
88
+ 'wholesaler_order_pipeline': deque([0] * ORDER_PASSING_DELAY, maxlen=ORDER_PASSING_DELAY),
89
+ 'retailer_order_pipeline': deque([0] * ORDER_PASSING_DELAY, maxlen=ORDER_PASSING_DELAY),
90
+
91
  'decision_step': 'initial_order',
92
  'human_initial_order': None,
93
+ 'current_ai_suggestion': None,
94
  }
95
 
96
  for i, name in enumerate(roles):
97
  upstream = roles[i + 1] if i + 1 < len(roles) else None
98
  downstream = roles[i - 1] if i - 1 >= 0 else None
99
+
100
  if name == "Distributor": shipping_weeks = FACTORY_SHIPPING_DELAY
101
+ elif name == "Factory": shipping_weeks = 0 # 工厂不收货
102
+ else: shipping_weeks = SHIPPING_DELAY # R和W是2周
103
+
104
  st.session_state.game_state['echelons'][name] = {
105
  'name': name, 'inventory': INITIAL_INVENTORY, 'backlog': INITIAL_BACKLOG,
106
  'incoming_shipments': deque([0] * shipping_weeks, maxlen=shipping_weeks),
 
128
  match = re.search(r'\d+', raw_text)
129
  if match: return int(match.group(0)), raw_text
130
  st.warning(f"LLM for {echelon_name} did not return a valid number. Defaulting to 4. Raw Response: '{raw_text}'")
131
+ return 4, raw_text
132
  except Exception as e:
133
  st.error(f"API call failed for {echelon_name}: {e}. Defaulting to 4.")
134
  return 4, f"API_ERROR: {e}"
135
 
136
+ # =============== MODIFIED FUNCTION (Prompt uses new pipeline view) ===============
137
  def get_llm_prompt(echelon_state_decision_point: dict, week: int, llm_personality: str, info_sharing: str, all_echelons_state_decision_point: dict) -> str:
 
138
  e_state = echelon_state_decision_point
139
  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"
140
+
141
+ # 查找正确的订单队列
142
+ order_pipeline_to_show = deque()
143
+ if e_state['name'] == 'Distributor':
144
+ order_pipeline_to_show = st.session_state.game_state['distributor_order_pipeline']
145
+ elif e_state['name'] == 'Wholesaler':
146
+ order_pipeline_to_show = st.session_state.game_state['wholesaler_order_pipeline']
147
+ elif e_state['name'] == 'Retailer':
148
+ order_pipeline_to_show = st.session_state.game_state['retailer_order_pipeline']
149
+
150
  if e_state['name'] == 'Factory':
151
  task_word = "production quantity"
152
+ base_info += f"- Your Production Pipeline (In Production): {list(st.session_state.game_state['factory_production_pipeline'])}\n"
153
+ base_info += f"- Orders waiting for production (Just Arrived): {list(st.session_state.game_state['factory_order_pipeline'])}"
154
  else:
155
  task_word = "order quantity"
156
+ base_info += f"- Shipments In Transit To You (On the way): {list(e_state['incoming_shipments'])}\n"
157
+ base_info += f"- Orders You Placed (In transit to supplier): {list(order_pipeline_to_show)}"
158
+
159
+ # --- Perfect Rational ---
160
  if llm_personality == 'perfect_rational' and info_sharing == 'full':
161
  stable_demand = 8
162
+ if e_state['name'] == 'Factory': total_lead_time = FACTORY_LEAD_TIME + ORDER_PASSING_DELAY
163
  elif e_state['name'] == 'Distributor': total_lead_time = ORDER_PASSING_DELAY + FACTORY_LEAD_TIME + FACTORY_SHIPPING_DELAY
164
  else: total_lead_time = ORDER_PASSING_DELAY + SHIPPING_DELAY
165
  safety_stock = 4
166
  target_inventory_level = (stable_demand * total_lead_time) + safety_stock
167
+
168
  if e_state['name'] == 'Factory':
169
+ # IP = Inv - Backlog + In Production + Orders Waiting
170
+ inventory_position = (e_state['inventory'] - e_state['backlog']
171
+ + sum(st.session_state.game_state['factory_production_pipeline'])
172
+ + sum(st.session_state.game_state['factory_order_pipeline']))
173
+ inv_pos_components = f"(Inv={e_state['inventory']} - Backlog={e_state['backlog']} + InProd={sum(st.session_state.game_state['factory_production_pipeline'])} + Waiting={sum(st.session_state.game_state['factory_order_pipeline'])})"
174
  else:
175
+ # IP = Inv - Backlog + In Transit Shipments + Orders In Transit to Supplier
176
+ inventory_position = (e_state['inventory'] - e_state['backlog']
177
+ + sum(e_state['incoming_shipments'])
178
+ + sum(order_pipeline_to_show))
179
+ inv_pos_components = f"(Inv={e_state['inventory']} - Backlog={e_state['backlog']} + InTransitShip={sum(e_state['incoming_shipments'])} + InTransitOrder={sum(order_pipeline_to_show)})"
180
+
181
  optimal_order = max(0, int(target_inventory_level - inventory_position))
182
  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."
183
+
184
  elif llm_personality == 'perfect_rational' and info_sharing == 'local':
185
  safety_stock = 4; anchor_demand = e_state['incoming_order']
186
  inventory_correction = safety_stock - (e_state['inventory'] - e_state['backlog'])
187
+
188
  if e_state['name'] == 'Factory':
189
+ supply_line = sum(st.session_state.game_state['factory_production_pipeline']) + sum(st.session_state.game_state['factory_order_pipeline'])
190
+ supply_line_desc = "In Production / Waiting"
191
  else:
192
+ supply_line = sum(e_state['incoming_shipments']) + sum(order_pipeline_to_show)
193
+ supply_line_desc = "Supply Line (In Transit Shipments + Orders)"
194
+
195
  calculated_order = anchor_demand + inventory_correction - supply_line
196
  rational_local_order = max(0, int(calculated_order))
197
  return f"**You are a perfectly rational supply chain AI with ONLY LOCAL information.**\nYou must use a logical heuristic to make a stable decision. A proven method is \"Anchoring and Adjustment\".\n\n{base_info}\n\n**Rational Calculation (Anchoring & Adjustment):**\n1. **Anchor on Demand:** Your best guess for future demand is your last incoming order: **{anchor_demand} units**.\n2. **Adjust for Inventory:** You want to hold a safety stock of {safety_stock} units. Your current stock (before shipping) is {e_state['inventory'] - e_state['backlog']}. You need to order an extra **{inventory_correction} units** to correct this.\n3. **Account for {supply_line_desc}:** You already have **{supply_line} units** being processed. These should be subtracted from your new decision.\n\n**Final Calculation:**\n* Decision = (Anchor Demand) + (Inventory Adjustment) - ({supply_line_desc})\n* Decision = {anchor_demand} + {inventory_correction} - {supply_line} = **{rational_local_order} units**.\n**Your Task:** Confirm this locally rational {task_word}. Respond with a single integer."
198
+
199
+ # --- Human-like ---
200
  elif llm_personality == 'human_like' and info_sharing == 'full':
201
  full_info_str = f"\n**Full Supply Chain Information (State Before Shipping):**\n- End-Customer Demand this week: {get_customer_demand(week)} units.\n"
202
  for name, other_e_state in all_echelons_state_decision_point.items():
 
220
  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.
221
  **React emotionally.** What is your knee-jerk {task_word}? Respond with a single integer.
222
  """
223
+ # ==============================================================================
224
 
225
+ # =============== CORRECTED step_game FUNCTION (Fixed Lead Time Logic v4.21) ===============
226
  def step_game(human_final_order: int, human_initial_order: int, ai_suggestion: int):
 
227
  state = st.session_state.game_state
228
  week, echelons, human_role = state['week'], state['echelons'], state['human_role']
229
  llm_personality, info_sharing = state['llm_personality'], state['info_sharing']
230
  echelon_order = ["Retailer", "Wholesaler", "Distributor", "Factory"]
231
  llm_raw_responses = {}
232
+
233
+ # Store state at the very beginning of the week (End of last week)
234
  opening_inventories = {name: e['inventory'] for name, e in echelons.items()}
235
  opening_backlogs = {name: e['backlog'] for name, e in echelons.items()}
236
  arrived_this_week = {name: 0 for name in echelon_order}
237
+
238
+ # --- Game Simulation Steps ---
239
+
240
+ # Step 1: Shipments Arrive (from incoming_shipments queue)
241
  inventory_after_arrival = {}
 
 
 
 
 
 
242
  for name in ["Retailer", "Wholesaler", "Distributor"]:
243
  arrived_shipment = 0
244
  if echelons[name]['incoming_shipments']:
245
  arrived_shipment = echelons[name]['incoming_shipments'].popleft()
246
  arrived_this_week[name] = arrived_shipment
247
  inventory_after_arrival[name] = echelons[name]['inventory'] + arrived_shipment
248
+
249
+ # Step 2: Orders Arrive (from order_pipeline queue)
250
  total_backlog_before_shipping = {}
251
  for name in echelon_order:
252
  incoming_order_for_this_week = 0
253
+ if name == "Retailer":
254
+ incoming_order_for_this_week = get_customer_demand(week)
255
  else:
256
+ # Check the correct order pipeline based on the downstream partner
257
  downstream_name = echelons[name]['downstream_name']
258
+ if downstream_name == 'Distributor':
259
+ if state['distributor_order_pipeline']: incoming_order_for_this_week = state['distributor_order_pipeline'].popleft()
260
+ elif downstream_name == 'Wholesaler':
261
+ if state['wholesaler_order_pipeline']: incoming_order_for_this_week = state['wholesaler_order_pipeline'].popleft()
262
+ elif downstream_name == 'Retailer':
263
+ if state['retailer_order_pipeline']: incoming_order_for_this_week = state['retailer_order_pipeline'].popleft()
264
+
265
  echelons[name]['incoming_order'] = incoming_order_for_this_week
266
+ # Factory's 'incoming_order' is now set (from distributor_order_pipeline)
267
+
268
+ # Calculate intermediate state for Factory (production completion)
269
+ if name == "Factory":
270
+ produced_units = 0
271
+ if state['factory_production_pipeline']:
272
+ produced_units = state['factory_production_pipeline'].popleft()
273
+ arrived_this_week["Factory"] = produced_units
274
+ inventory_after_arrival["Factory"] = factory_state['inventory'] + produced_units
275
+
276
  total_backlog_before_shipping[name] = echelons[name]['backlog'] + incoming_order_for_this_week
277
+
278
+ # --- Create State Snapshot for AI/Human Decision Point ---
279
  decision_point_states = {}
280
  for name in echelon_order:
281
  decision_point_states[name] = {
282
+ 'name': name,
283
+ 'inventory': inventory_after_arrival[name],
284
+ 'backlog': total_backlog_before_shipping[name],
285
+ 'incoming_order': echelons[name]['incoming_order'],
286
  'incoming_shipments': echelons[name]['incoming_shipments'].copy() if name != "Factory" else deque(),
287
  }
288
+
289
+ # --- Step 4: Agent Decisions (Place Orders / Schedule Production) ---
290
  for name in echelon_order:
291
+ e = echelons[name]
292
+ prompt_state = decision_point_states[name]
293
+
294
+ if name == human_role:
295
+ order_amount, raw_resp = human_final_order, "HUMAN_FINAL_INPUT"
296
  else:
297
  prompt = get_llm_prompt(prompt_state, week, llm_personality, info_sharing, decision_point_states)
298
  order_amount, raw_resp = get_llm_order_decision(prompt, name)
299
+
300
+ llm_raw_responses[name] = raw_resp
301
+ e['order_placed'] = max(0, order_amount)
302
+
303
+ # Put the order into the correct pipeline to simulate ORDER_PASSING_DELAY
304
+ if name == 'Distributor': state['distributor_order_pipeline'].append(e['order_placed'])
305
+ elif name == 'Wholesaler': state['wholesaler_order_pipeline'].append(e['order_placed'])
306
+ elif name == 'Retailer': state['retailer_order_pipeline'].append(e['order_placed'])
307
+ # Factory's 'order_placed' is its production decision
308
+
309
+
310
+ # --- Step 3 (Logic): Fulfill orders (Ship Beer) ---
311
  units_shipped = {name: 0 for name in echelon_order}
312
  for name in echelon_order:
313
+ e = echelons[name]
314
+ demand_to_meet = total_backlog_before_shipping[name]
315
+ available_inv = inventory_after_arrival[name]
316
+
317
+ e['shipment_sent'] = min(available_inv, demand_to_meet)
318
+ units_shipped[name] = e['shipment_sent']
319
+
320
+ # Update the main state dict's inventory and backlog to reflect END OF WEEK state
321
+ e['inventory'] = available_inv - e['shipment_sent']
322
+ e['backlog'] = demand_to_meet - e['shipment_sent']
323
+
324
+ # --- Step 5: Advance Pipelines (New Logic) ---
325
+
326
+ # Factory: takes its incoming order (from distributor_order_pipeline)
327
+ # and schedules it for production (adds to factory_production_pipeline)
328
+ # This simulates FACTORY_LEAD_TIME
329
+ # *** BUG FIX: Factory's order_placed IS its production decision ***
330
+ # *** Factory's incoming_order is what drives its decision ***
331
+
332
+ # Factory's decision ('order_placed') from Step 4 enters the production pipeline
333
+ state['factory_production_pipeline'].append(echelons["Factory"]['order_placed'])
334
+
335
+ # What the Factory *shipped* in Step 3 (units_shipped["Factory"])
336
+ # now enters the Distributor's shipping queue
337
+ if units_shipped["Factory"] > 0:
338
+ echelons['Distributor']['incoming_shipments'].append(units_shipped["Factory"])
339
+
340
+ # What the Distributor *shipped* in Step 3...
341
+ if units_shipped['Distributor'] > 0:
342
+ echelons['Wholesaler']['incoming_shipments'].append(units_shipped['Distributor'])
343
+
344
+ # What the Wholesaler *shipped* in Step 3...
345
+ if units_shipped['Wholesaler'] > 0:
346
+ echelons['Retailer']['incoming_shipments'].append(units_shipped['Wholesaler'])
347
+
348
+
349
+ # --- Calculate Costs & Log (End of Week) ---
350
  log_entry = {'timestamp': datetime.utcnow().isoformat() + "Z", 'week': week, **state}
351
+ # Clean up fields
352
+ del log_entry['echelons'], log_entry['factory_production_pipeline'], log_entry['logs']
353
+ for key in ['distributor_order_pipeline', 'wholesaler_order_pipeline', 'retailer_order_pipeline', 'factory_order_pipeline']:
354
+ if key in log_entry: del log_entry[key]
355
+
356
+
357
  for name in echelon_order:
358
+ e = echelons[name]
359
+ e['weekly_cost'] = (e['inventory'] * HOLDING_COST) + (e['backlog'] * BACKLOG_COST)
360
+ e['total_cost'] += e['weekly_cost']
361
+
362
+ log_entry[f'{name}.inventory'] = e['inventory']; log_entry[f'{name}.backlog'] = e['backlog']
363
+ log_entry[f'{name}.incoming_order'] = e['incoming_order']; log_entry[f'{name}.order_placed'] = e['order_placed']
364
+ log_entry[f'{name}.shipment_sent'] = e['shipment_sent']; log_entry[f'{name}.weekly_cost'] = e['weekly_cost']
365
+ log_entry[f'{name}.total_cost'] = e['total_cost']; log_entry[f'{name}.llm_raw_response'] = llm_raw_responses.get(name, "")
366
  log_entry[f'{name}.opening_inventory'] = opening_inventories[name]; log_entry[f'{name}.opening_backlog'] = opening_backlogs[name]
367
  log_entry[f'{name}.arrived_this_week'] = arrived_this_week[name]
368
+
369
+ if name != 'Factory':
370
+ log_entry[f'{name}.arriving_next_week'] = list(e['incoming_shipments'])[0] if e['incoming_shipments'] else 0
371
+ else:
372
+ log_entry[f'{name}.production_completing_next_week'] = list(state['factory_production_pipeline'])[0] if state['factory_production_pipeline'] else 0
373
+
374
  log_entry[f'{human_role}.initial_order'] = human_initial_order; log_entry[f'{human_role}.ai_suggestion'] = ai_suggestion
375
  state['logs'].append(log_entry)
376
+
377
+ # --- Advance Week ---
378
+ state['week'] += 1; state['decision_step'] = 'initial_order'
379
+ # 'last_week_orders' is no longer needed with this pipeline logic
380
+ # We rely on the order pipelines
381
  if state['week'] > WEEKS: state['game_running'] = False
382
+ # ==============================================================================
383
+
384
 
385
  def plot_results(df: pd.DataFrame, title: str, human_role: str):
386
  # This function remains correct.
 
412
  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')
413
  plt.tight_layout(rect=[0, 0, 1, 0.96]); return fig
414
 
415
+ # =============== Leaderboard Functions (Unchanged) ===============
416
+ @st.cache_data(ttl=60)
 
 
417
  def load_leaderboard_data():
418
+ if not hf_api or not HF_REPO_ID: return {}
 
 
419
  try:
420
+ 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")
421
+ with open(local_path, 'r', encoding='utf-8') as f: return json.load(f)
 
 
 
 
 
 
 
422
  except EntryNotFoundError:
423
  st.sidebar.info("Leaderboard file not found. A new one will be created.")
424
  return {}
 
427
  return {}
428
 
429
  def save_leaderboard_data(data):
430
+ if not hf_api or not HF_REPO_ID or not HF_TOKEN: return
 
 
 
431
  try:
432
  local_path = LOCAL_LOG_DIR / LEADERBOARD_FILE
433
+ with open(local_path, 'w', encoding='utf-8') as f: json.dump(data, f, indent=2, ensure_ascii=False)
434
+ 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")
 
 
 
 
 
 
 
 
435
  st.sidebar.success("Leaderboard updated!")
436
+ st.cache_data.clear()
437
  except Exception as e:
438
  st.sidebar.error(f"Failed to upload leaderboard: {e}")
439
 
440
  def display_rankings(df, top_n=10):
 
441
  if df.empty:
442
  st.info("No completed games for this category yet. Be the first!")
443
  return
 
 
444
  df['total_cost'] = pd.to_numeric(df['total_cost'], errors='coerce')
445
  df['order_std_dev'] = pd.to_numeric(df['order_std_dev'], errors='coerce')
446
  df = df.dropna(subset=['total_cost', 'order_std_dev'])
447
  if df.empty:
448
  st.info("No valid completed games for this category yet.")
449
  return
 
450
  c1, c2, c3 = st.columns(3)
 
451
  with c1:
452
  st.subheader("🏆 Supply Chain Champions")
453
  st.caption(f"Top {top_n} - Lowest Total Cost")
 
455
  champs_df['total_cost'] = champs_df['total_cost'].map('${:,.2f}'.format)
456
  champs_df.rename(columns={'id': 'Participant', 'total_cost': 'Total Cost'}, inplace=True)
457
  st.dataframe(champs_df[['Participant', 'Total Cost']], use_container_width=True, hide_index=True)
 
458
  with c2:
459
  st.subheader("👑 Bullwhip Kings")
460
  st.caption(f"Top {top_n} - Highest Total Cost")
 
462
  kings_df['total_cost'] = kings_df['total_cost'].map('${:,.2f}'.format)
463
  kings_df.rename(columns={'id': 'Participant', 'total_cost': 'Total Cost'}, inplace=True)
464
  st.dataframe(kings_df[['Participant', 'Total Cost']], use_container_width=True, hide_index=True)
 
465
  with c3:
466
  st.subheader("🧘 Mr. Smooth")
467
  st.caption(f"Top {top_n} - Lowest Order Variation (Std. Dev.)")
 
471
  st.dataframe(smooth_df[['Participant', 'Order Std. Dev.']], use_container_width=True, hide_index=True)
472
 
473
  def show_leaderboard_ui():
 
474
  st.markdown("---")
475
  st.header("📊 The Bullwhip Leaderboard")
476
  st.caption("Leaderboard updates after you finish a game. Cached for 60 seconds.")
 
477
  leaderboard_data = load_leaderboard_data()
478
  if not leaderboard_data:
479
  st.info("No leaderboard data yet. Be the first to finish a game!")
480
  else:
481
  try:
482
  df = pd.DataFrame(leaderboard_data.values())
483
+ if 'id' not in df.columns and not df.empty: df['id'] = list(leaderboard_data.keys())
 
 
484
  if 'total_cost' not in df.columns or 'order_std_dev' not in df.columns or 'setting' not in df.columns:
485
  st.error("Leaderboard data is corrupted or incomplete.")
486
  return
 
487
  groups = sorted(df.setting.unique())
488
  tabs = st.tabs(["**Overall**"] + groups)
489
+ with tabs[0]: display_rankings(df)
 
 
 
490
  for i, group_name in enumerate(groups):
491
  with tabs[i+1]:
492
  df_group = df[df.setting == group_name].copy()
493
  display_rankings(df_group)
494
  except Exception as e:
495
  st.error(f"Error displaying leaderboard: {e}")
496
+ st.dataframe(leaderboard_data)
 
497
 
 
 
498
  def save_logs_and_upload(state: dict):
499
  if not state.get('logs'):
500
  st.warning("No log data to save.")
501
  return
502
+ participant_id = state['participant_id']
 
503
  logs_df = None
 
 
504
  try:
505
  logs_df = pd.json_normalize(state['logs'])
 
506
  safe_participant_id = re.sub(r'[^a-zA-Z0-9_-]', '_', participant_id)
507
  fname = LOCAL_LOG_DIR / f"log_{safe_participant_id}_{int(time.time())}.csv"
 
508
  for col in logs_df.select_dtypes(include=['object']).columns: logs_df[col] = logs_df[col].astype(str)
509
  logs_df.to_csv(fname, index=False)
510
  st.success(f"Log successfully saved locally: `{fname}`")
511
  with open(fname, "rb") as f: st.download_button("📥 Download Log CSV", data=f, file_name=fname.name, mime="text/csv")
 
512
  if HF_TOKEN and HF_REPO_ID and hf_api:
513
  with st.spinner("Uploading log CSV to Hugging Face Hub..."):
514
  try:
 
517
  except Exception as e_upload: st.error(f"Upload to Hugging Face failed: {e_upload}")
518
  except Exception as e_save:
519
  st.error(f"Error processing or saving log CSV: {e_save}")
520
+ return
521
+ if logs_df is None: return
 
 
 
522
  st.subheader("Updating Leaderboard...")
523
  try:
524
  human_role = state['human_role']
 
 
525
  total_cost = logs_df[f'{human_role}.total_cost'].iloc[-1]
526
  order_std_dev = logs_df[f'{human_role}.order_placed'].std()
527
  setting_name = f"{state['llm_personality']} / {state['info_sharing']}"
 
528
  new_entry = {
529
+ 'id': participant_id, 'setting': setting_name,
530
+ 'total_cost': float(total_cost),
531
+ 'order_std_dev': float(order_std_dev) if pd.notna(order_std_dev) else 0.0
 
532
  }
 
 
533
  leaderboard_data = load_leaderboard_data()
 
534
  leaderboard_data[participant_id] = new_entry
535
  save_leaderboard_data(leaderboard_data)
 
536
  except Exception as e_board:
537
  st.error(f"Error calculating or saving leaderboard score: {e_board}")
538
  # ==============================================================================
539
 
 
540
  # -----------------------------------------------------------------------------
541
+ # 4. Streamlit UI (Adjusted for Custom ID, Leaderboard, and UI Fixes)
542
  # -----------------------------------------------------------------------------
543
  st.title("🍺 The Beer Game: A Human-AI Collaboration Challenge")
544
 
 
548
  # --- Game Setup & Instructions ---
549
  if 'game_state' not in st.session_state or not st.session_state.game_state.get('game_running', False):
550
 
551
+ # Introduction is removed as requested
552
+
553
  st.markdown("---")
554
  st.header("⚙️ Game Configuration")
555
 
 
556
  participant_id = st.text_input("Enter Your Name or Team ID:", key="participant_id_input", placeholder="e.g., Team A")
 
557
 
558
  c1, c2 = st.columns(2)
559
  with c1:
 
561
  with c2:
562
  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.")
563
 
 
564
  if st.button("🚀 Start Game", type="primary", disabled=(client is None)):
565
  if not participant_id:
566
  st.error("Please enter a Name or Team ID to start!")
 
 
 
 
 
 
 
 
567
  else:
568
+ existing_data = load_leaderboard_data()
569
+ if participant_id in existing_data:
570
+ # Check for a re-click confirmation
571
+ if st.session_state.get('last_id_warning') == participant_id:
572
+ st.session_state.pop('last_id_warning', None)
573
+ init_game_state(llm_personality, info_sharing, participant_id)
574
+ st.rerun()
575
+ else:
576
+ st.session_state['last_id_warning'] = participant_id
577
+ st.warning(f"ID '{participant_id}' already exists! Your score will be overwritten. Click 'Start Game' again to confirm.")
578
+ else:
579
+ if 'last_id_warning' in st.session_state:
580
+ del st.session_state['last_id_warning']
581
+ init_game_state(llm_personality, info_sharing, participant_id)
582
+ st.rerun()
583
 
 
584
  show_leaderboard_ui()
 
585
 
586
  # --- Main Game Interface ---
587
  elif 'game_state' in st.session_state and st.session_state.game_state.get('game_running'):
588
  state = st.session_state.game_state
589
  week, human_role, echelons, info_sharing = state['week'], state['human_role'], state['echelons'], state['info_sharing']
590
+ echelon_order = ["Retailer", "Wholesaler", "Distributor", "Factory"]
591
 
592
 
593
  st.header(f"Week {week} / {WEEKS}")
 
594
  st.subheader(f"Your Role: **{human_role}** ({state['participant_id']}) | AI Mode: **{state['llm_personality'].replace('_', ' ')}** | Information: **{state['info_sharing']}**")
595
  st.markdown("---")
596
+ st.subheader("Supply Chain Status (Start of Week State)")
597
 
598
+ # =============== MODIFIED UI LOGIC (v4.21) ===============
599
  if info_sharing == 'full':
600
  cols = st.columns(4)
601
+ for i, name in enumerate(echelon_order):
602
  with cols[i]:
603
+ e = echelons[name]
604
  icon = "👤" if name == human_role else "🤖"
605
 
606
  if name == human_role:
 
610
 
611
  st.metric("Inventory (Opening)", e['inventory'])
612
  st.metric("Backlog (Opening)", e['backlog'])
 
 
613
 
614
+ # --- Calculate and Display This Week's Events ---
615
+ # Incoming Order (arriving in Step 2)
616
  current_incoming_order = 0
617
  if name == "Retailer":
618
  current_incoming_order = get_customer_demand(week)
619
  else:
620
+ # PEEK at the order pipeline
621
  downstream_name = e['downstream_name']
622
+ if downstream_name == 'Distributor': pipeline_peek = state['distributor_order_pipeline']
623
+ elif downstream_name == 'Wholesaler': pipeline_peek = state['wholesaler_order_pipeline']
624
+ elif downstream_name == 'Retailer': pipeline_peek = state['retailer_order_pipeline']
625
+ else: pipeline_peek = deque()
626
+ current_incoming_order = list(pipeline_peek)[0] if pipeline_peek else 0
627
+
628
  st.write(f"Incoming Order (This Week): **{current_incoming_order}**")
629
 
630
  if name == "Factory":
 
635
  else:
636
  arriving_this_week = list(e['incoming_shipments'])[0] if e['incoming_shipments'] else 0
637
  st.write(f"Arriving This Week: **{arriving_this_week}**")
638
+
639
+ arriving_next = 0
640
+ if len(e['incoming_shipments']) > 1:
641
+ arriving_next = list(e['incoming_shipments'])[1]
642
+ # Handle 2-week delay display for R/W
643
+ elif name in ('Wholesaler', 'Retailer') and e['incoming_shipments'].maxlen == 2:
644
+ if len(e['incoming_shipments']) == 1: # Only one item in queue
645
+ arriving_next = list(e['incoming_shipments'])[0] # This must be the item for next week
646
+ arriving_this_week = 0 # This week's arrival must have been 0
647
+ # Re-calculate arriving_this_week for R/W based on maxlen
648
+ arriving_this_week = list(e['incoming_shipments'])[0] if len(e['incoming_shipments']) == e['incoming_shipments'].maxlen else 0
649
+ arriving_next = list(e['incoming_shipments'])[1] if len(e['incoming_shipments']) == e['incoming_shipments'].maxlen else (list(e['incoming_shipments'])[0] if len(e['incoming_shipments']) == 1 else 0)
650
+
651
+ # Overwrite for clarity
652
+ st.write(f"Arriving This Week: **{arriving_this_week}**")
653
+ st.write(f"Arriving Next Week: **{arriving_next}**")
654
+ else: # Distributor case (maxlen 1)
655
+ arriving_next = 0 # Peek at index 1 is correct, it doesn't exist for maxlen=1
656
+ st.write(f"Arriving Next Week: **{arriving_next}**")
657
 
658
  else: # Local Info Mode
659
  st.info("In Local Information mode, you can only see your own status dashboard.")
660
+ e = echelons[human_role] # Distributor
661
+ st.markdown(f"### 👤 **<span style='color:#FF4B4B;'>{human_role} (Your Dashboard - Start of Week State)</span>**", unsafe_allow_html=True)
662
 
663
  col1, col2, col3 = st.columns(3)
664
  with col1:
 
666
  st.metric("Backlog (Opening)", e['backlog'])
667
 
668
  with col2:
669
+ # Calculate Incoming Order for this week
670
  current_incoming_order = 0
671
+ pipeline_peek = state['wholesaler_order_pipeline']
672
+ current_incoming_order = list(pipeline_peek)[0] if pipeline_peek else 0
 
673
  st.write(f"**Incoming Order (This Week):**\n# {current_incoming_order}")
674
 
675
  with col3:
 
678
  st.write(f"**Shipment Arriving (This Week):**\n# {arriving_this_week}")
679
 
680
  # Arriving NEXT week (Peek at the next item in the 1-week delay queue)
681
+ arriving_next = 0 # maxlen is 1, so index [1] doesn't exist
682
  st.write(f"**Shipment Arriving (Next Week):**\n# {arriving_next}")
683
 
684
+ # =======================================================
685
+
686
  st.markdown("---")
687
  st.header("Your Decision (Step 4)")
688
 
689
  # Prepare the state snapshot for the AI prompt (State AFTER arrivals/orders, BEFORE shipping)
690
  all_decision_point_states = {}
691
  for name in echelon_order:
692
+ e_curr = echelons[name]
693
  arrived = 0
694
  if name == "Factory":
695
  if state['factory_production_pipeline']: arrived = list(state['factory_production_pipeline'])[0]
 
700
  if name == "Retailer": inc_order_this_week = get_customer_demand(week)
701
  else:
702
  ds_name = e_curr['downstream_name']
703
+ pipeline_peek = deque()
704
+ if ds_name == 'Distributor': pipeline_peek = state['distributor_order_pipeline']
705
+ elif ds_name == 'Wholesaler': pipeline_peek = state['wholesaler_order_pipeline']
706
+ elif ds_name == 'Retailer': pipeline_peek = state['retailer_order_pipeline']
707
+ inc_order_this_week = list(pipeline_peek)[0] if pipeline_peek else 0
708
 
709
  inv_after_arrival = e_curr['inventory'] + arrived
710
  backlog_after_new_order = e_curr['backlog'] + inc_order_this_week
 
720
  if state['decision_step'] == 'initial_order':
721
  with st.form(key="initial_order_form"):
722
  st.markdown("#### **Step 4a:** Based on the dashboard, submit your **initial** order to the Factory.")
723
+ initial_order = st.number_input("Your Initial Order Quantity:", min_value=0, step=1, value=None) # Start blank
724
  if st.form_submit_button("Submit Initial Order & See AI Suggestion", type="primary"):
725
  state['human_initial_order'] = int(initial_order) if initial_order is not None else 0
726
  state['decision_step'] = 'final_order'
727
+
728
+ prompt_sugg = get_llm_prompt(human_echelon_state_for_prompt, week, state['llm_personality'], state['info_sharing'], all_decision_point_states)
729
+ ai_suggestion, _ = get_llm_order_decision(prompt_sugg, f"{human_role} (Suggestion)")
730
+ state['current_ai_suggestion'] = ai_suggestion # Store it
731
+
732
  st.rerun()
733
 
734
  elif state['decision_step'] == 'final_order':
735
  st.success(f"Your initial order was: **{state['human_initial_order']}** units.")
736
+
737
+ ai_suggestion = state.get('current_ai_suggestion', 4) # Read stored value
738
 
739
  with st.form(key="final_order_form"):
740
  st.markdown(f"#### **Step 4b:** The AI suggests ordering **{ai_suggestion}** units.")
741
  st.markdown("Considering the AI's advice, submit your **final** order to end the week. (This order will arrive in 3 weeks).")
742
+ st.number_input("Your Final Order Quantity:", min_value=0, step=1, key='final_order_input', value=None) # Start blank
743
 
744
  if st.form_submit_button("Submit Final Order & Advance to Next Week"):
745
  final_order_value = st.session_state.get('final_order_input', 0)
 
789
  st.sidebar.markdown(f"**Game ID**: `{state['participant_id']}`\n\n**Current Week**: {week}")
790
  if st.sidebar.button("🔄 Reset Game"):
791
  if 'final_order_input' in st.session_state: del st.session_state.final_order_input
792
+ if 'current_ai_suggestion' in st.session_state.game_state: del st.session_state.game_state['current_ai_suggestion']
793
  del st.session_state.game_state
794
  st.rerun()
795
 
 
809
  except Exception as e:
810
  st.error(f"Error generating final report: {e}")
811
 
 
812
  show_leaderboard_ui()
 
813
 
814
  if st.button("✨ Start a New Game"):
815
  del st.session_state.game_state