Files
hackerthon-vote/assign_teams.py
th-kim0823 6e517be918 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>
2026-04-26 18:25:46 +09:00

318 lines
11 KiB
Python

"""
참가자 34명 → 7팀 배정 (5명 6팀 + 4명 1팀).
- 부서별 균등 분배 (max - min ≤ 1)
- 시니어 균등 분배 (max - min ≤ 1, 모든 팀 ≥1)
- 김태현은 진행요원 (참가 X)
- AI Efficiency Tech는 시니어/주니어 정보 없음 → 균등 분배 대상 외
결과: participants.json (이름→팀 매핑) + 콘솔 출력.
"""
import json
import random
from collections import Counter, defaultdict
from pathlib import Path
# 시니어 명단 (Platform/Data/HPC/System만 알고 있음)
SENIORS = {
"한지승", "손현준", "강승형", "변수민", # Platform/Data
"김정명", "김영관", "전효준", # HPC
"박영훈", "서희", "김병훈", # System
}
PEOPLE = [
# (name, dept)
("한지승", "MLOps Platform"),
("변수민", "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
# 34명 / 7팀 = 5명 6팀 + 4명 1팀
TEAM_SIZES = [5] * 6 + [4]
SEED = 20260428 # 행사일 시드 (재현 가능)
# 자동 배정 후 수동 swap (이름 쌍)
MANUAL_SWAPS = [
("김영관", "이준석"),
]
def assign_one(seed):
"""
가변 팀 사이즈 균등 분배.
각 사람을 한 명씩 할당:
- 우선순위 1: 남은 슬롯 많은 팀
- 우선순위 2: 이 부서 인원 적게 받은 팀
- tie: random
효과:
- 팀 사이즈 정확히 TEAM_SIZES (가변)
- 부서별 max - min ≤ 1 (자연스럽게)
- 시니어 균등은 시드 재시도로 검증
"""
rng = random.Random(seed)
by_dept = defaultdict(list)
for name, dept in PEOPLE:
by_dept[dept].append(name)
for d in by_dept:
rng.shuffle(by_dept[d])
teams = [[] for _ in range(NUM_TEAMS)]
remaining = TEAM_SIZES[:]
depts_sorted = sorted(by_dept.keys(), key=lambda d: -len(by_dept[d]))
for dept in depts_sorted:
for person in by_dept[dept]:
dept_counts = [Counter(d for _, d in t).get(dept, 0) for t in teams]
priority = sorted(
range(NUM_TEAMS),
key=lambda i: (
-remaining[i],
dept_counts[i],
rng.random(),
),
)
for ti in priority:
if remaining[ti] > 0:
teams[ti].append((person, dept))
remaining[ti] -= 1
break
return teams
def validate(teams):
"""모든 제약 검증."""
sizes = [len(t) for t in teams]
if sorted(sizes, reverse=True) != sorted(TEAM_SIZES, reverse=True):
return False, "팀 사이즈 불일치"
# 부서별 max - min ≤ 1
all_depts = {d for _, d in PEOPLE}
for dept in all_depts:
per_team = [Counter(d for _, d in t).get(dept, 0) for t in teams]
if max(per_team) - min(per_team) > 1:
return False, f"부서 {dept} 분포 불균등: {per_team}"
# EffTech 모든 팀 ≥1
for ti, team in enumerate(teams):
eff = sum(1 for _, d in team if d == "AI Efficiency Tech")
if eff < 1:
return False, f"{ti+1} EffTech 0명"
# 시니어 검증
senior_per_team = [
sum(1 for n, _ in t if n in SENIORS) for t in teams
]
if min(senior_per_team) < 1:
return False, f"시니어 0명 팀 존재: {senior_per_team}"
if max(senior_per_team) - min(senior_per_team) > 1:
return False, f"시니어 분포 불균등: {senior_per_team}"
# 한지승(지각 가능) 특수 제약
# 1. 4명 팀에 배치 금지 (지각 시 3명 → 너무 적음)
# 2. 한지승 팀에 다른 시니어 ≥1명 (지각 시 시니어 0 방지)
for team in teams:
names = [n for n, _ in team]
if "한지승" in names:
if len(team) < 5:
return False, "한지승 4명팀 배치 (지각 시 3명)"
other_seniors = sum(1 for n in names if n in SENIORS and n != "한지승")
if other_seniors < 1:
return False, "한지승 팀에 다른 시니어 0명 (지각 시 시니어 0)"
return True, "OK"
def apply_swaps(teams, swaps):
"""이름 쌍을 받아 두 사람의 팀 위치를 교환."""
name_to_loc = {}
for ti, team in enumerate(teams):
for pi, (name, _) in enumerate(team):
name_to_loc[name] = (ti, pi)
for a, b in swaps:
if a not in name_to_loc or b not in name_to_loc:
raise ValueError(f"swap 대상 못 찾음: {a}, {b}")
ti_a, pi_a = name_to_loc[a]
ti_b, pi_b = name_to_loc[b]
if ti_a == ti_b:
continue # 같은 팀이면 의미 없음
teams[ti_a][pi_a], teams[ti_b][pi_b] = (
teams[ti_b][pi_b],
teams[ti_a][pi_a],
)
# 위치 갱신
name_to_loc[a] = (ti_b, pi_b)
name_to_loc[b] = (ti_a, pi_a)
return teams
def assign(base_seed):
"""시드 재시도로 모든 제약 만족하는 배정 찾기. 이후 수동 swap 적용."""
for offset in range(10000):
seed = base_seed + offset
teams = assign_one(seed)
ok, msg = validate(teams)
if ok:
teams = apply_swaps(teams, MANUAL_SWAPS)
return teams, seed, offset + 1
raise RuntimeError("10000회 시도에도 모든 제약 만족 실패")
def main():
assert len(PEOPLE) == sum(TEAM_SIZES), (
f"인원수 불일치: {len(PEOPLE)}명, 기대 {sum(TEAM_SIZES)}"
)
teams, seed_used, attempts = assign(SEED)
print(f"# 시드 {seed_used} (시도 {attempts}회), 팀 사이즈 {TEAM_SIZES}\n")
# 부서별 분포 검증
dept_dist = defaultdict(list)
for team in teams:
team_counts = Counter(d for _, d in team)
for dept in {d for _, d in PEOPLE}:
dept_dist[dept].append(team_counts.get(dept, 0))
print("# 부서별 팀 분배 (max - min ≤ 1 = 균등)")
for dept, dist in sorted(dept_dist.items(), key=lambda x: -sum(x[1])):
print(f" {dept}: {dist} (총 {sum(dist)}명)")
senior_dist = [sum(1 for n, _ in t if n in SENIORS) for t in teams]
print(f"\n# 시니어 분배: {senior_dist} (총 {sum(senior_dist)}명, 모든 팀 ≥1)")
print()
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()
)
n_senior = sum(1 for n, _ in team if n in SENIORS)
size = len(team)
late_note = " ⏰한지승 지각시 -1" if any(n == "한지승" for n, _ in team) else ""
print(f"## {team_name} ({size}명, 시니어 {n_senior}, {dept_summary}){late_note}")
for name, dept in team:
tag = " ⭐시니어" if name in SENIORS else ""
if name == "김재현":
tag += " 🌱최주니어"
if name == "한지승":
tag += " ⏰지각가능"
print(f" - {name} ({dept}){tag}")
mapping[name] = team_name
print()
# 단일 hackathon.json (모든 state 포함). 기존 파일 있으면 votes/titles 등 보존.
people_records = []
for i, team in enumerate(teams, 1):
team_name = f"{i}"
for name, dept in team:
note_parts = []
if name == "한지승":
note_parts.append("지각 가능")
if name == "김재현":
note_parts.append("최주니어")
people_records.append(
{
"name": name,
"team": team_name,
"dept": dept,
"senior": name in SENIORS,
"notes": ", ".join(note_parts),
}
)
data_path = Path(__file__).parent / "hackathon.json"
if data_path.exists():
data = json.loads(data_path.read_text(encoding="utf-8"))
else:
data = {
"settings": {"voting_open": True},
"titles": {},
"tie_breaks": {},
"votes": [],
}
data["people"] = people_records
# 누락 키 보강
for k, v in [
("settings", {"voting_open": True}),
("titles", {}),
("tie_breaks", {}),
("votes", []),
]:
data.setdefault(k, v)
data_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"저장: {data_path}")
# teams.md 박제 (진행자 인쇄/공유용)
md_lines = ["# 해커톤 팀 배정 (확정)\n"]
md_lines.append(f"- 시드: `{seed_used}`\n")
md_lines.append(f"- 총 {len(PEOPLE)}명, {NUM_TEAMS}팀, 사이즈 {TEAM_SIZES}\n")
md_lines.append("- 김태현: 진행요원 (참여 X)\n")
md_lines.append("- ⭐ 시니어, 🌱 최주니어, ⏰ 지각 가능\n\n")
md_lines.append("| 팀 | 인원 | 시니어 | 멤버 |\n|---|---|---|---|\n")
for i, team in enumerate(teams, 1):
names_fmt = []
for name, _ in team:
t = name
if name in SENIORS:
t += ""
if name == "김재현":
t += "🌱"
if name == "한지승":
t += ""
names_fmt.append(t)
n_senior = sum(1 for n, _ in team if n in SENIORS)
md_lines.append(
f"| 팀{i} | {len(team)} | {n_senior} | {', '.join(names_fmt)} |\n"
)
md_out = Path(__file__).parent / "teams.md"
md_out.write_text("".join(md_lines), encoding="utf-8")
print(f"저장: {md_out}")
if __name__ == "__main__":
main()