Lilli98 commited on
Commit
7cd8c1a
·
verified ·
1 Parent(s): b5ad736

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +503 -139
app.py CHANGED
@@ -1,168 +1,532 @@
1
  # app.py
 
 
 
 
 
 
 
 
 
 
 
2
  import os
 
3
  import time
4
  import uuid
5
  import random
6
- import pandas as pd
 
 
 
7
  import streamlit as st
 
8
  import openai
9
- from huggingface_hub import HfApi, HfFolder, Repository
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
- # -----------------------------
12
- # 0. 配置
13
- # -----------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  openai.api_key = os.getenv("OPENAI_API_KEY")
15
- HF_TOKEN = os.getenv("HF_TOKEN") # 需要在 Hugging Face Space Secrets 里设置
16
- HF_REPO_ID = os.getenv("HF_REPO_ID", "your-username/beer-game-logs") # 你要创建的 dataset repo
17
 
18
- api = HfApi()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
- # -----------------------------
21
- # 1. 经典 Beer Game 参数
22
- # -----------------------------
23
- WEEKS = 36
24
- TRANSPORT_DELAY = 2
25
- ORDER_DELAY = 1
26
 
27
- # 经典客户需求序列:前 4 周恒定 4,之后 8,最后再变动
28
- def classic_demand():
 
 
 
 
 
 
 
29
  demand = []
30
- for t in range(WEEKS):
31
  if t < 4:
32
  demand.append(4)
33
- elif 4 <= t < 20:
34
- demand.append(8)
35
  else:
36
- demand.append(random.choice([4, 6, 8, 10]))
37
  return demand
38
 
39
- CUSTOMER_DEMAND = classic_demand()
40
-
41
- # -----------------------------
42
- # 2. 初始化游戏状态
43
- # -----------------------------
44
- def init_state():
45
- return {
46
- "week": 0,
47
- "inventory": {"retailer": 12, "distributor": 12, "wholesaler": 12, "factory": 12},
48
- "backlog": {"retailer": 0, "distributor": 0, "wholesaler": 0, "factory": 0},
49
- "orders": {"retailer": [], "distributor": [], "wholesaler": [], "factory": []},
50
- "pipeline": {"retailer": [], "distributor": [], "wholesaler": [], "factory": []},
51
- "logs": []
 
 
 
 
 
 
 
 
 
52
  }
 
53
 
54
- # -----------------------------
55
- # 3. LLM 决策(OpenAI 调用)
56
- # -----------------------------
57
- def llm_decision(role, state):
58
- prompt = f"""
59
- You are playing the Beer Game as the {role}.
60
- Current week: {state['week']}
61
- Inventory: {state['inventory'][role]}
62
- Backlog: {state['backlog'][role]}
63
- Please decide how many units to order from your upstream partner.
64
- Return only an integer.
65
  """
66
- try:
67
- response = openai.chat.completions.create(
68
- model="gpt-4o-mini",
69
- messages=[{"role": "user", "content": prompt}],
70
- max_tokens=10,
71
- )
72
- order = int("".join([c for c in response.choices[0].message.content if c.isdigit()]) or 4)
73
- except Exception as e:
74
- st.error(f"LLM error ({role}): {e}")
75
- order = 4
76
- return order
77
-
78
- # -----------------------------
79
- # 4. 更新状态
80
- # -----------------------------
81
- def update_state(state, distributor_order):
82
- t = state["week"]
83
-
84
- # 客户需求作用在零售商
85
- demand = CUSTOMER_DEMAND[t]
86
- retailer_inventory = state["inventory"]["retailer"]
87
- shipped = min(retailer_inventory, demand)
88
- state["inventory"]["retailer"] -= shipped
89
- state["backlog"]["retailer"] += demand - shipped
90
-
91
- # 其他角色按 pipeline 收货
92
- for role in ["retailer", "distributor", "wholesaler", "factory"]:
93
- if state["pipeline"][role]:
94
- arrived = state["pipeline"][role].pop(0)
95
- state["inventory"][role] += arrived
96
-
97
- # 下单:零售商由 LLM 控制
98
- state["orders"]["retailer"].append(llm_decision("retailer", state))
99
- state["orders"]["distributor"].append(distributor_order)
100
- state["orders"]["wholesaler"].append(llm_decision("wholesaler", state))
101
- state["orders"]["factory"].append(llm_decision("factory", state))
102
-
103
- # 加入运输延迟
104
- for role in ["retailer", "distributor", "wholesaler", "factory"]:
105
- order = state["orders"][role][-1]
106
- state["pipeline"][role].append(0) # 本周发不出
107
- state["pipeline"][role].append(order) # 延迟两周才到
108
-
109
- # 记录日志
110
- log = {
111
- "week": t,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  "demand": demand,
113
- "orders": {r: state["orders"][r][-1] for r in state["orders"]},
 
 
 
 
 
 
 
114
  "inventory": dict(state["inventory"]),
115
  "backlog": dict(state["backlog"]),
116
- "timestamp": time.time(),
 
 
117
  }
118
- state["logs"].append(log)
119
 
 
120
  state["week"] += 1
 
121
  return state
122
 
123
- # -----------------------------
124
- # 5. 保存日志到 HF Datasets
125
- # -----------------------------
126
- def save_logs_to_hf(participant_id, logs):
127
- df = pd.DataFrame(logs)
128
- filename = f"logs_{participant_id}.csv"
129
- df.to_csv(filename, index=False)
130
-
131
- # 推送到 HF Hub dataset
132
- repo_url = api.create_repo(repo_id=HF_REPO_ID, token=HF_TOKEN, repo_type="dataset", exist_ok=True)
133
- repo = Repository(local_dir="hf_logs", clone_from=HF_REPO_ID, use_auth_token=HF_TOKEN)
134
- os.makedirs("hf_logs", exist_ok=True)
135
- df.to_csv(f"hf_logs/{filename}", index=False)
136
- repo.git_add()
137
- repo.git_commit(f"Add logs for {participant_id}")
138
- repo.git_push()
139
-
140
- # -----------------------------
141
- # 6. Streamlit 界面
142
- # -----------------------------
143
- st.set_page_config(page_title="Beer Game Experiment", layout="wide")
144
- st.title("Beer Game Experiment")
145
-
146
- if "state" not in st.session_state:
147
- st.session_state["participant_id"] = str(uuid.uuid4())
148
- st.session_state["state"] = init_state()
149
-
150
- state = st.session_state["state"]
151
-
152
- st.write(f"### Week {state['week']} / {WEEKS}")
153
- st.write(f"Your Inventory: {state['inventory']['distributor']}, Backlog: {state['backlog']['distributor']}")
154
-
155
- distributor_order = st.number_input("Enter your order (units):", min_value=0, step=1, value=4)
156
-
157
- if st.button("Submit Order"):
158
- st.session_state["state"] = update_state(state, distributor_order)
159
-
160
- if state["week"] >= WEEKS:
161
- st.success("Game finished! Saving logs...")
162
- save_logs_to_hf(st.session_state["participant_id"], state["logs"])
163
- st.balloons()
164
- st.stop()
165
-
166
- # 显示实时日志
167
- st.write("### Logs (this session)")
168
- st.dataframe(pd.DataFrame(state["logs"]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # app.py
2
+ """
3
+ Beer Game — Full Streamlit app for Hugging Face Spaces
4
+ - Classic parameters (transport delay 2 weeks, order delay 1 week)
5
+ - Human = Distributor (must Submit Order, then press Next Week)
6
+ - LLM agents (Retailer, Wholesaler, Factory) using OpenAI gpt-4o-mini
7
+ - Info sharing toggle + configurable demand history length
8
+ - Per-participant sessions (participant_id via URL query param or input)
9
+ - Detailed logging (orders, shipments, inventory, backlog, timestamps, raw LLM outputs)
10
+ - Automatic upload of per-participant CSV logs to Hugging Face Datasets Hub
11
+ """
12
+
13
  import os
14
+ import re
15
  import time
16
  import uuid
17
  import random
18
+ import json
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+
22
  import streamlit as st
23
+ import pandas as pd
24
  import openai
25
+ from huggingface_hub import upload_file, HfApi
26
+
27
+ # ---------------------------
28
+ # CONFIGURABLE PARAMETERS
29
+ # ---------------------------
30
+ # Classic Beer Game choices: choose 24 or 36 depending on experiment design
31
+ DEFAULT_WEEKS = 36
32
+ TRANSPORT_DELAY = 2 # shipments take 2 weeks to arrive
33
+ ORDER_DELAY = 1 # orders incur 1-week processing delay (modeled via pipeline)
34
+ INITIAL_INVENTORY = 12
35
+ INITIAL_BACKLOG = 0
36
+
37
+ # OpenAI model to use for agents
38
+ OPENAI_MODEL = "gpt-4o-mini"
39
+
40
+ # Local folder to hold temporary log files before upload
41
+ LOCAL_LOG_DIR = Path("logs")
42
+ LOCAL_LOG_DIR.mkdir(exist_ok=True)
43
+
44
+ # ---------------------------
45
+ # Helper functions
46
+ # ---------------------------
47
+ def now_iso():
48
+ return datetime.utcnow().isoformat(timespec="milliseconds") + "Z"
49
+
50
+ def fmt(o):
51
+ try:
52
+ return json.dumps(o, ensure_ascii=False)
53
+ except Exception:
54
+ return str(o)
55
 
56
+ # ---------------------------
57
+ # Hugging Face upload helper
58
+ # ---------------------------
59
+ HF_TOKEN = os.getenv("HF_TOKEN")
60
+ HF_REPO_ID = os.getenv("HF_REPO_ID") # "Lilli98/beer-game-logs"
61
+ hf_api = HfApi()
62
+
63
+ def upload_log_to_hf(local_path: Path, participant_id: str):
64
+ """
65
+ Upload a local CSV file to HF dataset repo under path logs/<participant_id>/...
66
+ Requires HF_TOKEN and HF_REPO_ID set as environment variables (Space secrets).
67
+ """
68
+ if not HF_TOKEN or not HF_REPO_ID:
69
+ st.info("HF_TOKEN or HF_REPO_ID not configured; skipping upload to HF Hub.")
70
+ return None
71
+
72
+ dest_path_in_repo = f"logs/{participant_id}/{local_path.name}"
73
+ try:
74
+ upload_file(
75
+ path_or_fileobj=str(local_path),
76
+ path_in_repo=dest_path_in_repo,
77
+ repo_id=HF_REPO_ID,
78
+ repo_type="dataset",
79
+ token=HF_TOKEN
80
+ )
81
+ st.success(f"Uploaded logs to Hugging Face: {HF_REPO_ID}/{dest_path_in_repo}")
82
+ return f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{dest_path_in_repo}"
83
+ except Exception as e:
84
+ st.error(f"Failed to upload logs to HF Hub: {e}")
85
+ return None
86
+
87
+ # ---------------------------
88
+ # OpenAI helper
89
+ # ---------------------------
90
  openai.api_key = os.getenv("OPENAI_API_KEY")
 
 
91
 
92
+ def call_llm_for_order(role: str, local_state: dict, info_sharing_visible: bool, demand_history: list, max_tokens=40, temperature=0.7):
93
+ """
94
+ Call OpenAI to decide an integer order for `role`.
95
+ Returns (order_int, raw_text)
96
+ """
97
+ # Compose a careful prompt giving only local info unless info_sharing_visible is True
98
+ visible_history = demand_history if info_sharing_visible else []
99
+
100
+ prompt = (
101
+ f"You are the {role} in a 4-player Beer Game (Retailer -> Wholesaler -> Distributor -> Factory).\n"
102
+ f"Current week: {local_state['week']}\n"
103
+ f"Local state for {role}:\n"
104
+ f"- Inventory: {local_state['inventory'][role]}\n"
105
+ f"- Backlog: {local_state['backlog'][role]}\n"
106
+ f"- Incoming shipment next week (front of pipeline): {local_state['pipeline'][role][0] if local_state['pipeline'][role] else 0}\n"
107
+ f"- Incoming order this week: {local_state['incoming_orders'].get(role, 0)}\n"
108
+ )
109
+ if visible_history:
110
+ prompt += f"- Customer demand history (visible to you): {visible_history}\n"
111
+ prompt += (
112
+ "\nDecide a non-negative integer order quantity to place to your upstream supplier this week.\n"
113
+ "Reply with a single integer only. You may optionally append a short one-sentence reason after a dash."
114
+ )
115
+
116
+ try:
117
+ resp = openai.ChatCompletion.create(
118
+ model=OPENAI_MODEL,
119
+ messages=[
120
+ {"role": "system", "content": "You are an automated Beer Game agent who decides weekly orders."},
121
+ {"role": "user", "content": prompt}
122
+ ],
123
+ max_tokens=max_tokens,
124
+ temperature=temperature,
125
+ n=1
126
+ )
127
+ raw = resp.choices[0].message.get("content", "").strip()
128
+ except Exception as e:
129
+ raw = f"OPENAI_ERROR: {str(e)}"
130
+ # fallback later
131
+
132
+ # Extract first integer from model output
133
+ m = re.search(r"(-?\d+)", raw or "")
134
+ order = None
135
+ if m:
136
+ try:
137
+ order = int(m.group(1))
138
+ if order < 0:
139
+ order = 0
140
+ except:
141
+ order = None
142
+
143
+ # fallback heuristic if parsing failed or error
144
+ if order is None:
145
+ # simple policy: target inventory = INITIAL_INVENTORY + incoming_order
146
+ incoming = local_state['incoming_orders'].get(role, 0) or 0
147
+ target = INITIAL_INVENTORY + incoming
148
+ order = max(0, target - (local_state['inventory'].get(role, 0) or 0))
149
+ raw = (raw + " | PARSE_FALLBACK").strip()
150
 
151
+ return int(order), raw
 
 
 
 
 
152
 
153
+ # ---------------------------
154
+ # Game mechanics
155
+ # ---------------------------
156
+ def make_classic_demand(weeks: int):
157
+ """
158
+ Typical demand: first 4 weeks stable (4), then shock (8) for many weeks, then maybe fluctuations.
159
+ We'll implement: weeks 0-3 => 4; weeks 4..(weeks-1) => 8
160
+ You can adjust as needed.
161
+ """
162
  demand = []
163
+ for t in range(weeks):
164
  if t < 4:
165
  demand.append(4)
 
 
166
  else:
167
+ demand.append(8)
168
  return demand
169
 
170
+ def init_game(weeks=DEFAULT_WEEKS):
171
+ """
172
+ Return a dict representing full game state for a single participant/session.
173
+ """
174
+ roles = ["retailer", "wholesaler", "distributor", "factory"]
175
+ state = {
176
+ "participant_id": None,
177
+ "week": 1,
178
+ "weeks_total": weeks,
179
+ "roles": roles,
180
+ "inventory": {r: INITIAL_INVENTORY for r in roles},
181
+ "backlog": {r: INITIAL_BACKLOG for r in roles},
182
+ # pipeline: each role has a queue representing shipments that will arrive next weeks;
183
+ # we keep length = TRANSPORT_DELAY, front is arriving next week.
184
+ "pipeline": {r: [0] * TRANSPORT_DELAY for r in roles},
185
+ "incoming_orders": {r: 0 for r in roles}, # orders received this week from downstream
186
+ "orders_history": {r: [] for r in roles},
187
+ "shipments_history": {r: [] for r in roles},
188
+ "logs": [],
189
+ "info_sharing": False,
190
+ "info_history_weeks": 0,
191
+ "customer_demand": make_classic_demand(weeks),
192
  }
193
+ return state
194
 
195
+ def step_game(state: dict, distributor_order: int):
 
 
 
 
 
 
 
 
 
 
196
  """
197
+ Apply one week's dynamics.
198
+ Order of events (typical simplification):
199
+ 1. Customer demand hits retailer this week.
200
+ 2. Deliveries that are at pipeline[front] arrive to each role this week.
201
+ 3. Roles fulfill incoming orders from downstream (if backlog arises).
202
+ 4. Human (distributor) order is recorded; LLMs decide orders for their roles.
203
+ 5. Place orders into upstream's pipeline so they will arrive after TRANSPORT_DELAY.
204
+ 6. Log everything.
205
+ """
206
+ week = state["week"]
207
+ roles = state["roles"]
208
+
209
+ # 1) Customer demand for this week to retailer
210
+ demand = state["customer_demand"][week - 1] # week is 1-indexed
211
+ state["incoming_orders"]["retailer"] = demand
212
+
213
+ # 2) Shipments arrive (front of pipeline)
214
+ arriving = {}
215
+ for r in roles:
216
+ # Pop front arrival if exists
217
+ arr = 0
218
+ if len(state["pipeline"][r]) > 0:
219
+ arr = state["pipeline"][r].pop(0)
220
+ state["inventory"][r] += arr
221
+ arriving[r] = arr
222
+
223
+ # 3) Fulfill incoming orders from downstream (downstream -> this role)
224
+ # For each role, the incoming_order is whatever downstream ordered last turn.
225
+ # For first week, incoming_orders maybe zero for non-retailer; that's fine.
226
+ shipments_out = {}
227
+ for r in roles:
228
+ incoming = state["incoming_orders"].get(r, 0) or 0
229
+ inv = state["inventory"].get(r, 0) or 0
230
+ shipped = min(inv, incoming)
231
+ state["inventory"][r] -= shipped
232
+ # any unfilled becomes backlog
233
+ unfilled = incoming - shipped
234
+ if unfilled > 0:
235
+ state["backlog"][r] += unfilled
236
+ shipments_out[r] = shipped
237
+ state["shipments_history"][r].append(shipped)
238
+
239
+ # 4) Record human distributor order (this week's order placed by distributor)
240
+ # distributor_order is the order placed to wholesaler by the distributor this week
241
+ # Save to orders_history for distributor
242
+ state["orders_history"]["distributor"].append(int(distributor_order))
243
+ # Also set downstream->upstream linking: the upstream (wholesaler) will see distributor_order as incoming next period
244
+ state["incoming_orders"]["wholesaler"] = int(distributor_order)
245
+
246
+ # 5) LLM decisions for AI roles (retailer, wholesaler, factory)
247
+ demand_history_visible = []
248
+ if state["info_sharing"] and state["info_history_weeks"] > 0:
249
+ start_idx = max(0, (week - 1) - state["info_history_weeks"])
250
+ demand_history_visible = state["customer_demand"][start_idx: (week - 1)]
251
+
252
+ llm_outputs = {}
253
+ for role in ["retailer", "wholesaler", "factory"]:
254
+ order_val, raw = call_llm_for_order(role.title(), state_snapshot_for_prompt(state), state["info_sharing"], demand_history_visible)
255
+ order_val = max(0, int(order_val))
256
+ state["orders_history"][role].append(order_val)
257
+ llm_outputs[role] = {"order": order_val, "raw": raw}
258
+ # set incoming_orders for upstream relation: upstream will see this order next period
259
+ # e.g., if retailer orders X, upstream (distributor) incoming_orders will be X
260
+ if role == "retailer":
261
+ state["incoming_orders"]["distributor"] = order_val
262
+ elif role == "wholesaler":
263
+ state["incoming_orders"]["factory"] = order_val
264
+ # factory's upstream is the supplier/external: we don't model beyond factory
265
+
266
+ # 6) Place orders into pipelines: these are shipments that will be sent upstream now and arrive after TRANSPORT_DELAY
267
+ # In the simple Beer Game, the shipped amounts are based on inventories; but orders placed lead to upstream shipments in future after they process.
268
+ # We'll model that orders placed this week translate into future shipments arriving after TRANSPORT_DELAY at the ordering party.
269
+ for role in roles:
270
+ # Determine the order placed by this role this week:
271
+ if role == "distributor":
272
+ placed_order = int(distributor_order)
273
+ else:
274
+ # role in orders_history last appended
275
+ placed_order = state["orders_history"][role][-1] if state["orders_history"][role] else 0
276
+
277
+ # For the downstream partner (the entity that will receive the shipment), we append to that partner's pipeline tail
278
+ # Example: distributor placed order to wholesaler -> wholesaler will receive shipment after TRANSPORT_DELAY
279
+ # Map role -> downstream partner (who receives shipments from role)
280
+ # shipments flow downstream: factory -> wholesaler -> distributor -> retailer
281
+ downstream_map = {
282
+ "factory": "wholesaler",
283
+ "wholesaler": "distributor",
284
+ "distributor": "retailer",
285
+ "retailer": None
286
+ }
287
+ downstream = downstream_map.get(role)
288
+ if downstream:
289
+ # append zeros if pipeline too short to ensure correct index, then append placed_order at tail
290
+ # We want the placed_order to be delivered to downstream after TRANSPORT_DELAY weeks (so push at tail)
291
+ state["pipeline"][downstream].append(placed_order)
292
+
293
+ # 7) Log the week's summary
294
+ log_entry = {
295
+ "timestamp": now_iso(),
296
+ "week": week,
297
  "demand": demand,
298
+ "arriving": arriving,
299
+ "shipments_out": shipments_out,
300
+ "orders_submitted": {
301
+ "distributor": int(distributor_order),
302
+ "retailer": state["orders_history"]["retailer"][-1] if state["orders_history"]["retailer"] else None,
303
+ "wholesaler": state["orders_history"]["wholesaler"][-1] if state["orders_history"]["wholesaler"] else None,
304
+ "factory": state["orders_history"]["factory"][-1] if state["orders_history"]["factory"] else None,
305
+ },
306
  "inventory": dict(state["inventory"]),
307
  "backlog": dict(state["backlog"]),
308
+ "info_sharing": state["info_sharing"],
309
+ "info_history_weeks": state["info_history_weeks"],
310
+ "llm_raw": {k: v["raw"] for k, v in llm_outputs.items()}
311
  }
312
+ state["logs"].append(log_entry)
313
 
314
+ # 8) Advance week
315
  state["week"] += 1
316
+
317
  return state
318
 
319
+ def state_snapshot_for_prompt(state):
320
+ """
321
+ Prepare a compact snapshot of state for LLM prompt (avoid sending huge objects).
322
+ We'll include week, inventory and backlog for each role and incoming_orders for this week.
323
+ """
324
+ snap = {
325
+ "week": state["week"],
326
+ "inventory": state["inventory"].copy(),
327
+ "backlog": state["backlog"].copy(),
328
+ "incoming_orders": state["incoming_orders"].copy(),
329
+ # pipeline front (arriving next week)
330
+ "incoming_shipments_next_week": {r: (state["pipeline"][r][0] if state["pipeline"][r] else 0) for r in state["roles"]}
331
+ }
332
+ return snap
333
+
334
+ # ---------------------------
335
+ # Persistence: local + HF upload
336
+ # ---------------------------
337
+ def save_logs_local(state, participant_id):
338
+ df = pd.json_normalize(state["logs"])
339
+ fname = LOCAL_LOG_DIR / f"logs_{participant_id}_{int(time.time())}.csv"
340
+ df.to_csv(fname, index=False)
341
+ return fname
342
+
343
+ def save_and_upload(state, participant_id):
344
+ local_path = save_logs_local(state, participant_id)
345
+ url = upload_log_to_hf(local_path, participant_id)
346
+ return local_path, url
347
+
348
+ # ---------------------------
349
+ # Streamlit UI & session management
350
+ # ---------------------------
351
+ st.set_page_config(page_title="Beer Game Distributor (Human) + LLM Agents", layout="wide")
352
+ st.title("🍺 Beer Game — Human Distributor vs LLM agents")
353
+
354
+ # Participant id: prefer query param or user input
355
+ qp = st.query_params
356
+ pid_from_q = qp.get("participant_id", [None])[0] if qp else None
357
+
358
+ pid_input = st.text_input("Participant ID (leave blank to auto-generate or use ?participant_id=ID in URL)", value=pid_from_q or "")
359
+ if pid_input:
360
+ participant_id = pid_input.strip()
361
+ else:
362
+ if "auto_pid" not in st.session_state:
363
+ st.session_state["auto_pid"] = str(uuid.uuid4())[:8]
364
+ participant_id = st.session_state["auto_pid"]
365
+
366
+ st.sidebar.markdown(f"**Participant ID:** `{participant_id}`")
367
+
368
+ # Multi-session container in st.session_state
369
+ if "sessions" not in st.session_state:
370
+ st.session_state["sessions"] = {}
371
+
372
+ if participant_id not in st.session_state["sessions"]:
373
+ st.session_state["sessions"][participant_id] = init_game(DEFAULT_WEEKS)
374
+ st.session_state["sessions"][participant_id]["participant_id"] = participant_id
375
+
376
+ state = st.session_state["sessions"][participant_id]
377
+
378
+ # Sidebar controls: info sharing, demand history slider, quick config
379
+ st.sidebar.header("Experiment controls")
380
+ state["info_sharing"] = st.sidebar.checkbox("Enable Information Sharing (show customer demand to all roles)", value=state.get("info_sharing", False))
381
+ state["info_history_weeks"] = st.sidebar.slider("How many past weeks of demand to share (0 = none)", 0, 8, value=state.get("info_history_weeks", 0))
382
+ st.sidebar.markdown("---")
383
+ st.sidebar.write("Model for LLM agents:")
384
+ st.sidebar.write(OPENAI_MODEL)
385
+ st.sidebar.markdown("---")
386
+ st.sidebar.write("HF upload settings:")
387
+ st.sidebar.write(f"- HF_REPO_ID: {HF_REPO_ID or 'NOT SET'}")
388
+ st.sidebar.write(f"- HF_TOKEN: {'SET' if HF_TOKEN else 'NOT SET'}")
389
+
390
+ # Main UI: show week, metrics, panels
391
+ col_main, col_sidebar = st.columns([3, 1])
392
+
393
+ with col_main:
394
+ st.header(f"Week {state['week']} / {state['weeks_total']}")
395
+ # show demand for this week (if info sharing or for distributor only?)
396
+ demand_display = state["customer_demand"][state["week"] - 1] if state["week"] - 1 < len(state["customer_demand"]) else None
397
+ st.subheader(f"Customer demand (retailer receives this week): {demand_display}")
398
+
399
+ # show role panels in a grid
400
+ roles = state["roles"]
401
+ panels = st.columns(len(roles))
402
+ for i, role in enumerate(roles):
403
+ with panels[i]:
404
+ st.markdown(f"### {role.title()}")
405
+ st.metric("Inventory", state["inventory"][role])
406
+ st.metric("Backlog", state["backlog"][role])
407
+ incoming = state["incoming_orders"].get(role, 0)
408
+ st.write(f"Incoming order (this week): **{incoming}**")
409
+ next_shipment = state["pipeline"][role][0] if state["pipeline"][role] else 0
410
+ st.write(f"Incoming shipment next week: **{next_shipment}**")
411
+
412
+ st.markdown("---")
413
+ # Distributor input box + submit button
414
+ with st.form(key=f"order_form_{participant_id}", clear_on_submit=False):
415
+ st.write("### Your (Distributor) decision this week")
416
+ default_val = state["incoming_orders"].get("distributor", 4) or 4
417
+ distributor_order = st.number_input("Order to place to upstream (Wholesaler):", min_value=0, step=1, value=default_val)
418
+ submitted = st.form_submit_button("Submit Order (locks your decision)")
419
+
420
+ if submitted:
421
+ # store pending order in session until Next Week pressed
422
+ st.session_state.setdefault("pending_orders", {})
423
+ st.session_state["pending_orders"][participant_id] = int(distributor_order)
424
+ st.success(f"Order submitted: {distributor_order}. Now click 'Next Week' to process the week.")
425
+
426
+ st.markdown("---")
427
+ # Next Week button: only enabled if pending order exists
428
+ pending = st.session_state.get("pending_orders", {}).get(participant_id, None)
429
+ if pending is None:
430
+ st.info("Please submit your order first to enable Next Week processing.")
431
+ else:
432
+ if st.button("Next Week — process week and invoke LLM agents"):
433
+ # step game with pending order
434
+ try:
435
+ state = step_game(state, pending)
436
+ # save state back
437
+ st.session_state["sessions"][participant_id] = state
438
+ # auto-save logs to HF after each week (can change to only end of game)
439
+ local_path = save_logs_local_and_return(state, participant_id) if 'save_logs_local_and_return' in globals() else None
440
+ # default: immediate upload
441
+ local_file = save_logs_local(state, participant_id)
442
+ uploaded_url = None
443
+ if HF_TOKEN and HF_REPO_ID:
444
+ uploaded_url = upload_log_to_hf(local_file, participant_id)
445
+ # remove pending order
446
+ del st.session_state["pending_orders"][participant_id]
447
+ st.success(f"Week processed. Advanced to week {state['week']}.")
448
+ if uploaded_url:
449
+ st.info(f"Logs uploaded to HF: {uploaded_url}")
450
+ except Exception as e:
451
+ st.error(f"Error during Next Week processing: {e}")
452
+
453
+ st.markdown("### Recent logs")
454
+ if state["logs"]:
455
+ # show last 6 logs in a readable table
456
+ df = pd.json_normalize(state["logs"][-6:])
457
+ st.dataframe(df, use_container_width=True)
458
+ else:
459
+ st.write("No logs yet. Submit your first order and press Next Week.")
460
+
461
+ with col_sidebar:
462
+ st.subheader("Information Sharing (preview)")
463
+ st.write("Toggle on to share real customer demand (current + recent weeks) with all LLM agents.")
464
+ st.write(f"Sharing {state['info_history_weeks']} weeks of history (0 = only current week).")
465
+ if state["info_sharing"]:
466
+ # display recent demand history according to slider
467
+ h = state["info_history_weeks"]
468
+ start = max(0, (state["week"] - 1) - h)
469
+ hist = state["customer_demand"][start: state["week"]]
470
+ st.write("Demand visible to agents:", hist)
471
+
472
+ st.markdown("---")
473
+ st.subheader("Admin / Debug")
474
+ if st.button("Test LLM connection"):
475
+ if not openai.api_key:
476
+ st.error("OpenAI API key is missing. Set OPENAI_API_KEY in Space Secrets.")
477
+ else:
478
+ # quick test prompt
479
+ try:
480
+ test_prompt = "You are a helpful agent. Reply with '42'."
481
+ resp = openai.ChatCompletion.create(
482
+ model=OPENAI_MODEL,
483
+ messages=[{"role":"user","content":test_prompt}],
484
+ max_tokens=10
485
+ )
486
+ st.write("LLM raw:", resp.choices[0].message.get("content"))
487
+ except Exception as e:
488
+ st.error(f"LLM test failed: {e}")
489
+
490
+ st.markdown("---")
491
+ if st.button("Save logs now (manual)"):
492
+ if not state["logs"]:
493
+ st.info("No logs to save yet.")
494
+ else:
495
+ local_file = save_logs_local(state, participant_id)
496
+ if HF_TOKEN and HF_REPO_ID:
497
+ url = upload_log_to_hf(local_file, participant_id)
498
+ if url:
499
+ st.success("Logs uploaded.")
500
+ else:
501
+ st.success(f"Saved local file: {local_file}")
502
+
503
+ # ---------------------------
504
+ # Utility save functions (placed after UI to avoid NameError in some deployments)
505
+ # ---------------------------
506
+ def save_logs_local(state: dict, participant_id: str):
507
+ """
508
+ Save logs to local logs directory and return Path.
509
+ """
510
+ df = pd.json_normalize(state["logs"])
511
+ fname = LOCAL_LOG_DIR / f"logs_{participant_id}_{int(time.time())}.csv"
512
+ df.to_csv(fname, index=False)
513
+ return fname
514
+
515
+ # alias used earlier if present
516
+ def save_logs_local_and_return(state: dict, participant_id: str):
517
+ return save_logs_local(state, participant_id)
518
+
519
+ # ---------------------------
520
+ # End-of-game auto actions
521
+ # ---------------------------
522
+ # If game has finished for this participant, offer final download / upload
523
+ if state["week"] > state["weeks_total"]:
524
+ st.success("Game completed for this participant.")
525
+ # prepare final CSV
526
+ final_csv = save_logs_local(state, participant_id)
527
+ with open(final_csv, "rb") as f:
528
+ st.download_button("Download final logs CSV", data=f, file_name=final_csv.name, mime="text/csv")
529
+ if HF_TOKEN and HF_REPO_ID:
530
+ url = upload_log_to_hf(final_csv, participant_id)
531
+ if url:
532
+ st.write(f"Final logs uploaded to HF Hub: {url}")