Lilli98 commited on
Commit
82b5b25
·
verified ·
1 Parent(s): 28d44ba

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +257 -193
app.py CHANGED
@@ -1,5 +1,5 @@
1
  # app.py
2
- # @title Beer Game Final Version (v4.23 - Fixed Final Decision State Bug)
3
 
4
  # -----------------------------------------------------------------------------
5
  # 1. Import Libraries
@@ -25,7 +25,6 @@ import numpy as np
25
  # -----------------------------------------------------------------------------
26
  st.set_page_config(page_title="Beer Game: Human-AI Collaboration", layout="wide")
27
 
28
-
29
  # -----------------------------------------------------------------------------
30
  # 2. Game Parameters & API Configuration
31
  # -----------------------------------------------------------------------------
@@ -33,10 +32,10 @@ st.set_page_config(page_title="Beer Game: Human-AI Collaboration", layout="wide"
33
  WEEKS = 24
34
  INITIAL_INVENTORY = 12
35
  INITIAL_BACKLOG = 0
36
- ORDER_PASSING_DELAY = 1 # Handled by last_week_orders
37
- SHIPPING_DELAY = 2 # General shipping delay (R->W, W->D)
38
- FACTORY_LEAD_TIME = 1
39
- FACTORY_SHIPPING_DELAY = 1 # Specific delay from Factory to Distributor
40
  HOLDING_COST = 0.5
41
  BACKLOG_COST = 1.0
42
 
@@ -59,7 +58,6 @@ except Exception as e:
59
  else:
60
  st.session_state.initialization_error = None
61
 
62
-
63
  # -----------------------------------------------------------------------------
64
  # 3. Core Game Logic Functions
65
  # -----------------------------------------------------------------------------
@@ -67,30 +65,42 @@ else:
67
  def get_customer_demand(week: int) -> int:
68
  return 4 if week <= 4 else 8
69
 
70
- # =============== MODIFIED Initialization (Added current_ai_suggestion) ===============
71
  def init_game_state(llm_personality: str, info_sharing: str, participant_id: str):
72
  roles = ["Retailer", "Wholesaler", "Distributor", "Factory"]
73
- human_role = "Distributor" # Role is fixed
74
 
75
  st.session_state.game_state = {
76
- 'game_running': True,
77
- 'participant_id': participant_id,
78
- 'week': 1,
79
  'human_role': human_role, 'llm_personality': llm_personality,
80
  'info_sharing': info_sharing, 'logs': [], 'echelons': {},
 
 
 
 
 
81
  'factory_production_pipeline': deque([0] * FACTORY_LEAD_TIME, maxlen=FACTORY_LEAD_TIME),
 
 
 
 
 
 
 
 
82
  'decision_step': 'initial_order',
83
  'human_initial_order': None,
84
- 'current_ai_suggestion': None, # 新增:用于存储AI建议
85
- 'last_week_orders': {name: 0 for name in roles}
86
  }
87
 
88
  for i, name in enumerate(roles):
89
  upstream = roles[i + 1] if i + 1 < len(roles) else None
90
  downstream = roles[i - 1] if i - 1 >= 0 else None
 
91
  if name == "Distributor": shipping_weeks = FACTORY_SHIPPING_DELAY
92
- elif name == "Factory": shipping_weeks = 0
93
- else: shipping_weeks = SHIPPING_DELAY
 
94
  st.session_state.game_state['echelons'][name] = {
95
  'name': name, 'inventory': INITIAL_INVENTORY, 'backlog': INITIAL_BACKLOG,
96
  'incoming_shipments': deque([0] * shipping_weeks, maxlen=shipping_weeks),
@@ -118,50 +128,75 @@ def get_llm_order_decision(prompt: str, echelon_name: str) -> (int, str):
118
  match = re.search(r'\d+', raw_text)
119
  if match: return int(match.group(0)), raw_text
120
  st.warning(f"LLM for {echelon_name} did not return a valid number. Defaulting to 4. Raw Response: '{raw_text}'")
121
- return 4, raw_text # Default to 4
122
  except Exception as e:
123
  st.error(f"API call failed for {echelon_name}: {e}. Defaulting to 4.")
124
  return 4, f"API_ERROR: {e}"
125
 
 
126
  def get_llm_prompt(echelon_state_decision_point: dict, week: int, llm_personality: str, info_sharing: str, all_echelons_state_decision_point: dict) -> str:
127
- # This function's logic remains correct (from v4.17).
128
  e_state = echelon_state_decision_point
129
  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"
 
 
 
 
 
 
 
 
 
 
130
  if e_state['name'] == 'Factory':
131
  task_word = "production quantity"
132
- base_info += f"- Your Production Pipeline (completing next week onwards): {list(st.session_state.game_state['factory_production_pipeline'])}"
 
133
  else:
134
  task_word = "order quantity"
135
- base_info += f"- Shipments In Transit To You (arriving next week onwards): {list(e_state['incoming_shipments'])}"
 
 
 
136
  if llm_personality == 'perfect_rational' and info_sharing == 'full':
137
  stable_demand = 8
138
- if e_state['name'] == 'Factory': total_lead_time = FACTORY_LEAD_TIME
139
  elif e_state['name'] == 'Distributor': total_lead_time = ORDER_PASSING_DELAY + FACTORY_LEAD_TIME + FACTORY_SHIPPING_DELAY
140
  else: total_lead_time = ORDER_PASSING_DELAY + SHIPPING_DELAY
141
  safety_stock = 4
142
  target_inventory_level = (stable_demand * total_lead_time) + safety_stock
 
143
  if e_state['name'] == 'Factory':
144
- inventory_position = (e_state['inventory'] - e_state['backlog'] + sum(st.session_state.game_state['factory_production_pipeline']))
145
- inv_pos_components = f"(Inv={e_state['inventory']} - Backlog={e_state['backlog']} + InProd={sum(st.session_state.game_state['factory_production_pipeline'])})"
 
 
 
146
  else:
147
- order_in_transit_to_supplier = st.session_state.game_state['last_week_orders'].get(e_state['name'], 0)
148
- inventory_position = (e_state['inventory'] - e_state['backlog'] + sum(e_state['incoming_shipments']) + order_in_transit_to_supplier)
149
- inv_pos_components = f"(Inv={e_state['inventory']} - Backlog={e_state['backlog']} + InTransitShip={sum(e_state['incoming_shipments'])} + OrderToSupplier={order_in_transit_to_supplier})"
 
 
 
150
  optimal_order = max(0, int(target_inventory_level - inventory_position))
151
  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."
 
152
  elif llm_personality == 'perfect_rational' and info_sharing == 'local':
153
  safety_stock = 4; anchor_demand = e_state['incoming_order']
154
  inventory_correction = safety_stock - (e_state['inventory'] - e_state['backlog'])
 
155
  if e_state['name'] == 'Factory':
156
- supply_line = sum(st.session_state.game_state['factory_production_pipeline'])
157
- supply_line_desc = "In Production"
158
  else:
159
- order_in_transit_to_supplier = st.session_state.game_state['last_week_orders'].get(e_state['name'], 0)
160
- supply_line = sum(e_state['incoming_shipments']) + order_in_transit_to_supplier
161
- supply_line_desc = "Supply Line (In Transit Shipments + Order To Supplier)"
162
  calculated_order = anchor_demand + inventory_correction - supply_line
163
  rational_local_order = max(0, int(calculated_order))
164
  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."
 
 
165
  elif llm_personality == 'human_like' and info_sharing == 'full':
166
  full_info_str = f"\n**Full Supply Chain Information (State Before Shipping):**\n- End-Customer Demand this week: {get_customer_demand(week)} units.\n"
167
  for name, other_e_state in all_echelons_state_decision_point.items():
@@ -185,79 +220,167 @@ def get_llm_prompt(echelon_state_decision_point: dict, week: int, llm_personalit
185
  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.
186
  **React emotionally.** What is your knee-jerk {task_word}? Respond with a single integer.
187
  """
 
188
 
 
189
  def step_game(human_final_order: int, human_initial_order: int, ai_suggestion: int):
190
- # This function's logic remains correct (from v4.17).
191
  state = st.session_state.game_state
192
  week, echelons, human_role = state['week'], state['echelons'], state['human_role']
193
  llm_personality, info_sharing = state['llm_personality'], state['info_sharing']
194
  echelon_order = ["Retailer", "Wholesaler", "Distributor", "Factory"]
195
  llm_raw_responses = {}
 
 
196
  opening_inventories = {name: e['inventory'] for name, e in echelons.items()}
197
  opening_backlogs = {name: e['backlog'] for name, e in echelons.items()}
198
  arrived_this_week = {name: 0 for name in echelon_order}
 
 
 
 
199
  inventory_after_arrival = {}
200
- factory_state = echelons["Factory"]
201
- produced_units = 0
202
- if state['factory_production_pipeline']:
203
- produced_units = state['factory_production_pipeline'].popleft()
204
- arrived_this_week["Factory"] = produced_units
205
- inventory_after_arrival["Factory"] = factory_state['inventory'] + produced_units
206
  for name in ["Retailer", "Wholesaler", "Distributor"]:
207
  arrived_shipment = 0
208
  if echelons[name]['incoming_shipments']:
209
  arrived_shipment = echelons[name]['incoming_shipments'].popleft()
210
  arrived_this_week[name] = arrived_shipment
211
  inventory_after_arrival[name] = echelons[name]['inventory'] + arrived_shipment
 
 
212
  total_backlog_before_shipping = {}
213
  for name in echelon_order:
214
  incoming_order_for_this_week = 0
215
- if name == "Retailer": incoming_order_for_this_week = get_customer_demand(week)
 
216
  else:
 
217
  downstream_name = echelons[name]['downstream_name']
218
- if downstream_name: incoming_order_for_this_week = state['last_week_orders'].get(downstream_name, 0)
 
 
 
 
 
 
219
  echelons[name]['incoming_order'] = incoming_order_for_this_week
 
 
 
 
 
 
 
 
 
 
220
  total_backlog_before_shipping[name] = echelons[name]['backlog'] + incoming_order_for_this_week
 
 
221
  decision_point_states = {}
222
  for name in echelon_order:
223
  decision_point_states[name] = {
224
- 'name': name, 'inventory': inventory_after_arrival[name],
225
- 'backlog': total_backlog_before_shipping[name], 'incoming_order': echelons[name]['incoming_order'],
 
 
226
  'incoming_shipments': echelons[name]['incoming_shipments'].copy() if name != "Factory" else deque(),
227
  }
228
- current_week_orders = {}
 
229
  for name in echelon_order:
230
- e = echelons[name]; prompt_state = decision_point_states[name]
231
- if name == human_role: order_amount, raw_resp = human_final_order, "HUMAN_FINAL_INPUT"
 
 
 
232
  else:
233
  prompt = get_llm_prompt(prompt_state, week, llm_personality, info_sharing, decision_point_states)
234
  order_amount, raw_resp = get_llm_order_decision(prompt, name)
235
- llm_raw_responses[name] = raw_resp; e['order_placed'] = max(0, order_amount); current_week_orders[name] = e['order_placed']
236
- state['factory_production_pipeline'].append(echelons["Factory"]['order_placed'])
 
 
 
 
 
 
 
 
 
 
237
  units_shipped = {name: 0 for name in echelon_order}
238
  for name in echelon_order:
239
- e = echelons[name]; demand_to_meet = total_backlog_before_shipping[name]; available_inv = inventory_after_arrival[name]
240
- e['shipment_sent'] = min(available_inv, demand_to_meet); units_shipped[name] = e['shipment_sent']
241
- e['inventory'] = available_inv - e['shipment_sent']; e['backlog'] = demand_to_meet - e['shipment_sent']
242
- if units_shipped["Factory"] > 0: echelons['Distributor']['incoming_shipments'].append(units_shipped["Factory"])
243
- if units_shipped['Distributor'] > 0: echelons['Wholesaler']['incoming_shipments'].append(units_shipped['Distributor'])
244
- if units_shipped['Wholesaler'] > 0: echelons['Retailer']['incoming_shipments'].append(units_shipped['Wholesaler'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  log_entry = {'timestamp': datetime.utcnow().isoformat() + "Z", 'week': week, **state}
246
- del log_entry['echelons'], log_entry['factory_production_pipeline'], log_entry['logs'], log_entry['last_week_orders']
247
- if 'current_ai_suggestion' in log_entry: del log_entry['current_ai_suggestion'] # Clean up
 
 
 
 
248
  for name in echelon_order:
249
- e = echelons[name]; e['weekly_cost'] = (e['inventory'] * HOLDING_COST) + (e['backlog'] * BACKLOG_COST); e['total_cost'] += e['weekly_cost']
250
- for key in ['inventory', 'backlog', 'incoming_order', 'order_placed', 'shipment_sent', 'weekly_cost', 'total_cost']: log_entry[f'{name}.{key}'] = e[key]
251
- log_entry[f'{name}.llm_raw_response'] = llm_raw_responses.get(name, "")
 
 
 
 
 
252
  log_entry[f'{name}.opening_inventory'] = opening_inventories[name]; log_entry[f'{name}.opening_backlog'] = opening_backlogs[name]
253
  log_entry[f'{name}.arrived_this_week'] = arrived_this_week[name]
254
- if name != 'Factory': log_entry[f'{name}.arriving_next_week'] = list(e['incoming_shipments'])[0] if e['incoming_shipments'] else 0
255
- else: log_entry[f'{name}.production_completing_next_week'] = list(state['factory_production_pipeline'])[0] if state['factory_production_pipeline'] else 0
 
 
 
 
256
  log_entry[f'{human_role}.initial_order'] = human_initial_order; log_entry[f'{human_role}.ai_suggestion'] = ai_suggestion
257
  state['logs'].append(log_entry)
258
- state['week'] += 1; state['decision_step'] = 'initial_order'; state['last_week_orders'] = current_week_orders
259
- state['current_ai_suggestion'] = None # Clean up
 
 
 
260
  if state['week'] > WEEKS: state['game_running'] = False
 
 
261
 
262
  def plot_results(df: pd.DataFrame, title: str, human_role: str):
263
  # This function remains correct.
@@ -289,24 +412,13 @@ def plot_results(df: pd.DataFrame, title: str, human_role: str):
289
  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')
290
  plt.tight_layout(rect=[0, 0, 1, 0.96]); return fig
291
 
292
-
293
- # =============== NEW: Leaderboard Functions ===============
294
-
295
- @st.cache_data(ttl=60) # 缓存1分钟
296
  def load_leaderboard_data():
297
- """从Hugging Face Hub下载并加载排行榜数据。"""
298
- if not hf_api or not HF_REPO_ID:
299
- return {} # 没有HF连接,返回空
300
  try:
301
- local_path = hf_hub_download(
302
- repo_id=HF_REPO_ID,
303
- repo_type="dataset",
304
- filename=LEADERBOARD_FILE,
305
- token=HF_TOKEN,
306
- cache_dir=LOCAL_LOG_DIR / "hf_cache" # 明确指定缓存目录
307
- )
308
- with open(local_path, 'r', encoding='utf-8') as f:
309
- return json.load(f)
310
  except EntryNotFoundError:
311
  st.sidebar.info("Leaderboard file not found. A new one will be created.")
312
  return {}
@@ -315,44 +427,27 @@ def load_leaderboard_data():
315
  return {}
316
 
317
  def save_leaderboard_data(data):
318
- """将更新后的排行榜数据保存到Hugging Face Hub。"""
319
- if not hf_api or not HF_REPO_ID or not HF_TOKEN:
320
- st.sidebar.warning("Cannot save leaderboard. HF credentials missing.")
321
- return
322
  try:
323
  local_path = LOCAL_LOG_DIR / LEADERBOARD_FILE
324
- with open(local_path, 'w', encoding='utf-8') as f:
325
- json.dump(data, f, indent=2, ensure_ascii=False)
326
-
327
- hf_api.upload_file(
328
- path_or_fileobj=str(local_path),
329
- path_in_repo=LEADERBOARD_FILE,
330
- repo_id=HF_REPO_ID,
331
- repo_type="dataset",
332
- token=HF_TOKEN,
333
- commit_message="Update leaderboard"
334
- )
335
  st.sidebar.success("Leaderboard updated!")
336
- st.cache_data.clear() # 清除缓存
337
  except Exception as e:
338
  st.sidebar.error(f"Failed to upload leaderboard: {e}")
339
 
340
  def display_rankings(df, top_n=10):
341
- """在UI上显示三个排名的辅助函数。"""
342
  if df.empty:
343
  st.info("No completed games for this category yet. Be the first!")
344
  return
345
-
346
- # 数据清洗:确保成本和标准差是数字
347
  df['total_cost'] = pd.to_numeric(df['total_cost'], errors='coerce')
348
  df['order_std_dev'] = pd.to_numeric(df['order_std_dev'], errors='coerce')
349
  df = df.dropna(subset=['total_cost', 'order_std_dev'])
350
  if df.empty:
351
  st.info("No valid completed games for this category yet.")
352
  return
353
-
354
  c1, c2, c3 = st.columns(3)
355
-
356
  with c1:
357
  st.subheader("🏆 Supply Chain Champions")
358
  st.caption(f"Top {top_n} - Lowest Total Cost")
@@ -360,7 +455,6 @@ def display_rankings(df, top_n=10):
360
  champs_df['total_cost'] = champs_df['total_cost'].map('${:,.2f}'.format)
361
  champs_df.rename(columns={'id': 'Participant', 'total_cost': 'Total Cost'}, inplace=True)
362
  st.dataframe(champs_df[['Participant', 'Total Cost']], use_container_width=True, hide_index=True)
363
-
364
  with c2:
365
  st.subheader("👑 Bullwhip Kings")
366
  st.caption(f"Top {top_n} - Highest Total Cost")
@@ -368,7 +462,6 @@ def display_rankings(df, top_n=10):
368
  kings_df['total_cost'] = kings_df['total_cost'].map('${:,.2f}'.format)
369
  kings_df.rename(columns={'id': 'Participant', 'total_cost': 'Total Cost'}, inplace=True)
370
  st.dataframe(kings_df[['Participant', 'Total Cost']], use_container_width=True, hide_index=True)
371
-
372
  with c3:
373
  st.subheader("🧘 Mr. Smooth")
374
  st.caption(f"Top {top_n} - Lowest Order Variation (Std. Dev.)")
@@ -378,62 +471,44 @@ def display_rankings(df, top_n=10):
378
  st.dataframe(smooth_df[['Participant', 'Order Std. Dev.']], use_container_width=True, hide_index=True)
379
 
380
  def show_leaderboard_ui():
381
- """加载数据并显示完整的排行榜UI。"""
382
  st.markdown("---")
383
  st.header("📊 The Bullwhip Leaderboard")
384
  st.caption("Leaderboard updates after you finish a game. Cached for 60 seconds.")
385
-
386
  leaderboard_data = load_leaderboard_data()
387
  if not leaderboard_data:
388
  st.info("No leaderboard data yet. Be the first to finish a game!")
389
  else:
390
  try:
391
  df = pd.DataFrame(leaderboard_data.values())
392
- # 确保id列存在
393
- if 'id' not in df.columns and not df.empty:
394
- df['id'] = list(leaderboard_data.keys())
395
-
396
  if 'total_cost' not in df.columns or 'order_std_dev' not in df.columns or 'setting' not in df.columns:
397
  st.error("Leaderboard data is corrupted or incomplete.")
398
  return
399
-
400
  groups = sorted(df.setting.unique())
401
  tabs = st.tabs(["**Overall**"] + groups)
402
-
403
- with tabs[0]: # Overall
404
- display_rankings(df)
405
-
406
  for i, group_name in enumerate(groups):
407
  with tabs[i+1]:
408
  df_group = df[df.setting == group_name].copy()
409
  display_rankings(df_group)
410
  except Exception as e:
411
  st.error(f"Error displaying leaderboard: {e}")
412
- st.dataframe(leaderboard_data) # 原始数据以供调试
413
- # ==============================================================================
414
 
415
-
416
- # =============== MODIFIED Function (Updates Leaderboard) ===============
417
  def save_logs_and_upload(state: dict):
418
  if not state.get('logs'):
419
  st.warning("No log data to save.")
420
  return
421
-
422
- participant_id = state['participant_id'] # 这是您输入的自定义ID
423
  logs_df = None
424
-
425
- # 1. Save individual log CSV
426
  try:
427
  logs_df = pd.json_normalize(state['logs'])
428
- # 确保文件名安全
429
  safe_participant_id = re.sub(r'[^a-zA-Z0-9_-]', '_', participant_id)
430
  fname = LOCAL_LOG_DIR / f"log_{safe_participant_id}_{int(time.time())}.csv"
431
-
432
  for col in logs_df.select_dtypes(include=['object']).columns: logs_df[col] = logs_df[col].astype(str)
433
  logs_df.to_csv(fname, index=False)
434
  st.success(f"Log successfully saved locally: `{fname}`")
435
  with open(fname, "rb") as f: st.download_button("📥 Download Log CSV", data=f, file_name=fname.name, mime="text/csv")
436
-
437
  if HF_TOKEN and HF_REPO_ID and hf_api:
438
  with st.spinner("Uploading log CSV to Hugging Face Hub..."):
439
  try:
@@ -442,40 +517,28 @@ def save_logs_and_upload(state: dict):
442
  except Exception as e_upload: st.error(f"Upload to Hugging Face failed: {e_upload}")
443
  except Exception as e_save:
444
  st.error(f"Error processing or saving log CSV: {e_save}")
445
- return # Don't proceed to leaderboard if CSV failed
446
-
447
- # 2. Update and upload leaderboard.json
448
- if logs_df is None: return # Ensure logs_df was created
449
-
450
  st.subheader("Updating Leaderboard...")
451
  try:
452
  human_role = state['human_role']
453
-
454
- # Calculate metrics
455
  total_cost = logs_df[f'{human_role}.total_cost'].iloc[-1]
456
  order_std_dev = logs_df[f'{human_role}.order_placed'].std()
457
  setting_name = f"{state['llm_personality']} / {state['info_sharing']}"
458
-
459
  new_entry = {
460
- 'id': participant_id, # 使用自定义ID
461
- 'setting': setting_name,
462
- 'total_cost': float(total_cost), # 确保是JSON兼容的float
463
- 'order_std_dev': float(order_std_dev) if pd.notna(order_std_dev) else 0.0 # 处理NaN
464
  }
465
-
466
- # Load, update, save
467
  leaderboard_data = load_leaderboard_data()
468
- # 使用ID作为键来允许覆盖/更新 (同名/同组名的人会更新成绩)
469
  leaderboard_data[participant_id] = new_entry
470
  save_leaderboard_data(leaderboard_data)
471
-
472
  except Exception as e_board:
473
  st.error(f"Error calculating or saving leaderboard score: {e_board}")
474
  # ==============================================================================
475
 
476
-
477
  # -----------------------------------------------------------------------------
478
- # 4. Streamlit UI (Adjusted for Custom ID and Leaderboard)
479
  # -----------------------------------------------------------------------------
480
  st.title("🍺 The Beer Game: A Human-AI Collaboration Challenge")
481
 
@@ -485,12 +548,12 @@ else:
485
  # --- Game Setup & Instructions ---
486
  if 'game_state' not in st.session_state or not st.session_state.game_state.get('game_running', False):
487
 
 
 
488
  st.markdown("---")
489
  st.header("⚙️ Game Configuration")
490
 
491
- # =============== NEW: Participant ID Input ===============
492
  participant_id = st.text_input("Enter Your Name or Team ID:", key="participant_id_input", placeholder="e.g., Team A")
493
- # =======================================================
494
 
495
  c1, c2 = st.columns(2)
496
  with c1:
@@ -498,58 +561,46 @@ else:
498
  with c2:
499
  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.")
500
 
501
- # =============== MODIFIED: Start Game Button ===============
502
  if st.button("🚀 Start Game", type="primary", disabled=(client is None)):
503
  if not participant_id:
504
  st.error("Please enter a Name or Team ID to start!")
505
  else:
506
- # 检查ID是否已存在
507
  existing_data = load_leaderboard_data()
508
  if participant_id in existing_data:
509
- st.warning(f"ID '{participant_id}' already exists! Your score will be overwritten. Click 'Start Game' again to confirm.")
510
- # 简单地要求再次点击,或者可以添加一个复选框
511
- # 为了课堂使用,我们先假设他们会自己协调
512
- # 或者让他们加个后缀,比如 "Team A - 2"
513
- # 我们允许覆盖,但给出警告
514
- if "confirm_overwrite" not in st.session_state:
515
- st.session_state.confirm_overwrite = False
516
-
517
- if st.session_state.get(f"last_clicked_id") == participant_id:
518
- # 如果他们再次点击(ID没变)
519
  init_game_state(llm_personality, info_sharing, participant_id)
520
- st.session_state.pop("last_clicked_id", None)
521
  st.rerun()
522
  else:
523
- st.session_state[f"last_clicked_id"] = participant_id
524
-
525
  else:
526
- # Pass the participant_id to the init function
 
527
  init_game_state(llm_personality, info_sharing, participant_id)
528
  st.rerun()
529
- # ===========================================================
530
 
531
- # =============== NEW: Show Leaderboard on Start Page ===============
532
  show_leaderboard_ui()
533
- # =================================================================
534
 
535
  # --- Main Game Interface ---
536
  elif 'game_state' in st.session_state and st.session_state.game_state.get('game_running'):
537
  state = st.session_state.game_state
538
  week, human_role, echelons, info_sharing = state['week'], state['human_role'], state['echelons'], state['info_sharing']
539
- echelon_order = ["Retailer", "Wholesaler", "Distributor", "Factory"] # Define here for UI
540
 
541
 
542
  st.header(f"Week {week} / {WEEKS}")
543
- # 显示自定义ID
544
  st.subheader(f"Your Role: **{human_role}** ({state['participant_id']}) | AI Mode: **{state['llm_personality'].replace('_', ' ')}** | Information: **{state['info_sharing']}**")
545
  st.markdown("---")
546
- st.subheader("Supply Chain Status (Start of Week State)") # Clarified Timing
547
 
 
548
  if info_sharing == 'full':
549
  cols = st.columns(4)
550
- for i, name in enumerate(echelon_order): # Use the defined echelon_order
551
  with cols[i]:
552
- e = echelons[name] # Get the echelon state
553
  icon = "👤" if name == human_role else "🤖"
554
 
555
  if name == human_role:
@@ -559,17 +610,21 @@ else:
559
 
560
  st.metric("Inventory (Opening)", e['inventory'])
561
  st.metric("Backlog (Opening)", e['backlog'])
562
-
563
- # 移除成本显示
564
 
565
- # --- NEW: Added Arriving This Week ---
 
566
  current_incoming_order = 0
567
  if name == "Retailer":
568
  current_incoming_order = get_customer_demand(week)
569
  else:
 
570
  downstream_name = e['downstream_name']
571
- if downstream_name:
572
- current_incoming_order = state['last_week_orders'].get(downstream_name, 0)
 
 
 
 
573
  st.write(f"Incoming Order (This Week): **{current_incoming_order}**")
574
 
575
  if name == "Factory":
@@ -584,16 +639,26 @@ else:
584
  arriving_next = 0
585
  if len(e['incoming_shipments']) > 1:
586
  arriving_next = list(e['incoming_shipments'])[1]
587
- # 修正 2-week delay (R/W) 的显示
588
- elif name in ('Wholesaler', 'Retailer') and len(e['incoming_shipments']) > 0 and e['incoming_shipments'].maxlen == 2:
589
- arriving_next = 0 # Peek at index 1 is correct, if it's not there, it's 0
590
-
591
- st.write(f"Arriving Next Week: **{arriving_next}**")
 
 
 
 
 
 
 
 
 
 
592
 
593
  else: # Local Info Mode
594
  st.info("In Local Information mode, you can only see your own status dashboard.")
595
- e = echelons[human_role]
596
- st.markdown(f"### 👤 **<span style='color:#FF4B4B;'>{human_role} (Your Dashboard - Start of Week State)</span>**", unsafe_allow_html=True) # Highlight self
597
 
598
  col1, col2, col3 = st.columns(3)
599
  with col1:
@@ -601,10 +666,10 @@ else:
601
  st.metric("Backlog (Opening)", e['backlog'])
602
 
603
  with col2:
 
604
  current_incoming_order = 0
605
- downstream_name = e['downstream_name'] # Wholesaler
606
- if downstream_name:
607
- current_incoming_order = state['last_week_orders'].get(downstream_name, 0)
608
  st.write(f"**Incoming Order (This Week):**\n# {current_incoming_order}")
609
 
610
  with col3:
@@ -613,16 +678,18 @@ else:
613
  st.write(f"**Shipment Arriving (This Week):**\n# {arriving_this_week}")
614
 
615
  # Arriving NEXT week (Peek at the next item in the 1-week delay queue)
616
- arriving_next = list(e['incoming_shipments'])[1] if len(e['incoming_shipments']) > 1 else 0
617
  st.write(f"**Shipment Arriving (Next Week):**\n# {arriving_next}")
618
 
 
 
619
  st.markdown("---")
620
  st.header("Your Decision (Step 4)")
621
 
622
  # Prepare the state snapshot for the AI prompt (State AFTER arrivals/orders, BEFORE shipping)
623
  all_decision_point_states = {}
624
  for name in echelon_order:
625
- e_curr = echelons[name] # This is END OF LAST WEEK state
626
  arrived = 0
627
  if name == "Factory":
628
  if state['factory_production_pipeline']: arrived = list(state['factory_production_pipeline'])[0]
@@ -633,7 +700,11 @@ else:
633
  if name == "Retailer": inc_order_this_week = get_customer_demand(week)
634
  else:
635
  ds_name = e_curr['downstream_name']
636
- if ds_name: inc_order_this_week = state['last_week_orders'].get(ds_name, 0)
 
 
 
 
637
 
638
  inv_after_arrival = e_curr['inventory'] + arrived
639
  backlog_after_new_order = e_curr['backlog'] + inc_order_this_week
@@ -654,20 +725,16 @@ else:
654
  state['human_initial_order'] = int(initial_order) if initial_order is not None else 0
655
  state['decision_step'] = 'final_order'
656
 
657
- # --- NEW: Calculate and store suggestion ONCE ---
658
  prompt_sugg = get_llm_prompt(human_echelon_state_for_prompt, week, state['llm_personality'], state['info_sharing'], all_decision_point_states)
659
  ai_suggestion, _ = get_llm_order_decision(prompt_sugg, f"{human_role} (Suggestion)")
660
  state['current_ai_suggestion'] = ai_suggestion # Store it
661
- # ------------------------------------------------
662
 
663
  st.rerun()
664
 
665
  elif state['decision_step'] == 'final_order':
666
  st.success(f"Your initial order was: **{state['human_initial_order']}** units.")
667
 
668
- # --- NEW: Read stored suggestion ---
669
  ai_suggestion = state.get('current_ai_suggestion', 4) # Read stored value
670
- # -----------------------------------
671
 
672
  with st.form(key="final_order_form"):
673
  st.markdown(f"#### **Step 4b:** The AI suggests ordering **{ai_suggestion}** units.")
@@ -681,7 +748,6 @@ else:
681
  step_game(final_order_value, state['human_initial_order'], ai_suggestion)
682
 
683
  if 'final_order_input' in st.session_state: del st.session_state.final_order_input
684
- if 'current_ai_suggestion' in state: del state['current_ai_suggestion'] # Clean up
685
  st.rerun()
686
 
687
  st.markdown("---")
@@ -743,10 +809,8 @@ else:
743
  except Exception as e:
744
  st.error(f"Error generating final report: {e}")
745
 
746
- # =============== NEW: Show Leaderboard on End Page ===============
747
  show_leaderboard_ui()
748
- # ===============================================================
749
 
750
  if st.button("✨ Start a New Game"):
751
  del st.session_state.game_state
752
- st.rerun()
 
1
  # app.py
2
+ # @title Beer Game Final Version (v4.24 - Corrected 3-Week Lead Time Logic & UI)
3
 
4
  # -----------------------------------------------------------------------------
5
  # 1. Import Libraries
 
25
  # -----------------------------------------------------------------------------
26
  st.set_page_config(page_title="Beer Game: Human-AI Collaboration", layout="wide")
27
 
 
28
  # -----------------------------------------------------------------------------
29
  # 2. Game Parameters & API Configuration
30
  # -----------------------------------------------------------------------------
 
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
 
 
58
  else:
59
  st.session_state.initialization_error = None
60
 
 
61
  # -----------------------------------------------------------------------------
62
  # 3. Core Game Logic Functions
63
  # -----------------------------------------------------------------------------
 
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":
 
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
 
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.")
 
748
  step_game(final_order_value, state['human_initial_order'], ai_suggestion)
749
 
750
  if 'final_order_input' in st.session_state: del st.session_state.final_order_input
 
751
  st.rerun()
752
 
753
  st.markdown("---")
 
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
816
+ st.rerun()