403 lines
15 KiB
Python
403 lines
15 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
|
|
|
|
DEFAULT_TOPICS_SEED = [
|
|
{
|
|
"id": "T1",
|
|
"title": "내 인생 시간 도둑 처단기",
|
|
"tagline": "매일 짜증나는 반복 작업 하나를 2시간 안에 박살내기.",
|
|
"tone": "실용 + 살짝 치트키",
|
|
"items": [
|
|
"Slack 멘션 자동 분류/요약기 — '진짜 날 부른 거' vs 'FYI' 분리",
|
|
"Jira 티켓 1줄 자동 요약 + 다음 액션 제안기",
|
|
"회의 캘린더 → 하루 시작 브리핑 (\"오늘 3개 있고 2개는 안 가도 됨\")",
|
|
"PR 리뷰 우선순위 큐 (크기/긴급도/차단여부 기반)",
|
|
"반복 쿼리/kubectl 명령 매크로 CLI — 자주 치는 10개를 1글자로",
|
|
"온콜 노이즈 필터 — 진짜 볼 알람 vs 무시해도 되는 알람",
|
|
"\"이번 주 내 활동 자동 요약\" — PR/티켓/리뷰 통합 리포트",
|
|
"Grafana 자주 보는 패널 즐겨찾기 통합 뷰",
|
|
"Slack 스레드 장문 요약기 — 놓친 채널 따라잡기용",
|
|
"\"이 회의 들어가야 함?\" 분류기 — 캘린더 제목·참석자 기반 추천",
|
|
],
|
|
},
|
|
{
|
|
"id": "T2",
|
|
"title": "벼르던 사이드 프로젝트",
|
|
"tagline": "평소 \"저거 하나 만들고 싶은데\" 하던 개인 토이를 2시간 안에 작은 완성품으로.",
|
|
"tone": "몰입 + 작은 완결성",
|
|
"items": [
|
|
"내 PR/커밋 패턴 분석 개인 대시보드 (시간대/요일/사이즈 분포)",
|
|
"팀 Wiki/Notion을 터미널에서 fzf 스타일로 검색하는 CLI",
|
|
"사내 모델/데이터셋 메타데이터 검색 프로토타입",
|
|
"git 히스토리 인터랙티브 시각화 뷰어",
|
|
"\"오늘 내가 한 일\" 자동 일기 생성기 (커밋/PR/티켓 통합)",
|
|
"PR 코멘트 감정/톤 분석으로 팀 리뷰 문화 리포트",
|
|
"로컬 Kubernetes 리소스 관계 그래프 실시간 시각화",
|
|
"사내 논문/테크 문서 RAG 검색 도구",
|
|
"터미널에서 차트 포함된 마크다운 뷰어",
|
|
"북마크/링크 자동 분류·태깅 개인 도구",
|
|
],
|
|
},
|
|
{
|
|
"id": "T3",
|
|
"title": "오버엔지니어링 선수권",
|
|
"tagline": "평소 안 써본 무거운 기술 패턴을 일부러 작은 문제에 적용해 배우기.",
|
|
"tone": "학습형 오버엔지니어링",
|
|
"items": [
|
|
"Todo 앱에 이벤트 소싱 + CQRS 제대로 적용",
|
|
"간단한 계산기 서비스에 OpenTelemetry 풀 트레이싱 구축",
|
|
"문서 검색 기능에 벡터 DB + 하이브리드 검색 (BM25 + 임베딩)",
|
|
"파일 업로드에 S3 presigned URL + 체크섬 검증 + 재시도 로직 정식 설계",
|
|
"팀 투표 기능을 Raft 합의 알고리즘으로 구현",
|
|
"회의실 예약을 Kafka 이벤트 스트리밍 기반으로",
|
|
"로컬 개발 환경을 완전한 K8s 매니페스트 (Deployment/Service/Ingress/HPA)로 재현",
|
|
"LLM + RAG 기반 PR 자동 리뷰 봇 아키텍처 설계·구현",
|
|
"멀티 에이전트 협업(프롬프트 2~3단계)으로 간단한 의사결정 시스템",
|
|
"사이드카 패턴으로 로깅/메트릭/인증 분리 데모",
|
|
],
|
|
},
|
|
{
|
|
"id": "T4",
|
|
"title": "팀에게 주는 작은 선물",
|
|
"tagline": "동료를 돕는 도구/봇/사이트. 특정인을 놀리는 게 아니라 팀 전체를 위한 것.",
|
|
"tone": "실질적 도움 + 가벼운 온기",
|
|
"items": [
|
|
"배포 상태 집계·알림 봇 (성공/실패/롤백 요약)",
|
|
"신입 한 주 서바이벌 가이드 자동 생성기 (온보딩 링크/문서 수집)",
|
|
"팀 내부 용어/약어 사전 봇 — 신입/리서처 친화",
|
|
"아침 브리핑 봇 — 오늘 회의/배포/만료 알람 한방",
|
|
"점심 투표 1분 컷 봇 — 선택지 자동 생성 후 이모지 투표",
|
|
"팀 반복 질문 FAQ 봇 — 같은 질문 반복되는 채널용",
|
|
"온콜 교대 시 인수인계 자동 요약 생성기",
|
|
"회의실 스마트 추천 — 인원/시간대/위치 기반",
|
|
"사내 서비스 변경사항 요약 구독 봇",
|
|
"\"이번 주 팀 지표 한 장\" 리포트 — 머지 PR, 해결 티켓, 배포 수",
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
def ensure_topics_seeded(data):
|
|
"""topics 비어있으면 default 시드. 기존 있으면 보존."""
|
|
cats = data.get("topics", {}).get("categories", [])
|
|
if not cats:
|
|
data.setdefault("topics", {})
|
|
data["topics"]["categories"] = DEFAULT_TOPICS_SEED
|
|
|
|
|
|
# 시니어 명단 (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
|
|
ensure_topics_seeded(data) # 신규
|
|
# 누락 키 보강
|
|
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()
|