From d70746cf6a4c4c441a057ced22c52aec0cab1e1b Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Mon, 27 Apr 2026 19:30:47 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20implementation=20plan=20=E2=80=94=20?= =?UTF-8?q?=ED=95=B4=EC=BB=A4=ED=86=A4=20=EC=A7=84=ED=96=89=20=EC=95=B1=20?= =?UTF-8?q?(19=20tasks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD bite-sized: deps → schema → helpers → seed → QR/URL → voter gate → show dispatcher → 3 stages → admin stage/topics/url → docs → e2e → smoke. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-27-hackathon-event-flow.md | 1422 +++++++++++++++++ 1 file changed, 1422 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-27-hackathon-event-flow.md diff --git a/docs/superpowers/plans/2026-04-27-hackathon-event-flow.md b/docs/superpowers/plans/2026-04-27-hackathon-event-flow.md new file mode 100644 index 0000000..69ff520 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-hackathon-event-flow.md @@ -0,0 +1,1422 @@ +# 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('
🚀 해커톤
', unsafe_allow_html=True) + st.markdown('
팀 편성
', unsafe_allow_html=True) + st.info("Task 9에서 구현") # placeholder, 다음 태스크에서 교체 + + +def render_stage_topics(data): + st.markdown('
💡 예시 주제
', unsafe_allow_html=True) + st.info("Task 10에서 구현") + + +def render_stage_vote(data): + st.markdown('
🗳 투표 시작
', unsafe_allow_html=True) + st.info("Task 11에서 구현") +``` + +- [ ] **Step 3: `main()` 라우팅 갱신** + +```python +def main(): + st.set_page_config(page_title="해커톤", page_icon="🚀", layout="wide") + mode = st.query_params.get("mode", "show") + if mode == "admin": + render_admin() + elif mode == "ceremony": + render_ceremony() + elif mode == "raw": + render_raw() + elif mode == "vote": + render_voter() + else: + render_show() +``` + +기존 default → `render_voter` 였음. 이제 default → `render_show`. `?mode=vote`로 voter 진입. + +- [ ] **Step 4: 라우트 검증** + +```bash +docker cp app.py hackathon-vote:/app/app.py +docker compose restart vote +sleep 3 +curl -s -o /dev/null -w "/=%{http_code} vote=%{http_code} admin=%{http_code}\n" \ + http://localhost:8501/ \ + "http://localhost:8501/?mode=vote" \ + "http://localhost:8501/?mode=admin&token=mlops2026" +``` + +Expected: 모두 200. + +- [ ] **Step 5: Commit** + +```bash +git add app.py +git commit -m "feat: render_show dispatcher + 큰 화면 CSS, default '/' 변경" +``` + +--- + +## Task 9: Stage `intro` — 팀 편성 + 안내 + +**Files:** +- Modify: `app.py` — `render_stage_intro` 본구현 + +- [ ] **Step 1: `render_stage_intro` 교체** + +```python +def render_stage_intro(data): + st.markdown('
🚀 MLOps 해커톤 2026
', unsafe_allow_html=True) + st.markdown('
팀 편성
', unsafe_allow_html=True) + + people = data.get("people", []) + teams = {} + for p in people: + teams.setdefault(p["team"], []).append(p["name"]) + team_names = sorted(teams.keys()) + + # 4×2 그리드 (7팀 + 1 빈 칸) + rows = [team_names[i:i + 4] for i in range(0, len(team_names), 4)] + for row in rows: + cols = st.columns(4) + for col, team in zip(cols, row): + members = teams[team] + members_html = "
".join(members) + with col: + st.markdown( + f'
' + f'
{team}
' + f'
{members_html}
' + f'
', + unsafe_allow_html=True, + ) + + st.markdown( + '
' + '📋 순서: 팀 편성 → 주제 소개 → 해킹 (2시간) → 발표 → 투표 → 시상' + '
', + unsafe_allow_html=True, + ) + st.markdown( + '
' + '🏆 시상 부문: 🎉 재미상 · 🏆 완성도상 · 🛠 실용성상 (1팀 1상)' + '
', + unsafe_allow_html=True, + ) +``` + +- [ ] **Step 2: 수동 검증** + +```bash +docker cp app.py hackathon-vote:/app/app.py +docker compose restart vote +sleep 3 +``` + +브라우저: `http://localhost:8501/` → 7팀 그리드 + 순서/시상 박스 큰 글씨로 보이는지 시각 확인. + +- [ ] **Step 3: HTTP 200 회귀** + +```bash +curl -s -o /dev/null -w "show=%{http_code}\n" http://localhost:8501/ +``` + +Expected: 200. + +- [ ] **Step 4: Commit** + +```bash +git add app.py +git commit -m "feat: stage intro — 팀편성 4×2 그리드 + 순서/시상 박스" +``` + +--- + +## Task 10: Stage `topics` — 4 카테고리 × 10 주제 + +**Files:** +- Modify: `app.py` — `render_stage_topics` 본구현 + +- [ ] **Step 1: `render_stage_topics` 교체** + +```python +def render_stage_topics(data): + st.markdown('
💡 예시 주제
', unsafe_allow_html=True) + st.markdown('
영감 얻으세요 — 똑같이 안 만들어도 됩니다
', unsafe_allow_html=True) + + cats = data.get("topics", {}).get("categories", []) + if not cats: + st.warning("주제가 비어 있습니다. 어드민에서 입력하세요.") + return + + # 2×2 그리드 + rows = [cats[i:i + 2] for i in range(0, len(cats), 2)] + for row in rows: + cols = st.columns(2) + for col, cat in zip(cols, row): + cat_id = cat.get("id", "T?") + items_html = "".join( + f'
▸ {item}
' for item in cat.get("items", []) + ) + with col: + st.markdown( + f'
' + f'
{cat_id}. {cat.get("title", "")}
' + f'
{cat.get("tagline", "")}
' + f'
톤: {cat.get("tone", "")}
' + f' {items_html}' + f'
', + unsafe_allow_html=True, + ) +``` + +- [ ] **Step 2: stage 전환 후 시각 확인** + +```bash +docker cp app.py hackathon-vote:/app/app.py +docker compose restart vote +sleep 3 +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; set_stage('topics') +" +``` + +브라우저로 `/` 진입 → 2×2 카테고리 카드 + 색상 그라디언트 + 10주제 list. + +- [ ] **Step 3: Commit** + +```bash +git add app.py +git commit -m "feat: stage topics — 2×2 카테고리 그리드 + 10주제 list + 색상" +``` + +--- + +## Task 11: Stage `vote` — QR + 진행률 + autorefresh + +**Files:** +- Modify: `app.py` — `render_stage_vote` 본구현 + autorefresh import + +- [ ] **Step 1: import 추가** + +`app.py` import 섹션 끝에: + +```python +from streamlit_autorefresh import st_autorefresh +``` + +- [ ] **Step 2: `render_stage_vote` 교체** + +```python +def render_stage_vote(data): + st_autorefresh(interval=3000, key="vote_poll") + + st.markdown('
🗳 투표
', unsafe_allow_html=True) + st.markdown( + '
📱 휴대폰으로 QR 스캔 → 본인 이름 선택 → 투표
', + unsafe_allow_html=True, + ) + + vote_url = compute_vote_url() + qr_png = make_qr_png(vote_url) + + c1, c2, c3 = st.columns([1, 2, 1]) + with c2: + st.image(qr_png, use_container_width=False, width=500) + st.markdown( + f'
{vote_url}
', + unsafe_allow_html=True, + ) + + votes = data.get("votes", []) + total = len(data.get("people", [])) + voted = len(votes) + pct = int(100 * voted / total) if total else 0 + st.markdown( + f'
{voted} / {total}
', + unsafe_allow_html=True, + ) + st.progress(pct / 100 if total else 0) +``` + +- [ ] **Step 3: 검증** + +```bash +docker cp app.py hackathon-vote:/app/app.py +docker compose restart vote +sleep 3 +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; set_stage('vote') +" +``` + +브라우저 `/` → QR + 카운터 보이는지. 모바일에서 QR 스캔 → vote URL 진입 확인 (LAN IP + ?mode=vote). + +- [ ] **Step 4: HTTP 회귀** + +```bash +curl -s -o /dev/null -w "show=%{http_code} vote=%{http_code}\n" \ + http://localhost:8501/ "http://localhost:8501/?mode=vote" +``` + +Expected: 200 / 200. + +- [ ] **Step 5: Commit** + +```bash +git add app.py +git commit -m "feat: stage vote — QR + 카운터 + autorefresh 3초" +``` + +--- + +## Task 12: Admin — Stage 진행 Section + +**Files:** +- Modify: `app.py` — `render_admin()` 상단 + +- [ ] **Step 1: stage section 삽입** + +`render_admin()` 의 "🔗 다른 페이지 URL" expander 다음에: + +```python + st.divider() + st.subheader("🎬 Stage 진행") + cur = get_stage() + st.markdown(f"**현재 stage:** `{cur}`") + + stage_order = list(VALID_STAGES) # ("intro", "topics", "vote") + idx = stage_order.index(cur) if cur in stage_order else 0 + + sc1, sc2, sc3 = st.columns(3) + with sc1: + if st.button("← 이전 stage", disabled=(idx == 0)): + set_stage(stage_order[idx - 1]) + st.rerun() + with sc2: + chosen = st.radio( + "직접 선택", + stage_order, + index=idx, + horizontal=True, + label_visibility="collapsed", + key="stage_radio", + ) + if chosen != cur: + if st.button("적용", key="stage_apply"): + set_stage(chosen) + st.rerun() + with sc3: + if st.button("다음 stage →", disabled=(idx == len(stage_order) - 1)): + set_stage(stage_order[idx + 1]) + st.rerun() + + if cur == "vote": + st.caption("ℹ️ vote stage 진입 시 투표가 자동 open 됨. 마감은 아래 '투표 마감' 버튼으로.") +``` + +- [ ] **Step 2: 수동 테스트** + +```bash +docker cp app.py hackathon-vote:/app/app.py +docker compose restart vote +sleep 3 +``` + +브라우저 `?mode=admin&token=mlops2026` → "Stage 진행" section. 다음 → 클릭 → 큰 화면 (`/`)이 3초 내 갱신되는지 확인 (autorefresh는 vote stage만 — 그 외는 수동 새로고침 또는 admin이 set 후 큰 화면 새로고침). + +- [ ] **Step 3: Commit** + +```bash +git add app.py +git commit -m "feat: admin stage 진행 section (이전/직접/다음)" +``` + +--- + +## Task 13: Admin — 주제 편집 (Form Mode) + +**Files:** +- Modify: `app.py` + +- [ ] **Step 1: form section 삽입** + +stage section 직후에: + +```python + st.divider() + st.subheader("🗒 주제 편집") + cur_topics = get_topics() + if not cur_topics: + st.warning("주제 시드 비어있음. 컨테이너 재시작 시 시드 자동 적용됨.") + return_early_topics = True + else: + return_early_topics = False + + if not return_early_topics: + edit_mode = st.radio( + "편집 모드", ["Form", "JSON 직접 편집"], horizontal=True, key="topics_mode" + ) + + if edit_mode == "Form": + with st.form("topics_form"): + new_cats = [] + for cat in cur_topics: + cid = cat.get("id", "T?") + with st.expander(f"{cid}. {cat.get('title', '')}", expanded=False): + title = st.text_input( + "title", cat.get("title", ""), key=f"t_{cid}_title" + ) + tagline = st.text_input( + "tagline", cat.get("tagline", ""), key=f"t_{cid}_tagline" + ) + tone = st.text_input( + "tone", cat.get("tone", ""), key=f"t_{cid}_tone" + ) + items = [] + for i, item in enumerate(cat.get("items", []) + [""] * (10 - len(cat.get("items", [])))): + items.append( + st.text_input( + f"주제 {i + 1}", + item, + key=f"t_{cid}_item_{i}", + ) + ) + items = [x for x in items if x.strip()] + new_cats.append( + { + "id": cid, + "title": title.strip(), + "tagline": tagline.strip(), + "tone": tone.strip(), + "items": items, + } + ) + if st.form_submit_button("주제 저장"): + update_topics(new_cats) + st.success("저장됨. 큰 화면 다음 갱신 시 반영.") + st.rerun() +``` + +- [ ] **Step 2: 수동 테스트** + +브라우저 admin → "주제 편집" → 첫 카테고리 expander 열고 title 변경 → 저장 → 큰 화면 (`/`, stage=topics)에서 변경 반영 확인. + +- [ ] **Step 3: Commit** + +```bash +git add app.py +git commit -m "feat: admin 주제 편집 — form mode (4 카테고리 expander)" +``` + +--- + +## Task 14: Admin — 주제 편집 (JSON Mode) + +**Files:** +- Modify: `app.py` + +- [ ] **Step 1: JSON mode 분기 추가** + +Task 13에서 `if edit_mode == "Form":` 블록 다음에: + +```python + else: # JSON 직접 편집 + current_json = json.dumps( + {"categories": cur_topics}, ensure_ascii=False, indent=2 + ) + edited = st.text_area( + "topics JSON", + value=current_json, + height=400, + key="topics_json_editor", + ) + jc1, jc2 = st.columns(2) + with jc1: + if st.button("JSON 검증"): + try: + parsed = json.loads(edited) + cats = parsed.get("categories", []) + if not isinstance(cats, list): + st.error("'categories'는 list 여야 합니다.") + else: + st.success(f"OK — {len(cats)}개 카테고리") + except json.JSONDecodeError as e: + st.error(f"JSON 파싱 실패: {e}") + with jc2: + if st.button("JSON 저장", type="primary"): + try: + parsed = json.loads(edited) + cats = parsed.get("categories", []) + if not isinstance(cats, list): + st.error("'categories'는 list 여야 합니다.") + else: + update_topics(cats) + st.success("저장됨.") + st.rerun() + except json.JSONDecodeError as e: + st.error(f"저장 실패 — JSON 파싱 에러: {e}") +``` + +- [ ] **Step 2: 수동 테스트** + +JSON mode로 전환 → invalid JSON 입력 → 검증 클릭 → 에러 표시 확인. valid JSON 저장 → 반영 확인. + +- [ ] **Step 3: Commit** + +```bash +git add app.py +git commit -m "feat: admin 주제 편집 — JSON 직접 편집 + 검증" +``` + +--- + +## Task 15: Admin — `public_base_url` Override + +**Files:** +- Modify: `app.py` + +- [ ] **Step 1: `set_public_base_url` 헬퍼** + +`set_voting_open` 부근에: + +```python +def set_public_base_url(url): + def _fn(data): + data.setdefault("settings", {}) + if url: + data["settings"]["public_base_url"] = url.strip() + else: + data["settings"].pop("public_base_url", None) + update_data(_fn) +``` + +- [ ] **Step 2: admin section에 입력란 추가** + +stage section 끝(또는 vote 안내 caption 직후)에: + +```python + st.markdown("**📱 모바일 QR target URL**") + cur_url = compute_vote_url() + st.caption(f"현재: `{cur_url}`") + cur_override = load_data().get("settings", {}).get("public_base_url", "") + new_override = st.text_input( + "Override (비워두면 자동 감지)", + value=cur_override, + placeholder="http://192.168.1.10:8501", + key="qr_override", + ) + if st.button("Override 저장"): + set_public_base_url(new_override) + st.success("저장됨.") + st.rerun() +``` + +- [ ] **Step 3: 검증** + +admin → URL 입력 → 저장 → vote stage `/` → QR target URL이 변경된 것 확인. + +- [ ] **Step 4: Commit** + +```bash +git add app.py +git commit -m "feat: admin public_base_url override (QR target)" +``` + +--- + +## Task 16: `show-urls.sh` 갱신 + +**Files:** +- Modify: `show-urls.sh` + +- [ ] **Step 1: show + vote URL 추가** + +기존 "참가자 투표" section을 다음으로 교체: + +```bash +echo "🖥 큰 화면 (발표자):" +echo " http://localhost:${PORT}/" +[[ -n "$LAN_IP" ]] && echo " http://${LAN_IP}:${PORT}/ (LAN)" +echo +echo "📱 모바일 투표 (QR target):" +echo " http://localhost:${PORT}/?mode=vote" +[[ -n "$LAN_IP" ]] && echo " http://${LAN_IP}:${PORT}/?mode=vote" +``` + +- [ ] **Step 2: 검증** + +```bash +./show-urls.sh +``` + +Expected: 큰 화면 / 어드민 / 시상식 / JSON / 모바일 vote 5개 URL 출력. + +- [ ] **Step 3: Commit** + +```bash +git add show-urls.sh +git commit -m "docs: show-urls.sh — 큰 화면 + 모바일 vote URL 추가" +``` + +--- + +## Task 17: README 갱신 + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: 흐름 / URL section 갱신** + +기존 "흐름" section 교체: + +```markdown +## 흐름 (행사 진행) + +1. **Stage 1 — 팀 편성 + 안내** (큰 화면 `/`) +2. **Stage 2 — 예시 주제** (큰 화면 `/`, 어드민이 "다음 stage →") +3. **해킹** (앱 외부, 2시간) +4. **발표** +5. **Stage 3 — 투표** (큰 화면에 QR, 모바일 → `/?mode=vote`) +6. **시상** (`/?mode=ceremony&token=mlops2026`) +``` + +기존 URL section 갱신: + +```markdown +- 큰 화면: `http://<서버>:8501/` +- 모바일 투표 (QR target): `http://<서버>:8501/?mode=vote` +- 어드민: `http://<서버>:8501/?mode=admin&token=mlops2026` +- 시상식: `http://<서버>:8501/?mode=ceremony&token=mlops2026` +- JSON 원본: `http://<서버>:8501/?mode=raw&token=mlops2026` +``` + +기존 데이터 파일 section 끝부분에 추가: + +```markdown +- `topics.categories` 4 카테고리 × 10 items. 어드민에서 form / JSON 둘 다 편집. +- `settings.current_stage` ∈ {"intro","topics","vote"} — 어드민에서 stage 컨트롤. +``` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: README — 새 흐름 + URL + topics 설명" +``` + +--- + +## Task 18: E2E 추가 시나리오 회귀 + +**Files:** +- (이미 Task 2~6에서 추가됨) + +- [ ] **Step 1: 모든 e2e 통과 확인** + +```bash +docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py +docker exec hackathon-vote python3 /tmp/e2e.py +``` + +Expected: 기존 12 + 신규 (Task 2,3,4,5,6에서 추가) → 총 ≥16. 모두 통과. + +- [ ] **Step 2: 결과 확인** + +마지막 줄 `# N 중 통과 N, 실패 0` 패턴. + +- [ ] **Step 3: 회귀 시 fix** + +실패가 있으면 해당 task로 돌아가 fix. + +--- + +## Task 19: 라우트 + 부팅 smoke test + +**Files:** (없음 — 검증만) + +- [ ] **Step 1: 빈 컨테이너 부팅 검증** + +```bash +docker compose down +rm -rf data +docker compose up -d +sleep 10 +docker logs hackathon-vote 2>&1 | tail -20 +``` + +Expected: `[init] 시드 완료` 로그 + Streamlit 부팅 메시지. + +- [ ] **Step 2: 시드 데이터 검증** + +```bash +docker exec hackathon-vote python3 -c " +import json +d = json.load(open('/app/data/hackathon.json')) +assert d['settings']['current_stage'] == 'intro' +assert len(d['topics']['categories']) == 4 +for c in d['topics']['categories']: + assert len(c['items']) == 10 +print('seed OK') +" +``` + +- [ ] **Step 3: 라우트 5개 200 확인** + +```bash +curl -s -o /dev/null -w "show=%{http_code} vote=%{http_code} admin=%{http_code} raw=%{http_code} ceremony=%{http_code}\n" \ + http://localhost:8501/ \ + "http://localhost:8501/?mode=vote" \ + "http://localhost:8501/?mode=admin&token=mlops2026" \ + "http://localhost:8501/?mode=raw&token=mlops2026" \ + "http://localhost:8501/?mode=ceremony&token=mlops2026" +``` + +Expected: 모두 200. + +- [ ] **Step 4: 사용자 시각 검증 (브라우저)** + +다음 시나리오 직접 클릭/확인: +1. `/` → intro: 팀 7개 + 순서/시상 박스 큰 글씨. +2. admin → "다음 stage →" 클릭 → 새로고침 (`/`) → topics: 2×2 카테고리, 색상 그라디언트. +3. 다시 "다음 →" → vote: QR + 카운터 + 진행률 bar. autorefresh 동작 확인 (3초마다 화면 깜빡임 없이 갱신). +4. 모바일 (또는 동일 LAN 다른 기기)로 QR 스캔 → vote 화면 진입 → 한 번 투표 → 큰 화면 카운터 1 증가 (3초 내). +5. admin → 주제 편집 form → 한 줄 변경 → topics 화면 변경 반영. +6. JSON mode → invalid JSON 입력 → 에러 표시 확인. + +- [ ] **Step 5: Commit (smoke 통과)** + +(코드 변경 없으면 commit 생략. 있으면 fix 후 commit.) + +--- + +## Self-Review Notes + +스펙 매핑 체크 (Spec → Task): + +- 라우트 5개 → Task 8 (dispatcher), Task 7 (gate) +- Stage 모델 (intro/topics/vote) → Task 3 (helpers), Task 8-11 (rendering) +- 데이터 스키마 변경 → Task 2, 5 +- 어드민 stage 컨트롤 → Task 12 +- 어드민 주제 편집 (form + JSON) → Task 13, 14 +- public_base_url override → Task 15 +- QR + autorefresh → Task 6, 11 +- 시드 → Task 5 +- 테스트 → Task 2, 3, 4, 5, 6, 18 +- 큰 화면 CSS → Task 8, 9, 10, 11 +- show-urls.sh → Task 16 +- README → Task 17 + +스펙 모든 요구사항 → task로 매핑됨. 누락 없음. + +타입 일관성: `categories` list, `current_stage` str ∈ VALID_STAGES, `voting_open` bool. 함수 시그니처 일관 — `get_topics()` → list, `update_topics(list)`, `set_stage(str)`, `can_accept_votes(data)` → bool.