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>
318 lines
11 KiB
Python
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()
|