# Hackathon Event Flow Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 투표 앱을 해커톤 전체 진행 앱으로 확장. 큰 화면 stage (intro → topics → vote) + 모바일 QR 투표 + 어드민 stage/topics 컨트롤. **Architecture:** 기존 `app.py` (Streamlit) 확장. `hackathon.json`에 `topics` + `settings.current_stage` 키 추가. 큰 화면 default `/` 라우트가 `current_stage` 따라 dispatch. QR은 `qrcode[pil]`, 진행률 폴링은 `streamlit-autorefresh`. 어드민 콘솔에 stage/topics 두 section 추가. **Tech Stack:** Python 3.12, Streamlit, Docker. 신규 의존성: `qrcode[pil]`, `streamlit-autorefresh`. **Spec:** `docs/superpowers/specs/2026-04-27-hackathon-event-flow-design.md` --- ## File Structure | 파일 | 변경 종류 | 책임 | |---|---|---| | `requirements.txt` | modify | 의존성 2개 추가 | | `app.py` | modify | Stage 라우트 + 헬퍼 + 신규 admin section. ~750 → ~1100 LOC. | | `assign_teams.py` | modify | `DEFAULT_TOPICS_SEED` 상수 + `ensure_topics_seeded()` 호출 | | `tests/e2e.py` | modify | 4 시나리오 추가 (stage 시드, stage 전환, gate, topics atomic) | | `show-urls.sh` | modify | show + vote URL 추가 | | `README.md` | modify | 새 흐름 설명 | `app.py`는 크지만 단일 파일 패턴 유지 (기존 코드베이스 컨벤션). 함수 분리로 가독성 확보. --- ## Task 1: 의존성 추가 + 컨테이너 재빌드 검증 **Files:** - Modify: `requirements.txt` - [ ] **Step 1: requirements.txt 갱신** 기존 파일 읽고 `qrcode[pil]`, `streamlit-autorefresh` 추가. ``` streamlit qrcode[pil] streamlit-autorefresh ``` - [ ] **Step 2: 컨테이너 재빌드** ```bash docker compose down docker compose up -d --build ``` - [ ] **Step 3: import 검증** ```bash docker exec hackathon-vote python3 -c "import qrcode; from streamlit_autorefresh import st_autorefresh; print('OK')" ``` Expected: `OK` 출력. - [ ] **Step 4: 기존 라우트 회귀 확인** ```bash curl -s -o /dev/null -w "vote=%{http_code} admin=%{http_code} raw=%{http_code}\n" \ http://localhost:8501/ \ "http://localhost:8501/?mode=admin&token=mlops2026" \ "http://localhost:8501/?mode=raw&token=mlops2026" ``` Expected: `vote=200 admin=200 raw=200`. - [ ] **Step 5: Commit** ```bash git add requirements.txt git commit -m "deps: qrcode[pil] + streamlit-autorefresh" ``` --- ## Task 2: `_empty_state` 확장 (스키마 갱신) **Files:** - Modify: `app.py` — `_empty_state` 함수 - Test: `tests/e2e.py` — schema test - [ ] **Step 1: 실패하는 테스트 추가** `tests/e2e.py` 끝에 추가: ```python def test_empty_state_has_topics_and_stage(): from app import _empty_state s = _empty_state() assert s["settings"]["current_stage"] == "intro" assert s["topics"] == {"categories": []} run("empty_state 신규 키", test_empty_state_has_topics_and_stage) ``` (`run()`은 기존 e2e.py의 헬퍼 — 같은 패턴 따름.) - [ ] **Step 2: 테스트 실패 확인** ```bash docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py docker exec hackathon-vote python3 /tmp/e2e.py ``` Expected: `❌ empty_state 신규 키` (assertion 실패). - [ ] **Step 3: `_empty_state` 갱신** `app.py`의 `_empty_state` 함수를 다음으로 교체: ```python def _empty_state(): return { "people": [], "settings": {"voting_open": True, "current_stage": "intro"}, "titles": {}, "tie_breaks": {}, "votes": [], "topics": {"categories": []}, } ``` - [ ] **Step 4: 테스트 통과 확인** ```bash docker cp app.py hackathon-vote:/app/app.py docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py docker exec hackathon-vote python3 /tmp/e2e.py ``` Expected: `✅ empty_state 신규 키` 포함, 전체 통과. - [ ] **Step 5: Commit** ```bash git add app.py tests/e2e.py git commit -m "feat: _empty_state — current_stage + topics 키 추가" ``` --- ## Task 3: Stage 헬퍼 (`get_stage`, `set_stage`, `can_accept_votes`) **Files:** - Modify: `app.py` — 신규 함수 3개 - Test: `tests/e2e.py` - [ ] **Step 1: 실패 테스트 작성** `tests/e2e.py`에 추가: ```python def test_stage_helpers(): from app import get_stage, set_stage, can_accept_votes, load_data, save_data, _empty_state save_data(_empty_state()) assert get_stage() == "intro" assert can_accept_votes(load_data()) is False # intro → False set_stage("vote") d = load_data() assert d["settings"]["current_stage"] == "vote" assert d["settings"]["voting_open"] is True assert can_accept_votes(d) is True set_stage("topics") d = load_data() assert d["settings"]["current_stage"] == "topics" assert d["settings"]["voting_open"] is True # 떠나도 voting_open 유지 assert can_accept_votes(d) is False # stage 다르면 False # 명시적 마감 후엔 vote 다시 들어와도 voting_open 자동 True (set_stage가 set) d["settings"]["voting_open"] = False save_data(d) set_stage("vote") assert can_accept_votes(load_data()) is True run("stage 헬퍼", test_stage_helpers) ``` - [ ] **Step 2: 테스트 실패 확인** ```bash docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py docker exec hackathon-vote python3 /tmp/e2e.py ``` Expected: `ImportError` 또는 `AttributeError` (함수 미정의). - [ ] **Step 3: 헬퍼 추가 (`app.py`)** `set_voting_open` 함수 아래에 추가: ```python VALID_STAGES = ("intro", "topics", "vote") def get_stage(): return load_data().get("settings", {}).get("current_stage", "intro") def set_stage(stage): if stage not in VALID_STAGES: raise ValueError(f"invalid stage: {stage!r}") def _fn(data): data["settings"]["current_stage"] = stage if stage == "vote": data["settings"]["voting_open"] = True update_data(_fn) def can_accept_votes(data): s = data.get("settings", {}) return s.get("current_stage") == "vote" and s.get("voting_open", False) ``` - [ ] **Step 4: 테스트 통과 확인** ```bash docker cp app.py hackathon-vote:/app/app.py docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py docker exec hackathon-vote python3 /tmp/e2e.py ``` Expected: `✅ stage 헬퍼` 포함. - [ ] **Step 5: Commit** ```bash git add app.py tests/e2e.py git commit -m "feat: stage 헬퍼 (get_stage, set_stage, can_accept_votes)" ``` --- ## Task 4: Topics 헬퍼 (`get_topics`, `update_topics`) **Files:** - Modify: `app.py` - Test: `tests/e2e.py` - [ ] **Step 1: 실패 테스트 작성** ```python def test_topics_helpers(): from app import get_topics, update_topics, load_data, save_data, _empty_state save_data(_empty_state()) assert get_topics() == [] sample = [ {"id": "T1", "title": "테스트", "tagline": "tl", "tone": "tn", "items": [f"item{i}" for i in range(10)]} ] update_topics(sample) assert get_topics() == sample # atomic 갱신 확인 sample2 = [{"id": "T1", "title": "교체", "tagline": "tl", "tone": "tn", "items": [f"x{i}" for i in range(10)]}] update_topics(sample2) assert get_topics() == sample2 run("topics 헬퍼", test_topics_helpers) ``` - [ ] **Step 2: 실패 확인** ```bash docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py docker exec hackathon-vote python3 /tmp/e2e.py ``` Expected: 함수 미정의 에러. - [ ] **Step 3: 헬퍼 추가 (`app.py`)** `set_voting_open` 부근에: ```python def get_topics(): return load_data().get("topics", {}).get("categories", []) def update_topics(categories): def _fn(data): data.setdefault("topics", {}) data["topics"]["categories"] = categories update_data(_fn) ``` - [ ] **Step 4: 통과 확인** ```bash docker cp app.py hackathon-vote:/app/app.py docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py docker exec hackathon-vote python3 /tmp/e2e.py ``` - [ ] **Step 5: Commit** ```bash git add app.py tests/e2e.py git commit -m "feat: topics 헬퍼 (get_topics, update_topics)" ``` --- ## Task 5: Topics 시드 (`assign_teams.py`) **Files:** - Modify: `assign_teams.py` - Test: `tests/e2e.py` - [ ] **Step 1: 실패 테스트 작성** `tests/e2e.py`에 추가: ```python def test_topics_seeded_after_assign(): from app import get_topics, _empty_state, save_data # 빈 상태로 reset save_data(_empty_state()) assert get_topics() == [] # assign_teams 의 ensure_topics_seeded 적용 from assign_teams import ensure_topics_seeded from app import load_data data = load_data() ensure_topics_seeded(data) save_data(data) cats = get_topics() assert len(cats) == 4 for c in cats: assert len(c["items"]) == 10 assert c["id"] in ("T1", "T2", "T3", "T4") assert c["title"] assert c["tagline"] assert c["tone"] run("topics 시드", test_topics_seeded_after_assign) ``` - [ ] **Step 2: 실패 확인** `ensure_topics_seeded` 미존재 → ImportError. - [ ] **Step 3: `assign_teams.py` 갱신** 파일 상단 (import 다음)에 상수 추가: ```python 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 ``` `main()` 함수의 `data["people"] = people_records` 직후에 호출 한 줄 추가: ```python data["people"] = people_records ensure_topics_seeded(data) # 신규 ``` - [ ] **Step 4: 통과 확인** ```bash docker cp assign_teams.py hackathon-vote:/app/assign_teams.py docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py docker exec hackathon-vote python3 /tmp/e2e.py ``` - [ ] **Step 5: Commit** ```bash git add assign_teams.py tests/e2e.py git commit -m "feat: DEFAULT_TOPICS_SEED + ensure_topics_seeded" ``` --- ## Task 6: QR 헬퍼 (`make_qr_png`) + Vote URL resolver **Files:** - Modify: `app.py` - Test: `tests/e2e.py` - [ ] **Step 1: 실패 테스트** ```python def test_make_qr_png(): from app import make_qr_png png = make_qr_png("http://localhost:8501/?mode=vote") # PNG signature 8 bytes assert png[:8] == b"\x89PNG\r\n\x1a\n" assert len(png) > 100 def test_compute_vote_url_priority(): import os from app import compute_vote_url, save_data, load_data, _empty_state save_data(_empty_state()) # 1. settings.public_base_url 우선 d = load_data() d["settings"]["public_base_url"] = "http://example.com:9000" save_data(d) assert compute_vote_url() == "http://example.com:9000/?mode=vote" # 2. env fallback d["settings"].pop("public_base_url") save_data(d) os.environ["PUBLIC_BASE_URL"] = "http://env-host:7777" assert compute_vote_url() == "http://env-host:7777/?mode=vote" os.environ.pop("PUBLIC_BASE_URL") # 3. localhost fallback (LAN 자동 감지 실패 또는 디폴트) url = compute_vote_url() assert url.endswith("/?mode=vote") assert url.startswith("http://") run("QR PNG 생성", test_make_qr_png) run("vote URL 우선순위", test_compute_vote_url_priority) ``` - [ ] **Step 2: 실패 확인** 함수 미정의. - [ ] **Step 3: app.py에 헬퍼 추가** import 추가 (파일 상단): ```python import socket from io import BytesIO import qrcode ``` 함수 추가 (파일 어디든, 예: `compute_winners` 위): ```python def make_qr_png(url: str, box_size: int = 20) -> bytes: img = qrcode.make(url, box_size=box_size, border=2) buf = BytesIO() img.save(buf, format="PNG") return buf.getvalue() def _detect_lan_ip() -> str: """LAN IP 자동 감지. 실패 시 'localhost'.""" try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) ip = s.getsockname()[0] s.close() return ip except Exception: return "localhost" def compute_vote_url() -> str: data = load_data() base = ( data.get("settings", {}).get("public_base_url") or os.environ.get("PUBLIC_BASE_URL") or f"http://{_detect_lan_ip()}:8501" ) return f"{base.rstrip('/')}/?mode=vote" ``` - [ ] **Step 4: 통과 확인** ```bash docker cp app.py hackathon-vote:/app/app.py docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py docker exec hackathon-vote python3 /tmp/e2e.py ``` - [ ] **Step 5: Commit** ```bash git add app.py tests/e2e.py git commit -m "feat: QR PNG 생성 + vote URL resolver" ``` --- ## Task 7: Voter Stage Gate **Files:** - Modify: `app.py` — `render_voter()` 진입부 - [ ] **Step 1: 기존 `render_voter` 위치 파악** ```bash grep -n "def render_voter" app.py ``` - [ ] **Step 2: 진입 가드 추가** `render_voter()` 함수 본문 첫 줄에: ```python def render_voter(): if not can_accept_votes(load_data()): st.title("🗳 해커톤 투표") st.info("⏳ 지금은 투표 시간이 아닙니다. 진행자가 투표 stage로 전환할 때까지 기다려주세요.") return # ... 기존 로직 그대로 ``` 기존 `is_voting_open()` 체크가 함수 안에 있다면 위 가드가 상위 — 중복은 그대로 둠 (수비 깊이). - [ ] **Step 3: 수동 검증** ```bash docker cp app.py hackathon-vote:/app/app.py docker compose restart vote sleep 3 # stage가 intro인 상태에서 vote URL 진입 curl -s "http://localhost:8501/?mode=vote" | grep -c "투표 시간이 아닙니다" || true ``` WebSocket 안 거치면 텍스트 안 나올 수 있음 → 브라우저로 직접 확인. HTTP 200이면 OK: ```bash curl -s -o /dev/null -w "vote=%{http_code}\n" "http://localhost:8501/?mode=vote" ``` - [ ] **Step 4: stage=vote 전환 후 정상 진입 확인** Python으로: ```bash docker exec hackathon-vote python3 -c " import os; os.environ['DATA_PATH']='/app/data/hackathon.json' import sys; sys.path.insert(0,'/app') from app import set_stage, can_accept_votes, load_data set_stage('vote') print('can_vote:', can_accept_votes(load_data())) " ``` Expected: `can_vote: True`. - [ ] **Step 5: Commit** ```bash git add app.py git commit -m "feat: voter stage gate (current_stage=vote && voting_open)" ``` --- ## Task 8: `render_show()` Dispatcher + 큰 화면 CSS **Files:** - Modify: `app.py` — 신규 함수 + `main()` 라우팅 - [ ] **Step 1: 큰 화면 CSS 상수 추가** `CATEGORIES` 상수 아래에: ```python SHOW_CSS = """ """ ``` - [ ] **Step 2: dispatcher 함수 추가** `render_voter` 위 또는 아래에: ```python def render_show(): data = load_data() st.markdown(SHOW_CSS, unsafe_allow_html=True) stage = data.get("settings", {}).get("current_stage", "intro") if stage == "topics": render_stage_topics(data) elif stage == "vote": render_stage_vote(data) else: render_stage_intro(data) def render_stage_intro(data): st.markdown('