feat: 35명 7팀 배정 + 이름 기반 자동 팀 매핑

- assign_teams.py: 부서 다양성 제약(같은 부서 ≤2명) 시드 고정 배정
- participants.json: 이름→팀 매핑 산출물
- app.py: 이름 선택 → 본인 팀 자동 표시 (수동 입력 부정 차단)
- 어드민 참여율 메트릭 + 미투표자 목록

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-04-25 19:02:27 +09:00
parent 5fe8842e88
commit e661372f84
4 changed files with 215 additions and 34 deletions

View File

@@ -2,18 +2,28 @@
35명 / 7팀 / 3분야 (재미·완성도·실용성) 투표 앱. 본인 팀 제외 투표.
## 흐름
1. `assign_teams.py` 실행 → `participants.json` 생성 (이름→팀 매핑)
2. `app.py` 실행 → 참가자가 본인 이름 선택 → 자동 본인 팀 매핑 → 다른 6팀에 3분야 투표
3. 어드민 페이지에서 분야별 1위와 2위 차이만 공개 (하위 표수는 expander 내부)
## 실행
```bash
# 1. 의존성 설치
pip install -r requirements.txt
# 2. 환경변수 (선택)
export TEAMS="팀1,팀2,팀3,팀4,팀5,팀6,팀7" # 콤마 구분
export ADMIN_TOKEN="강한-토큰-아무거나"
export VOTE_DB="votes.db" # sqlite 파일 경로
# 2. 팀 배정 (시드 고정 = 재현 가능)
python3 assign_teams.py
# → participants.json 저장됨
# 3. 실행 (홈서버, 외부 접속 허용)
# 3. 환경변수 (선택)
export ADMIN_TOKEN="강한-토큰-아무거나"
export VOTE_DB="votes.db"
export PARTICIPANTS="participants.json"
# 4. 홈서버 실행 (외부 접속 허용)
streamlit run app.py --server.address 0.0.0.0 --server.port 8501
```
@@ -31,7 +41,8 @@ streamlit run app.py --server.address 0.0.0.0 --server.port 8501
## 데이터
`votes.db` (sqlite). 테이블: `votes(id, voter_name UNIQUE, voter_team, fun_team, polish_team, utility_team, created_at)`.
- `participants.json` — 이름→팀 매핑 (`assign_teams.py` 산출물)
- `votes.db` (sqlite) — 테이블: `votes(id, voter_name UNIQUE, voter_team, fun_team, polish_team, utility_team, created_at)`
## 운영 팁

88
app.py
View File

@@ -1,18 +1,35 @@
"""
해커톤 투표 앱
- 참가자: 본인 팀 제외하고 3분야 투표
- 참가자: 이름 선택 → 본인 팀 자동 → 본인 팀 제외 3분야 투표
- 진행자: ?mode=admin&token=XXX 로 결과 확인
"""
import json
import os
import sqlite3
from datetime import datetime
from pathlib import Path
import streamlit as st
DB_PATH = os.environ.get("VOTE_DB", "votes.db")
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "change-me")
TEAMS = os.environ.get("TEAMS", "팀1,팀2,팀3,팀4,팀5,팀6,팀7").split(",")
TEAMS = [t.strip() for t in TEAMS if t.strip()]
PARTICIPANTS_PATH = os.environ.get(
"PARTICIPANTS", str(Path(__file__).parent / "participants.json")
)
def load_participants():
"""이름→팀 매핑. 파일 없으면 빈 dict."""
p = Path(PARTICIPANTS_PATH)
if not p.exists():
return {}
return json.loads(p.read_text(encoding="utf-8"))
PARTICIPANTS = load_participants()
TEAMS = sorted(set(PARTICIPANTS.values())) if PARTICIPANTS else [
f"{i}" for i in range(1, 8)
]
CATEGORIES = [
("fun_team", "🎉 재미상"),
@@ -42,31 +59,35 @@ def get_conn():
def render_voter():
st.title("🗳 해커톤 투표")
st.caption("본인 팀 제외하고 다른 팀에 투표하세요. 한 번만 제출 가능.")
st.caption("이름 선택 → 본인 팀 자동 매핑 → 본인 팀 제외 3분야 투표. 한 번만 제출 가능.")
if not PARTICIPANTS:
st.error("참가자 명단이 없습니다. `assign_teams.py` 먼저 실행하세요.")
return
name = st.selectbox(
"본인 이름",
options=sorted(PARTICIPANTS.keys()),
index=None,
placeholder="이름 선택",
)
if not name:
st.info("이름을 선택하면 본인 팀이 자동 표시됩니다.")
return
my_team = PARTICIPANTS[name]
st.info(f"본인 팀: **{my_team}**")
candidates = [t for t in TEAMS if t != my_team]
with st.form("vote", clear_on_submit=False):
name = st.text_input("이름", placeholder="홍길동")
my_team = st.selectbox("본인 팀", TEAMS, index=None, placeholder="선택")
if my_team:
candidates = [t for t in TEAMS if t != my_team]
st.divider()
picks = {}
for col, label in CATEGORIES:
picks[col] = st.radio(label, candidates, index=None, key=col)
submitted = st.form_submit_button("제출")
else:
st.info("본인 팀을 먼저 선택하세요.")
submitted = st.form_submit_button("제출", disabled=True)
picks = {}
st.divider()
picks = {}
for col, label in CATEGORIES:
picks[col] = st.radio(label, candidates, index=None, key=col)
submitted = st.form_submit_button("제출")
if submitted:
if not name.strip():
st.error("이름을 입력하세요.")
return
if not my_team:
st.error("본인 팀을 선택하세요.")
return
if any(picks.get(col) is None for col, _ in CATEGORIES):
st.error("3분야 모두 선택하세요.")
return
@@ -80,7 +101,7 @@ def render_voter():
VALUES (?, ?, ?, ?, ?, ?)
""",
(
name.strip(),
name,
my_team,
picks["fun_team"],
picks["polish_team"],
@@ -89,10 +110,10 @@ def render_voter():
),
)
conn.commit()
st.success(f"{name.strip()} 투표 완료. 창 닫아도 됩니다.")
st.success(f"{name}님 ({my_team}) 투표 완료. 창 닫아도 됩니다.")
st.balloons()
except sqlite3.IntegrityError:
st.error(f"'{name.strip()}' 이미 투표한 이름입니다. 진행자에게 문의하세요.")
st.error(f"'{name}' 이미 투표. 진행자에게 문의하세요.")
finally:
conn.close()
@@ -108,9 +129,20 @@ def render_admin():
conn = get_conn()
total = conn.execute("SELECT COUNT(*) FROM votes").fetchone()[0]
col_a, col_b = st.columns(2)
expected = len(PARTICIPANTS) if PARTICIPANTS else None
col_a, col_b, col_c = st.columns(3)
col_a.metric("투표 참여자", f"{total}")
col_b.metric("팀 수", f"{len(TEAMS)}")
if expected:
pct = int(100 * total / expected)
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()}
not_voted = [n for n in PARTICIPANTS.keys() if n not in voted]
with st.expander(f"⏳ 미투표자 ({len(not_voted)}명)"):
for n in sorted(not_voted):
st.write(f"- {n} ({PARTICIPANTS[n]})")
st.divider()
st.subheader("📊 분야별 집계")

101
assign_teams.py Normal file
View File

@@ -0,0 +1,101 @@
"""
참가자 35명 → 7팀 (팀당 5명) 배정.
같은 부서 2명 이하 제약. 랜덤 시드로 재현 가능.
결과: participants.json (이름→팀 매핑) + 콘솔 출력.
"""
import json
import random
from collections import Counter
from pathlib import Path
PEOPLE = [
("한지승", "MLOps Platform"),
("변수민", "MLOps Data"),
("박재호", "MLOps Data"),
("김태현", "MLOps Data"),
("강승형", "MLOps Data"),
("손현준", "MLOps Data"),
("김동국", "MLOps Data"),
("김재현", "MLOps HPC"),
("이준석", "MLOps HPC"),
("오근현", "MLOps HPC"),
("김정명", "MLOps HPC"),
("김영관", "MLOps HPC"),
("유용혁", "MLOps HPC"),
("최호진", "MLOps HPC"),
("전효준", "MLOps HPC"),
("김병훈", "MLOps System"),
("이지환", "MLOps System"),
("서희", "MLOps System"),
("정채윤", "MLOps System"),
("장혁진", "MLOps System"),
("장다현", "MLOps System"),
("박영훈", "MLOps System"),
("길주현", "MLOps System"),
("조민정", "AI Efficiency Tech"),
("김민섭", "AI Efficiency Tech"),
("김호승", "AI Efficiency Tech"),
("서한배", "AI Efficiency Tech"),
("심성환", "AI Efficiency Tech"),
("유준희", "AI Efficiency Tech"),
("이성재", "AI Efficiency Tech"),
("이재광", "AI Efficiency Tech"),
("이정태", "AI Efficiency Tech"),
("이준형", "AI Efficiency Tech"),
("정현준", "AI Efficiency Tech"),
("유지원", "AI Efficiency Tech"),
]
NUM_TEAMS = 7
TEAM_SIZE = 5
MAX_SAME_DEPT = 2
SEED = 20260428 # 행사일 시드 (재현 가능)
def assign(seed):
rng = random.Random(seed)
for attempt in range(5000):
shuffled = PEOPLE[:]
rng.shuffle(shuffled)
teams = [
shuffled[i * TEAM_SIZE : (i + 1) * TEAM_SIZE]
for i in range(NUM_TEAMS)
]
ok = all(
max(Counter(d for _, d in team).values()) <= MAX_SAME_DEPT
for team in teams
)
if ok:
return teams, attempt + 1
raise RuntimeError(f"제약 만족하는 배정 5000회 시도에도 실패")
def main():
assert len(PEOPLE) == NUM_TEAMS * TEAM_SIZE, (
f"인원수 불일치: {len(PEOPLE)}명, 기대 {NUM_TEAMS * TEAM_SIZE}"
)
teams, attempts = assign(SEED)
print(f"# 시드 {SEED}, 시도 {attempts}회 만에 배정 완료\n")
mapping = {}
for i, team in enumerate(teams, 1):
team_name = f"{i}"
dept_counts = Counter(d for _, d in team)
dept_summary = ", ".join(f"{d.replace('MLOps ', '').replace('AI Efficiency Tech', 'AI Eff')} {c}" for d, c in dept_counts.items())
print(f"## {team_name} ({dept_summary})")
for name, dept in team:
print(f" - {name} ({dept})")
mapping[name] = team_name
print()
out = Path(__file__).parent / "participants.json"
out.write_text(
json.dumps(mapping, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"저장: {out}")
if __name__ == "__main__":
main()

37
participants.json Normal file
View File

@@ -0,0 +1,37 @@
{
"유준희": "팀1",
"김재현": "팀1",
"조민정": "팀1",
"손현준": "팀1",
"유용혁": "팀1",
"한지승": "팀2",
"김정명": "팀2",
"박재호": "팀2",
"김동국": "팀2",
"장혁진": "팀2",
"강승형": "팀3",
"김태현": "팀3",
"김민섭": "팀3",
"이성재": "팀3",
"길주현": "팀3",
"서한배": "팀4",
"최호진": "팀4",
"김영관": "팀4",
"이정태": "팀4",
"박영훈": "팀4",
"유지원": "팀5",
"전효준": "팀5",
"정현준": "팀5",
"장다현": "팀5",
"정채윤": "팀5",
"오근현": "팀6",
"이준형": "팀6",
"이준석": "팀6",
"서희": "팀6",
"김호승": "팀6",
"이재광": "팀7",
"이지환": "팀7",
"김병훈": "팀7",
"심성환": "팀7",
"변수민": "팀7"
}