Files
ni-quiz/main.py
T

446 lines
24 KiB
Python

from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse
import json, time, random, sqlite3, os
from datetime import datetime
DB_PATH = os.environ.get("DB_PATH", "/data/quiz.db")
# ── DATABASE ──────────────────────────────────────────────────────────────────
def get_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
with get_db() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
ended_at TEXT,
total_players INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES sessions(id),
player TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
rank INTEGER,
dept TEXT NOT NULL DEFAULT '',
finished_at TEXT NOT NULL
);
""")
# Safe migration: add dept column if it doesn't exist yet
try:
conn.execute("ALTER TABLE scores ADD COLUMN dept TEXT NOT NULL DEFAULT ''")
except Exception:
pass # column already exists
init_db()
def save_session(players: list) -> int:
now = datetime.utcnow().isoformat()
with get_db() as conn:
cur = conn.execute(
"INSERT INTO sessions (started_at, ended_at, total_players) VALUES (?,?,?)",
(now, now, len(players))
)
session_id = cur.lastrowid
ranked = sorted(players, key=lambda p: p["score"], reverse=True)
for rank, p in enumerate(ranked, 1):
conn.execute(
"INSERT INTO scores (session_id, player, score, rank, dept, finished_at) VALUES (?,?,?,?,?,?)",
(session_id, p.get("display", p["name"]), p["score"], rank, p.get("dept", ""), now)
)
return session_id
# ── APP ───────────────────────────────────────────────────────────────────────
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# ── QUESTIONS ─────────────────────────────────────────────────────────────────
QUESTIONS = [
{
"q": "NI construye herramientas propias en vez de depender de licencias comerciales. ¿Por qué?\n\n[EN] NI builds its own tools instead of relying on commercial licenses. Why?",
"options": [
"Para tener control total, escalar sin costo por usuario y adaptarse a nuestros flujos reales / Full control, scale without per-user cost, adapt to real workflows",
"Para ahorrar en hardware de servidores / To save on server hardware",
"Porque las herramientas comerciales no existen para redes / Commercial tools don't exist for networks",
"Es un requisito del contrato de Liberty / It's a Liberty contract requirement"
],
"correct": 0,
"fun_fact": "Código propio = sin techo de plataforma. Podemos extender, integrar y mejorar sin pedir permiso ni pagar más. / Our code = no platform ceiling. We extend, integrate, and improve without asking permission or paying more."
},
{
"q": "¿Para quién construye herramientas el equipo de Network Intelligence?\n\n[EN] Who does the Network Intelligence team build tools for?",
"options": [
"Solo para gerencia ejecutiva / Only for executive management",
"Para clientes residenciales de Liberty / For Liberty residential customers",
"Para los equipos de NOC, ingeniería, operaciones y técnicos de campo / For NOC, engineering, operations, and field technician teams",
"Para el equipo de ventas B2B / For the B2B sales team"
],
"correct": 2,
"fun_fact": "El objetivo no es reportar — es que quien opera la red pueda decidir y actuar más rápido. / The goal isn't reporting — it's enabling the people running the network to decide and act faster."
},
{
"q": "Pathfinder junta monitoreo, rutas, topología y análisis operacional en un solo lugar. ¿Cuál es su propósito principal?\n\n[EN] Pathfinder brings together monitoring, routes, topology, and operational analysis in one place. What is its main purpose?",
"options": [
"Gestionar el presupuesto de proyectos de red / Manage the network project budget",
"Reemplazar el sistema de ticketing de la empresa / Replace the company ticketing system",
"Automatizar facturación a clientes B2B / Automate billing for B2B customers",
"Ayudar a ingeniería y NOC a ver el mismo contexto de red para decidir y actuar más rápido / Help engineering and NOC see the same network context to decide and act faster"
],
"correct": 3,
"fun_fact": "Pathfinder no es solo un dashboard — es un centro operativo. Monitoreo, rutas, topología, backbone e interfaces en una sola experiencia. / Pathfinder isn't just a dashboard — it's an operations center. Monitoring, routes, topology, backbone, and interfaces in one experience."
},
{
"q": "MetricFlow es la plataforma KPI del equipo de NI. ¿Qué problema resuelve?\n\n[EN] MetricFlow is NI's KPI platform. What problem does it solve?",
"options": [
"Reemplaza los emails internos del equipo / Replaces internal team emails",
"Centraliza métricas de desempeño, capacidad y tendencias para que los dominios puedan medir cumplimiento / Centralizes performance, capacity, and trend metrics so domains can measure compliance",
"Administra las cuentas de acceso de los empleados / Manages employee access accounts",
"Genera facturas automáticas para clientes / Generates automatic invoices for customers"
],
"correct": 1,
"fun_fact": "MetricFlow convierte datos dispersos en KPIs por dominio — desempeño, capacidad, tendencia y cumplimiento en un solo lugar. / MetricFlow turns scattered data into domain KPIs — performance, capacity, trend, and compliance in one place."
},
{
"q": "Fiber Admin organiza el inventario físico de fibra de LCPR. ¿Qué información centraliza?\n\n[EN] Fiber Admin organizes LCPR's physical fiber inventory. What information does it centralize?",
"options": [
"Contratos de proveedores y facturas de mantenimiento / Vendor contracts and maintenance invoices",
"Horarios del personal de planta externa / External plant staff schedules",
"Hubs, puertos, conexiones y empalmes de fibra para coordinar trabajo entre dominios / Hubs, ports, connections, and fiber splices to coordinate work across domains",
"Configuraciones de routers y switches de core / Core router and switch configurations"
],
"correct": 2,
"fun_fact": "Fiber Admin convierte inventario físico en información confiable. Sin eso, coordinar cambios entre dominios depende de quién recuerda qué. / Fiber Admin turns physical inventory into reliable information. Without it, coordinating changes across domains depends on who remembers what."
},
{
"q": "Los Reportes Automatizados con AI transforman datos crudos en algo más útil. ¿Qué produce exactamente?\n\n[EN] Automated AI Reports transform raw data into something more useful. What exactly does it produce?",
"options": [
"Alertas de red en tiempo real para el NOC / Real-time network alerts for NOC",
"Backups automáticos de bases de datos / Automatic database backups",
"Resúmenes, narrativas y reportes ejecutivos listos para compartir — no solo tablas de números / Summaries, narratives, and executive reports ready to share — not just number tables",
"Configuraciones automáticas de equipos de red / Automatic network equipment configurations"
],
"correct": 2,
"fun_fact": "Un reporte que solo muestra números no explica qué cambió ni qué hacer. AI convierte métricas en comunicación accionable para cualquier audiencia. / A report that only shows numbers doesn't explain what changed or what to do. AI turns metrics into actionable communication for any audience."
},
{
"q": "Znuny es la pieza que cierra el ciclo operacional del pipeline de NI. ¿Cuál es su rol clave?\n\n[EN] Znuny is the piece that closes the operational cycle in NI's pipeline. What is its key role?",
"options": [
"Conectar detección, notificación, tarea, seguimiento y resolución — que cada señal tenga dueño y cierre / Connect detection, notification, task, follow-up, and resolution — every signal has an owner and a close",
"Almacenar backups de configuración de red / Store network configuration backups",
"Procesar pagos de nómina del equipo / Process team payroll payments",
"Monitorear el consumo eléctrico del datacenter / Monitor datacenter power consumption"
],
"correct": 0,
"fun_fact": "Znuny no es solo un sistema de tickets. Es la pieza que ata el pipeline completo: lo que se detecta termina resuelto, con historial y evidencia. / Znuny isn't just a ticketing system. It's the piece that ties the whole pipeline together: what gets detected ends up resolved, with history and evidence."
},
{
"q": "El equipo de NI también apoya dominios fuera de la red pura. ¿Cuál de estas áreas es un ejemplo de eso?\n\n[EN] NI also supports domains outside pure network work. Which of these is an example?",
"options": [
"Administración de torres celulares / Cell tower administration",
"Gestión de inventario de almacén / Warehouse inventory management",
"Soporte técnico a clientes de cable / Technical support for cable customers",
"Dashboards de BI y automatización de flujos para Construcción y PNM en Power BI y Excel / BI dashboards and workflow automation for Construction and PNM in Power BI and Excel"
],
"correct": 3,
"fun_fact": "NI no es solo red — construimos soluciones analíticas para otros dominios VPTO usando las herramientas correctas para cada caso. / NI isn't just network — we build analytical solutions for other VPTO domains using the right tools for each case."
},
{
"q": "¿Cuál es la visión del equipo para el próximo capítulo — AIOps?\n\n[EN] What is the team's vision for the next chapter — AIOps?",
"options": [
"Reemplazar a todos los ingenieros con inteligencia artificial / Replace all engineers with artificial intelligence",
"Comprar una plataforma comercial de AIOps / Purchase a commercial AIOps platform",
"Detectar y predecir fallas de red antes de que el cliente las reporte, con recomendaciones automatizadas / Detect and predict network failures before the customer reports them, with automated recommendations",
"Migrar toda la infraestructura a la nube pública / Migrate all infrastructure to public cloud"
],
"correct": 2,
"fun_fact": "La meta: el técnico sale a campo con el diagnóstico ya hecho — no a diagnosticar. La red se auto-diagnostica. / The goal: the technician goes to the field with the diagnosis already done — not to diagnose. The network self-diagnoses."
},
{
"q": "¿Qué diferencia al equipo de NI de un equipo de soporte TI tradicional?\n\n[EN] What sets the NI team apart from a traditional IT support team?",
"options": [
"NI solo atiende tickets de soporte / NI only handles support tickets",
"NI construye producto interno — herramientas, plataformas y automatización que transforman cómo opera la red / NI builds internal product — tools, platforms, and automation that transform how the network operates",
"NI administra el presupuesto de licencias de software / NI manages the software license budget",
"NI hace instalaciones físicas de equipos en campo / NI does physical equipment installations in the field"
],
"correct": 1,
"fun_fact": "El backlog de NI no es soporte — es producto interno. Cada tarea construye capacidad operacional real. / The NI backlog isn't support — it's internal product. Every task builds real operational capability."
}
]
# ── GAME STATE ────────────────────────────────────────────────────────────────
class GameState:
def __init__(self):
self.reset()
def reset(self):
self.phase = "lobby"
self.players: dict = {}
self.current_q = -1
self.q_start_time = 0
self.answers_this_round = []
# 4 generals (indices 0-4) + 1 tool (indices 5-9), shuffled together
generals = random.sample(range(5), 4)
tool = random.sample(range(5, 10), 1)
combined = generals + tool
random.shuffle(combined)
self.question_order = combined
game = GameState()
# ── CONNECTION MANAGER ────────────────────────────────────────────────────────
class ConnectionManager:
def __init__(self):
self.players: dict = {}
self.hosts: list = []
async def connect_player(self, ws_id: str, ws: WebSocket):
await ws.accept()
self.players[ws_id] = ws
async def connect_host(self, ws: WebSocket):
await ws.accept()
self.hosts.append(ws)
def disconnect(self, ws_id: str):
self.players.pop(ws_id, None)
game.players.pop(ws_id, None)
def disconnect_host(self, ws: WebSocket):
if ws in self.hosts:
self.hosts.remove(ws)
async def send_to(self, ws_id: str, data: dict):
ws = self.players.get(ws_id)
if ws:
try:
await ws.send_json(data)
except Exception:
pass
async def broadcast_players(self, data: dict):
dead = []
for ws_id, ws in list(self.players.items()):
try:
await ws.send_json(data)
except Exception:
dead.append(ws_id)
for d in dead:
self.disconnect(d)
async def broadcast_hosts(self, data: dict):
dead = []
for ws in list(self.hosts):
try:
await ws.send_json(data)
except Exception:
dead.append(ws)
for d in dead:
self.disconnect_host(d)
async def broadcast_all(self, data: dict):
await self.broadcast_players(data)
await self.broadcast_hosts(data)
mgr = ConnectionManager()
def get_leaderboard():
ranked = sorted(game.players.values(), key=lambda p: p["score"], reverse=True)
return [{"name": p.get("display", p["name"]), "score": p["score"], "dept": p.get("dept","")} for p in ranked]
# ── ROUTES ────────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def player_page(request: Request):
return templates.TemplateResponse("player.html", {"request": request})
@app.get("/host", response_class=HTMLResponse)
async def host_page(request: Request):
return templates.TemplateResponse("host.html", {"request": request})
@app.get("/scores", response_class=HTMLResponse)
async def scores_page(request: Request):
return templates.TemplateResponse("scores.html", {"request": request})
@app.delete("/api/scores/clear")
async def clear_scores():
with get_db() as conn:
conn.execute("DELETE FROM scores")
conn.execute("DELETE FROM sessions")
return {"ok": True}
@app.get("/api/scores")
async def api_scores():
with get_db() as conn:
sessions = conn.execute(
"SELECT * FROM sessions ORDER BY started_at DESC LIMIT 50"
).fetchall()
result = []
for s in sessions:
players = conn.execute(
"SELECT player, score, rank, dept FROM scores WHERE session_id=? ORDER BY rank",
(s["id"],)
).fetchall()
result.append({
"id": s["id"],
"started_at": s["started_at"],
"ended_at": s["ended_at"],
"total_players": s["total_players"],
"players": [{"player": p["player"], "score": p["score"], "rank": p["rank"], "dept": p["dept"] if "dept" in p.keys() else ""} for p in players]
})
return JSONResponse(result)
# ── PLAYER WEBSOCKET ──────────────────────────────────────────────────────────
@app.websocket("/ws/player/{ws_id}")
async def player_ws(ws: WebSocket, ws_id: str):
await mgr.connect_player(ws_id, ws)
try:
await mgr.send_to(ws_id, {"type": "phase", "phase": game.phase})
async for raw in ws.iter_text():
msg = json.loads(raw)
if msg["type"] == "join":
name = msg["name"].strip()[:40]
dept = msg.get("dept", "").strip()[:40]
display = msg.get("display", name).strip()[:40]
if not name:
continue
game.players[ws_id] = {"name": name, "score": 0, "answered": False, "dept": dept, "display": display}
await mgr.send_to(ws_id, {"type": "joined", "name": name, "display": display, "dept": dept})
await mgr.broadcast_hosts({
"type": "lobby_update",
"players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()],
"count": len(game.players)
})
elif msg["type"] == "answer":
if game.phase != "question":
continue
player = game.players.get(ws_id)
if not player or player["answered"]:
continue
elapsed = time.time() - game.q_start_time
TIME_LIMIT = 30
if elapsed > TIME_LIMIT:
continue
player["answered"] = True
chosen = msg["choice"]
q_idx = game.question_order[game.current_q]
correct = QUESTIONS[q_idx]["correct"]
is_correct = chosen == correct
points = 0
if is_correct:
speed_bonus = max(0, (TIME_LIMIT - elapsed) / TIME_LIMIT)
points = int(200 + 800 * speed_bonus)
player["score"] += points
game.answers_this_round.append({
"name": player["name"], "correct": is_correct, "elapsed": round(elapsed, 2)
})
await mgr.send_to(ws_id, {
"type": "answer_result",
"correct": is_correct,
"points": points,
"total": player["score"],
"correct_idx": correct
})
answered = sum(1 for p in game.players.values() if p["answered"])
await mgr.broadcast_hosts({
"type": "answer_update",
"answered": answered,
"total": len(game.players),
"details": game.answers_this_round
})
except WebSocketDisconnect:
mgr.disconnect(ws_id)
await mgr.broadcast_hosts({
"type": "lobby_update",
"players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()],
"count": len(game.players)
})
# ── HOST WEBSOCKET ────────────────────────────────────────────────────────────
@app.websocket("/ws/host")
async def host_ws(ws: WebSocket):
await mgr.connect_host(ws)
try:
await ws.send_json({
"type": "lobby_update",
"players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()],
"count": len(game.players)
})
async for raw in ws.iter_text():
msg = json.loads(raw)
if msg["type"] == "start_game":
existing_players = dict(game.players) # preserve joined players
game.reset()
game.players = existing_players # restore them
# Reset scores and state for a fresh game
for p in game.players.values():
p["score"] = 0
p["answered"] = False
await mgr.broadcast_all({"type": "phase", "phase": "lobby"})
elif msg["type"] == "next_question":
game.current_q += 1
if game.current_q >= len(game.question_order):
lb = get_leaderboard()
save_session(list(game.players.values())) # ← persist to SQLite
game.phase = "podium"
await mgr.broadcast_all({"type": "podium", "leaderboard": lb})
continue
game.phase = "question"
game.q_start_time = time.time()
game.answers_this_round = []
for p in game.players.values():
p["answered"] = False
q_idx = game.question_order[game.current_q]
q = QUESTIONS[q_idx]
await mgr.broadcast_all({
"type": "question",
"number": game.current_q + 1,
"total": len(game.question_order),
"q": q["q"],
"options": q["options"],
"time_limit": 30,
"correct_idx_hint": q["correct"]
})
elif msg["type"] == "show_results":
game.phase = "results"
q_idx = game.question_order[game.current_q]
q = QUESTIONS[q_idx]
await mgr.broadcast_all({
"type": "results",
"correct_idx": q["correct"],
"fun_fact": q["fun_fact"],
"leaderboard": get_leaderboard()
})
elif msg["type"] == "reset":
game.reset()
await mgr.broadcast_all({"type": "phase", "phase": "lobby"})
elif msg["type"] == "clear_lobby":
await mgr.broadcast_players({"type": "kicked"})
game.reset()
await mgr.broadcast_hosts({"type": "lobby_update", "players": [], "count": 0})
except WebSocketDisconnect:
mgr.disconnect_host(ws)