refactor: DB 제거 → 단일 hackathon.json (JSON only)

DB(sqlite + WAL) 제거. 모든 state를 단일 JSON 파일로 통합.
일회용/내부용이라 유지보수성/확장성보다 단순성 우선.

변경:
- app.py: sqlite3 import 제거. load_data/save_data + threading.RLock + atomic write
  - votes: list of dict
  - titles, tie_breaks, settings: dict
  - people: roster (assign_teams가 채움)
  - 누락 키 자동 보강
- assign_teams.py: hackathon.json 단일 출력. 기존 votes/titles 보존
- Dockerfile/compose: votes.db volume 제거. hackathon.json read-write mount
- tests/e2e.py: 12개 (12/12 통과). load/save/insert_vote/clear_votes/atomic 추가
- README: 새 데이터 구조 문서화
- roster.json/participants.json 제거 (hackathon.json으로 통합)

호스트 편집 워크플로:
- jq/vi로 hackathon.json 직접 편집
- 앱 매 요청 reload — 컨테이너 재시작 불필요

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-04-26 18:25:46 +09:00
parent 6cfc75e3b8
commit 6e517be918
8 changed files with 413 additions and 530 deletions

534
app.py
View File

@@ -1,225 +1,180 @@
"""
해커톤 투표 앱
해커톤 투표 앱 — DB 없이 단일 JSON.
- 참가자: 이름 선택 → 본인 팀 자동 → 본인 팀 제외 3분야 투표
- 진행자: ?mode=admin&token=XXX 로 결과 확인
- 시상: ?mode=ceremony&token=XXX
"""
import csv
import io
import json
import os
import sqlite3
import random as _rand
import threading
from datetime import datetime
from pathlib import Path
import streamlit as st
DB_PATH = os.environ.get("VOTE_DB", "votes.db")
DATA_PATH = os.environ.get(
"DATA_PATH", str(Path(__file__).parent / "hackathon.json")
)
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "change-me")
ROSTER_PATH = os.environ.get(
"ROSTER", str(Path(__file__).parent / "roster.json")
)
LEGACY_PARTICIPANTS_PATH = os.environ.get(
"PARTICIPANTS", str(Path(__file__).parent / "participants.json")
)
def load_roster():
"""
roster.json 우선, 없으면 legacy participants.json fallback.
매 호출마다 디스크에서 read → 호스트 편집 핫리로드.
return: {"people": [...]} 형식 dict
"""
roster_p = Path(ROSTER_PATH)
if roster_p.exists():
return json.loads(roster_p.read_text(encoding="utf-8"))
legacy_p = Path(LEGACY_PARTICIPANTS_PATH)
if legacy_p.exists():
# legacy: {name: team_str}
legacy = json.loads(legacy_p.read_text(encoding="utf-8"))
return {
"people": [
{"name": n, "team": t, "dept": "", "senior": False, "notes": ""}
for n, t in legacy.items()
]
}
return {"people": []}
def get_participants():
"""이름→팀 매핑. 매 호출 reload (호스트 편집 즉시 반영)."""
roster = load_roster()
return {p["name"]: p["team"] for p in roster.get("people", [])}
def get_teams():
"""팀명 정렬 list. 매 호출 reload."""
parts = get_participants()
return sorted(set(parts.values())) if parts else [f"{i}" for i in range(1, 8)]
CATEGORIES = [
("fun_team", "🎉 재미상", "손선풍기 5개"),
("polish_team", "🏆 완성도상", "양우산 5개"),
("utility_team", "🛠 실용성상", "팜레스트 5개"),
]
# 수상 결정 우선순위 (높을수록 먼저 결정, 후순위 상에서 그 팀 제외)
# 팜레스트(실용성) > 양우산(완성도) > 손선풍기(재미)
PRIZE_PRIORITY = ["utility_team", "polish_team", "fun_team"]
_lock = threading.RLock()
def get_conn():
conn = sqlite3.connect(DB_PATH, timeout=10)
# WAL 모드: 동시 read/write 충돌 ↓
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA synchronous = NORMAL")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
voter_name TEXT NOT NULL UNIQUE,
employee_id TEXT,
voter_team TEXT NOT NULL,
fun_team TEXT NOT NULL,
polish_team TEXT NOT NULL,
utility_team TEXT NOT NULL,
created_at TEXT NOT NULL
def _empty_state():
return {
"people": [],
"settings": {"voting_open": True},
"titles": {},
"tie_breaks": {},
"votes": [],
}
def load_data():
"""매 호출 디스크 read. 호스트 편집 즉시 반영."""
with _lock:
p = Path(DATA_PATH)
if not p.exists():
return _empty_state()
try:
data = json.loads(p.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return _empty_state()
# 누락 키 채움
base = _empty_state()
base.update(data)
for k, v in _empty_state().items():
base.setdefault(k, v)
return base
def save_data(data):
"""원자적 write — tmp 파일 + rename."""
with _lock:
tmp = DATA_PATH + ".tmp"
Path(tmp).write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
"""
)
# 기존 DB 마이그레이션
cols = [r[1] for r in conn.execute("PRAGMA table_info(votes)").fetchall()]
if "employee_id" not in cols:
conn.execute("ALTER TABLE votes ADD COLUMN employee_id TEXT")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS tie_breaks (
category TEXT PRIMARY KEY,
winner_team TEXT NOT NULL,
method TEXT NOT NULL, -- 'random' or 'manual'
decided_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS team_titles (
team_name TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT ''
)
"""
)
conn.commit()
return conn
os.replace(tmp, DATA_PATH)
def update_data(fn):
"""load → modify → save 원자적 묶음."""
with _lock:
data = load_data()
result = fn(data)
save_data(data)
return result
def get_participants():
return {p["name"]: p["team"] for p in load_data().get("people", [])}
def get_teams():
parts = get_participants()
return sorted(set(parts.values())) if parts else [f"{i}" for i in range(1, 8)]
def get_titles():
"""팀명 → 결과물 제목 dict. 매 호출 DB 조회 (라이브 반영)."""
conn = get_conn()
rows = conn.execute("SELECT team_name, title FROM team_titles").fetchall()
conn.close()
return dict(rows)
return load_data().get("titles", {})
def set_title(team, title):
conn = get_conn()
conn.execute(
"INSERT INTO team_titles (team_name, title) VALUES (?, ?) "
"ON CONFLICT(team_name) DO UPDATE SET title = excluded.title",
(team, title.strip()),
)
conn.commit()
conn.close()
def _fn(data):
data["titles"][team] = title.strip()
update_data(_fn)
def is_voting_open():
return load_data().get("settings", {}).get("voting_open", True)
def set_voting_open(flag):
def _fn(data):
data["settings"]["voting_open"] = bool(flag)
update_data(_fn)
def get_tie_breaks():
return load_data().get("tie_breaks", {})
def save_tie_break(category, winner, method):
def _fn(data):
data["tie_breaks"][category] = {
"winner_team": winner,
"method": method,
"decided_at": datetime.now().isoformat(timespec="seconds"),
}
update_data(_fn)
def clear_tie_break(category):
def _fn(data):
data["tie_breaks"].pop(category, None)
update_data(_fn)
def insert_vote(voter_name, employee_id, voter_team, picks):
"""UNIQUE on voter_name. 이미 있으면 ValueError."""
def _fn(data):
existing = {v["voter_name"] for v in data["votes"]}
if voter_name in existing:
raise ValueError("DUPLICATE_VOTER")
data["votes"].append(
{
"voter_name": voter_name,
"employee_id": employee_id,
"voter_team": voter_team,
"fun_team": picks["fun_team"],
"polish_team": picks["polish_team"],
"utility_team": picks["utility_team"],
"created_at": datetime.now().isoformat(timespec="seconds"),
}
)
update_data(_fn)
def list_votes():
return load_data().get("votes", [])
def clear_votes():
def _fn(data):
data["votes"] = []
update_data(_fn)
def fmt_team(team, titles):
"""팀 라벨 — 제목 있으면 'N팀 — 제목' 없으면 'N팀'."""
t = titles.get(team, "")
return f"{team}{t}" if t else team
def is_voting_open():
conn = get_conn()
row = conn.execute(
"SELECT value FROM settings WHERE key = 'voting_open'"
).fetchone()
conn.close()
return row is None or row[0] == "1"
def set_voting_open(flag):
conn = get_conn()
conn.execute(
"INSERT INTO settings (key, value) VALUES ('voting_open', ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
("1" if flag else "0",),
)
conn.commit()
conn.close()
def get_tie_breaks():
conn = get_conn()
rows = conn.execute("SELECT category, winner_team FROM tie_breaks").fetchall()
conn.close()
return dict(rows)
def save_tie_break(category, winner, method):
conn = get_conn()
conn.execute(
"INSERT INTO tie_breaks (category, winner_team, method, decided_at) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(category) DO UPDATE SET "
"winner_team = excluded.winner_team, "
"method = excluded.method, "
"decided_at = excluded.decided_at",
(category, winner, method, datetime.now().isoformat(timespec="seconds")),
)
conn.commit()
conn.close()
def clear_tie_break(category):
conn = get_conn()
conn.execute("DELETE FROM tie_breaks WHERE category = ?", (category,))
conn.commit()
conn.close()
def compute_winners():
"""
우선순위 기반 1팀 1상 + 동률 처리.
동률 발견 시 tie_breaks 저장 결정 사용. 미결정이면 status='tie' 반환.
return: (winners dict, rankings dict)
winners[col] = {
"status": "ok"|"tie"|"empty",
"team": str, # ok 시
"votes": int,
"diff": int, # ok 시 2위와 차이
"tied": [str], # tie 시 동률 후보들
"method": str|None, # tie_break 적용 방식
}
"""
conn = get_conn()
"""우선순위 기반 1팀 1상 + 동률 처리."""
data = load_data()
votes = data.get("votes", [])
tie_decisions = data.get("tie_breaks", {})
rankings = {}
for col, _, _ in CATEGORIES:
rankings[col] = conn.execute(
f"SELECT {col} AS team, COUNT(*) AS c FROM votes "
f"GROUP BY {col} ORDER BY c DESC, team ASC"
).fetchall()
tie_decisions = dict(
conn.execute(
"SELECT category, winner_team || '||' || method FROM tie_breaks"
).fetchall()
)
conn.close()
counts = {}
for v in votes:
t = v.get(col)
if t:
counts[t] = counts.get(t, 0) + 1
rankings[col] = sorted(counts.items(), key=lambda x: (-x[1], x[0]))
winners = {}
excluded = set()
@@ -234,10 +189,10 @@ def compute_winners():
tied = [t for t, c in filtered if c == top_votes]
if len(tied) > 1:
decision = tie_decisions.get(col, "")
chosen, _, method = decision.partition("||")
if chosen and chosen in tied:
winner = chosen
decision = tie_decisions.get(col)
if decision and decision.get("winner_team") in tied:
winner = decision["winner_team"]
method = decision.get("method", "")
else:
winners[col] = {
"status": "tie",
@@ -262,6 +217,42 @@ def compute_winners():
return winners, rankings
def archive_results():
"""결과 timestamped JSON. 모든 winners ok일 때만."""
winners, _ = compute_winners()
if any(w.get("status") != "ok" for w in winners.values()):
return None
titles = get_titles()
data = {
"timestamp": datetime.now().isoformat(timespec="seconds"),
"results": [],
}
for col, label, prize in CATEGORIES:
w = winners.get(col)
if w and w.get("status") == "ok":
data["results"].append(
{
"category": label,
"prize": prize,
"team": w["team"],
"title": titles.get(w["team"], ""),
"votes": w["votes"],
"diff_2nd": w["diff"],
"method": w.get("method") or "majority",
}
)
archive_dir = os.path.dirname(DATA_PATH) or "."
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = os.path.join(archive_dir, f"results_{ts}.json")
Path(path).write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
return path
# --- UI ---
def render_voter():
st.title("🗳 해커톤 투표")
@@ -277,33 +268,25 @@ def render_voter():
- 본인 팀 자동 매핑
- 본인 팀 **제외**, 다른 6팀 중 각 분야별 1팀씩 투표 (총 3표)
- **한 번만 제출 가능** (이름 unique)
- 사번은 사칭 의심 시 추적용으로 기록됨 (본인 이름이 아닌데 투표한 경우 사번 주인에게 확인)
- 사번은 사칭 의심 시 추적용으로 기록됨
**수상 결정 — 1팀 1상 한정**
- 우선순위: 🛠 **실용성상 (팜레스트)** > 🏆 **완성도상 (양우산)** > 🎉 **재미상 (손선풍기)**
- 실용성 1위 팀이 팜레스트 수상 → 그 팀은 다른 상 후보에서 자동 제외
- 완성도상도 같은 방식, 마지막 재미상까지 결정
- → 한 팀이 모든 분야 1위여도 **가장 비싼 상 1개**만 받음
- 상위상 수상 팀은 후순위 상에서 자동 제외
- 한 팀이 모든 분야 1위여도 **가장 비싼 상 1개**만 받음
**시상 발표 순서**
- 🎉 재미상 → 🏆 완성도상 → 🛠 실용성상 (긴장감 build-up, 최고가 마무리)
- 🎉 재미상 → 🏆 완성도상 → 🛠 실용성상 (긴장감 build-up)
"""
)
st.caption("이름 선택 → 본인 팀 자동 매핑 → 본인 팀 제외 3분야 투표. 한 번만 제출 가능.")
PARTS = get_participants()
TEAMS = get_teams()
if not PARTS:
st.error("참가자 명단이 없습니다. `assign_teams.py` 먼저 실행하세요.")
return
# 이미 투표한 사람 미리 조회 → selectbox에 ✅ 표시
conn = get_conn()
voted_set = {
r[0] for r in conn.execute("SELECT voter_name FROM votes").fetchall()
}
conn.close()
voted_set = {v["voter_name"] for v in list_votes()}
def fmt_name(n):
return f"{n} ✅ (이미 투표함)" if n in voted_set else n
@@ -356,32 +339,12 @@ def render_voter():
if any(picks.get(col) is None for col, _, _ in CATEGORIES):
st.error("3분야 모두 선택하세요.")
return
conn = get_conn()
try:
conn.execute(
"""
INSERT INTO votes
(voter_name, employee_id, voter_team, fun_team, polish_team, utility_team, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
name,
employee_id.strip(),
my_team,
picks["fun_team"],
picks["polish_team"],
picks["utility_team"],
datetime.now().isoformat(timespec="seconds"),
),
)
conn.commit()
insert_vote(name, employee_id.strip(), my_team, picks)
st.success(f"{name}님 ({my_team}) 투표 완료. 창 닫아도 됩니다.")
st.balloons()
except sqlite3.IntegrityError:
except ValueError:
st.error(f"'{name}' 이미 투표함. 진행자에게 문의하세요.")
finally:
conn.close()
def render_admin():
@@ -392,7 +355,6 @@ def render_admin():
st.title("🔐 진행자 콘솔")
# 투표 마감 토글
voting_open = is_voting_open()
cur_label = "🟢 투표 진행 중" if voting_open else "🔴 투표 마감됨"
st.markdown(f"### 투표 상태: {cur_label}")
@@ -410,10 +372,10 @@ def render_admin():
PARTS = get_participants()
TEAMS = get_teams()
conn = get_conn()
total = conn.execute("SELECT COUNT(*) FROM votes").fetchone()[0]
votes = list_votes()
total = len(votes)
expected = len(PARTS) if PARTS else None
col_a, col_b, col_c = st.columns(3)
col_a.metric("투표 참여자", f"{total}")
col_b.metric("팀 수", f"{len(TEAMS)}")
@@ -422,7 +384,7 @@ def render_admin():
col_c.metric("참여율", f"{pct}% ({total}/{expected})")
if expected and total < expected:
voted = {row[0] for row in conn.execute("SELECT voter_name FROM votes").fetchall()}
voted = {v["voter_name"] for v in votes}
not_voted = [n for n in PARTS.keys() if n not in voted]
with st.expander(f"⏳ 미투표자 ({len(not_voted)}명)"):
for n in sorted(not_voted):
@@ -430,7 +392,6 @@ def render_admin():
st.divider()
st.subheader("📝 팀별 결과물 제목 입력")
st.caption("발표 직후 입력하면 투표 페이지에 즉시 반영됩니다.")
titles = get_titles()
with st.form("titles_form"):
new_titles = {}
@@ -460,7 +421,6 @@ def render_admin():
}
public_lines = []
# 동률 미해결 부문 먼저 표시 (진행자 액션 필요)
pending_ties = [
(col, label, prize)
for col, label, prize in CATEGORIES
@@ -487,14 +447,13 @@ def render_admin():
if status == "tie":
tied = result["tied"]
votes = result["votes"]
votes_n = result["votes"]
tied_labels = ", ".join(fmt_team(t, titles) for t in tied)
st.warning(f"🟰 동률 ({votes}표): {tied_labels}")
st.warning(f"🟰 동률 ({votes_n}표): {tied_labels}")
ca, cb = st.columns([1, 2])
with ca:
if st.button("🎲 즉석 추첨", key=f"draw_{col}"):
import random as _r
chosen = _r.choice(tied)
chosen = _rand.choice(tied)
save_tie_break(col, chosen, "random")
st.rerun()
with cb:
@@ -511,7 +470,6 @@ def render_admin():
st.rerun()
continue
# status == "ok"
winner_team = result["team"]
winner_votes = result["votes"]
diff = result["diff"]
@@ -527,7 +485,7 @@ def render_admin():
f"(2위와 {diff}표 차이)"
)
if method:
if st.button(f"동률 결정 취소 (재추첨)", key=f"clear_{col}"):
if st.button("동률 결정 취소 (재추첨)", key=f"clear_{col}"):
clear_tie_break(col)
st.rerun()
public_lines.append(
@@ -548,22 +506,24 @@ def render_admin():
st.divider()
with st.expander("🔍 감사 로그 (사칭 추적용)"):
st.caption("이름, 사번, 시각, 투표 내역. 의심 시 사번 주인에게 확인.")
rows = conn.execute(
"SELECT created_at, voter_name, employee_id, voter_team, "
"fun_team, polish_team, utility_team FROM votes ORDER BY created_at"
).fetchall()
if not rows:
if not votes:
st.caption("투표 없음")
else:
import io
import csv
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["시각", "이름", "사번", "본인팀", "재미", "완성도", "실용성"])
for r in rows:
w.writerow(r)
# UTF-8 BOM → Excel 한글 호환
for v in votes:
w.writerow(
[
v["created_at"],
v["voter_name"],
v.get("employee_id") or "",
v["voter_team"],
v["fun_team"],
v["polish_team"],
v["utility_team"],
]
)
csv_data = "" + buf.getvalue()
st.download_button(
"CSV 내려받기",
@@ -573,24 +533,23 @@ def render_admin():
)
st.dataframe(
{
"시각": [r[0] for r in rows],
"이름": [r[1] for r in rows],
"사번": [r[2] or "" for r in rows],
"본인팀": [r[3] for r in rows],
"재미": [r[4] for r in rows],
"완성도": [r[5] for r in rows],
"실용성": [r[6] for r in rows],
"시각": [v["created_at"] for v in votes],
"이름": [v["voter_name"] for v in votes],
"사번": [v.get("employee_id") or "" for v in votes],
"본인팀": [v["voter_team"] for v in votes],
"재미": [v["fun_team"] for v in votes],
"완성도": [v["polish_team"] for v in votes],
"실용성": [v["utility_team"] for v in votes],
},
use_container_width=True,
hide_index=True,
)
# 사번 중복 의심 (같은 사번이 여러 이름으로 투표)
from collections import defaultdict
by_emp = defaultdict(list)
for r in rows:
if r[2]:
by_emp[r[2]].append(r[1])
for v in votes:
if v.get("employee_id"):
by_emp[v["employee_id"]].append(v["voter_name"])
dups = {emp: names for emp, names in by_emp.items() if len(set(names)) > 1}
if dups:
st.error("⚠️ 같은 사번이 여러 이름으로 투표한 케이스:")
@@ -600,74 +559,29 @@ def render_admin():
st.divider()
with st.expander("⚠️ 위험 작업"):
if st.button("모든 투표 삭제 (되돌릴 수 없음)"):
conn.execute("DELETE FROM votes")
conn.commit()
clear_votes()
st.warning("전체 삭제됨. 새로고침하세요.")
conn.close()
def archive_results():
"""결과를 timestamped JSON 파일로 저장 (DB 손실 보험)."""
winners, _ = compute_winners()
if any(w.get("status") != "ok" for w in winners.values()):
return None
titles = get_titles()
data = {
"timestamp": datetime.now().isoformat(timespec="seconds"),
"results": [],
}
for col, label, prize in CATEGORIES:
w = winners.get(col)
if w and w.get("status") == "ok":
data["results"].append(
{
"category": label,
"prize": prize,
"team": w["team"],
"title": titles.get(w["team"], ""),
"votes": w["votes"],
"diff_2nd": w["diff"],
"method": w.get("method") or "majority",
}
)
archive_dir = os.path.dirname(DB_PATH) or "."
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = os.path.join(archive_dir, f"results_{ts}.json")
Path(path).write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
return path
def render_ceremony():
"""시상식 reveal 페이지. 진행자가 클릭으로 단계별 공개."""
token = st.query_params.get("token", "")
if token != ADMIN_TOKEN:
st.error("권한 없음. ?mode=ceremony&token=... 형식 필요.")
return
titles = get_titles()
# 투표 0건이면 ceremony 진입 불가
conn = get_conn()
total_votes = conn.execute("SELECT COUNT(*) FROM votes").fetchone()[0]
conn.close()
if total_votes == 0:
votes = list_votes()
if not votes:
st.warning("📊 아직 투표가 없습니다. 투표 종료 후 시상식을 시작하세요.")
return
# 투표가 아직 열려 있으면 경고 (시상 도중 결과 변경 위험)
if is_voting_open():
st.error(
"⚠️ 투표가 아직 진행 중입니다. 어드민에서 '투표 마감' 후 시상하세요. "
"(현재 결과가 시상 중 바뀔 수 있음)"
"⚠️ 투표가 아직 진행 중입니다. 어드민에서 '투표 마감' 후 시상하세요."
)
return
winners, _ = compute_winners()
# 동률 미해결 있으면 ceremony 차단 (부문/후보 정보는 노출 X — 발표 spoiler 방지)
pending_count = sum(
1 for col, _, _ in CATEGORIES
if winners.get(col, {}).get("status") == "tie"
@@ -676,13 +590,11 @@ def render_ceremony():
st.warning("⏳ 시상 준비 중입니다. 잠시만 기다려주세요.")
return
# 진입 시 1회 archive (DB 손실 보험)
if not st.session_state.get("ceremony_archived"):
archived_path = archive_results()
if archived_path:
st.session_state.ceremony_archived = archived_path
# CATEGORIES 순서로 reveal (손선풍기 → 양우산 → 팜레스트)
results = []
for col, label, prize in CATEGORIES:
result = winners.get(col, {})
@@ -740,7 +652,7 @@ def render_ceremony():
st.rerun()
else:
label, prize, winner, votes, diff = results[step - 1]
label, prize, winner, votes_n, diff = results[step - 1]
st.markdown(f'<div class="stage-title">{label}</div>', unsafe_allow_html=True)
st.markdown(f'<div class="stage-prize">🎁 상품: {prize}</div>', unsafe_allow_html=True)
@@ -757,7 +669,7 @@ def render_ceremony():
unsafe_allow_html=True,
)
st.markdown(
f'<div class="winner-meta">{votes}표 (2위와 {diff}표 차이)</div>',
f'<div class="winner-meta">{votes_n}표 (2위와 {diff}표 차이)</div>',
unsafe_allow_html=True,
)
next_label = "다음 부문 →" if step < len(results) else "마무리 →"