BA / app.py
farquasar's picture
Update app.py
fbc7d84 verified
Raw
History Blame Contribute Delete
37 kB
import os
import json
import time
import random
import threading
from pathlib import Path
from dataclasses import dataclass, field
from typing import Dict, List, Any, Optional, Tuple
import gradio as gr
import pandas as pd
import matplotlib.pyplot as plt
# OpenAI Python SDK (v1+)
from openai import OpenAI
# ----------------------------
# Configuration (HF Secrets)
# ----------------------------
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
MODEL = os.getenv("OPENAI_MODEL", "gpt-3.5-turbo-0125").strip()
TEACHER_PIN = os.getenv("TEACHER_PIN", "1234").strip()
# If you don't want AI at all, set this to "0" in HF Variables
AI_ENABLED_DEFAULT = os.getenv("AI_ENABLED", "1").strip() != "0"
client = OpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None
# ----------------------------
# Game constants
# ----------------------------
DATASETS = {
"Sales": {"desc": "Transactions by hour/product (2 years)", "cost": 5},
"Inventory": {"desc": "Production, waste, stockouts", "cost": 4},
"Customers": {"desc": "Loyalty behavior, visit frequency", "cost": 5},
"Staffing": {"desc": "Schedules and service times", "cost": 3},
"External": {"desc": "Weather + academic calendar", "cost": 2},
"Feedback": {"desc": "Ratings + text reviews", "cost": 3},
}
BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data"
DATASET_FILES = {
"Sales": "sales.csv",
"Inventory": "inventory.csv",
"Customers": "customers.csv",
"Staffing": "staffing.csv",
"External": "external.csv",
"Feedback": "feedback.csv",
}
EVENT_CARDS = [
("Exam week", "Demand spikes +25% for 7 days. Morning queue risk increases."),
("Rainy stretch", "Foot traffic drops ~15%. Afternoon sales become volatile."),
("Competitor promo", "Nearby café runs 2-for-1 coffees. Price sensitivity increases."),
("Supplier price increase", "Pastry input costs rise 12%. Margin pressure."),
("App promo goes viral", "New users surge. More first-time buyers, less predictable demand."),
]
DECISIONS = [
("Reduce pastry production", "Reduce baseline pastry production by 15%."),
("Afternoon discounts", "Offer 10–25% discount after 4 PM on selected items."),
("Add morning staff", "Add 1 staff member 8–10 AM to reduce queue time."),
("Change product mix", "Add 1 vegan option, remove 1 low-selling pastry."),
("Invest: Demand forecasting", "Build forecasting to reduce stockouts & waste."),
("Invest: Segmentation", "Cluster customers for targeted offers."),
("Invest: Sentiment analysis", "Analyze reviews for themes and priorities."),
("Invest: Dynamic pricing", "Automate discounts based on predicted demand/waste."),
]
# ----------------------------
# In-memory store (session-based)
# ----------------------------
rooms_lock = threading.Lock()
rooms: Dict[str, Any] = {} # room_code -> state dict
def _now_ms() -> int:
return int(time.time() * 1000)
def _new_room_code() -> str:
return f"ROOM-{random.randint(1000, 9999)}"
def _safe_json_loads(text: str) -> Optional[dict]:
try:
# try direct
return json.loads(text)
except Exception:
# try to extract a JSON object from text
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
try:
return json.loads(text[start : end + 1])
except Exception:
return None
return None
# ----------------------------
# AI prompts
# ----------------------------
BUSINESS_CONTEXT = """
You are the AI Business Assistant for SmartCampus Café (inside a university).
Known situation:
- 1,200–1,800 customers/day; peaks 8–10 AM and 1–3 PM
- Pastry waste about 12%
- Queues during morning peak
- Sales drop after 4 PM
- Competition nearby + delivery apps
Data exists but may need to be requested selectively with a budget.
Your job:
- Provide background about the business when asked.
- Brainstorm approaches, variables, baselines, KPIs, risks.
- Give hints ONLY when students ask for hints.
- Ask clarifying questions when students are vague.
- Do NOT provide final numeric answers or “the best decision”.
- Do NOT invent dataset values.
- Encourage validation, baselines, and limitations.
Keep responses concise and practical.
""".strip()
STUDENT_GAME_BRIEF = """
## Business Context
You are the analytics team for **SmartCampus Café**, a university coffee shop with:
- 1,200-1,800 customers per day
- Peak demand at **8-10 AM** and **1-3 PM**
- About **12% pastry waste**
- Long queues during morning rush
- Sales drop after 4 PM
- Pressure from nearby competitors and delivery apps
## The Core Problem
Your team must make better operating decisions that improve:
- **Profit** (waste, discounts, costs, stockouts)
- **Customer experience** (wait times, availability, satisfaction)
You cannot unlock all data at once, so you need to choose datasets strategically under a budget.
## What Is Expected From Students
1. Form a clear business question.
2. Request datasets with justification (what you need and why).
3. Use unlocked files/plots to extract evidence (patterns, comparisons, trends).
4. Propose one decision and justify it with data.
5. Include risks, limitations, and a KPI/monitoring plan.
Good answers are not just opinions. They connect **decision -> evidence -> expected impact -> risk controls**.
""".strip()
def ai_chat(team_msg: str, room_state: dict, team_name: str) -> str:
if not (AI_ENABLED_DEFAULT and client):
return "AI is disabled (missing OPENAI_API_KEY or AI_ENABLED=0)."
team = room_state["teams"].get(team_name, {})
unlocked = sorted(list(team.get("unlocked_datasets", [])))
budget_left = team.get("budget_left", 20)
state_summary = {
"round": room_state["round"],
"event": room_state.get("active_event"),
"team": team_name,
"budget_left": budget_left,
"unlocked_datasets": unlocked,
}
# Responses API (OpenAI)
resp = client.responses.create(
model=MODEL,
input=[
{"role": "system", "content": BUSINESS_CONTEXT},
{
"role": "user",
"content": f"Game state: {json.dumps(state_summary)}\n\nStudent message: {team_msg}",
},
],
max_output_tokens=400,
)
return resp.output_text.strip() if hasattr(resp, "output_text") else str(resp)
DATASET_GATE_PROMPT = """
You are evaluating a team's request to unlock a dataset.
Return STRICT JSON only, with keys:
- allow: boolean
- dataset: one of ["Sales","Inventory","Customers","Staffing","External","Feedback"] or null
- reason: short string
- suggestion: short string (how to ask better or what to do next)
Rules:
- Allow only if the student states a clear business question and how the dataset will be used.
- If unclear, deny and ask for a sharper question.
- Do not reveal or invent dataset contents.
""".strip()
def ai_gate_dataset(request_text: str, room_state: dict, team_name: str) -> dict:
if not (AI_ENABLED_DEFAULT and client):
return {
"allow": True,
"dataset": None,
"reason": "AI disabled; defaulting to manual approval.",
"suggestion": "Teacher can approve manually.",
}
team = room_state["teams"].get(team_name, {})
unlocked = sorted(list(team.get("unlocked_datasets", [])))
budget_left = team.get("budget_left", 20)
payload = {
"round": room_state["round"],
"team": team_name,
"budget_left": budget_left,
"already_unlocked": unlocked,
"request": request_text,
"available_datasets": list(DATASETS.keys()),
}
resp = client.responses.create(
model=MODEL,
input=[
{"role": "system", "content": DATASET_GATE_PROMPT},
{"role": "user", "content": json.dumps(payload)},
],
max_output_tokens=250,
)
text = resp.output_text.strip() if hasattr(resp, "output_text") else ""
data = _safe_json_loads(text)
if not data:
return {
"allow": False,
"dataset": None,
"reason": "Could not parse AI response as JSON.",
"suggestion": "Rephrase the request: state business question + intended use.",
}
# sanitize
ds = data.get("dataset")
if ds not in list(DATASETS.keys()):
data["dataset"] = None
data["allow"] = bool(data.get("allow", False))
data["reason"] = str(data.get("reason", ""))[:220]
data["suggestion"] = str(data.get("suggestion", ""))[:220]
return data
# ----------------------------
# Game mechanics (simple, deterministic-ish)
# ----------------------------
def simulate_impact(decision_key: str, room_state: dict, team_name: str) -> Tuple[int, int, str]:
"""
Returns (profit_points_delta, customer_points_delta, note)
Simple rule-based impact; you can later replace with your own simulator.
"""
round_no = room_state["round"]
event = room_state.get("active_event", None)
profit = 0
cust = 0
note = ""
if decision_key == "Reduce pastry production":
profit += 3
cust -= 1 # occasional stockouts risk
note = "Less waste, slight availability risk."
elif decision_key == "Afternoon discounts":
profit += 2
cust += 1
note = "Better late-day traffic; margin trade-off."
elif decision_key == "Add morning staff":
profit -= 1
cust += 3
note = "Queues improve; labor cost increases."
elif decision_key == "Change product mix":
profit += 1
cust += 1
note = "Better fit for preferences; small operational change."
elif decision_key == "Invest: Demand forecasting":
profit += 4
cust += 1
note = "Better planning; benefit grows with use."
elif decision_key == "Invest: Segmentation":
profit += 2
cust += 1
note = "More targeted promos; needs execution."
elif decision_key == "Invest: Sentiment analysis":
profit += 1
cust += 2
note = "Prioritizes improvements; indirect revenue impact."
elif decision_key == "Invest: Dynamic pricing":
profit += 3
cust += 0
note = "Reduces waste, boosts revenue; needs guardrails."
# event modifiers
if event and event[0] == "Exam week":
if decision_key in ["Add morning staff", "Invest: Demand forecasting"]:
profit += 1
cust += 1
note += " (Great timing for exam week.)"
if event and event[0] == "Rainy stretch":
if decision_key == "Afternoon discounts":
profit += 1
note += " (Helps counter low traffic.)"
# slight diminishing returns by round
if round_no >= 3:
profit = max(profit - 1, -3)
return profit, cust, note
# ----------------------------
# Room/state helpers
# ----------------------------
def create_room(teacher_pin: str, budget_per_team: int = 20) -> Tuple[str, str]:
if teacher_pin.strip() != TEACHER_PIN:
return "", "Incorrect teacher PIN."
code = _new_room_code()
with rooms_lock:
rooms[code] = {
"created_at": _now_ms(),
"round": 1,
"budget_per_team": int(budget_per_team),
"active_event": None,
"teams": {}, # team_name -> state
"log": [],
}
return code, f"Room created: {code}"
def start_free_play(team_name: str, budget_per_team: int = 20) -> Tuple[dict, str]:
team_name = (team_name or "").strip()
if not team_name:
team_name = f"Solo Team {random.randint(10, 99)}"
code = f"FREE-{random.randint(1000, 9999)}"
with rooms_lock:
while code in rooms:
code = f"FREE-{random.randint(1000, 9999)}"
rooms[code] = {
"created_at": _now_ms(),
"round": 1,
"budget_per_team": int(budget_per_team),
"active_event": None,
"teams": {
team_name: {
"joined_at": _now_ms(),
"budget_left": int(budget_per_team),
"unlocked_datasets": set(),
"profit_points": 0,
"customer_points": 0,
"submissions": [],
}
},
"log": [{"t": _now_ms(), "type": "free_play_start", "team": team_name}],
}
return {"room": code, "team": team_name}, f"Free play started in {code} as {team_name}."
def join_room(room_code: str, team_name: str) -> Tuple[dict, str]:
room_code = room_code.strip().upper()
team_name = team_name.strip()
if not room_code or not team_name:
return {}, "Provide room code and team name."
with rooms_lock:
if room_code not in rooms:
return {}, "Room not found."
r = rooms[room_code]
if team_name not in r["teams"]:
r["teams"][team_name] = {
"joined_at": _now_ms(),
"budget_left": r["budget_per_team"],
"unlocked_datasets": set(),
"profit_points": 0,
"customer_points": 0,
"submissions": [], # list of dicts
}
r["log"].append({"t": _now_ms(), "type": "join", "team": team_name})
return {"room": room_code, "team": team_name}, f"Joined {room_code} as {team_name}."
def get_room_state(room_code: str) -> Optional[dict]:
with rooms_lock:
return rooms.get(room_code)
def dataset_file_path(dataset_name: str) -> Optional[Path]:
filename = DATASET_FILES.get(dataset_name)
if not filename:
return None
return DATA_DIR / filename
def scoreboard_df(room_state: dict) -> List[List[Any]]:
rows = []
for team, st in room_state["teams"].items():
rows.append([
team,
st.get("budget_left", 0),
st.get("profit_points", 0),
st.get("customer_points", 0),
st.get("profit_points", 0) + st.get("customer_points", 0),
", ".join(sorted(list(st.get("unlocked_datasets", [])))),
])
rows.sort(key=lambda r: r[4], reverse=True)
return rows
# ----------------------------
# Gradio callbacks
# ----------------------------
def ui_refresh(session: dict) -> Tuple[str, List[List[Any]], str]:
if not session:
return "Not joined.", [], ""
room_state = get_room_state(session["room"])
if not room_state:
return "Room expired or restarted.", [], ""
ev = room_state["active_event"]
ev_text = f"{ev[0]}{ev[1]}" if ev else "No active event."
header = f"Room: {session['room']} | Team: {session['team']} | Round: {room_state['round']} | Event: {ev_text}"
return header, scoreboard_df(room_state), ev_text
def ui_ai_chat(session: dict, message: str, chat_history: list):
if not session:
return chat_history, "Join a room first."
room_state = get_room_state(session["room"])
if not room_state:
return chat_history, "Room expired or restarted."
if not message.strip():
return chat_history, ""
reply = ai_chat(message.strip(), room_state, session["team"])
if chat_history is None:
chat_history = []
chat_history = chat_history + [
{"role": "user", "content": message.strip()},
{"role": "assistant", "content": reply},
]
return chat_history, ""
def ui_request_dataset(session: dict, request_text: str) -> Tuple[str, List[List[Any]]]:
if not session:
return "Join a room first.", []
room_state = get_room_state(session["room"])
if not room_state:
return "Room expired or restarted.", []
team_name = session["team"]
gate = ai_gate_dataset(request_text.strip(), room_state, team_name)
# If AI didn't name a dataset, ask user to specify it
if gate.get("allow") and not gate.get("dataset"):
return f"Approved in principle, but dataset not specified. Choose one dataset name. Hint: {gate.get('suggestion','')}", scoreboard_df(room_state)
ds = gate.get("dataset")
if not gate.get("allow") or not ds:
return f"Denied: {gate.get('reason','')} Suggestion: {gate.get('suggestion','')}", scoreboard_df(room_state)
# apply unlock + cost
with rooms_lock:
r = rooms.get(session["room"])
if not r:
return "Room expired or restarted.", []
team = r["teams"].get(team_name)
if not team:
return "Team not found.", scoreboard_df(r)
if ds in team["unlocked_datasets"]:
return f"Dataset already unlocked: {ds}", scoreboard_df(r)
cost = DATASETS[ds]["cost"]
if team["budget_left"] < cost:
return f"Not enough budget for {ds} (cost {cost}). Budget left: {team['budget_left']}", scoreboard_df(r)
team["budget_left"] -= cost
team["unlocked_datasets"].add(ds)
r["log"].append({"t": _now_ms(), "type": "unlock", "team": team_name, "dataset": ds, "cost": cost})
return f"Unlocked dataset: {ds} (cost {DATASETS[ds]['cost']}). {gate.get('reason','')}", scoreboard_df(get_room_state(session["room"]))
def ui_submit_work(session: dict, decision: str, model_card: str, decision_report: str) -> str:
if not session:
return "Join a room first."
room_state = get_room_state(session["room"])
if not room_state:
return "Room expired or restarted."
decision = decision.strip()
if not decision:
return "Pick a decision."
# apply deterministic impact
profit_delta, cust_delta, note = simulate_impact(decision, room_state, session["team"])
submission = {
"t": _now_ms(),
"round": room_state["round"],
"decision": decision,
"model_card": (model_card or "").strip(),
"decision_report": (decision_report or "").strip(),
"profit_delta": profit_delta,
"customer_delta": cust_delta,
"note": note,
}
with rooms_lock:
r = rooms.get(session["room"])
if not r:
return "Room expired or restarted."
team = r["teams"].get(session["team"])
if not team:
return "Team not found."
team["profit_points"] += profit_delta
team["customer_points"] += cust_delta
team["submissions"].append(submission)
r["log"].append({"t": _now_ms(), "type": "submit", "team": session["team"], "decision": decision})
return f"Submitted. Impact: Profit {profit_delta:+d}, Customer {cust_delta:+d}. {note}"
def _word_count(text: str) -> int:
return len([w for w in (text or "").strip().split() if w])
def _contains_any(text: str, terms: List[str]) -> bool:
t = (text or "").lower()
return any(term in t for term in terms)
def ui_tutor_feedback(session: dict, decision: str, model_card: str, decision_report: str) -> str:
if not session:
return "Join a room first."
room_state = get_room_state(session["room"])
if not room_state:
return "Room expired or restarted."
team = room_state["teams"].get(session["team"], {})
unlocked = sorted(list(team.get("unlocked_datasets", [])))
model_card = (model_card or "").strip()
decision_report = (decision_report or "").strip()
merged_text = f"{model_card}\n{decision_report}".lower()
step_1 = bool(decision and decision.strip()) and _word_count(decision_report) >= 40
step_2 = len(unlocked) > 0 and _contains_any(merged_text, [d.lower() for d in unlocked])
step_3 = _contains_any(merged_text, ["baseline", "validation", "metric", "compare", "experiment", "assumption"])
step_4 = _contains_any(merged_text, ["risk", "limitation", "trade-off", "uncertainty", "bias"])
step_5 = _contains_any(merged_text, ["next", "plan", "monitor", "kpi", "target", "threshold", "weekly"])
checks = [
("Step 1 - Decision clarity", step_1, "State one concrete action and a report of at least ~40 words."),
("Step 2 - Evidence from data", step_2, "Reference at least one unlocked dataset and what signal it shows."),
("Step 3 - Method and validation", step_3, "Add baseline/validation/metric language to show rigor."),
("Step 4 - Risks and limitations", step_4, "Include at least one downside, uncertainty, or limitation."),
("Step 5 - Execution plan + KPI", step_5, "Define next step, owner/timing, and KPI to monitor."),
]
passed = sum(1 for _, ok, _ in checks if ok)
status_lines = []
for label, ok, hint in checks:
status = "PASS" if ok else "NEEDS WORK"
status_lines.append(f"- **{label}: {status}** \n {hint}")
summary = (
f"### Tutor Feedback (Step-by-step)\n"
f"Team: `{session['team']}` | Round: `{room_state['round']}` | Score: `{passed}/5`\n\n"
+ "\n".join(status_lines)
)
if passed <= 2:
summary += "\n\n<div class='feedback-box'><b>Tutor next move:</b> Improve Step 2 and Step 3 first. Add specific dataset evidence and one validation metric.</div>"
elif passed == 3:
summary += "\n\n<div class='feedback-box'><b>Tutor next move:</b> Strong draft. Tighten risks and KPI execution details to make it decision-ready.</div>"
else:
summary += "\n\n<div class='feedback-box'><b>Tutor next move:</b> This is close to publishable. Make numbers explicit for KPI targets and timeline.</div>"
return summary
def ui_get_dataset_file(session: dict, dataset_name: str) -> Tuple[str, Optional[str]]:
if not session:
return "Join a room first.", None
room_state = get_room_state(session["room"])
if not room_state:
return "Room expired or restarted.", None
team = room_state["teams"].get(session["team"])
if not team:
return "Team not found.", None
dataset_name = (dataset_name or "").strip()
if dataset_name not in DATASETS:
return "Choose a valid dataset.", None
if dataset_name not in team.get("unlocked_datasets", set()):
return f"Dataset not unlocked yet: {dataset_name}", None
path = dataset_file_path(dataset_name)
if not path or not path.exists():
return f"File not found for {dataset_name}. Expected: data/{DATASET_FILES.get(dataset_name, '')}", None
return f"File ready: {path.name}", str(path)
def ui_dataset_plot(session: dict, dataset_name: str, plot_type: str):
if not session:
return "Join a room first.", None
room_state = get_room_state(session["room"])
if not room_state:
return "Room expired or restarted.", None
team = room_state["teams"].get(session["team"])
if not team:
return "Team not found.", None
dataset_name = (dataset_name or "").strip()
if dataset_name not in DATASETS:
return "Choose a valid dataset.", None
if dataset_name not in team.get("unlocked_datasets", set()):
return f"Dataset not unlocked yet: {dataset_name}", None
path = dataset_file_path(dataset_name)
if not path or not path.exists():
return f"File not found for {dataset_name}.", None
try:
df = pd.read_csv(path)
except Exception as exc:
return f"Could not load CSV: {exc}", None
fig, ax = plt.subplots(figsize=(8, 4.5))
try:
if dataset_name == "Sales":
data = df.groupby("hour", as_index=False)["revenue_eur"].sum()
if plot_type == "Line":
ax.plot(data["hour"], data["revenue_eur"], marker="o", color="#2d6a4f")
else:
ax.bar(data["hour"], data["revenue_eur"], color="#2d6a4f")
ax.set_title("Sales: Revenue by Hour")
ax.set_xlabel("Hour")
ax.set_ylabel("Revenue (EUR)")
elif dataset_name == "Inventory":
if plot_type == "Line":
ax.plot(df["item"], df["waste"], marker="o", color="#cc5803")
else:
ax.bar(df["item"], df["waste"], color="#cc5803")
ax.set_title("Inventory: Waste by Item")
ax.set_xlabel("Item")
ax.set_ylabel("Waste")
ax.tick_params(axis="x", rotation=30)
elif dataset_name == "Customers":
if plot_type == "Line":
data = df.groupby("segment", as_index=False)["avg_basket_eur"].mean()
ax.plot(data["segment"], data["avg_basket_eur"], marker="o", color="#3867d6")
else:
data = df.groupby("segment", as_index=False)["avg_basket_eur"].mean()
ax.bar(data["segment"], data["avg_basket_eur"], color="#3867d6")
ax.set_title("Customers: Avg Basket by Segment")
ax.set_xlabel("Segment")
ax.set_ylabel("Avg Basket (EUR)")
elif dataset_name == "Staffing":
data = df.groupby("shift", as_index=False)["avg_service_seconds"].mean()
if plot_type == "Line":
ax.plot(data["shift"], data["avg_service_seconds"], marker="o", color="#6a4c93")
else:
ax.bar(data["shift"], data["avg_service_seconds"], color="#6a4c93")
ax.set_title("Staffing: Avg Service Time by Shift")
ax.set_xlabel("Shift")
ax.set_ylabel("Seconds")
elif dataset_name == "External":
if plot_type == "Line":
ax.plot(df["date"], df["foot_traffic_index"], marker="o", color="#118ab2")
else:
ax.bar(df["date"], df["foot_traffic_index"], color="#118ab2")
ax.set_title("External: Foot Traffic Index by Date")
ax.set_xlabel("Date")
ax.set_ylabel("Foot Traffic Index")
ax.tick_params(axis="x", rotation=30)
elif dataset_name == "Feedback":
data = df.groupby("theme", as_index=False)["rating"].mean()
if plot_type == "Line":
ax.plot(data["theme"], data["rating"], marker="o", color="#2a9d8f")
else:
ax.bar(data["theme"], data["rating"], color="#2a9d8f")
ax.set_title("Feedback: Avg Rating by Theme")
ax.set_xlabel("Theme")
ax.set_ylabel("Average Rating")
ax.tick_params(axis="x", rotation=20)
ax.grid(alpha=0.2)
fig.tight_layout()
return f"Showing {plot_type.lower()} chart for {dataset_name}.", fig
except Exception as exc:
plt.close(fig)
return f"Could not generate plot: {exc}", None
def teacher_next_round(pin: str, room_code: str) -> str:
if pin.strip() != TEACHER_PIN:
return "Incorrect teacher PIN."
room_code = room_code.strip().upper()
with rooms_lock:
if room_code not in rooms:
return "Room not found."
rooms[room_code]["round"] += 1
rooms[room_code]["log"].append({"t": _now_ms(), "type": "round_advance", "round": rooms[room_code]["round"]})
return f"Advanced to round {rooms[room_code]['round']}."
def teacher_set_event(pin: str, room_code: str, event_name: str) -> str:
if pin.strip() != TEACHER_PIN:
return "Incorrect teacher PIN."
room_code = room_code.strip().upper()
with rooms_lock:
if room_code not in rooms:
return "Room not found."
if event_name == "None":
rooms[room_code]["active_event"] = None
else:
match = [e for e in EVENT_CARDS if e[0] == event_name]
rooms[room_code]["active_event"] = match[0] if match else None
rooms[room_code]["log"].append({"t": _now_ms(), "type": "event_set", "event": event_name})
return f"Event set: {event_name}"
def teacher_draw_random_event(pin: str, room_code: str) -> str:
if pin.strip() != TEACHER_PIN:
return "Incorrect teacher PIN."
room_code = room_code.strip().upper()
ev = random.choice(EVENT_CARDS)
with rooms_lock:
if room_code not in rooms:
return "Room not found."
rooms[room_code]["active_event"] = ev
rooms[room_code]["log"].append({"t": _now_ms(), "type": "event_draw", "event": ev[0]})
return f"Drew event: {ev[0]}{ev[1]}"
def teacher_room_overview(pin: str, room_code: str) -> str:
if pin.strip() != TEACHER_PIN:
return "Incorrect teacher PIN."
room_code = room_code.strip().upper()
r = get_room_state(room_code)
if not r:
return "Room not found."
lines = [f"Room {room_code} | Round {r['round']} | Teams {len(r['teams'])}"]
ev = r.get("active_event")
lines.append(f"Event: {ev[0]}{ev[1]}" if ev else "Event: None")
lines.append("")
for team, st in r["teams"].items():
lines.append(f"- {team}: budget {st['budget_left']}, profit {st['profit_points']}, customer {st['customer_points']}, unlocked {sorted(list(st['unlocked_datasets']))}")
return "\n".join(lines)
# ----------------------------
# Gradio UI
# ----------------------------
with gr.Blocks(
title="AI Café Challenge (Session-Based)",
theme=gr.themes.Soft(primary_hue="green", secondary_hue="orange", neutral_hue="stone"),
) as demo:
gr.Markdown(
"""
<div class="hero">
<h1>AI Café Challenge</h1>
<p>Run your campus café like a data team: unlock evidence, test decisions, and iterate with tutor feedback.</p>
</div>
"""
)
gr.Markdown(
"""
### How to play
1. Join an existing room, or click **Start Free Play** to explore solo.
2. Ask the assistant for hints and request datasets with a clear business question.
3. Use unlocked files and plots to build evidence, then justify your choice with facts (which dataset, what pattern, and why that supports your decision).
4. Submit a decision with model card + decision report.
5. Use **Tutor Feedback (Step-by-step)** to improve your work each round.
"""
)
with gr.Accordion("Business Context, Problem, and Expectations", open=True):
gr.Markdown(STUDENT_GAME_BRIEF)
session = gr.State({}) # per-user session state: {"room":..., "team":...}
with gr.Tab("Student"):
with gr.Row():
room_in = gr.Textbox(label="Room code", placeholder="ROOM-1234")
team_in = gr.Textbox(label="Team name", placeholder="e.g., Team Orion")
join_btn = gr.Button("Join")
free_play_btn = gr.Button("Start Free Play", variant="primary")
join_status = gr.Markdown("Not joined.", elem_classes=["block-panel"])
header = gr.Markdown("")
score_table = gr.Dataframe(
headers=["Team", "Budget left", "Profit", "Customer", "Total", "Unlocked datasets"],
datatype=["str", "number", "number", "number", "number", "str"],
row_count=8,
col_count=(6, "fixed"),
interactive=False,
label="Scoreboard",
)
event_text = gr.Markdown("")
refresh_btn = gr.Button("Refresh scoreboard")
gr.Markdown("## AI Business Assistant (Hints / Background / Brainstorming)")
chatbot = gr.Chatbot(label="Assistant", height=320)
chat_msg = gr.Textbox(label="Message", placeholder="Ask for background, or request a hint, or brainstorm approaches.")
chat_btn = gr.Button("Send")
gr.Markdown("## Request a dataset (AI will approve/deny)")
gr.Markdown(
"Justify the request in 1-2 sentences: **business question + chosen dataset + how you will use it + expected decision impact**."
)
ds_request = gr.Textbox(label="Dataset request", placeholder='Example: "We want to forecast hourly pastry demand; please unlock Sales to model seasonality and peaks."')
ds_btn = gr.Button("Request dataset")
ds_status = gr.Markdown("", elem_classes=["block-panel"])
gr.Markdown("## Access unlocked data files")
file_ds_dd = gr.Dropdown(list(DATASETS.keys()), label="Unlocked dataset", value="Sales")
file_btn = gr.Button("Get dataset file")
file_status = gr.Markdown("", elem_classes=["block-panel"])
data_file = gr.File(label="Dataset file", interactive=False)
gr.Markdown("## Data Plots")
with gr.Row():
plot_ds_dd = gr.Dropdown(list(DATASETS.keys()), label="Dataset", value="Sales")
plot_type_dd = gr.Dropdown(["Bar", "Line"], label="Plot type", value="Bar")
plot_btn = gr.Button("Show plot", variant="primary")
plot_status = gr.Markdown("", elem_classes=["block-panel"])
plot_view = gr.Plot(label="Dataset chart")
gr.Markdown("## Submit work (Decision + Model Card + Decision Report)")
decision_dd = gr.Dropdown([d[0] for d in DECISIONS], label="Decision", value=DECISIONS[0][0])
model_card = gr.Textbox(
label="Model Card (short)",
lines=8,
placeholder="Business objective...\nData used...\nMethod...\nValidation...\nKey insight...\nLimitations...",
)
decision_report = gr.Textbox(
label="Decision Report (150–200 words)",
lines=6,
placeholder="What action? Evidence? Expected impact? Confidence (Low/Med/High).",
)
submit_btn = gr.Button("Submit")
submit_status = gr.Markdown("", elem_classes=["block-panel"])
gr.Markdown("## Tutor Feedback (Step-by-step)")
tutor_btn = gr.Button("Review Draft")
tutor_feedback = gr.Markdown("Tutor feedback will appear here after you review a draft.", elem_classes=["block-panel"])
def _join(room_code, team_name):
sess, msg = join_room(room_code, team_name)
return sess, msg
def _free_play(team_name):
return start_free_play(team_name)
join_btn.click(_join, [room_in, team_in], [session, join_status]).then(
ui_refresh, [session], [header, score_table, event_text]
)
free_play_btn.click(_free_play, [team_in], [session, join_status]).then(
ui_refresh, [session], [header, score_table, event_text]
)
refresh_btn.click(ui_refresh, [session], [header, score_table, event_text])
chat_btn.click(ui_ai_chat, [session, chat_msg, chatbot], [chatbot, chat_msg])
ds_btn.click(ui_request_dataset, [session, ds_request], [ds_status, score_table])
file_btn.click(ui_get_dataset_file, [session, file_ds_dd], [file_status, data_file])
plot_btn.click(ui_dataset_plot, [session, plot_ds_dd, plot_type_dd], [plot_status, plot_view])
tutor_btn.click(ui_tutor_feedback, [session, decision_dd, model_card, decision_report], [tutor_feedback])
submit_btn.click(ui_submit_work, [session, decision_dd, model_card, decision_report], [submit_status]).then(
ui_refresh, [session], [header, score_table, event_text]
).then(
ui_tutor_feedback, [session, decision_dd, model_card, decision_report], [tutor_feedback]
)
with gr.Tab("Teacher"):
gr.Markdown("## Teacher Controls (PIN-protected)")
t_pin = gr.Textbox(label="Teacher PIN", type="password", placeholder="Set TEACHER_PIN in HF Secrets")
budget = gr.Number(label="Budget per team", value=20, precision=0)
create_btn = gr.Button("Create new room")
room_out = gr.Textbox(label="Room code (share with students)")
create_msg = gr.Markdown("")
create_btn.click(create_room, [t_pin, budget], [room_out, create_msg])
gr.Markdown("### Manage existing room")
t_room = gr.Textbox(label="Room code", placeholder="ROOM-1234")
next_round_btn = gr.Button("Next round")
next_round_msg = gr.Markdown("")
next_round_btn.click(teacher_next_round, [t_pin, t_room], [next_round_msg])
gr.Markdown("### Events")
ev_names = ["None"] + [e[0] for e in EVENT_CARDS]
ev_dd = gr.Dropdown(ev_names, label="Set event", value="None")
set_ev_btn = gr.Button("Set event")
draw_ev_btn = gr.Button("Draw random event")
ev_msg = gr.Markdown("")
set_ev_btn.click(teacher_set_event, [t_pin, t_room, ev_dd], [ev_msg])
draw_ev_btn.click(teacher_draw_random_event, [t_pin, t_room], [ev_msg])
gr.Markdown("### Overview")
overview_btn = gr.Button("Show room overview")
overview_txt = gr.Textbox(label="Overview", lines=12)
overview_btn.click(teacher_room_overview, [t_pin, t_room], [overview_txt])
gr.Markdown("")
# """
# **Deployment notes**
# - Add `OPENAI_API_KEY` as a Hugging Face **Secret**
# - Optionally set `OPENAI_MODEL` (default: `gpt-3.5-turbo-0125`)
# - Set `TEACHER_PIN` as a **Secret**
# - Set `AI_ENABLED=0` to run without AI
# - Keep dataset files under `data/` for the unlock/download flow
# """
# )
if __name__ == "__main__":
demo.launch()
# Some environments cannot access localhost directly (proxy/restricted networks).
# Enable a share link by default, configurable with GRADIO_SHARE=0 to disable.
# use_share = os.getenv("GRADIO_SHARE", "1").strip() != "0"
# demo.launch(share=use_share)