diff --git a/docs/lotto-premium-roadmap.md b/docs/lotto-premium-roadmap.md
new file mode 100644
index 0000000..fa0f9d0
--- /dev/null
+++ b/docs/lotto-premium-roadmap.md
@@ -0,0 +1,252 @@
+# 로또랩 프리미엄 서비스 고도화 로드맵
+
+> 작성일: 2026-03-19
+> 목표: 번호 생성 도구 → 데이터 기반 로또 전략 코치
+
+---
+
+## 1. 현재 서비스 한계
+
+현재 구조는 **"번호 생성 도구"** 수준으로 수익화에 한계가 있음.
+
+| 문제 | 내용 |
+|------|------|
+| 차별점 부재 | 무료 로또 번호 생성기와 구분되지 않음 |
+| 신뢰 근거 부족 | 사용자가 결과를 믿을 데이터 시각화 없음 |
+| 리텐션 약함 | 지속적으로 돌아올 이유가 없음 |
+
+---
+
+## 2. 포지셔닝 전환
+
+> **"번호 생성"이 아니라 "데이터 기반 로또 전략 코치"**
+
+사람들이 구독료를 지불하는 심리적 동기:
+
+- **확신**: 내가 선택한 번호가 좋은 선택이라는 데이터 근거
+- **FOMO**: 이번 주 리포트를 못 받으면 놓치는 느낌
+- **소유감**: 내 데이터와 이력이 축적된다는 느낌
+
+---
+
+## 3. 고도화 방향 (5가지)
+
+### 3-1. 당첨 근접도 추적 — 신뢰 기반 구축
+
+**목표**: 기존 채점 데이터(`check_results_for_draw`)를 신뢰 지표로 전환
+
+**구현 내용**:
+- 추천 번호의 회차별 일치 개수 통계 집계
+- 전국 평균 대비 성과 비교 지표 노출
+- 매주 "지난 주 내 번호 성과" 이메일/푸시 발송
+
+**예시 UI 문구**:
+```
+"지난 52주간 우리 추천번호의 평균 일치 개수: 2.7개 (전국 평균 1.9개)"
+"3개 일치율이 일반 무작위 대비 43% 높습니다"
+```
+
+**활용 데이터**: 기존 `recommendations` + `draws` 테이블 채점 결과
+
+**우선순위**: ⭐⭐⭐ (데이터 이미 존재, 즉시 구현 가능)
+
+---
+
+### 3-2. 개인화 분석 리포트 — 프리미엄 핵심 기능
+
+**목표**: 모든 사용자에게 동일한 번호 → 개인 패턴 기반 맞춤 추천
+
+**구현 내용**:
+- 사용자 번호 선택 이력 패턴 분석
+- 홀짝 비율, 번호대 분포, 연속번호 포함률 등 개인 성향 분석
+- 약점을 보완한 AI 보정 추천번호 생성
+
+**예시 분석 항목**:
+```
+"당신은 홀수를 선호하는 경향 (67%)"
+"당신이 자주 피하는 번호대: 30번대"
+"당신 번호의 약점: 연속번호 포함률 낮음"
+→ "이를 보완한 AI 보정 추천번호 제공"
+```
+
+**신규 테이블**: `user_preferences`
+
+**우선순위**: ⭐⭐ (신규 테이블 및 분석 로직 필요)
+
+---
+
+### 3-3. 회차별 공략 리포트 — 킬러 콘텐츠
+
+**목표**: 매주 추첨 전 발행하는 주간 분석 레포트 → 구독 유지 동기
+
+**구현 내용**:
+- 매주 자동 생성되는 회차별 공략 리포트
+- 과출현/냉각 번호 분석
+- 패턴 기반 번호군 추천
+- AI 신뢰도 점수 표시
+
+**예시 리포트 구조**:
+```
+[1180회 공략 리포트]
+- 최근 10회 과출현 번호 제외 추천
+- 이번 주 "냉각 구간" 번호 (오랫동안 미출현)
+- 패턴 분석: 직전 3회 연속 출현한 번호군
+- AI 신뢰도 점수: 87/100
+```
+
+**스케줄러**: 매주 토요일 추첨 전 자동 생성 (APScheduler)
+
+**우선순위**: ⭐⭐⭐ (주간 구독 모델의 핵심 훅)
+
+---
+
+### 3-4. 번호 포트폴리오 관리 — 차별화 UX
+
+**목표**: 로또를 투자처럼 관리하는 경험 제공
+
+**구현 내용**:
+- 세트 분류: 고위험/안정형/균형형
+- 구매 금액 직접 입력 → 수익률 자동 계산
+- 누적 투자 대비 당첨금 통계
+
+**예시 화면**:
+```
+내 번호 포트폴리오
+├── 고위험/고수익 세트 (출현 빈도 낮은 번호 조합)
+├── 안정형 세트 (평균 출현 패턴)
+└── 균형형 세트 (시뮬레이션 최적화)
+
+이번 주 매입: 3세트 (₩3,000)
+누적 투자: ₩240,000 / 누적 당첨: ₩45,000
+수익률: -81.2% (전국 평균 대비 +12.1%)
+```
+
+**활용 데이터**: `best_picks`, `recommendations` 확장
+
+**우선순위**: ⭐⭐ (UX 임팩트 큼, 중기 구현)
+
+---
+
+### 3-5. 커뮤니티 + 소셜 증거 — 바이럴 유도
+
+**목표**: 사용자 참여 및 구전 마케팅
+
+**구현 내용**:
+- 이번 주 가장 많이 선택된 번호 TOP 10 공개
+- "나와 같은 번호 선택한 회원 수" 표시
+- AI 추천으로 X개 일치 달성한 회원 수 표시
+
+**예시**:
+```
+"이번 주 가장 많이 선택된 번호 TOP 10"
+"AI 추천 번호로 3개 일치 달성한 회원: 1,247명"
+"나와 같은 번호를 선택한 회원: 34명"
+```
+
+**전략**: 무료 티어에 일부 공개 → 상세 분석은 유료 전환
+
+**우선순위**: ⭐ (회원 시스템 구축 후 가능)
+
+---
+
+## 4. 구독 티어 설계
+
+| 기능 | 무료 | 스탠다드 (₩2,900/월) | 프리미엄 (₩5,900/월) |
+|------|:----:|:----:|:----:|
+| 기본 추천 번호 | 1세트 | 5세트 | 무제한 |
+| 통계 분석 | 기본 | 심화 | 전체 |
+| 회차 공략 리포트 | - | 주간 요약 | 풀 리포트 |
+| 개인 패턴 분석 | - | - | ✓ |
+| 번호 포트폴리오 | - | ✓ | ✓ |
+| 당첨 근접도 통계 | - | ✓ | ✓ |
+| 당첨 알림 | - | 이메일 | 이메일 + 앱 |
+
+---
+
+## 5. 기술 구현 로드맵
+
+### Phase 1 — 즉시 가능 (데이터 이미 존재)
+
+- [ ] 추천 이력 채점 통계 API (`GET /api/lotto/stats/performance`)
+- [ ] 신뢰도 지표 UI (평균 일치 개수, 전국 평균 비교)
+- [ ] 회차별 공략 리포트 API (`GET /api/lotto/report/{drw_no}`)
+- [ ] 개인 추천 이력 성과 대시보드
+
+### Phase 2 — 단기 (1-2주)
+
+- [ ] `user_preferences` 테이블 설계 및 구현
+- [ ] 개인 패턴 분석 API (`GET /api/lotto/analysis/personal`)
+- [ ] 주간 리포트 자동 생성 스케줄러 (토요일 오전)
+- [ ] 투자 추적 기능 (구매 금액 입력 → 수익률 계산)
+- [ ] `purchase_history` 테이블 추가
+
+### Phase 3 — 중기 (1개월)
+
+- [ ] 회원 시스템 구축 (JWT 인증, SQLite `users` 테이블)
+- [ ] 구독 플랜 관리 (`subscription_plans`, `user_subscriptions` 테이블)
+- [ ] 결제 연동 (Toss Payments 또는 Stripe)
+- [ ] 이메일 발송 자동화 (SendGrid)
+- [ ] 소셜 증거 데이터 집계 API
+
+---
+
+## 6. DB 스키마 확장 계획
+
+```sql
+-- Phase 2
+CREATE TABLE purchase_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ draw_no INTEGER NOT NULL,
+ amount INTEGER NOT NULL, -- 구매 금액 (원)
+ sets INTEGER NOT NULL DEFAULT 1, -- 구매 세트 수
+ prize INTEGER DEFAULT 0, -- 당첨금
+ note TEXT,
+ created_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE TABLE user_preferences (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ odd_ratio REAL, -- 홀수 선호 비율
+ high_ratio REAL, -- 고번호(23+) 선호 비율
+ consecutive INTEGER, -- 연속번호 포함 선호 여부
+ excluded_numbers TEXT, -- JSON 배열, 기피 번호
+ updated_at TEXT DEFAULT (datetime('now'))
+);
+
+-- Phase 3
+CREATE TABLE users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ plan TEXT DEFAULT 'free', -- free | standard | premium
+ plan_expires_at TEXT,
+ created_at TEXT DEFAULT (datetime('now'))
+);
+```
+
+---
+
+## 7. API 확장 계획
+
+| Phase | 메서드 | 경로 | 설명 |
+|-------|--------|------|------|
+| 1 | GET | `/api/lotto/stats/performance` | 추천 성과 통계 (평균 일치 수 등) |
+| 1 | GET | `/api/lotto/report/latest` | 최신 회차 공략 리포트 |
+| 1 | GET | `/api/lotto/report/{drw_no}` | 특정 회차 공략 리포트 |
+| 2 | GET | `/api/lotto/purchase` | 구매 이력 조회 |
+| 2 | POST | `/api/lotto/purchase` | 구매 이력 추가 |
+| 2 | GET | `/api/lotto/purchase/stats` | 투자 수익률 통계 |
+| 2 | GET | `/api/lotto/analysis/personal` | 개인 패턴 분석 |
+| 3 | POST | `/api/auth/register` | 회원가입 |
+| 3 | POST | `/api/auth/login` | 로그인 |
+| 3 | GET | `/api/subscription/plans` | 구독 플랜 목록 |
+| 3 | POST | `/api/subscription/checkout` | 결제 시작 |
+
+---
+
+## 참고
+
+- 현재 운영 중인 lotto API: `CLAUDE.md` → `lotto-lab API 목록` 섹션 참고
+- 채점 로직: `backend/app/checker.py`
+- 시뮬레이션 로직: `backend/app/recommender.py`
+- DB 스키마: `backend/app/db.py` `init_db()`
diff --git a/docs/superpowers/plans/2026-04-05-lotto-purchase-strategy-evolution.md b/docs/superpowers/plans/2026-04-05-lotto-purchase-strategy-evolution.md
deleted file mode 100644
index 045105e..0000000
--- a/docs/superpowers/plans/2026-04-05-lotto-purchase-strategy-evolution.md
+++ /dev/null
@@ -1,1498 +0,0 @@
-# Lotto 구매 연동 + 전략 진화 시스템 구현 계획
-
-> **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:** 로또 추천 번호의 실제/가상 구매, 자동 결과 체크, EMA 기반 전략 진화 메타 추천 시스템 구현
-
-**Architecture:** 기존 lotto-backend(backend/) 서비스에 `purchase_manager.py`, `strategy_evolver.py` 2개 모듈 추가. 기존 `purchase_history` 테이블을 ALTER TABLE로 확장하고, `strategy_performance`, `strategy_weights` 2개 테이블 신규 생성. checker.py에서 purchase 체크 연동하여 자동 순환 파이프라인 완성.
-
-**Tech Stack:** Python 3.12, FastAPI, SQLite, APScheduler (기존 스택 그대로)
-
-**Spec:** `docs/superpowers/specs/2026-04-05-lotto-purchase-strategy-evolution-design.md`
-
----
-
-## 파일 구조
-
-### 신규 파일
-| 파일 | 역할 |
-|------|------|
-| `backend/app/purchase_manager.py` | 구매 이력 관리 + 결과 체크 로직 |
-| `backend/app/strategy_evolver.py` | EMA 계산 + Softmax 가중치 + 스마트 추천 |
-| `backend/tests/test_purchase_manager.py` | purchase_manager 단위 테스트 |
-| `backend/tests/test_strategy_evolver.py` | strategy_evolver 단위 테스트 |
-| `backend/tests/test_integration.py` | 체커 연동 통합 테스트 |
-
-### 수정 파일
-| 파일 | 변경 내용 |
-|------|----------|
-| `backend/app/db.py` | purchase_history ALTER + 신규 테이�� 2개 + CRUD 함수 |
-| `backend/app/checker.py` | check_results_for_draw() 끝에 purchase 체크 호출 |
-| `backend/app/main.py` | 신규 API 엔드포인트 + Pydantic 모델 + import |
-
----
-
-## Task 1: DB 스키마 확장 — purchase_history ALTER + 신규 테이블
-
-**Files:**
-- Modify: `backend/app/db.py:254-268` (purchase_history 생성 부분)
-- Modify: `backend/app/db.py:21` (init_db 함수 내부에 신규 ��이블 추가)
-- Test: `backend/tests/test_purchase_manager.py`
-
-- [ ] **Step 1: 테스트 파일 생성 — DB 스키마 검증 테스트**
-
-```python
-# backend/tests/test_purchase_manager.py
-import sys, os
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
-
-import sqlite3
-import pytest
-from unittest.mock import patch
-
-# 테스트용 임시 DB 경로
-TEST_DB = ":memory:"
-
-def _get_test_conn():
- conn = sqlite3.connect(TEST_DB)
- conn.row_factory = sqlite3.Row
- return conn
-
-def test_purchase_history_has_new_columns():
- """purchase_history 테이블에 신규 컬럼이 존재하는지 검증"""
- with patch("db.DB_PATH", TEST_DB):
- import db
- db.DB_PATH = TEST_DB
- db.init_db()
-
- conn = db._conn()
- cols = {r["name"] for r in conn.execute("PRAGMA table_info(purchase_history)").fetchall()}
- assert "numbers" in cols
- assert "is_real" in cols
- assert "source_strategy" in cols
- assert "source_detail" in cols
- assert "checked" in cols
- assert "results" in cols
- assert "total_prize" in cols
- # 기존 컬럼도 유지
- assert "draw_no" in cols
- assert "amount" in cols
- assert "sets" in cols
- assert "prize" in cols
- assert "note" in cols
- conn.close()
-
-
-def test_strategy_performance_table_exists():
- """strategy_performance 테이블이 생성되는지 검증"""
- with patch("db.DB_PATH", TEST_DB):
- import db
- db.DB_PATH = TEST_DB
- db.init_db()
-
- conn = db._conn()
- cols = {r["name"] for r in conn.execute("PRAGMA table_info(strategy_performance)").fetchall()}
- assert "strategy" in cols
- assert "draw_no" in cols
- assert "sets_count" in cols
- assert "total_correct" in cols
- assert "avg_score" in cols
- conn.close()
-
-
-def test_strategy_weights_table_exists():
- """strategy_weights 테이블이 생성되고 초기값이 있는지 검증"""
- with patch("db.DB_PATH", TEST_DB):
- import db
- db.DB_PATH = TEST_DB
- db.init_db()
-
- conn = db._conn()
- rows = conn.execute("SELECT * FROM strategy_weights ORDER BY strategy").fetchall()
- strategies = {r["strategy"] for r in rows}
- assert strategies == {"combined", "simulation", "heatmap", "manual", "custom"}
- # 가중치 합이 1.0
- total_weight = sum(r["weight"] for r in rows)
- assert abs(total_weight - 1.0) < 0.01
- conn.close()
-```
-
-- [ ] **Step 2: 테스트 실행 — 실패 확인**
-
-Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_purchase_manager.py -v`
-Expected: FAIL — 신규 컬럼/테이블 미존재
-
-- [ ] **Step 3: db.py init_db()에 purchase_history ALTER 추가**
-
-`backend/app/db.py`의 `init_db()` 함수에서, 기존 `purchase_history` CREATE TABLE 이후(268행 근처)에 추가:
-
-```python
- # ── purchase_history 컬럼 확장 (기존 데이터 보존) ──────────────────────
- _ensure_column(conn, "purchase_history", "numbers",
- "ALTER TABLE purchase_history ADD COLUMN numbers TEXT NOT NULL DEFAULT '[]'")
- _ensure_column(conn, "purchase_history", "is_real",
- "ALTER TABLE purchase_history ADD COLUMN is_real INTEGER NOT NULL DEFAULT 1")
- _ensure_column(conn, "purchase_history", "source_strategy",
- "ALTER TABLE purchase_history ADD COLUMN source_strategy TEXT NOT NULL DEFAULT 'manual'")
- _ensure_column(conn, "purchase_history", "source_detail",
- "ALTER TABLE purchase_history ADD COLUMN source_detail TEXT NOT NULL DEFAULT '{}'")
- _ensure_column(conn, "purchase_history", "checked",
- "ALTER TABLE purchase_history ADD COLUMN checked INTEGER NOT NULL DEFAULT 0")
- _ensure_column(conn, "purchase_history", "results",
- "ALTER TABLE purchase_history ADD COLUMN results TEXT NOT NULL DEFAULT '[]'")
- _ensure_column(conn, "purchase_history", "total_prize",
- "ALTER TABLE purchase_history ADD COLUMN total_prize INTEGER NOT NULL DEFAULT 0")
-```
-
-- [ ] **Step 4: db.py init_db()에 strategy_performance 테이블 추가**
-
-기존 `weekly_reports` 테이블 생성 부분(270행 근처) 이후에 추가:
-
-```python
- # ── strategy_performance 테이블 ────────────────────────────────────────
- conn.execute(
- """
- CREATE TABLE IF NOT EXISTS strategy_performance (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- strategy TEXT NOT NULL,
- draw_no INTEGER NOT NULL,
- sets_count INTEGER NOT NULL DEFAULT 0,
- total_correct INTEGER NOT NULL DEFAULT 0,
- max_correct INTEGER NOT NULL DEFAULT 0,
- prize_total INTEGER NOT NULL DEFAULT 0,
- avg_score REAL NOT NULL DEFAULT 0.0,
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- UNIQUE(strategy, draw_no)
- );
- """
- )
-
- # ── strategy_weights 테이블 ────────────────────────────────────────────
- conn.execute(
- """
- CREATE TABLE IF NOT EXISTS strategy_weights (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- strategy TEXT NOT NULL UNIQUE,
- weight REAL NOT NULL DEFAULT 0.2,
- ema_score REAL NOT NULL DEFAULT 0.15,
- total_sets INTEGER NOT NULL DEFAULT 0,
- total_hits_3plus INTEGER NOT NULL DEFAULT 0,
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
- );
- """
- )
-
- # strategy_weights 초기값 시드 (이미 있으면 무시)
- _INIT_WEIGHTS = [
- ("combined", 0.30, 0.15),
- ("simulation", 0.25, 0.15),
- ("heatmap", 0.20, 0.15),
- ("manual", 0.15, 0.15),
- ("custom", 0.10, 0.15),
- ]
- for strat, w, ema in _INIT_WEIGHTS:
- conn.execute(
- "INSERT OR IGNORE INTO strategy_weights (strategy, weight, ema_score) VALUES (?, ?, ?)",
- (strat, w, ema),
- )
-```
-
-- [ ] **Step 5: 테스트 실행 — 통과 확인**
-
-Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_purchase_manager.py -v`
-Expected: 3 tests PASS
-
-- [ ] **Step 6: 커밋**
-
-```bash
-cd C:/Users/jaeoh/Desktop/workspace/web-backend
-git add backend/app/db.py backend/tests/test_purchase_manager.py
-git commit -m "lotto-lab: DB 스키마 확장 — purchase_history ALTER + strategy 테이블 추가"
-```
-
----
-
-## Task 2: DB CRUD 함수 — 구매 이력 확장 + 전략 성과/가중치
-
-**Files:**
-- Modify: `backend/app/db.py:1140-1230` (기존 purchase CRUD 확장 + 신규 함수)
-- Test: `backend/tests/test_purchase_manager.py` (추가)
-
-- [ ] **Step 1: 테스트 추가 — 확장된 purchase CRUD**
-
-`backend/tests/test_purchase_manager.py`에 추가:
-
-```python
-def test_add_purchase_with_numbers():
- """번호 포함 구매 등록"""
- with patch("db.DB_PATH", TEST_DB):
- import db
- db.DB_PATH = TEST_DB
- db.init_db()
-
- result = db.add_purchase(
- draw_no=1125,
- amount=2000,
- sets=2,
- prize=0,
- note="테스트",
- numbers=[[3, 12, 23, 34, 38, 45], [7, 14, 21, 29, 36, 42]],
- is_real=True,
- source_strategy="combined",
- source_detail={"recommendation_ids": [1, 2]},
- )
- assert result["draw_no"] == 1125
- assert result["is_real"] == 1
- assert result["source_strategy"] == "combined"
- assert result["numbers"] == [[3, 12, 23, 34, 38, 45], [7, 14, 21, 29, 36, 42]]
- assert result["checked"] == 0
-
-
-def test_get_purchases_filter_is_real():
- """is_real 필터 동작"""
- with patch("db.DB_PATH", TEST_DB):
- import db
- db.DB_PATH = TEST_DB
- db.init_db()
-
- db.add_purchase(draw_no=1125, amount=1000, sets=1, is_real=True, source_strategy="combined")
- db.add_purchase(draw_no=1125, amount=1000, sets=1, is_real=False, source_strategy="simulation")
-
- real = db.get_purchases(is_real=True)
- virtual = db.get_purchases(is_real=False)
- assert all(r["is_real"] == 1 for r in real)
- assert all(r["is_real"] == 0 for r in virtual)
-
-
-def test_get_purchase_stats_by_type():
- """실제/가상 분리 통계"""
- with patch("db.DB_PATH", TEST_DB):
- import db
- db.DB_PATH = TEST_DB
- db.init_db()
-
- db.add_purchase(draw_no=1125, amount=2000, sets=2, prize=5000, is_real=True, source_strategy="combined")
- db.add_purchase(draw_no=1125, amount=1000, sets=1, prize=0, is_real=False, source_strategy="simulation")
-
- stats = db.get_purchase_stats()
- assert stats["total"]["invested"] == 3000
- assert stats["real"]["invested"] == 2000
- assert stats["virtual"]["invested"] == 1000
-
-
-def test_upsert_strategy_performance():
- """전략 성과 upsert"""
- with patch("db.DB_PATH", TEST_DB):
- import db
- db.DB_PATH = TEST_DB
- db.init_db()
-
- db.upsert_strategy_performance("combined", 1125, sets_count=2, total_correct=5, max_correct=3, avg_score=0.42)
- rows = db.get_strategy_performance("combined")
- assert len(rows) == 1
- assert rows[0]["sets_count"] == 2
-
- # upsert: 같은 전략+회차 → 업데이트
- db.upsert_strategy_performance("combined", 1125, sets_count=3, total_correct=7, max_correct=4, avg_score=0.50)
- rows = db.get_strategy_performance("combined")
- assert len(rows) == 1
- assert rows[0]["sets_count"] == 3
-
-
-def test_update_strategy_weight():
- """전략 가중치 업데이트"""
- with patch("db.DB_PATH", TEST_DB):
- import db
- db.DB_PATH = TEST_DB
- db.init_db()
-
- db.update_strategy_weight("combined", weight=0.35, ema_score=0.28, total_sets=15, total_hits_3plus=3)
- weights = db.get_strategy_weights()
- combined = next(w for w in weights if w["strategy"] == "combined")
- assert combined["weight"] == 0.35
- assert combined["ema_score"] == 0.28
-```
-
-- [ ] **Step 2: 테스트 실행 — 실패 확인**
-
-Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_purchase_manager.py -v`
-Expected: 새 테스트들 FAIL — 함수 미존재
-
-- [ ] **Step 3: db.py — add_purchase 함수 시그니처 확장**
-
-기존 `add_purchase` (db.py:1154) 를 확장:
-
-```python
-def add_purchase(draw_no: int, amount: int, sets: int, prize: int = 0, note: str = "",
- numbers: list = None, is_real: bool = True,
- source_strategy: str = "manual", source_detail: dict = None) -> Dict[str, Any]:
- import json as _json
- numbers_json = _json.dumps(numbers or [], ensure_ascii=False)
- detail_json = _json.dumps(source_detail or {}, ensure_ascii=False)
- is_real_int = 1 if is_real else 0
- with _conn() as conn:
- conn.execute(
- """INSERT INTO purchase_history
- (draw_no, amount, sets, prize, note, numbers, is_real, source_strategy, source_detail)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
- (draw_no, amount, sets, prize, note, numbers_json, is_real_int, source_strategy, detail_json),
- )
- row = conn.execute("SELECT * FROM purchase_history WHERE rowid = last_insert_rowid()").fetchone()
- return _purchase_row_to_dict(row)
-```
-
-- [ ] **Step 4: db.py — _purchase_row_to_dict 확장**
-
-기존 `_purchase_row_to_dict` (db.py:1142) 를 확장:
-
-```python
-def _purchase_row_to_dict(r) -> Dict[str, Any]:
- import json as _json
- numbers_raw = r["numbers"] if "numbers" in r.keys() else "[]"
- detail_raw = r["source_detail"] if "source_detail" in r.keys() else "{}"
- results_raw = r["results"] if "results" in r.keys() else "[]"
- return {
- "id": r["id"],
- "draw_no": r["draw_no"],
- "amount": r["amount"],
- "sets": r["sets"],
- "prize": r["prize"],
- "note": r["note"],
- "created_at": r["created_at"],
- "numbers": _json.loads(numbers_raw) if numbers_raw else [],
- "is_real": r["is_real"] if "is_real" in r.keys() else 1,
- "source_strategy": r["source_strategy"] if "source_strategy" in r.keys() else "manual",
- "source_detail": _json.loads(detail_raw) if detail_raw else {},
- "checked": r["checked"] if "checked" in r.keys() else 0,
- "results": _json.loads(results_raw) if results_raw else [],
- "total_prize": r["total_prize"] if "total_prize" in r.keys() else 0,
- }
-```
-
-- [ ] **Step 5: db.py — get_purchases에 is_real, strategy 필터 추가**
-
-기존 `get_purchases` (db.py:1164) 확장:
-
-```python
-def get_purchases(draw_no: int = None, days: int = None,
- is_real: bool = None, strategy: str = None,
- checked: bool = None) -> List[Dict[str, Any]]:
- conditions, params = [], []
- if draw_no is not None:
- conditions.append("draw_no = ?")
- params.append(draw_no)
- if days:
- conditions.append("created_at >= datetime('now', ? || ' days')")
- params.append(f"-{days}")
- if is_real is not None:
- conditions.append("is_real = ?")
- params.append(1 if is_real else 0)
- if strategy is not None:
- conditions.append("source_strategy = ?")
- params.append(strategy)
- if checked is not None:
- conditions.append("checked = ?")
- params.append(1 if checked else 0)
- where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
- with _conn() as conn:
- rows = conn.execute(
- f"SELECT * FROM purchase_history {where} ORDER BY draw_no DESC, id DESC",
- params,
- ).fetchall()
- return [_purchase_row_to_dict(r) for r in rows]
-```
-
-- [ ] **Step 6: db.py — get_purchase_stats 확장 (전체/실제/가상 + 전략별)**
-
-기존 `get_purchase_stats` (db.py:1206) 를 교체:
-
-```python
-def get_purchase_stats() -> Dict[str, Any]:
- import json as _json
-
- def _calc_group(rows):
- if not rows:
- return {"sets": 0, "invested": 0, "prize": 0, "roi": 0.0, "win_rate": 0.0}
- invested = sum(r["amount"] for r in rows)
- prize = sum(r["total_prize"] or r["prize"] for r in rows)
- wins = sum(1 for r in rows if (r["total_prize"] or r["prize"]) > 0)
- return {
- "sets": sum(r["sets"] for r in rows),
- "invested": invested,
- "prize": prize,
- "roi": round((prize / invested * 100 - 100) if invested else 0.0, 2),
- "win_rate": round(wins / len(rows) * 100, 2) if rows else 0.0,
- }
-
- with _conn() as conn:
- rows = conn.execute("SELECT * FROM purchase_history").fetchall()
-
- all_rows = [dict(r) for r in rows]
- real_rows = [r for r in all_rows if r.get("is_real", 1) == 1]
- virtual_rows = [r for r in all_rows if r.get("is_real", 1) == 0]
-
- # 전략별 집계
- by_strategy = {}
- for r in all_rows:
- strat = r.get("source_strategy", "manual")
- if strat not in by_strategy:
- by_strategy[strat] = []
- by_strategy[strat].append(r)
-
- strategy_stats = {}
- for strat, srows in by_strategy.items():
- s = _calc_group(srows)
- # results에서 correct 수 추출
- total_correct = 0
- count_sets = 0
- hits_3plus = 0
- for r in srows:
- results_raw = r.get("results", "[]")
- try:
- results = _json.loads(results_raw) if isinstance(results_raw, str) else results_raw
- except Exception:
- results = []
- for res in results:
- count_sets += 1
- c = res.get("correct", 0)
- total_correct += c
- if c >= 3:
- hits_3plus += 1
- s["avg_correct"] = round(total_correct / count_sets, 2) if count_sets else 0.0
- s["hits_3plus"] = hits_3plus
- strategy_stats[strat] = s
-
- return {
- "total": _calc_group(all_rows),
- "real": _calc_group(real_rows),
- "virtual": _calc_group(virtual_rows),
- "by_strategy": strategy_stats,
- # 하위호환: 기존 필드도 유지
- "total_records": len(all_rows),
- "total_invested": sum(r["amount"] for r in all_rows),
- "total_prize": sum(r.get("total_prize", 0) or r["prize"] for r in all_rows),
- "net": sum(r.get("total_prize", 0) or r["prize"] for r in all_rows) - sum(r["amount"] for r in all_rows),
- "return_rate": round((sum(r.get("total_prize", 0) or r["prize"] for r in all_rows) / sum(r["amount"] for r in all_rows) * 100) if sum(r["amount"] for r in all_rows) else 0.0, 2),
- "prize_count": sum(1 for r in all_rows if (r.get("total_prize", 0) or r["prize"]) > 0),
- "max_prize": max((r.get("total_prize", 0) or r["prize"] for r in all_rows), default=0),
- }
-```
-
-- [ ] **Step 7: db.py — strategy_performance CRUD 함수 추가**
-
-db.py 파일 끝에 추가:
-
-```python
-# ── strategy_performance CRUD ────────────────────────────────────────────────
-
-def upsert_strategy_performance(strategy: str, draw_no: int, sets_count: int = 0,
- total_correct: int = 0, max_correct: int = 0,
- prize_total: int = 0, avg_score: float = 0.0) -> None:
- with _conn() as conn:
- conn.execute(
- """INSERT INTO strategy_performance (strategy, draw_no, sets_count, total_correct, max_correct, prize_total, avg_score)
- VALUES (?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(strategy, draw_no) DO UPDATE SET
- sets_count=excluded.sets_count, total_correct=excluded.total_correct,
- max_correct=excluded.max_correct, prize_total=excluded.prize_total,
- avg_score=excluded.avg_score,
- updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')""",
- (strategy, draw_no, sets_count, total_correct, max_correct, prize_total, avg_score),
- )
-
-
-def get_strategy_performance(strategy: str = None, days: int = None) -> List[Dict[str, Any]]:
- conditions, params = [], []
- if strategy:
- conditions.append("strategy = ?")
- params.append(strategy)
- if days:
- conditions.append("updated_at >= datetime('now', ? || ' days')")
- params.append(f"-{days}")
- where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
- with _conn() as conn:
- rows = conn.execute(
- f"SELECT * FROM strategy_performance {where} ORDER BY draw_no ASC",
- params,
- ).fetchall()
- return [dict(r) for r in rows]
-
-
-# ── strategy_weights CRUD ─────────────────────────────────────────────────────
-
-def get_strategy_weights() -> List[Dict[str, Any]]:
- with _conn() as conn:
- rows = conn.execute("SELECT * FROM strategy_weights ORDER BY weight DESC").fetchall()
- return [dict(r) for r in rows]
-
-
-def update_strategy_weight(strategy: str, weight: float, ema_score: float,
- total_sets: int = None, total_hits_3plus: int = None) -> None:
- with _conn() as conn:
- fields = "weight=?, ema_score=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')"
- params = [weight, ema_score]
- if total_sets is not None:
- fields += ", total_sets=?"
- params.append(total_sets)
- if total_hits_3plus is not None:
- fields += ", total_hits_3plus=?"
- params.append(total_hits_3plus)
- params.append(strategy)
- conn.execute(f"UPDATE strategy_weights SET {fields} WHERE strategy=?", params)
-
-
-def update_purchase_results(purchase_id: int, results: list, total_prize: int) -> None:
- """구매 건의 결과를 갱신 (체커 호출 후)"""
- import json as _json
- with _conn() as conn:
- conn.execute(
- "UPDATE purchase_history SET results=?, total_prize=?, checked=1 WHERE id=?",
- (_json.dumps(results, ensure_ascii=False), total_prize, purchase_id),
- )
-```
-
-- [ ] **Step 8: db.py — exports 업데이트 (main.py import용)**
-
-db.py 파일 상단이나 기존 함수들이 이미 직접 import되므로, main.py의 import 문에 새 함수를 추가할 준비만 해둠. (Task 5에서 실행)
-
-- [ ] **Step 9: 테스트 실행 — 통과 확인**
-
-Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_purchase_manager.py -v`
-Expected: ALL PASS
-
-- [ ] **Step 10: 커밋**
-
-```bash
-cd C:/Users/jaeoh/Desktop/workspace/web-backend
-git add backend/app/db.py backend/tests/test_purchase_manager.py
-git commit -m "lotto-lab: 구매 CRUD 확장 + strategy_performance/weights CRUD 추가"
-```
-
----
-
-## Task 3: purchase_manager.py — 구매 결과 체크 로직
-
-**Files:**
-- Create: `backend/app/purchase_manager.py`
-- Test: `backend/tests/test_purchase_manager.py` (추가)
-
-- [ ] **Step 1: 테스트 추가 — 구매 결과 체크**
-
-`backend/tests/test_purchase_manager.py`에 추가:
-
-```python
-def test_check_purchases_for_draw():
- """특정 회차 구매 건들의 결과 체크"""
- with patch("db.DB_PATH", TEST_DB):
- import db
- db.DB_PATH = TEST_DB
- db.init_db()
-
- # draws 테이블에 테스트 데이터 삽입
- conn = db._conn()
- conn.execute(
- "INSERT OR IGNORE INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
- (1125, "2026-04-04", 3, 12, 23, 34, 38, 45, 7)
- )
- conn.commit()
- conn.close()
-
- # 1125회차 대상 구매 등록 (draw_no=1125)
- db.add_purchase(
- draw_no=1125, amount=2000, sets=2, numbers=[[3, 12, 23, 34, 38, 45], [1, 2, 3, 4, 5, 6]],
- is_real=True, source_strategy="combined",
- )
-
- from purchase_manager import check_purchases_for_draw
- checked = check_purchases_for_draw(1125)
- assert checked == 1 # 1건 체크됨
-
- purchases = db.get_purchases(draw_no=1125)
- p = purchases[0]
- assert p["checked"] == 1
- assert len(p["results"]) == 2
- # 첫 번째 세트: 6개 전부 맞음 = 1등
- assert p["results"][0]["rank"] == 1
- assert p["results"][0]["correct"] == 6
- # 두 번째 세트: 3개 맞음 (3, 34 -> 아니 3만 맞음... 확인)
- # [1,2,3,4,5,6] vs [3,12,23,34,38,45] → 3 하나만 맞음
- assert p["results"][1]["correct"] == 1
- assert p["results"][1]["rank"] == 0
-
-
-def test_check_purchases_updates_strategy_performance():
- """결과 체크 후 strategy_performance가 갱신되는지 검증"""
- with patch("db.DB_PATH", TEST_DB):
- import db
- db.DB_PATH = TEST_DB
- db.init_db()
-
- conn = db._conn()
- conn.execute(
- "INSERT OR IGNORE INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
- (1126, "2026-04-11", 5, 15, 25, 35, 40, 44, 10)
- )
- conn.commit()
- conn.close()
-
- db.add_purchase(
- draw_no=1126, amount=1000, sets=1, numbers=[[5, 15, 25, 10, 11, 12]],
- is_real=False, source_strategy="simulation",
- )
-
- from purchase_manager import check_purchases_for_draw
- check_purchases_for_draw(1126)
-
- perfs = db.get_strategy_performance("simulation")
- assert len(perfs) >= 1
- p = perfs[-1]
- assert p["draw_no"] == 1126
- assert p["sets_count"] == 1
-```
-
-- [ ] **Step 2: 테스트 실행 — 실패 확인**
-
-Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_purchase_manager.py::test_check_purchases_for_draw -v`
-Expected: FAIL — `purchase_manager` 모듈 미존재
-
-- [ ] **Step 3: purchase_manager.py 작성**
-
-```python
-# backend/app/purchase_manager.py
-"""
-구매 이력 관리 + 결과 체크 모듈.
-
-- check_purchases_for_draw(): 특정 회차 구매 건들의 결과를 자동 체크
-- 체커의 _calc_rank 재사용
-- 결과 체크 후 strategy_performance 자동 갱신
-"""
-import json
-import logging
-from .db import (
- _conn, get_draw, get_purchases, update_purchase_results,
- upsert_strategy_performance,
-)
-from .checker import _calc_rank
-
-logger = logging.getLogger("lotto-backend")
-
-RANK_PRIZE = {1: 0, 2: 0, 3: 1_500_000, 4: 50_000, 5: 5_000}
-
-
-def check_purchases_for_draw(drw_no: int) -> int:
- """
- 특정 회차 결과로 해당 회차 구매 건들을 채점한다.
-
- Returns: 채점한 구매 건 수
- """
- win_row = get_draw(drw_no)
- if not win_row:
- return 0
-
- win_nums = [win_row["n1"], win_row["n2"], win_row["n3"],
- win_row["n4"], win_row["n5"], win_row["n6"]]
- bonus = win_row["bonus"]
-
- # draw_no가 해당 회차이고 아직 체크 안 된 구매 건
- unchecked = get_purchases(draw_no=drw_no, checked=False)
-
- # 전략별 집계
- strategy_agg = {} # strategy -> {sets_count, total_correct, max_correct, prize_total, scores[]}
-
- count = 0
- for purchase in unchecked:
- numbers_list = purchase["numbers"]
- if not numbers_list:
- continue
-
- results = []
- for nums in numbers_list:
- rank, correct, has_bonus = _calc_rank(nums, win_nums, bonus)
- prize = RANK_PRIZE.get(rank, 0)
- results.append({
- "numbers": nums,
- "rank": rank,
- "correct": correct,
- "has_bonus": has_bonus,
- "prize": prize,
- })
-
- total_prize = sum(r["prize"] for r in results)
- update_purchase_results(purchase["id"], results, total_prize)
-
- # 전략별 집계
- strat = purchase["source_strategy"]
- if strat not in strategy_agg:
- strategy_agg[strat] = {"sets_count": 0, "total_correct": 0, "max_correct": 0, "prize_total": 0, "scores": []}
- agg = strategy_agg[strat]
- for r in results:
- agg["sets_count"] += 1
- agg["total_correct"] += r["correct"]
- agg["max_correct"] = max(agg["max_correct"], r["correct"])
- agg["prize_total"] += r["prize"]
- agg["scores"].append(r["correct"] / 6.0)
-
- count += 1
-
- # strategy_performance 갱신
- for strat, agg in strategy_agg.items():
- avg_score = sum(agg["scores"]) / len(agg["scores"]) if agg["scores"] else 0.0
- upsert_strategy_performance(
- strategy=strat,
- draw_no=drw_no,
- sets_count=agg["sets_count"],
- total_correct=agg["total_correct"],
- max_correct=agg["max_correct"],
- prize_total=agg["prize_total"],
- avg_score=round(avg_score, 4),
- )
-
- logger.info(f"[purchase_manager] {drw_no}회차 구매 {count}건 체크 완료")
- return count
-```
-
-- [ ] **Step 4: 테스트 실행 — 통과 확인**
-
-Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_purchase_manager.py -v`
-Expected: ALL PASS
-
-- [ ] **Step 5: 커밋**
-
-```bash
-cd C:/Users/jaeoh/Desktop/workspace/web-backend
-git add backend/app/purchase_manager.py backend/tests/test_purchase_manager.py
-git commit -m "lotto-lab: purchase_manager — 구매 결과 자동 체크 + 전략 성과 집계"
-```
-
----
-
-## Task 4: strategy_evolver.py — EMA + Softmax 가중치 진화 + 스마트 추천
-
-**Files:**
-- Create: `backend/app/strategy_evolver.py`
-- Test: `backend/tests/test_strategy_evolver.py`
-
-- [ ] **Step 1: 테스트 파일 생성**
-
-```python
-# backend/tests/test_strategy_evolver.py
-import sys, os
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
-
-import math
-import pytest
-from unittest.mock import patch
-
-TEST_DB = ":memory:"
-
-
-def test_calc_draw_score_basic():
- """세트별 결과 → draw_score 계산"""
- from strategy_evolver import calc_draw_score
-
- results = [
- {"correct": 3, "rank": 5}, # 3/6 + 0.1 = 0.6
- {"correct": 1, "rank": 0}, # 1/6 + 0 = 0.167
- ]
- score = calc_draw_score(results)
- expected = ((3/6 + 0.1) + (1/6)) / 2
- assert abs(score - expected) < 0.01
-
-
-def test_calc_draw_score_empty():
- """빈 결과 → 0"""
- from strategy_evolver import calc_draw_score
- assert calc_draw_score([]) == 0.0
-
-
-def test_recalculate_weights_softmax():
- """EMA → Softmax 가중치 변환"""
- from strategy_evolver import _softmax_weights
-
- ema_scores = {
- "combined": 0.30,
- "simulation": 0.25,
- "heatmap": 0.15,
- "manual": 0.10,
- "custom": 0.05,
- }
- weights = _softmax_weights(ema_scores)
-
- # 합이 1.0
- assert abs(sum(weights.values()) - 1.0) < 0.001
- # combined가 가장 높아야 함
- assert weights["combined"] > weights["simulation"]
- assert weights["simulation"] > weights["heatmap"]
- # 최소 가중치 5% 보장
- assert all(w >= 0.049 for w in weights.values())
-
-
-def test_recalculate_weights_min_weight():
- """한 전략의 EMA가 매우 낮아도 최소 5% 보장"""
- from strategy_evolver import _softmax_weights
-
- ema_scores = {
- "combined": 0.50,
- "simulation": 0.01,
- "heatmap": 0.01,
- "manual": 0.01,
- "custom": 0.01,
- }
- weights = _softmax_weights(ema_scores)
-
- assert weights["simulation"] >= 0.049
- assert weights["custom"] >= 0.049
- assert abs(sum(weights.values()) - 1.0) < 0.001
-
-
-def test_update_ema():
- """EMA 갱신 공식 검증"""
- from strategy_evolver import ALPHA
-
- old_ema = 0.15
- draw_score = 0.40
- new_ema = ALPHA * draw_score + (1 - ALPHA) * old_ema
- expected = 0.3 * 0.40 + 0.7 * 0.15 # = 0.12 + 0.105 = 0.225
- assert abs(new_ema - expected) < 0.001
-```
-
-- [ ] **Step 2: 테스트 실행 — 실패 확인**
-
-Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_strategy_evolver.py -v`
-Expected: FAIL — `strategy_evolver` 모듈 미존재
-
-- [ ] **Step 3: strategy_evolver.py 작성**
-
-```python
-# backend/app/strategy_evolver.py
-"""
-전략 진화 엔진 — EMA + Softmax 기반 적응형 가중치 관리.
-
-- calc_draw_score(): 구매 결과 → 성과 점수
-- update_ema_for_strategy(): 특정 전략의 EMA 갱신
-- recalculate_weights(): 전 전략 EMA → Softmax → 가중치 저장
-- generate_smart_recommendation(): 가중치 기반 메타 전략 추천
-"""
-import math
-import json
-import logging
-from typing import Dict, List, Any, Tuple
-
-from .db import (
- get_strategy_weights, update_strategy_weight,
- get_strategy_performance, get_best_picks, get_all_draw_numbers, get_latest_draw,
-)
-from .recommender import recommend_numbers, recommend_with_heatmap
-from .analyzer import generate_combined_recommendation, score_combination, build_analysis_cache
-from .db import list_recommendations_ex
-
-logger = logging.getLogger("lotto-backend")
-
-ALPHA = 0.3 # EMA 감쇠율
-TEMPERATURE = 2.0 # Softmax 온도
-MIN_WEIGHT = 0.05 # 최소 가중치
-INITIAL_EMA = 0.15 # 콜드스타트 초기값
-MIN_DATA_DRAWS = 10 # 학습 최소 회차
-
-STRATEGIES = ["combined", "simulation", "heatmap", "manual", "custom"]
-
-RANK_BONUS = {5: 0.1, 4: 0.3, 3: 0.6, 2: 0.8, 1: 1.0}
-
-
-def calc_draw_score(results: List[Dict]) -> float:
- """구매 결과 리스트 → 평균 성과 점수"""
- if not results:
- return 0.0
- scores = []
- for r in results:
- s = r.get("correct", 0) / 6.0
- s += RANK_BONUS.get(r.get("rank", 0), 0)
- scores.append(s)
- return sum(scores) / len(scores)
-
-
-def _softmax_weights(ema_scores: Dict[str, float]) -> Dict[str, float]:
- """EMA 점수 → Softmax → 최소 가중치 보장 → 정규화"""
- raw = {s: math.exp(ema / TEMPERATURE) for s, ema in ema_scores.items()}
- total = sum(raw.values())
- weights = {s: v / total for s, v in raw.items()}
-
- # 최소 가중치 보장
- clamped = {}
- surplus = 0.0
- unclamped = []
- for s, w in weights.items():
- if w < MIN_WEIGHT:
- clamped[s] = MIN_WEIGHT
- surplus += MIN_WEIGHT - w
- else:
- unclamped.append(s)
- clamped[s] = w
-
- # surplus를 unclamped에서 비례 차감
- if surplus > 0 and unclamped:
- unclamped_total = sum(clamped[s] for s in unclamped)
- for s in unclamped:
- clamped[s] -= surplus * (clamped[s] / unclamped_total)
-
- # 최종 정규화
- final_total = sum(clamped.values())
- return {s: round(v / final_total, 4) for s, v in clamped.items()}
-
-
-def update_ema_for_strategy(strategy: str, draw_score: float) -> float:
- """특정 전략의 EMA 갱신 + DB 저장. 새 EMA 반환."""
- weights = get_strategy_weights()
- current = next((w for w in weights if w["strategy"] == strategy), None)
- old_ema = current["ema_score"] if current else INITIAL_EMA
- new_ema = ALPHA * draw_score + (1 - ALPHA) * old_ema
- return new_ema
-
-
-def recalculate_weights() -> Dict[str, float]:
- """전 전략 EMA → Softmax → 가중치 재계산 + DB 저장"""
- weights_rows = get_strategy_weights()
- ema_scores = {w["strategy"]: w["ema_score"] for w in weights_rows}
-
- # 누락된 전략 보충
- for s in STRATEGIES:
- if s not in ema_scores:
- ema_scores[s] = INITIAL_EMA
-
- new_weights = _softmax_weights(ema_scores)
-
- for s, w in new_weights.items():
- row = next((r for r in weights_rows if r["strategy"] == s), None)
- update_strategy_weight(
- strategy=s,
- weight=w,
- ema_score=ema_scores[s],
- total_sets=row["total_sets"] if row else 0,
- total_hits_3plus=row["total_hits_3plus"] if row else 0,
- )
-
- logger.info(f"[strategy_evolver] 가중치 재계산: {new_weights}")
- return new_weights
-
-
-def evolve_after_check(strategy: str, draw_no: int, results: List[Dict]) -> None:
- """결과 체크 후 EMA 갱신 + 가중치 재계산 (purchase_manager에서 호출)"""
- draw_score = calc_draw_score(results)
- new_ema = update_ema_for_strategy(strategy, draw_score)
-
- weights_rows = get_strategy_weights()
- current = next((w for w in weights_rows if w["strategy"] == strategy), None)
- hits_3plus = sum(1 for r in results if r.get("correct", 0) >= 3)
-
- update_strategy_weight(
- strategy=strategy,
- weight=current["weight"] if current else 0.2,
- ema_score=new_ema,
- total_sets=(current["total_sets"] if current else 0) + len(results),
- total_hits_3plus=(current["total_hits_3plus"] if current else 0) + hits_3plus,
- )
-
- recalculate_weights()
-
-
-def get_weights_with_trend() -> Dict[str, Any]:
- """현재 가중치 + trend 정보 반환"""
- weights = get_strategy_weights()
- perfs = get_strategy_performance()
-
- # 전략별 최근 EMA 변화 추적 (최근 5회차)
- strat_perfs = {}
- for p in perfs:
- s = p["strategy"]
- if s not in strat_perfs:
- strat_perfs[s] = []
- strat_perfs[s].append(p)
-
- result = []
- for w in weights:
- # trend 계산: 최근 5회차 avg_score 변화
- sp = strat_perfs.get(w["strategy"], [])
- if len(sp) >= 5:
- recent_avg = sum(p["avg_score"] for p in sp[-3:]) / 3
- older_avg = sum(p["avg_score"] for p in sp[-5:-2]) / 3
- delta = recent_avg - older_avg
- trend = "up" if delta > 0.02 else ("down" if delta < -0.02 else "stable")
- else:
- trend = "stable"
-
- result.append({
- "strategy": w["strategy"],
- "weight": w["weight"],
- "ema_score": w["ema_score"],
- "total_sets": w["total_sets"],
- "hits_3plus": w["total_hits_3plus"],
- "trend": trend,
- })
-
- # 학습 상태
- all_draws = set()
- for p in perfs:
- all_draws.add(p["draw_no"])
-
- return {
- "weights": result,
- "last_evolved": weights[0]["updated_at"] if weights else None,
- "min_data_draws": MIN_DATA_DRAWS,
- "current_data_draws": len(all_draws),
- "status": "active" if len(all_draws) >= MIN_DATA_DRAWS else "learning",
- }
-
-
-def generate_smart_recommendation(sets: int = 5) -> Dict[str, Any]:
- """
- 전략 가중치 기반 메타 전략 추천.
-
- 1. 가중치 로드
- 2. 각 전략에서 후보 10세트 생성
- 3. meta_score = original_score × strategy_weight
- 4. 상위 N세트 선출 (중복 제거)
- """
- weights_data = get_strategy_weights()
- weight_map = {w["strategy"]: w["weight"] for w in weights_data}
- draws = get_all_draw_numbers()
- if not draws:
- return {"error": "No draw data"}
-
- latest = get_latest_draw()
- cache = build_analysis_cache(draws)
- past_recs = list_recommendations_ex(limit=100, sort="id_desc")
-
- candidates = [] # [{numbers, score, strategy, meta_score}]
- seen_keys = set()
-
- def _add_candidate(nums: list, strategy: str, raw_score: float = None):
- key = tuple(sorted(nums))
- if key in seen_keys:
- return
- seen_keys.add(key)
- if raw_score is None:
- sc = score_combination(nums, cache)
- raw_score = sc["score_total"]
- meta = raw_score * weight_map.get(strategy, 0.1)
- candidates.append({
- "numbers": sorted(nums),
- "raw_score": round(raw_score, 4),
- "strategy": strategy,
- "meta_score": round(meta, 4),
- })
-
- # combined: 10세트
- for _ in range(10):
- try:
- r = generate_combined_recommendation(draws)
- if "final_numbers" in r:
- _add_candidate(r["final_numbers"], "combined")
- except Exception:
- pass
-
- # simulation: best_picks 상위 10개
- best = get_best_picks(limit=10)
- for b in best:
- nums = json.loads(b["numbers"]) if isinstance(b["numbers"], str) else b["numbers"]
- _add_candidate(nums, "simulation", b.get("score_total"))
-
- # heatmap: 10세트
- for _ in range(10):
- try:
- r = recommend_with_heatmap(draws, past_recs)
- _add_candidate(r["numbers"], "heatmap")
- except Exception:
- pass
-
- # manual: 10세트
- for _ in range(10):
- try:
- r = recommend_numbers(draws)
- _add_candidate(r["numbers"], "manual")
- except Exception:
- pass
-
- # meta_score 기준 정렬, 상위 N개
- candidates.sort(key=lambda c: -c["meta_score"])
- top = candidates[:sets]
-
- # contribution 계산
- result_sets = []
- for c in top:
- # 이 번호에 기여한 전략들의 비율
- sc = score_combination(c["numbers"], cache)
- contributions = {}
- for strat in STRATEGIES:
- contributions[strat] = round(weight_map.get(strat, 0) * sc["score_total"], 4)
- contrib_total = sum(contributions.values()) or 1
- contributions = {s: round(v / contrib_total, 3) for s, v in contributions.items()}
-
- result_sets.append({
- "numbers": c["numbers"],
- "meta_score": c["meta_score"],
- "source_strategy": c["strategy"],
- "contribution": contributions,
- "individual_scores": {k: round(v, 4) for k, v in sc.items()},
- })
-
- # 학습 상태
- perfs = get_strategy_performance()
- data_draws = len(set(p["draw_no"] for p in perfs))
- status = "active" if data_draws >= MIN_DATA_DRAWS else "learning"
-
- return {
- "sets": result_sets,
- "strategy_weights_used": weight_map,
- "learning_status": {
- "draws_learned": data_draws,
- "status": status,
- "message": "" if status == "active" else f"{MIN_DATA_DRAWS}회차 이상 데이터 필요 (현재 {data_draws}회차)",
- },
- "based_on_latest_draw": latest["drw_no"] if latest else None,
- }
-```
-
-- [ ] **Step 4: 테스트 실행 — 통과 확인**
-
-Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_strategy_evolver.py -v`
-Expected: ALL PASS
-
-- [ ] **Step 5: 커밋**
-
-```bash
-cd C:/Users/jaeoh/Desktop/workspace/web-backend
-git add backend/app/strategy_evolver.py backend/tests/test_strategy_evolver.py
-git commit -m "lotto-lab: strategy_evolver — EMA/Softmax 가중치 진화 + 스마트 추천"
-```
-
----
-
-## Task 5: checker.py 연동 — 자동 파이프라인
-
-**Files:**
-- Modify: `backend/app/checker.py:28-66`
-- Test: `backend/tests/test_integration.py`
-
-- [ ] **Step 1: 통합 테스트 작성**
-
-```python
-# backend/tests/test_integration.py
-import sys, os
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
-
-import pytest
-from unittest.mock import patch
-
-TEST_DB = ":memory:"
-
-
-def test_check_results_triggers_purchase_check():
- """check_results_for_draw가 purchase 체크도 트리거하는지 검증"""
- with patch("db.DB_PATH", TEST_DB):
- import db
- db.DB_PATH = TEST_DB
- db.init_db()
-
- # 당첨번호 삽입: 1124회차 (base), 1125회차 (결과)
- conn = db._conn()
- conn.execute(
- "INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
- (1124, "2026-03-28", 1, 2, 3, 4, 5, 6, 7)
- )
- conn.execute(
- "INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
- (1125, "2026-04-04", 10, 20, 30, 35, 40, 44, 15)
- )
- conn.commit()
- conn.close()
-
- # 1125회차 대상 구매 (draw_no=1125)
- db.add_purchase(
- draw_no=1125, amount=1000, sets=1,
- numbers=[[10, 20, 30, 1, 2, 3]],
- is_real=True, source_strategy="combined",
- )
-
- from checker import check_results_for_draw
- check_results_for_draw(1125)
-
- # purchase도 체크되었는지 확인
- purchases = db.get_purchases(draw_no=1125)
- assert purchases[0]["checked"] == 1
- assert purchases[0]["results"][0]["correct"] == 3 # 10, 20, 30 맞음
-```
-
-- [ ] **Step 2: 테스트 실행 — 실패 확인**
-
-Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_integration.py -v`
-Expected: FAIL — checker.py에 purchase 연동 없음
-
-- [ ] **Step 3: checker.py 수정 — purchase 체크 연동**
-
-`backend/app/checker.py` 파일 끝(66행 이후)의 `check_results_for_draw` 함수에 추가:
-
-기존 함수의 마지막 `return count` 바로 위에 추가:
-
-```python
- # ── 구매 이력 체크 연동 ──────────────────────────────────────
- try:
- from .purchase_manager import check_purchases_for_draw
- purchase_count = check_purchases_for_draw(drw_no)
- if purchase_count > 0:
- # 전략 가중치 재계산
- from .strategy_evolver import recalculate_weights
- recalculate_weights()
- except ImportError:
- pass # purchase_manager 미설치 시 무시 (하위호환)
-```
-
-- [ ] **Step 4: 테스트 실행 — 통과 확인**
-
-Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_integration.py -v`
-Expected: PASS
-
-- [ ] **Step 5: 커밋**
-
-```bash
-cd C:/Users/jaeoh/Desktop/workspace/web-backend
-git add backend/app/checker.py backend/tests/test_integration.py
-git commit -m "lotto-lab: checker 연동 — 추첨 결과 시 purchase 자동 체크 + 가중치 재계산"
-```
-
----
-
-## Task 6: main.py — 신규 API 엔드포인트
-
-**Files:**
-- Modify: `backend/app/main.py:12-36` (import 확장)
-- Modify: `backend/app/main.py:258-308` (purchase API 확장)
-- Modify: `backend/app/main.py` (신규 전략/스마트 추천 API 추가)
-
-- [ ] **Step 1: main.py import 확장**
-
-`backend/app/main.py:12-36`의 import 블록에 추가:
-
-```python
-from .db import (
- # ... 기존 import 유지 ...
- # 신규: 전략 관련
- get_strategy_weights as db_get_strategy_weights,
- get_strategy_performance as db_get_strategy_performance,
- upsert_strategy_performance,
- update_strategy_weight,
- update_purchase_results,
-)
-from .purchase_manager import check_purchases_for_draw
-from .strategy_evolver import (
- get_weights_with_trend, recalculate_weights,
- generate_smart_recommendation,
-)
-```
-
-- [ ] **Step 2: PurchaseCreate Pydantic 모델 확장**
-
-기존 `PurchaseCreate` (main.py:260-265) 교체:
-
-```python
-class PurchaseCreate(BaseModel):
- draw_no: int
- amount: int
- sets: int = 1
- prize: int = 0
- note: str = ""
- # 신규 필드
- numbers: List[List[int]] = []
- is_real: bool = True
- source_strategy: str = "manual"
- source_detail: dict = {}
-```
-
-- [ ] **Step 3: PurchaseUpdate 확장**
-
-기존 `PurchaseUpdate` (main.py:268-273) 확장:
-
-```python
-class PurchaseUpdate(BaseModel):
- draw_no: Optional[int] = None
- amount: Optional[int] = None
- sets: Optional[int] = None
- prize: Optional[int] = None
- note: Optional[str] = None
- # 신규 필드
- numbers: Optional[List[List[int]]] = None
- is_real: Optional[bool] = None
- source_strategy: Optional[str] = None
-```
-
-- [ ] **Step 4: 기존 purchase API 수정**
-
-`api_purchase_create` (main.py:288-291) 수정:
-
-```python
-@app.post("/api/lotto/purchase", status_code=201)
-def api_purchase_create(body: PurchaseCreate):
- """구매 이력 추가 (실제/가상)"""
- sets = body.sets if body.sets > 0 else max(len(body.numbers), 1)
- amount = body.amount if body.amount > 0 else sets * 1000
- return add_purchase(
- draw_no=body.draw_no,
- amount=amount,
- sets=sets,
- prize=body.prize,
- note=body.note,
- numbers=body.numbers,
- is_real=body.is_real,
- source_strategy=body.source_strategy,
- source_detail=body.source_detail,
- )
-```
-
-`api_purchase_list` (main.py:282-285) 수정:
-
-```python
-@app.get("/api/lotto/purchase")
-def api_purchase_list(draw_no: Optional[int] = None, days: Optional[int] = None,
- is_real: Optional[bool] = None, strategy: Optional[str] = None):
- """구매 이력 조회 (필터: draw_no, days, is_real, strategy)"""
- return {"records": get_purchases(draw_no=draw_no, days=days, is_real=is_real, strategy=strategy)}
-```
-
-- [ ] **Step 5: 전략 API 추가**
-
-`main.py`의 purchase API 블록 이후에 추가:
-
-```python
-# ── 전략 진화 API ───────────────────────────────────────��────────────────────
-
-@app.get("/api/lotto/strategy/weights")
-def api_strategy_weights():
- """현재 전략별 가중치 + 성과 요약 + trend"""
- return get_weights_with_trend()
-
-
-@app.get("/api/lotto/strategy/performance")
-def api_strategy_performance(strategy: Optional[str] = None, days: Optional[int] = None):
- """전략별 회차 성과 이력 (차트용)"""
- rows = db_get_strategy_performance(strategy=strategy, days=days)
- return {"records": rows}
-
-
-@app.post("/api/lotto/strategy/evolve")
-def api_strategy_evolve():
- """수동 가중치 재계산 트리거"""
- new_weights = recalculate_weights()
- return {"ok": True, "weights": new_weights}
-
-
-# ── 스마트 추천 API ──────────────────────────────────────────────────────────
-
-@app.get("/api/lotto/recommend/smart")
-def api_recommend_smart(sets: int = 5):
- """전략 가중치 기반 메타 전략 추천"""
- sets = max(1, min(sets, 10))
- result = generate_smart_recommendation(sets=sets)
- if "error" in result:
- raise HTTPException(status_code=500, detail=result["error"])
- return result
-```
-
-- [ ] **Step 6: 테스트 — API 스모크 테스트**
-
-Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -c "from backend.app.main import app; print('import OK')"` 또는 docker compose up 후 curl 테스트.
-
-Expected: import 성공, 문법 오류 없음
-
-- [ ] **Step 7: 커밋**
-
-```bash
-cd C:/Users/jaeoh/Desktop/workspace/web-backend
-git add backend/app/main.py
-git commit -m "lotto-lab: 구매/전략/스마트추천 API 엔드포인트 추가"
-```
-
----
-
-## Task 7: 통합 검증 — Docker 빌드 + 전체 테스트
-
-**Files:** (수정 없음, 검증만)
-
-- [ ] **Step 1: 전체 테스트 실행**
-
-Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/ -v`
-Expected: ALL PASS
-
-- [ ] **Step 2: Docker 빌드 테스트**
-
-Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend && docker compose build lotto-backend`
-Expected: 빌드 성공
-
-- [ ] **Step 3: 로컬 실행 테스트**
-
-Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend && docker compose up -d lotto-backend`
-
-API 엔드포인트 확인:
-```bash
-# 전략 가중치 조회
-curl http://localhost:18000/api/lotto/strategy/weights
-
-# 스마트 추천
-curl "http://localhost:18000/api/lotto/recommend/smart?sets=3"
-
-# 가상 구매 등록
-curl -X POST http://localhost:18000/api/lotto/purchase \
- -H "Content-Type: application/json" \
- -d '{"draw_no":1125,"numbers":[[3,12,23,34,38,45]],"is_real":false,"amount":1000,"sets":1,"source_strategy":"smart"}'
-
-# 구매 이력 조회 (가상만)
-curl "http://localhost:18000/api/lotto/purchase?is_real=false"
-
-# 구매 통계
-curl http://localhost:18000/api/lotto/purchase/stats
-```
-
-Expected: 모든 API가 200 응답
-
-- [ ] **Step 4: CLAUDE.md 업데이트**
-
-`backend/` CLAUDE.md의 lotto-lab API 목록에 신규 API 추가:
-
-```markdown
-| GET | `/api/lotto/purchase` | 구매 이력 조회 (is_real, strategy, draw_no, days 필터) |
-| POST | `/api/lotto/purchase` | 구매 등록 (실제/가상, 번호, 전략 출처 포함) |
-| GET | `/api/lotto/purchase/stats` | 구매 통계 (전체/실제/가상 + 전략별) |
-| GET | `/api/lotto/strategy/weights` | 전략별 가중치 + 성과 + trend |
-| GET | `/api/lotto/strategy/performance` | 전략별 회차 성과 이력 (차트용) |
-| POST | `/api/lotto/strategy/evolve` | 수동 가중치 재계산 |
-| GET | `/api/lotto/recommend/smart` | 전략 진화 기반 메타 추천 |
-```
-
-lotto.db 테이블에 추가:
-
-```markdown
-| `strategy_performance` | 전략별 회차 성과 (EMA 입력 데이터) |
-| `strategy_weights` | 메타 전략 가중치 (EMA + Softmax) |
-```
-
-- [ ] **Step 5: 커밋**
-
-```bash
-cd C:/Users/jaeoh/Desktop/workspace/web-backend
-git add CLAUDE.md
-git commit -m "lotto-lab: CLAUDE.md 신규 API + 테이블 문서 업데이트"
-```
-
----
-
-## 요약
-
-| Task | 내용 | 파일 |
-|------|------|------|
-| 1 | DB 스키마 확장 | db.py (ALTER + 테이블 생성) |
-| 2 | DB CRUD 함수 | db.py (purchase 확장 + strategy CRUD) |
-| 3 | purchase_manager.py | 구매 결과 체크 + 전략 성과 집계 |
-| 4 | strategy_evolver.py | EMA + Softmax + 스마트 추천 |
-| 5 | checker.py 연동 | 자동 파이프라인 완성 |
-| 6 | main.py API | 9개 엔드포인트 추가 |
-| 7 | 통합 검증 | Docker 빌드 + API 테스트 + 문서 |
-
-**프론트엔드 작업은 별도 플랜으로 분리합니다** (web-ui 레포가 별도 Git 저장소이므로).
diff --git a/docs/superpowers/plans/2026-04-05-realestate-lab.md b/docs/superpowers/plans/2026-04-05-realestate-lab.md
deleted file mode 100644
index 001d5b7..0000000
--- a/docs/superpowers/plans/2026-04-05-realestate-lab.md
+++ /dev/null
@@ -1,1509 +0,0 @@
-# realestate-lab 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:** 부동산 청약 공고 자동 수집 + 프로필 기반 자격 매칭 독립 서비스 구축, 기존 lotto-backend 청약 코드 제거
-
-**Architecture:** 공공데이터포털(한국부동산원 청약홈 API)에서 매일 공고를 수집하여 SQLite에 저장하고, 사용자 프로필 기반으로 자격 매칭 점수를 산출하는 FastAPI 독립 서비스. stock-lab/music-lab과 동일한 Docker 컨테이너 패턴.
-
-**Tech Stack:** Python 3.12, FastAPI, SQLite, APScheduler, requests, pydantic
-
----
-
-## File Structure
-
-### New files (realestate-lab/)
-
-| File | Responsibility |
-|------|----------------|
-| `realestate-lab/app/__init__.py` | 패키지 마커 |
-| `realestate-lab/app/main.py` | FastAPI 앱, 라우트, APScheduler, startup/shutdown |
-| `realestate-lab/app/db.py` | SQLite 테이블 생성, 모든 CRUD 함수 |
-| `realestate-lab/app/collector.py` | 공공데이터포털 API 호출, 응답 파싱, DB 저장 |
-| `realestate-lab/app/matcher.py` | 프로필 기반 매칭 점수 산출 엔진 |
-| `realestate-lab/app/models.py` | Pydantic 요청/응답 모델 |
-| `realestate-lab/Dockerfile` | python:3.12-alpine 기반 컨테이너 |
-| `realestate-lab/requirements.txt` | 의존성 목록 |
-
-### Modified files
-
-| File | Change |
-|------|--------|
-| `docker-compose.yml` | realestate-lab 서비스 추가 |
-| `nginx/default.conf` | `/api/realestate/` 프록시 라우팅 추가 |
-| `scripts/deploy-nas.sh` | rsync 대상에 realestate-lab 추가 |
-| `backend/app/main.py` | 청약 관련 모델 + 라우트 + import 제거 |
-| `backend/app/db.py` | realestate_complexes, subscription_items, subscription_profile 테이블 및 CRUD 제거 |
-
----
-
-### Task 1: 프로젝트 스캐폴딩 (Dockerfile, requirements.txt, __init__.py)
-
-**Files:**
-- Create: `realestate-lab/app/__init__.py`
-- Create: `realestate-lab/requirements.txt`
-- Create: `realestate-lab/Dockerfile`
-
-- [ ] **Step 1: 디렉토리 생성 및 __init__.py**
-
-```bash
-mkdir -p realestate-lab/app
-```
-
-```python
-# realestate-lab/app/__init__.py
-```
-(빈 파일)
-
-- [ ] **Step 2: requirements.txt 작성**
-
-```
-# realestate-lab/requirements.txt
-requests==2.32.3
-fastapi==0.115.6
-uvicorn[standard]==0.30.6
-apscheduler==3.10.4
-pydantic>=2.0
-```
-
-- [ ] **Step 3: Dockerfile 작성**
-
-```dockerfile
-# realestate-lab/Dockerfile
-FROM python:3.12-alpine
-
-WORKDIR /app
-COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
-
-COPY . .
-
-CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
-```
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add realestate-lab/
-git commit -m "feat(realestate-lab): 프로젝트 스캐폴딩 — Dockerfile, requirements, init"
-```
-
----
-
-### Task 2: Pydantic 모델 정의 (models.py)
-
-**Files:**
-- Create: `realestate-lab/app/models.py`
-
-- [ ] **Step 1: models.py 작성**
-
-```python
-# realestate-lab/app/models.py
-from typing import Optional, List
-from pydantic import BaseModel
-
-
-# ── 공고 ─────────────────────────────────────────────────────────────────────
-
-class AnnouncementCreate(BaseModel):
- house_nm: str
- house_secd: str = "01"
- house_dtl_secd: Optional[str] = None
- rent_secd: Optional[str] = None
- region_code: Optional[str] = None
- region_name: Optional[str] = None
- address: Optional[str] = None
- total_units: Optional[int] = None
- rcrit_date: Optional[str] = None
- receipt_start: Optional[str] = None
- receipt_end: Optional[str] = None
- spsply_start: Optional[str] = None
- spsply_end: Optional[str] = None
- gnrl_rank1_start: Optional[str] = None
- gnrl_rank1_end: Optional[str] = None
- winner_date: Optional[str] = None
- contract_start: Optional[str] = None
- contract_end: Optional[str] = None
- homepage_url: Optional[str] = None
- pblanc_url: Optional[str] = None
- constructor: Optional[str] = None
- developer: Optional[str] = None
- move_in_month: Optional[str] = None
- is_speculative_area: Optional[str] = None
- is_price_cap: Optional[str] = None
- contact: Optional[str] = None
-
-
-class AnnouncementUpdate(BaseModel):
- house_nm: Optional[str] = None
- house_secd: Optional[str] = None
- house_dtl_secd: Optional[str] = None
- rent_secd: Optional[str] = None
- region_code: Optional[str] = None
- region_name: Optional[str] = None
- address: Optional[str] = None
- total_units: Optional[int] = None
- rcrit_date: Optional[str] = None
- receipt_start: Optional[str] = None
- receipt_end: Optional[str] = None
- spsply_start: Optional[str] = None
- spsply_end: Optional[str] = None
- gnrl_rank1_start: Optional[str] = None
- gnrl_rank1_end: Optional[str] = None
- winner_date: Optional[str] = None
- contract_start: Optional[str] = None
- contract_end: Optional[str] = None
- homepage_url: Optional[str] = None
- pblanc_url: Optional[str] = None
- constructor: Optional[str] = None
- developer: Optional[str] = None
- move_in_month: Optional[str] = None
- is_speculative_area: Optional[str] = None
- is_price_cap: Optional[str] = None
- contact: Optional[str] = None
-
-
-# ── 프로필 ───────────────────────────────────────────────────────────────────
-
-class ProfileUpdate(BaseModel):
- name: Optional[str] = None
- age: Optional[int] = None
- is_homeless: Optional[bool] = None
- is_householder: Optional[bool] = None
- subscription_months: Optional[int] = None
- subscription_amount: Optional[int] = None
- family_members: Optional[int] = None
- has_dependents: Optional[bool] = None
- children_count: Optional[int] = None
- is_newlywed: Optional[bool] = None
- marriage_months: Optional[int] = None
- has_newborn: Optional[bool] = None
- is_first_home: Optional[bool] = None
- income_level: Optional[str] = None
- preferred_regions: Optional[List[str]] = None
- preferred_types: Optional[List[str]] = None
- min_area: Optional[float] = None
- max_area: Optional[float] = None
- max_price: Optional[int] = None
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add realestate-lab/app/models.py
-git commit -m "feat(realestate-lab): Pydantic 요청 모델 정의"
-```
-
----
-
-### Task 3: DB 레이어 (db.py)
-
-**Files:**
-- Create: `realestate-lab/app/db.py`
-
-- [ ] **Step 1: db.py 작성 — 테이블 생성 + announcements CRUD**
-
-```python
-# realestate-lab/app/db.py
-import json
-import sqlite3
-import logging
-from typing import Dict, Any, List, Optional
-from datetime import date
-
-logger = logging.getLogger("realestate-lab")
-
-DB_PATH = "/app/data/realestate.db"
-
-
-def _conn():
- c = sqlite3.connect(DB_PATH)
- c.row_factory = sqlite3.Row
- c.execute("PRAGMA journal_mode=WAL;")
- c.execute("PRAGMA foreign_keys=ON;")
- return c
-
-
-def init_db():
- with _conn() as conn:
- # ── announcements ────────────────────────────────────────────────
- conn.execute("""
- CREATE TABLE IF NOT EXISTS announcements (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- house_manage_no TEXT NOT NULL,
- pblanc_no TEXT NOT NULL,
- house_nm TEXT,
- house_secd TEXT,
- house_dtl_secd TEXT,
- rent_secd TEXT,
- region_code TEXT,
- region_name TEXT,
- address TEXT,
- total_units INTEGER,
- rcrit_date TEXT,
- receipt_start TEXT,
- receipt_end TEXT,
- spsply_start TEXT,
- spsply_end TEXT,
- gnrl_rank1_start TEXT,
- gnrl_rank1_end TEXT,
- winner_date TEXT,
- contract_start TEXT,
- contract_end TEXT,
- homepage_url TEXT,
- pblanc_url TEXT,
- constructor TEXT,
- developer TEXT,
- move_in_month TEXT,
- is_speculative_area TEXT,
- is_price_cap TEXT,
- contact TEXT,
- status TEXT NOT NULL DEFAULT '청약예정',
- source TEXT NOT NULL DEFAULT 'manual',
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- UNIQUE(house_manage_no, pblanc_no)
- );
- """)
- conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_status ON announcements(status);")
- conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_region ON announcements(region_name);")
-
- # ── announcement_models ──────────────────────────────────────────
- conn.execute("""
- CREATE TABLE IF NOT EXISTS announcement_models (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- house_manage_no TEXT NOT NULL,
- pblanc_no TEXT NOT NULL,
- model_no TEXT,
- house_ty TEXT,
- supply_area REAL,
- general_units INTEGER DEFAULT 0,
- special_units INTEGER DEFAULT 0,
- multi_child_units INTEGER DEFAULT 0,
- newlywed_units INTEGER DEFAULT 0,
- first_life_units INTEGER DEFAULT 0,
- old_parent_units INTEGER DEFAULT 0,
- institution_units INTEGER DEFAULT 0,
- youth_units INTEGER DEFAULT 0,
- newborn_units INTEGER DEFAULT 0,
- top_amount INTEGER,
- UNIQUE(house_manage_no, pblanc_no, model_no)
- );
- """)
-
- # ── user_profile ─────────────────────────────────────────────────
- conn.execute("""
- CREATE TABLE IF NOT EXISTS user_profile (
- id INTEGER PRIMARY KEY DEFAULT 1,
- name TEXT,
- age INTEGER,
- is_homeless INTEGER,
- is_householder INTEGER,
- subscription_months INTEGER,
- subscription_amount INTEGER,
- family_members INTEGER,
- has_dependents INTEGER,
- children_count INTEGER DEFAULT 0,
- is_newlywed INTEGER,
- marriage_months INTEGER,
- has_newborn INTEGER,
- is_first_home INTEGER,
- income_level TEXT,
- preferred_regions TEXT NOT NULL DEFAULT '[]',
- preferred_types TEXT NOT NULL DEFAULT '[]',
- min_area REAL,
- max_area REAL,
- max_price INTEGER,
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
- );
- """)
-
- # ── match_results ────────────────────────────────────────────────
- conn.execute("""
- CREATE TABLE IF NOT EXISTS match_results (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- announcement_id INTEGER NOT NULL,
- model_id INTEGER,
- match_score INTEGER NOT NULL DEFAULT 0,
- match_reasons TEXT NOT NULL DEFAULT '[]',
- eligible_types TEXT NOT NULL DEFAULT '[]',
- is_new INTEGER NOT NULL DEFAULT 1,
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- UNIQUE(announcement_id, model_id)
- );
- """)
-
- # ── collect_log ──────────────────────────────────────────────────
- conn.execute("""
- CREATE TABLE IF NOT EXISTS collect_log (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- collected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- new_count INTEGER NOT NULL DEFAULT 0,
- total_count INTEGER NOT NULL DEFAULT 0,
- error TEXT
- );
- """)
-
-
-# ── 상태 자동 계산 ───────────────────────────────────────────────────────────
-
-def compute_status(receipt_start: str, receipt_end: str, winner_date: str) -> str:
- today = date.today().isoformat()
- if receipt_start and today < receipt_start:
- return "청약예정"
- if receipt_start and receipt_end and receipt_start <= today <= receipt_end:
- return "청약중"
- if receipt_end and winner_date and receipt_end < today <= winner_date:
- return "결과발표"
- if winner_date and today > winner_date:
- return "완료"
- return "청약예정"
-
-
-# ── announcements CRUD ───────────────────────────────────────────────────────
-
-def _ann_row_to_dict(r) -> Dict[str, Any]:
- return {c: r[c] for c in r.keys()}
-
-
-def upsert_announcement(data: Dict[str, Any]) -> Dict[str, Any]:
- """공고 upsert — house_manage_no + pblanc_no 기준."""
- status = compute_status(
- data.get("receipt_start", ""),
- data.get("receipt_end", ""),
- data.get("winner_date", ""),
- )
- with _conn() as conn:
- conn.execute("""
- INSERT INTO announcements (
- house_manage_no, pblanc_no, house_nm, house_secd, house_dtl_secd,
- rent_secd, region_code, region_name, address, total_units,
- rcrit_date, receipt_start, receipt_end, spsply_start, spsply_end,
- gnrl_rank1_start, gnrl_rank1_end, winner_date, contract_start,
- contract_end, homepage_url, pblanc_url, constructor, developer,
- move_in_month, is_speculative_area, is_price_cap, contact,
- status, source
- ) VALUES (
- :house_manage_no, :pblanc_no, :house_nm, :house_secd, :house_dtl_secd,
- :rent_secd, :region_code, :region_name, :address, :total_units,
- :rcrit_date, :receipt_start, :receipt_end, :spsply_start, :spsply_end,
- :gnrl_rank1_start, :gnrl_rank1_end, :winner_date, :contract_start,
- :contract_end, :homepage_url, :pblanc_url, :constructor, :developer,
- :move_in_month, :is_speculative_area, :is_price_cap, :contact,
- :status, :source
- )
- ON CONFLICT(house_manage_no, pblanc_no) DO UPDATE SET
- house_nm=excluded.house_nm,
- house_secd=excluded.house_secd,
- house_dtl_secd=excluded.house_dtl_secd,
- rent_secd=excluded.rent_secd,
- region_code=excluded.region_code,
- region_name=excluded.region_name,
- address=excluded.address,
- total_units=excluded.total_units,
- rcrit_date=excluded.rcrit_date,
- receipt_start=excluded.receipt_start,
- receipt_end=excluded.receipt_end,
- spsply_start=excluded.spsply_start,
- spsply_end=excluded.spsply_end,
- gnrl_rank1_start=excluded.gnrl_rank1_start,
- gnrl_rank1_end=excluded.gnrl_rank1_end,
- winner_date=excluded.winner_date,
- contract_start=excluded.contract_start,
- contract_end=excluded.contract_end,
- homepage_url=excluded.homepage_url,
- pblanc_url=excluded.pblanc_url,
- constructor=excluded.constructor,
- developer=excluded.developer,
- move_in_month=excluded.move_in_month,
- is_speculative_area=excluded.is_speculative_area,
- is_price_cap=excluded.is_price_cap,
- contact=excluded.contact,
- status=excluded.status,
- updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
- """, {**data, "status": status})
- row = conn.execute(
- "SELECT * FROM announcements WHERE house_manage_no = ? AND pblanc_no = ?",
- (data["house_manage_no"], data["pblanc_no"]),
- ).fetchone()
- return _ann_row_to_dict(row)
-
-
-def get_announcements(
- region: str = None,
- status: str = None,
- house_type: str = None,
- matched_only: bool = False,
- sort: str = "date",
- page: int = 1,
- size: int = 20,
-) -> Dict[str, Any]:
- conditions, params = [], []
- if region:
- conditions.append("a.region_name = ?")
- params.append(region)
- if status:
- conditions.append("a.status = ?")
- params.append(status)
- if house_type:
- conditions.append("a.house_secd = ?")
- params.append(house_type)
-
- join_clause = ""
- if matched_only:
- join_clause = "INNER JOIN match_results m ON m.announcement_id = a.id"
-
- where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
-
- order_map = {"date": "a.rcrit_date DESC", "score": "a.id DESC", "price": "a.id ASC"}
- order = order_map.get(sort, "a.rcrit_date DESC")
- if matched_only and sort == "score":
- order = "m.match_score DESC"
-
- offset = (page - 1) * size
-
- with _conn() as conn:
- total = conn.execute(
- f"SELECT COUNT(*) FROM announcements a {join_clause} {where}", params
- ).fetchone()[0]
- rows = conn.execute(
- f"SELECT a.* FROM announcements a {join_clause} {where} ORDER BY {order} LIMIT ? OFFSET ?",
- params + [size, offset],
- ).fetchall()
- return {
- "items": [_ann_row_to_dict(r) for r in rows],
- "total": total,
- "page": page,
- "size": size,
- }
-
-
-def get_announcement(ann_id: int) -> Optional[Dict[str, Any]]:
- with _conn() as conn:
- row = conn.execute("SELECT * FROM announcements WHERE id = ?", (ann_id,)).fetchone()
- if not row:
- return None
- ann = _ann_row_to_dict(row)
- models = conn.execute(
- "SELECT * FROM announcement_models WHERE house_manage_no = ? AND pblanc_no = ?",
- (ann["house_manage_no"], ann["pblanc_no"]),
- ).fetchall()
- ann["models"] = [dict(m) for m in models]
- return ann
-
-
-def create_announcement(data: Dict[str, Any]) -> Dict[str, Any]:
- """수동 공고 등록 (house_manage_no 자동 생성)."""
- import uuid
- data["house_manage_no"] = data.get("house_manage_no", f"MANUAL-{uuid.uuid4().hex[:8]}")
- data["pblanc_no"] = data.get("pblanc_no", "00")
- data["source"] = "manual"
- return upsert_announcement(data)
-
-
-def update_announcement(ann_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
- fields = {k: v for k, v in data.items() if v is not None}
- if not fields:
- return get_announcement(ann_id)
-
- # 날짜 변경 시 status 재계산
- with _conn() as conn:
- row = conn.execute("SELECT * FROM announcements WHERE id = ?", (ann_id,)).fetchone()
- if not row:
- return None
- current = _ann_row_to_dict(row)
- merged = {**current, **fields}
- status = compute_status(
- merged.get("receipt_start", ""),
- merged.get("receipt_end", ""),
- merged.get("winner_date", ""),
- )
- fields["status"] = status
-
- set_clauses = ", ".join(f"{k} = ?" for k in fields)
- set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
- conn.execute(
- f"UPDATE announcements SET {set_clauses} WHERE id = ?",
- list(fields.values()) + [ann_id],
- )
- return get_announcement(ann_id)
-
-
-def delete_announcement(ann_id: int) -> bool:
- with _conn() as conn:
- # 관련 매칭 결과도 삭제
- conn.execute("DELETE FROM match_results WHERE announcement_id = ?", (ann_id,))
- cur = conn.execute("DELETE FROM announcements WHERE id = ?", (ann_id,))
- return cur.rowcount > 0
-
-
-def update_all_statuses():
- """모든 진행중 공고의 status를 날짜 기반으로 재계산."""
- with _conn() as conn:
- rows = conn.execute(
- "SELECT id, receipt_start, receipt_end, winner_date FROM announcements WHERE status != '완료'"
- ).fetchall()
- for r in rows:
- new_status = compute_status(r["receipt_start"], r["receipt_end"], r["winner_date"])
- if new_status != "완료":
- conn.execute("UPDATE announcements SET status = ? WHERE id = ?", (new_status, r["id"]))
- else:
- conn.execute(
- "UPDATE announcements SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
- (new_status, r["id"]),
- )
-
-
-# ── announcement_models CRUD ─────────────────────────────────────────────────
-
-def upsert_model(data: Dict[str, Any]):
- with _conn() as conn:
- conn.execute("""
- INSERT INTO announcement_models (
- house_manage_no, pblanc_no, model_no, house_ty, supply_area,
- general_units, special_units, multi_child_units, newlywed_units,
- first_life_units, old_parent_units, institution_units,
- youth_units, newborn_units, top_amount
- ) VALUES (
- :house_manage_no, :pblanc_no, :model_no, :house_ty, :supply_area,
- :general_units, :special_units, :multi_child_units, :newlywed_units,
- :first_life_units, :old_parent_units, :institution_units,
- :youth_units, :newborn_units, :top_amount
- )
- ON CONFLICT(house_manage_no, pblanc_no, model_no) DO UPDATE SET
- house_ty=excluded.house_ty,
- supply_area=excluded.supply_area,
- general_units=excluded.general_units,
- special_units=excluded.special_units,
- multi_child_units=excluded.multi_child_units,
- newlywed_units=excluded.newlywed_units,
- first_life_units=excluded.first_life_units,
- old_parent_units=excluded.old_parent_units,
- institution_units=excluded.institution_units,
- youth_units=excluded.youth_units,
- newborn_units=excluded.newborn_units,
- top_amount=excluded.top_amount
- """, data)
-
-
-# ── user_profile CRUD ────────────────────────────────────────────────────────
-
-def _profile_row_to_dict(r) -> Dict[str, Any]:
- d = {}
- for c in r.keys():
- val = r[c]
- if c in ("is_homeless", "is_householder", "has_dependents", "is_newlywed",
- "has_newborn", "is_first_home"):
- d[c] = bool(val) if val is not None else None
- elif c in ("preferred_regions", "preferred_types"):
- d[c] = json.loads(val) if val else []
- else:
- d[c] = val
- return d
-
-
-def get_profile() -> Optional[Dict[str, Any]]:
- with _conn() as conn:
- r = conn.execute("SELECT * FROM user_profile WHERE id = 1").fetchone()
- return _profile_row_to_dict(r) if r else None
-
-
-def upsert_profile(data: Dict[str, Any]) -> Dict[str, Any]:
- updates = {}
- for k, v in data.items():
- if v is None:
- continue
- if isinstance(v, bool):
- updates[k] = 1 if v else 0
- elif isinstance(v, list):
- updates[k] = json.dumps(v)
- else:
- updates[k] = v
-
- with _conn() as conn:
- existing = conn.execute("SELECT id FROM user_profile WHERE id = 1").fetchone()
- if existing:
- if updates:
- set_clauses = ", ".join(f"{k} = ?" for k in updates)
- set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
- conn.execute(
- f"UPDATE user_profile SET {set_clauses} WHERE id = 1",
- list(updates.values()),
- )
- else:
- cols = ["id"] + list(updates.keys())
- vals = [1] + list(updates.values())
- placeholders = ", ".join("?" for _ in vals)
- conn.execute(
- f"INSERT INTO user_profile ({', '.join(cols)}) VALUES ({placeholders})",
- vals,
- )
- row = conn.execute("SELECT * FROM user_profile WHERE id = 1").fetchone()
- return _profile_row_to_dict(row)
-
-
-# ── match_results CRUD ───────────────────────────────────────────────────────
-
-def save_match_result(data: Dict[str, Any]):
- with _conn() as conn:
- conn.execute("""
- INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new)
- VALUES (:announcement_id, :model_id, :match_score, :match_reasons, :eligible_types, 1)
- ON CONFLICT(announcement_id, model_id) DO UPDATE SET
- match_score=excluded.match_score,
- match_reasons=excluded.match_reasons,
- eligible_types=excluded.eligible_types
- """, {
- **data,
- "match_reasons": json.dumps(data.get("match_reasons", [])),
- "eligible_types": json.dumps(data.get("eligible_types", [])),
- })
-
-
-def get_matches(page: int = 1, size: int = 20) -> Dict[str, Any]:
- offset = (page - 1) * size
- with _conn() as conn:
- total = conn.execute("SELECT COUNT(*) FROM match_results").fetchone()[0]
- rows = conn.execute("""
- SELECT m.*, a.house_nm, a.region_name, a.address, a.status as ann_status,
- a.receipt_start, a.receipt_end, a.winner_date, a.pblanc_url
- FROM match_results m
- JOIN announcements a ON a.id = m.announcement_id
- ORDER BY m.is_new DESC, m.match_score DESC
- LIMIT ? OFFSET ?
- """, (size, offset)).fetchall()
-
- items = []
- for r in rows:
- d = {c: r[c] for c in r.keys()}
- d["match_reasons"] = json.loads(d["match_reasons"]) if d["match_reasons"] else []
- d["eligible_types"] = json.loads(d["eligible_types"]) if d["eligible_types"] else []
- items.append(d)
- return {"items": items, "total": total, "page": page, "size": size}
-
-
-def mark_match_read(match_id: int) -> bool:
- with _conn() as conn:
- cur = conn.execute("UPDATE match_results SET is_new = 0 WHERE id = ?", (match_id,))
- return cur.rowcount > 0
-
-
-def clear_match_results():
- with _conn() as conn:
- conn.execute("DELETE FROM match_results")
-
-
-# ── collect_log CRUD ─────────────────────────────────────────────────────────
-
-def save_collect_log(new_count: int, total_count: int, error: str = None):
- with _conn() as conn:
- conn.execute(
- "INSERT INTO collect_log (new_count, total_count, error) VALUES (?, ?, ?)",
- (new_count, total_count, error),
- )
-
-
-def get_last_collect_log() -> Optional[Dict[str, Any]]:
- with _conn() as conn:
- r = conn.execute("SELECT * FROM collect_log ORDER BY id DESC LIMIT 1").fetchone()
- return dict(r) if r else None
-
-
-# ── 대시보드 ─────────────────────────────────────────────────────────────────
-
-def get_dashboard() -> Dict[str, Any]:
- with _conn() as conn:
- active = conn.execute(
- "SELECT COUNT(*) FROM announcements WHERE status IN ('청약예정', '청약중')"
- ).fetchone()[0]
- new_matches = conn.execute(
- "SELECT COUNT(*) FROM match_results WHERE is_new = 1"
- ).fetchone()[0]
- upcoming = conn.execute("""
- SELECT id, house_nm, receipt_start, receipt_end, status
- FROM announcements
- WHERE status IN ('청약예정', '청약중')
- ORDER BY receipt_start ASC
- LIMIT 5
- """).fetchall()
- return {
- "active_count": active,
- "new_match_count": new_matches,
- "upcoming": [dict(r) for r in upcoming],
- }
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add realestate-lab/app/db.py
-git commit -m "feat(realestate-lab): DB 레이어 — 테이블 생성 + 전체 CRUD"
-```
-
----
-
-### Task 4: 수집기 (collector.py)
-
-**Files:**
-- Create: `realestate-lab/app/collector.py`
-
-- [ ] **Step 1: collector.py 작성**
-
-```python
-# realestate-lab/app/collector.py
-import os
-import logging
-import requests
-from typing import List, Dict, Any
-
-from .db import upsert_announcement, upsert_model, save_collect_log
-
-logger = logging.getLogger("realestate-lab")
-
-API_BASE = "https://api.odcloud.kr/api/ApplyhomeInfoDetailSvc/v1"
-API_KEY = os.getenv("DATA_GO_KR_API_KEY", "")
-
-# 수집 대상 엔드포인트 (상세 + 주택형별 쌍)
-DETAIL_ENDPOINTS = [
- ("getAPTLttotPblancDetail", "getAPTLttotPblancMdl"),
- ("getUrbtyOfctlLttotPblancDetail", "getUrbtyOfctlLttotPblancMdl"),
- ("getRemndrLttotPblancDetail", "getRemndrLttotPblancMdl"),
- ("getPblPvtRentLttotPblancDetail", "getPblPvtRentLttotPblancMdl"),
- ("getOPTLttotPblancDetail", "getOPTLttotPblancMdl"),
-]
-
-
-def _api_call(endpoint: str, params: dict = None) -> List[Dict[str, Any]]:
- """공공데이터포털 API 호출. 페이지네이션 자동 처리."""
- if not API_KEY:
- logger.warning("DATA_GO_KR_API_KEY 미설정 — API 수집 건너뜀")
- return []
-
- url = f"{API_BASE}/{endpoint}"
- base_params = {
- "serviceKey": API_KEY,
- "perPage": 100,
- "returnType": "JSON",
- }
- if params:
- base_params.update(params)
-
- all_data = []
- page = 1
- while True:
- base_params["page"] = page
- try:
- resp = requests.get(url, params=base_params, timeout=30)
- resp.raise_for_status()
- body = resp.json()
- except Exception as e:
- logger.error(f"API 호출 실패: {endpoint} page={page} — {e}")
- break
-
- data = body.get("data", [])
- if not data:
- break
-
- all_data.extend(data)
- total = body.get("totalCount", 0)
- if len(all_data) >= total:
- break
- page += 1
-
- return all_data
-
-
-def _parse_apt_detail(raw: Dict[str, Any]) -> Dict[str, Any]:
- """APT 상세 API 응답을 announcements 스키마로 변환."""
- return {
- "house_manage_no": str(raw.get("HOUSE_MANAGE_NO", "")),
- "pblanc_no": str(raw.get("PBLANC_NO", "")),
- "house_nm": raw.get("HOUSE_NM"),
- "house_secd": raw.get("HOUSE_SECD"),
- "house_dtl_secd": raw.get("HOUSE_DTL_SECD"),
- "rent_secd": raw.get("RENT_SECD"),
- "region_code": raw.get("SUBSCRPT_AREA_CODE"),
- "region_name": raw.get("SUBSCRPT_AREA_CODE_NM"),
- "address": raw.get("HSSPLY_ADRES"),
- "total_units": raw.get("TOT_SUPLY_HSHLDCO"),
- "rcrit_date": raw.get("RCRIT_PBLANC_DE"),
- "receipt_start": raw.get("RCEPT_BGNDE") or raw.get("SUBSCRPT_RCEPT_BGNDE"),
- "receipt_end": raw.get("RCEPT_ENDDE") or raw.get("SUBSCRPT_RCEPT_ENDDE"),
- "spsply_start": raw.get("SPSPLY_RCEPT_BGNDE"),
- "spsply_end": raw.get("SPSPLY_RCEPT_ENDDE"),
- "gnrl_rank1_start": raw.get("GNRL_RNK1_CRSPAREA_RCPTDE") or raw.get("GNRL_RCEPT_BGNDE"),
- "gnrl_rank1_end": raw.get("GNRL_RNK1_CRSPAREA_ENDDE") or raw.get("GNRL_RCEPT_ENDDE"),
- "winner_date": raw.get("PRZWNER_PRESNATN_DE"),
- "contract_start": raw.get("CNTRCT_CNCLS_BGNDE"),
- "contract_end": raw.get("CNTRCT_CNCLS_ENDDE"),
- "homepage_url": raw.get("HMPG_ADRES"),
- "pblanc_url": raw.get("PBLANC_URL"),
- "constructor": raw.get("CNSTRCT_ENTRPS_NM"),
- "developer": raw.get("BSNS_MBY_NM"),
- "move_in_month": raw.get("MVN_PREARNGE_YM"),
- "is_speculative_area": raw.get("SPECLT_RDN_EARTH_AT"),
- "is_price_cap": raw.get("PARCPRC_ULS_AT"),
- "contact": raw.get("MDHS_TELNO"),
- "source": "auto",
- }
-
-
-def _parse_model(raw: Dict[str, Any]) -> Dict[str, Any]:
- """주택형별 API 응답을 announcement_models 스키마로 변환."""
- top = raw.get("LTTOT_TOP_AMOUNT")
- if isinstance(top, str):
- top = int(top.replace(",", "")) if top.strip() else None
- return {
- "house_manage_no": str(raw.get("HOUSE_MANAGE_NO", "")),
- "pblanc_no": str(raw.get("PBLANC_NO", "")),
- "model_no": raw.get("MODEL_NO"),
- "house_ty": raw.get("HOUSE_TY"),
- "supply_area": float(raw["SUPLY_AR"]) if raw.get("SUPLY_AR") else None,
- "general_units": raw.get("SUPLY_HSHLDCO", 0) or 0,
- "special_units": raw.get("SPSPLY_HSHLDCO", 0) or 0,
- "multi_child_units": raw.get("MNYCH_HSHLDCO", 0) or 0,
- "newlywed_units": raw.get("NWWDS_HSHLDCO", 0) or 0,
- "first_life_units": raw.get("LFE_FRST_HSHLDCO", 0) or 0,
- "old_parent_units": raw.get("OLD_PARNTS_SUPORT_HSHLDCO", 0) or 0,
- "institution_units": raw.get("INSTT_RECOMEND_HSHLDCO", 0) or 0,
- "youth_units": raw.get("YGMN_HSHLDCO", 0) or 0,
- "newborn_units": raw.get("NWBB_HSHLDCO", 0) or 0,
- "top_amount": top,
- }
-
-
-def collect_all() -> Dict[str, int]:
- """전체 수집 실행. 반환: {"new_count": N, "total_count": N}"""
- if not API_KEY:
- logger.warning("DATA_GO_KR_API_KEY 미설정 — 수집 건너뜀")
- save_collect_log(0, 0, "API 키 미설정")
- return {"new_count": 0, "total_count": 0}
-
- total_count = 0
- new_count = 0
- errors = []
-
- for detail_ep, model_ep in DETAIL_ENDPOINTS:
- try:
- # 상세 공고 수집
- details = _api_call(detail_ep)
- for raw in details:
- parsed = _parse_apt_detail(raw)
- if not parsed["house_manage_no"]:
- continue
- upsert_announcement(parsed)
- total_count += 1
-
- # 주택형별 상세 수집
- models = _api_call(model_ep)
- for raw in models:
- parsed = _parse_model(raw)
- if not parsed["house_manage_no"]:
- continue
- upsert_model(parsed)
-
- except Exception as e:
- logger.error(f"수집 에러 ({detail_ep}): {e}")
- errors.append(f"{detail_ep}: {str(e)}")
-
- error_msg = "; ".join(errors) if errors else None
- save_collect_log(new_count, total_count, error_msg)
- logger.info(f"수집 완료: total={total_count}, new={new_count}, errors={len(errors)}")
- return {"new_count": new_count, "total_count": total_count}
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add realestate-lab/app/collector.py
-git commit -m "feat(realestate-lab): 공공데이터포털 API 수집기"
-```
-
----
-
-### Task 5: 매칭 엔진 (matcher.py)
-
-**Files:**
-- Create: `realestate-lab/app/matcher.py`
-
-- [ ] **Step 1: matcher.py 작성**
-
-```python
-# realestate-lab/app/matcher.py
-import json
-import logging
-from typing import Dict, Any, List
-
-from .db import (
- get_profile, get_announcements, save_match_result, clear_match_results, _conn,
-)
-
-logger = logging.getLogger("realestate-lab")
-
-
-def _check_eligible_types(profile: Dict[str, Any], ann: Dict[str, Any]) -> List[str]:
- """프로필 기반 지원 가능 공급유형 판별."""
- types = []
- is_homeless = profile.get("is_homeless", False)
- is_householder = profile.get("is_householder", False)
- sub_months = profile.get("subscription_months", 0) or 0
- is_speculative = ann.get("is_speculative_area", "") == "Y"
-
- # 일반공급 1순위
- required_months = 24 if is_speculative else 12
- if is_homeless and is_householder and sub_months >= required_months:
- types.append("일반1순위")
- elif is_homeless:
- types.append("일반2순위")
-
- # 특별공급 — 신혼부부
- if profile.get("is_newlywed") and is_homeless:
- types.append("특별-신혼부부")
-
- # 특별공급 — 생애최초
- if profile.get("is_first_home") and is_homeless:
- types.append("특별-생애최초")
-
- # 특별공급 — 다자녀
- children = profile.get("children_count", 0) or 0
- if children >= 2 and is_homeless:
- types.append("특별-다자녀")
-
- # 특별공급 — 노부모부양
- if profile.get("has_dependents") and is_homeless:
- types.append("특별-노부모부양")
-
- # 특별공급 — 청년
- age = profile.get("age", 0) or 0
- if 19 <= age <= 39 and is_homeless:
- types.append("특별-청년")
-
- # 특별공급 — 신생아
- if profile.get("has_newborn") and is_homeless:
- types.append("특별-신생아")
-
- return types
-
-
-def _compute_score(profile: Dict[str, Any], ann: Dict[str, Any], models: List[Dict]) -> Dict[str, Any]:
- """매칭 점수 산출 (0~100)."""
- score = 0
- reasons = []
-
- # 1. 지역 매칭 (30점)
- pref_regions = profile.get("preferred_regions", [])
- if pref_regions and ann.get("region_name"):
- if ann["region_name"] in pref_regions:
- score += 30
- reasons.append(f"지역 일치: {ann['region_name']}")
-
- # 2. 주택유형 매칭 (10점)
- pref_types = profile.get("preferred_types", [])
- type_map = {"01": "APT", "02": "오피스텔", "04": "무순위", "09": "민간사전청약", "10": "신혼희망타운"}
- ann_type = type_map.get(ann.get("house_secd", ""), ann.get("house_secd", ""))
- if pref_types and ann_type in pref_types:
- score += 10
- reasons.append(f"유형 일치: {ann_type}")
-
- # 3. 면적 매칭 (15점)
- min_area = profile.get("min_area")
- max_area = profile.get("max_area")
- if models and (min_area is not None or max_area is not None):
- for m in models:
- area = m.get("supply_area", 0) or 0
- in_range = True
- if min_area and area < min_area:
- in_range = False
- if max_area and area > max_area:
- in_range = False
- if in_range and area > 0:
- score += 15
- reasons.append(f"면적 범위 내: {area}㎡")
- break
-
- # 4. 가격 매칭 (15점)
- max_price = profile.get("max_price")
- if models and max_price:
- for m in models:
- top = m.get("top_amount")
- if top and top <= max_price:
- score += 15
- reasons.append(f"가격 범위 내: {top}만원")
- break
-
- # 5. 자격 매칭 (30점)
- eligible = _check_eligible_types(profile, ann)
- if eligible:
- eligibility_score = min(len(eligible) * 10, 30)
- score += eligibility_score
- reasons.append(f"지원 가능: {', '.join(eligible)}")
-
- return {
- "match_score": score,
- "match_reasons": reasons,
- "eligible_types": eligible,
- }
-
-
-def run_matching():
- """전체 매칭 실행 — 프로필과 모든 활성 공고를 매칭."""
- profile = get_profile()
- if not profile:
- logger.info("프로필 미설정 — 매칭 건너뜀")
- return
-
- # 기존 매칭 결과 초기화
- clear_match_results()
-
- with _conn() as conn:
- anns = conn.execute(
- "SELECT * FROM announcements WHERE status IN ('청약예정', '청약중')"
- ).fetchall()
-
- for ann_row in anns:
- ann = {c: ann_row[c] for c in ann_row.keys()}
- models = conn.execute(
- "SELECT * FROM announcement_models WHERE house_manage_no = ? AND pblanc_no = ?",
- (ann["house_manage_no"], ann["pblanc_no"]),
- ).fetchall()
- model_list = [dict(m) for m in models]
-
- result = _compute_score(profile, ann, model_list)
- if result["match_score"] > 0:
- save_match_result({
- "announcement_id": ann["id"],
- "model_id": None,
- "match_score": result["match_score"],
- "match_reasons": result["match_reasons"],
- "eligible_types": result["eligible_types"],
- })
-
- logger.info("매칭 완료")
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add realestate-lab/app/matcher.py
-git commit -m "feat(realestate-lab): 프로필 기반 매칭 엔진"
-```
-
----
-
-### Task 6: FastAPI 앱 (main.py)
-
-**Files:**
-- Create: `realestate-lab/app/main.py`
-
-- [ ] **Step 1: main.py 작성**
-
-```python
-# realestate-lab/app/main.py
-import os
-import logging
-from contextlib import asynccontextmanager
-from fastapi import FastAPI, Query, HTTPException
-from fastapi.middleware.cors import CORSMiddleware
-from apscheduler.schedulers.background import BackgroundScheduler
-
-from .db import (
- init_db, get_announcements, get_announcement, create_announcement,
- update_announcement, delete_announcement, update_all_statuses,
- get_profile, upsert_profile, get_matches, mark_match_read,
- get_last_collect_log, get_dashboard,
-)
-from .collector import collect_all
-from .matcher import run_matching
-from .models import AnnouncementCreate, AnnouncementUpdate, ProfileUpdate
-
-logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
-logger = logging.getLogger("realestate-lab")
-
-scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
-
-
-def scheduled_collect():
- """매일 09:00 — 수집 + 매칭"""
- logger.info("스케줄 수집 시작")
- collect_all()
- run_matching()
- logger.info("스케줄 수집 + 매칭 완료")
-
-
-def scheduled_status_update():
- """매일 00:00 — 상태 갱신 + 재매칭"""
- logger.info("상태 갱신 시작")
- update_all_statuses()
- run_matching()
- logger.info("상태 갱신 + 재매칭 완료")
-
-
-@asynccontextmanager
-async def lifespan(app: FastAPI):
- init_db()
- scheduler.add_job(scheduled_collect, "cron", hour=9, minute=0, id="collect")
- scheduler.add_job(scheduled_status_update, "cron", hour=0, minute=0, id="status_update")
- scheduler.start()
- logger.info("realestate-lab 시작")
- yield
- scheduler.shutdown()
-
-
-app = FastAPI(lifespan=lifespan)
-
-_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
-app.add_middleware(
- CORSMiddleware,
- allow_origins=[o.strip() for o in _cors_origins],
- allow_credentials=False,
- allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
- allow_headers=["Content-Type"],
-)
-
-
-@app.get("/health")
-def health():
- return {"status": "ok"}
-
-
-# ── 공고 API ─────────────────────────────────────────────────────────────────
-
-@app.get("/api/realestate/announcements")
-def api_announcements(
- region: str = None,
- status: str = None,
- house_type: str = None,
- matched_only: bool = False,
- sort: str = "date",
- page: int = Query(1, ge=1),
- size: int = Query(20, ge=1, le=100),
-):
- return get_announcements(region, status, house_type, matched_only, sort, page, size)
-
-
-@app.get("/api/realestate/announcements/{ann_id}")
-def api_announcement_detail(ann_id: int):
- ann = get_announcement(ann_id)
- if not ann:
- raise HTTPException(status_code=404, detail="Announcement not found")
- return ann
-
-
-@app.post("/api/realestate/announcements", status_code=201)
-def api_announcement_create(body: AnnouncementCreate):
- return create_announcement(body.model_dump())
-
-
-@app.put("/api/realestate/announcements/{ann_id}")
-def api_announcement_update(ann_id: int, body: AnnouncementUpdate):
- updated = update_announcement(ann_id, body.model_dump(exclude_none=True))
- if not updated:
- raise HTTPException(status_code=404, detail="Announcement not found")
- return updated
-
-
-@app.delete("/api/realestate/announcements/{ann_id}")
-def api_announcement_delete(ann_id: int):
- if not delete_announcement(ann_id):
- raise HTTPException(status_code=404, detail="Announcement not found")
- return {"ok": True}
-
-
-# ── 수집 API ─────────────────────────────────────────────────────────────────
-
-@app.post("/api/realestate/collect")
-def api_collect():
- result = collect_all()
- run_matching()
- return result
-
-
-@app.get("/api/realestate/collect/status")
-def api_collect_status():
- log = get_last_collect_log()
- return log if log else {"collected_at": None, "new_count": 0, "total_count": 0, "error": None}
-
-
-# ── 프로필 API ───────────────────────────────────────────────────────────────
-
-@app.get("/api/realestate/profile")
-def api_profile_get():
- profile = get_profile()
- return profile if profile else {}
-
-
-@app.put("/api/realestate/profile")
-def api_profile_update(body: ProfileUpdate):
- return upsert_profile(body.model_dump(exclude_none=True))
-
-
-# ── 매칭 API ─────────────────────────────────────────────────────────────────
-
-@app.get("/api/realestate/matches")
-def api_matches(page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100)):
- return get_matches(page, size)
-
-
-@app.post("/api/realestate/matches/refresh")
-def api_matches_refresh():
- run_matching()
- return {"ok": True}
-
-
-@app.patch("/api/realestate/matches/{match_id}/read")
-def api_match_read(match_id: int):
- if not mark_match_read(match_id):
- raise HTTPException(status_code=404, detail="Match not found")
- return {"ok": True}
-
-
-# ── 대시보드 API ─────────────────────────────────────────────────────────────
-
-@app.get("/api/realestate/dashboard")
-def api_dashboard():
- return get_dashboard()
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add realestate-lab/app/main.py
-git commit -m "feat(realestate-lab): FastAPI 앱 + 스케줄러 + 전체 API 라우트"
-```
-
----
-
-### Task 7: 인프라 통합 (docker-compose, nginx, deploy script)
-
-**Files:**
-- Modify: `docker-compose.yml`
-- Modify: `nginx/default.conf`
-- Modify: `scripts/deploy-nas.sh`
-
-- [ ] **Step 1: docker-compose.yml에 realestate-lab 서비스 추가**
-
-`blog-lab` 서비스 블록 뒤, `travel-proxy` 블록 앞에 추가:
-
-```yaml
- realestate-lab:
- build:
- context: ./realestate-lab
- container_name: realestate-lab
- restart: unless-stopped
- ports:
- - "18800:8000"
- environment:
- - TZ=${TZ:-Asia/Seoul}
- - DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY:-}
- - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
- volumes:
- - ${REALESTATE_DATA_PATH:-./data/realestate}:/app/data
- healthcheck:
- test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
- interval: 30s
- timeout: 5s
- retries: 3
-```
-
-- [ ] **Step 2: nginx/default.conf에 realestate 프록시 추가**
-
-`/api/music/` 블록 뒤에 추가:
-
-```nginx
- # realestate API
- location /api/realestate/ {
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_pass http://realestate-lab:8000/api/realestate/;
- }
-```
-
-- [ ] **Step 3: scripts/deploy-nas.sh rsync 대상에 realestate-lab 추가**
-
-변경 전:
-```bash
-for dir in backend travel-proxy deployer stock-lab music-lab blog-lab nginx scripts; do
-```
-
-변경 후:
-```bash
-for dir in backend travel-proxy deployer stock-lab music-lab blog-lab realestate-lab nginx scripts; do
-```
-
-- [ ] **Step 4: nginx frontend 컨테이너 depends_on에 realestate-lab 추가**
-
-```yaml
- depends_on:
- - music-lab
- - blog-lab
- - realestate-lab
-```
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add docker-compose.yml nginx/default.conf scripts/deploy-nas.sh
-git commit -m "infra: realestate-lab Docker/Nginx/배포 스크립트 통합"
-```
-
----
-
-### Task 8: lotto-backend에서 청약 코드 제거
-
-**Files:**
-- Modify: `backend/app/main.py` — 라인 22~27 import, 881~957 (realestate), 960~1064 (subscription)
-- Modify: `backend/app/db.py` — 라인 184~214 (realestate_complexes 테이블), 216~300 (subscription 테이블), 837~963 (realestate CRUD), 966~1327 (subscription CRUD)
-
-- [ ] **Step 1: backend/app/main.py에서 청약 import 제거**
-
-라인 22~27 변경 — `# realestate` 와 `# subscription` import 블록 삭제:
-
-변경 전:
-```python
- # realestate
- get_all_complexes, get_complex, create_complex, update_complex, delete_complex,
- # subscription
- get_all_subscription_items, create_subscription_item,
- update_subscription_item, delete_subscription_item,
- get_subscription_profile, upsert_subscription_profile,
-```
-
-변경 후: (해당 6줄 삭제)
-
-- [ ] **Step 2: backend/app/main.py에서 RealEstate API 섹션 삭제 (라인 881~957)**
-
-`# ── RealEstate API ──` 부터 `return {"ok": True}` (957번 라인)까지 전체 삭제:
-- `VALID_STATUSES`, `VALID_PRIORITIES` 상수
-- `ComplexCreate`, `ComplexUpdate` 모델
-- `api_realestate_list`, `api_realestate_create`, `api_realestate_update`, `api_realestate_delete` 라우트
-
-- [ ] **Step 3: backend/app/main.py에서 Subscription API 섹션 삭제 (라인 960~1064)**
-
-`# ── Subscription API ──` 부터 파일 끝 `upsert_subscription_profile` 호출까지 전체 삭제:
-- `SubscriptionItemCreate`, `SubscriptionItemUpdate`, `SubscriptionProfile` 모델
-- `api_subscription_list`, `api_subscription_create`, `api_subscription_update`, `api_subscription_delete` 라우트
-- `api_subscription_profile_get`, `api_subscription_profile_put` 라우트
-
-- [ ] **Step 4: backend/app/db.py에서 realestate_complexes 테이블 생성 삭제 (라인 184~214)**
-
-`# ── realestate_complexes 테이블 ──` 블록 전체 삭제 (CREATE TABLE + CREATE INDEX)
-
-- [ ] **Step 5: backend/app/db.py에서 subscription_items 테이블 생성 삭제 (라인 216~252)**
-
-`# ── subscription_items 테이블 ──` 블록 전체 삭제 (CREATE TABLE + CREATE INDEX)
-
-- [ ] **Step 6: backend/app/db.py에서 subscription_profile 테이블 생성 삭제 (라인 282~300)**
-
-`# ── subscription_profile 테이블 ──` 블록 전체 삭제
-
-- [ ] **Step 7: backend/app/db.py에서 realestate_complexes CRUD 삭제 (라인 837~963)**
-
-`# ── realestate_complexes CRUD ──` 부터 `delete_complex` 함수 끝까지 전체 삭제:
-- `_complex_row_to_dict`, `get_all_complexes`, `get_complex`, `create_complex`, `update_complex`, `delete_complex`
-
-- [ ] **Step 8: backend/app/db.py에서 subscription CRUD 삭제 (라인 966~1327)**
-
-`# ── subscription_items CRUD ──` 부터 `upsert_subscription_profile` 함수 끝까지 전체 삭제:
-- `_SUB_ITEM_FIELD_MAP`, `_sub_item_row_to_dict`, `get_all_subscription_items`, `create_subscription_item`, `update_subscription_item`, `delete_subscription_item`
-- `_profile_row_to_dict`, `get_subscription_profile`, `upsert_subscription_profile`
-
-- [ ] **Step 9: Commit**
-
-```bash
-git add backend/app/main.py backend/app/db.py
-git commit -m "refactor(lotto-backend): 청약 관련 코드 완전 제거 — realestate-lab으로 이관"
-```
-
----
-
-### Task 9: CLAUDE.md 업데이트
-
-**Files:**
-- Modify: `CLAUDE.md`
-
-- [ ] **Step 1: CLAUDE.md 서비스 목록에 realestate-lab 추가**
-
-Docker 서비스 & 포트 테이블에 추가:
-```
-| `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API |
-```
-
-Nginx 라우팅 규칙 테이블에 추가:
-```
-| `/api/realestate/` | `realestate-lab:8000` | 부동산 청약 API |
-```
-
-서비스별 핵심 정보 섹션에 realestate-lab 추가.
-
-lotto-lab 테이블 목록에서 `realestate_complexes`, `subscription_items`, `subscription_profile` 참조 삭제 (해당 테이블이 lotto.db 테이블 목록에 있는 경우).
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add CLAUDE.md
-git commit -m "docs: CLAUDE.md에 realestate-lab 서비스 정보 추가"
-```
-
----
-
-### Task 10: 로컬 빌드 검증
-
-- [ ] **Step 1: Docker 빌드 테스트**
-
-```bash
-cd C:/Users/jaeoh/Desktop/workspace/web-backend
-docker compose build realestate-lab
-```
-
-Expected: 빌드 성공
-
-- [ ] **Step 2: 서비스 단독 기동 테스트**
-
-```bash
-docker compose up -d realestate-lab
-```
-
-- [ ] **Step 3: 헬스체크**
-
-```bash
-curl http://localhost:18800/health
-```
-
-Expected: `{"status": "ok"}`
-
-- [ ] **Step 4: 프로필 API 테스트**
-
-```bash
-curl -X PUT http://localhost:18800/api/realestate/profile \
- -H "Content-Type: application/json" \
- -d '{"name":"test","age":30,"is_homeless":true,"preferred_regions":["서울"]}'
-```
-
-Expected: 프로필 JSON 응답
-
-- [ ] **Step 5: 수동 수집 테스트**
-
-```bash
-curl -X POST http://localhost:18800/api/realestate/collect
-```
-
-Expected: `{"new_count": N, "total_count": N}` (API 키 설정 시 실제 데이터 수집)
-
-- [ ] **Step 6: 대시보드 테스트**
-
-```bash
-curl http://localhost:18800/api/realestate/dashboard
-```
-
-Expected: `{"active_count": N, "new_match_count": N, "upcoming": [...]}`
-
-- [ ] **Step 7: lotto-backend 정상 동작 확인**
-
-```bash
-curl http://localhost:18000/health
-```
-
-Expected: `{"status":"ok"}` (청약 코드 제거 후에도 정상)
diff --git a/docs/superpowers/plans/2026-04-08-music-lab-suno-enhancement.md b/docs/superpowers/plans/2026-04-08-music-lab-suno-enhancement.md
deleted file mode 100644
index ac952bf..0000000
--- a/docs/superpowers/plans/2026-04-08-music-lab-suno-enhancement.md
+++ /dev/null
@@ -1,2594 +0,0 @@
-# Music Lab Suno API 전체 기능 확장 구현 계획
-
-> **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:** Suno API의 미사용 기능을 전부 활용하여 music-lab을 완전한 AI 음악 프로덕션 스튜디오로 업그레이드한다.
-
-**Architecture:** 백엔드(music-lab)에 Suno API 신규 엔드포인트를 추가하고, 공통 폴링 헬퍼를 추출하여 중복을 제거한다. 프론트엔드(web-ui)는 1,725줄 단일 파일을 컴포넌트별로 분할하고 Phase별 UI를 추가한다.
-
-**Tech Stack:** Python 3.12, FastAPI, SQLite, React 18, Vanilla CSS, Vite
-
-**Spec:** `docs/superpowers/specs/2026-04-08-music-lab-suno-enhancement-design.md`
-
----
-
-## 파일 구조
-
-### 백엔드 (web-backend/music-lab/)
-
-| 파일 | 작업 |
-|------|------|
-| `app/suno_provider.py` | 수정: V5_5 모델 추가, 공통 폴링 헬퍼 추출, 신규 파라미터 매핑, Phase 1~3 함수 추가 |
-| `app/main.py` | 수정: GenerateRequest 확장, Phase 1~3 엔드포인트 추가 |
-| `app/db.py` | 수정: 마이그레이션 컬럼 추가, 트랙 업데이트 함수 추가 |
-
-### 프론트엔드 (web-ui/src/)
-
-| 파일 | 작업 |
-|------|------|
-| `api.js` | 수정: Phase 1~3 API 함수 추가 |
-| `pages/music/MusicStudio.jsx` | 수정: 컴포넌트 분할 후 메인 셸 역할 |
-| `pages/music/MusicStudio.css` | 수정: 신규 컴포넌트 스타일 추가 |
-| `pages/music/components/CreditsBadge.jsx` | 생성: 크레딧 잔액 배지 |
-| `pages/music/components/CreateTab.jsx` | 생성: 생성 폼 (기존 코드 이동 + Phase 1 확장) |
-| `pages/music/components/LyricsTab.jsx` | 생성: 가사 관리 (기존 코드 이동) |
-| `pages/music/components/LibraryTab.jsx` | 생성: 라이브러리 (기존 코드 이동 + 더보기 메뉴) |
-| `pages/music/components/AudioPlayer.jsx` | 생성: 오디오 플레이어 (기존 코드 이동) |
-| `pages/music/components/CoverArtModal.jsx` | 생성: 커버 이미지 선택 모달 |
-| `pages/music/components/StemModal.jsx` | 생성: 12스템 결과 모달 |
-| `pages/music/components/SyncedLyricsPlayer.jsx` | 생성: 타임스탬프 가사 오버레이 |
-| `pages/music/components/RemixTab.jsx` | 생성: Phase 3 업로드/리믹스 탭 |
-
----
-
-## Phase 1: 핵심 생성 강화
-
-### Task 1: 백엔드 — suno_provider 리팩토링 + V5_5 + 신규 파라미터
-
-**Files:**
-- Modify: `music-lab/app/suno_provider.py`
-
-- [ ] **Step 1: V5_5 모델 추가 + 공통 폴링 헬퍼 추출**
-
-`music-lab/app/suno_provider.py` 상단의 SUNO_MODELS에 V5_5를 추가하고, 기존 `_poll_until_complete`를 범용 헬퍼로 리팩토링:
-
-```python
-# SUNO_MODELS 리스트 끝에 추가 (line 31 뒤)
- {"id": "V5_5", "name": "V5.5", "max_duration": "8분", "description": "커스텀 모델, 최신 음악성"},
-```
-
-`_poll_until_complete` 함수를 범용화하여 다른 Suno 작업(WAV, 스템, 커버이미지 등)에도 재사용:
-
-```python
-def _poll_suno_record(
- record_info_path: str,
- suno_task_id: str,
- task_id: str,
- max_attempts: int = POLL_MAX_ATTEMPTS,
- interval: int = POLL_INTERVAL,
- progress_msg_map: dict = None,
-) -> Optional[dict]:
- """범용 Suno 작업 폴링. SUCCESS 시 response 객체 반환.
-
- record_info_path: 예) "/generate/record-info", "/wav/record-info"
- progress_msg_map: 상태별 메시지 오버라이드 (예: {"PENDING": "WAV 변환 대기 중..."})
- """
- error_statuses = {
- "CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED",
- "CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR",
- }
- default_msgs = {
- "PENDING": "대기열에서 대기 중...",
- "TEXT_SUCCESS": "가사 생성 완료, 음악 생성 중...",
- "FIRST_SUCCESS": "첫 번째 트랙 완료, 두 번째 생성 중...",
- "GENERATING": "생성 중...",
- }
- msgs = {**default_msgs, **(progress_msg_map or {})}
-
- for attempt in range(max_attempts):
- time.sleep(interval)
- try:
- resp = requests.get(
- f"{SUNO_BASE_URL}{record_info_path}",
- headers=_headers(),
- params={"taskId": suno_task_id},
- timeout=15,
- )
- if resp.status_code != 200:
- continue
-
- body = resp.json()
- if body.get("code") != 200:
- continue
-
- data = body.get("data", {})
- status = data.get("status", "")
- progress = min(15 + int((attempt / max_attempts) * 65), 79)
-
- if status == "SUCCESS":
- return data.get("response", data)
- elif status in error_statuses:
- error_msg = data.get("errorMessage") or data.get("msg") or f"Suno 작업 실패 ({status})"
- update_task(task_id, "failed", 0, "", error=error_msg)
- return None
- else:
- msg = msgs.get(status, f"처리 중... ({status})")
- if status == "FIRST_SUCCESS":
- progress = max(progress, 60)
- update_task(task_id, "processing", progress, msg)
-
- except Exception as e:
- logger.warning("Suno poll error (attempt %d): %s", attempt, e)
- continue
-
- update_task(task_id, "failed", 0, "", error="Suno 작업 타임아웃")
- return None
-```
-
-기존 `_poll_until_complete`를 호출하는 곳(`run_suno_generation`, `run_suno_extend`, `run_vocal_removal`)을 `_poll_suno_record` 호출로 교체:
-
-```python
-# run_suno_generation 내부 (기존: completed_tracks = _poll_until_complete(task_id, suno_task_id))
-response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
-if not response:
- return
-completed_tracks = response.get("sunoData") or []
-if not completed_tracks:
- update_task(task_id, "failed", 0, "", error="Suno 생성 완료했으나 트랙 데이터 없음")
- return
-```
-
-`run_suno_extend`와 `run_vocal_removal`도 동일 패턴으로 교체.
-
-- [ ] **Step 2: _build_suno_payload에 신규 파라미터 매핑 추가**
-
-```python
-def _build_suno_payload(params: dict) -> dict:
- """프론트엔드 params → sunoapi.org 요청 형식으로 변환."""
- # ... 기존 로직 유지 ...
-
- # 신규 파라미터 매핑 (None이 아닌 경우에만 포함)
- if params.get("vocal_gender"):
- payload["vocalGender"] = params["vocal_gender"]
- if params.get("negative_tags"):
- payload["negativeTags"] = params["negative_tags"]
- if params.get("style_weight") is not None:
- payload["styleWeight"] = params["style_weight"]
- if params.get("audio_weight") is not None:
- payload["audioWeight"] = params["audio_weight"]
-
- return payload
-```
-
-이 4줄을 `_build_suno_payload` 함수의 `return payload` 직전에 추가.
-
-- [ ] **Step 3: 커버 이미지 생성 함수 추가**
-
-`suno_provider.py` 하단에 추가:
-
-```python
-# ── 커버 이미지 생성 ─────────────────────────────────────────────────────────
-
-def run_cover_image(task_id: str, params: dict) -> None:
- """Suno 곡의 커버 이미지 2장을 생성."""
- try:
- if not SUNO_API_KEY:
- update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
- return
-
- update_task(task_id, "processing", 5, "커버 이미지 생성 요청 중...")
-
- suno_task_id = params.get("suno_task_id", "")
- if not suno_task_id:
- update_task(task_id, "failed", 0, "", error="suno_task_id가 필요합니다")
- return
-
- payload = {
- "taskId": suno_task_id,
- "callBackUrl": "https://example.com/noop",
- }
-
- resp = requests.post(
- f"{SUNO_BASE_URL}/suno/cover/generate",
- headers=_headers(),
- json=payload,
- timeout=30,
- )
-
- if resp.status_code != 200:
- update_task(task_id, "failed", 0, "", error=f"커버 이미지 API 오류: {resp.text[:300]}")
- return
-
- body = resp.json()
- if body.get("code") != 200:
- update_task(task_id, "failed", 0, "", error=f"커버 이미지 거부: {body.get('msg', 'unknown')}")
- return
-
- cover_task_id = body.get("data", {}).get("taskId", suno_task_id)
- update_task(task_id, "processing", 15, "커버 이미지 생성 중...")
-
- response = _poll_suno_record(
- "/suno/cover/record-info", cover_task_id, task_id,
- max_attempts=30, interval=5,
- progress_msg_map={"PENDING": "이미지 생성 대기 중...", "GENERATING": "이미지 생성 중..."},
- )
- if not response:
- return
-
- images = response.get("images") or response.get("sunoData") or []
- image_urls = []
- if isinstance(images, list):
- for img in images:
- if isinstance(img, str):
- image_urls.append(img)
- elif isinstance(img, dict):
- image_urls.append(img.get("imageUrl") or img.get("image_url", ""))
-
- update_task(task_id, "succeeded", 100, "커버 이미지 생성 완료",
- audio_url=json.dumps(image_urls))
-
- except Exception as e:
- logger.exception("Cover image generation error for task %s", task_id)
- update_task(task_id, "failed", 0, "", error=str(e))
-```
-
-suno_provider.py 상단에 `import json` 추가 필요.
-
-- [ ] **Step 4: 크레딧 조회 폴백 로직**
-
-```python
-def get_credits() -> Optional[dict]:
- """Suno API 잔여 크레딧 조회. 두 엔드포인트 폴백."""
- if not SUNO_API_KEY:
- return None
- # 신규 엔드포인트 먼저 시도
- for path in ["/generate/credit", "/get-credits"]:
- try:
- resp = requests.get(
- f"{SUNO_BASE_URL}{path}",
- headers=_headers(),
- timeout=15,
- )
- if resp.status_code == 200:
- body = resp.json()
- data = body.get("data", body)
- # /generate/credit은 정수 반환
- if isinstance(data, (int, float)):
- return {"credits_left": int(data)}
- return data
- except Exception as e:
- logger.warning("Suno credits API error (%s): %s", path, e)
- return None
-```
-
-- [ ] **Step 5: 커밋**
-
-```bash
-git add music-lab/app/suno_provider.py
-git commit -m "refactor(music-lab): 공통 폴링 헬퍼 추출 + V5_5 모델 + 신규 파라미터 + 커버이미지"
-```
-
----
-
-### Task 2: 백엔드 — DB 마이그레이션 + main.py 엔드포인트
-
-**Files:**
-- Modify: `music-lab/app/db.py`
-- Modify: `music-lab/app/main.py`
-
-- [ ] **Step 1: db.py 마이그레이션 + 업데이트 함수 추가**
-
-`db.py`의 `init_db()` 함수, 기존 마이그레이션 루프(line 72~) 뒤에 추가:
-
-```python
- # Phase 1~3 신규 컬럼 마이그레이션
- for col, default in [
- ("cover_images", "'[]'"),
- ("wav_url", "''"),
- ("video_url", "''"),
- ("stem_urls", "'{}'"),
- ]:
- try:
- conn.execute(f"ALTER TABLE music_library ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}")
- except sqlite3.OperationalError:
- pass
-```
-
-`_track_row_to_dict` 함수에 신규 컬럼 매핑 추가 (line 164 뒤):
-
-```python
- "cover_images": json.loads(r["cover_images"]) if "cover_images" in keys and r["cover_images"] else [],
- "wav_url": r["wav_url"] if "wav_url" in keys else "",
- "video_url": r["video_url"] if "video_url" in keys else "",
- "stem_urls": json.loads(r["stem_urls"]) if "stem_urls" in keys and r["stem_urls"] else {},
-```
-
-파일 하단에 업데이트 함수 추가:
-
-```python
-def update_track_cover_images(track_id: int, images: list) -> None:
- with _conn() as conn:
- conn.execute(
- "UPDATE music_library SET cover_images=? WHERE id=?",
- (json.dumps(images), track_id),
- )
-
-
-def update_track_wav_url(track_id: int, wav_url: str) -> None:
- with _conn() as conn:
- conn.execute(
- "UPDATE music_library SET wav_url=? WHERE id=?",
- (wav_url, track_id),
- )
-
-
-def update_track_video_url(track_id: int, video_url: str) -> None:
- with _conn() as conn:
- conn.execute(
- "UPDATE music_library SET video_url=? WHERE id=?",
- (video_url, track_id),
- )
-
-
-def update_track_stem_urls(track_id: int, stems: dict) -> None:
- with _conn() as conn:
- conn.execute(
- "UPDATE music_library SET stem_urls=? WHERE id=?",
- (json.dumps(stems), track_id),
- )
-```
-
-- [ ] **Step 2: main.py — GenerateRequest 스키마 확장**
-
-`main.py`의 `GenerateRequest` 클래스(line 90~)에 필드 추가:
-
-```python
-class GenerateRequest(BaseModel):
- provider: str = "suno"
- model: str = "V4"
- title: str = ""
- genre: str = ""
- moods: List[str] = []
- instruments: List[str] = []
- duration_sec: Optional[int] = None
- bpm: Optional[int] = None
- key: str = ""
- scale: str = ""
- prompt: str = ""
- # Suno 전용
- lyrics: str = ""
- instrumental: bool = False
- # Phase 1 신규
- vocal_gender: Optional[str] = None # "m" | "f"
- negative_tags: Optional[str] = None # 제외 스타일
- style_weight: Optional[float] = None # 0.0~1.0
- audio_weight: Optional[float] = None # 0.0~1.0
-```
-
-- [ ] **Step 3: main.py — 커버 이미지 엔드포인트 추가**
-
-`main.py` import에 `run_cover_image` 추가, 보컬분리 API 뒤에 엔드포인트 추가:
-
-```python
-from .suno_provider import (
- run_suno_generation, run_suno_extend, run_vocal_removal,
- run_cover_image,
- generate_lyrics, get_credits,
- SUNO_API_KEY, SUNO_MODELS,
-)
-
-
-# ── 커버 이미지 생성 API ────────────────────────────────────────────────────
-
-class CoverImageRequest(BaseModel):
- suno_task_id: str # Suno 생성 task ID
- track_id: Optional[int] = None # 라이브러리 트랙 ID (결과 저장용)
-
-
-@app.post("/api/music/cover-image")
-def cover_image(req: CoverImageRequest, background_tasks: BackgroundTasks):
- """Suno 곡의 커버 이미지 2장 생성."""
- if not SUNO_API_KEY:
- raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
-
- task_id = str(uuid.uuid4())
- params = req.model_dump()
- create_task(task_id, params, provider="suno")
- background_tasks.add_task(run_cover_image, task_id, params)
- return {"task_id": task_id, "provider": "suno"}
-```
-
-- [ ] **Step 4: 커밋**
-
-```bash
-git add music-lab/app/db.py music-lab/app/main.py
-git commit -m "feat(music-lab): Phase 1 DB 마이그레이션 + GenerateRequest 확장 + 커버이미지 엔드포인트"
-```
-
----
-
-### Task 3: 프론트엔드 — 컴포넌트 분할
-
-**Files:**
-- Modify: `web-ui/src/pages/music/MusicStudio.jsx`
-- Create: `web-ui/src/pages/music/components/AudioPlayer.jsx`
-- Create: `web-ui/src/pages/music/components/LyricsTab.jsx`
-- Create: `web-ui/src/pages/music/components/CreditsBadge.jsx`
-
-이 태스크는 기존 MusicStudio.jsx에서 독립적인 컴포넌트를 별도 파일로 추출한다. 기능 변경 없이 구조만 변경.
-
-- [ ] **Step 1: AudioPlayer 컴포넌트 추출**
-
-`web-ui/src/pages/music/components/AudioPlayer.jsx` 생성:
-
-```jsx
-import React, { useEffect, useRef, useState } from 'react';
-
-const pad = (n) => String(Math.floor(n)).padStart(2, '0');
-export const fmtTime = (s) => `${pad(s / 60)}:${pad(s % 60)}`;
-
-const AudioPlayer = ({ audioUrl, totalSec, accentColor }) => {
- const audioRef = useRef(null);
- const [playing, setPlaying] = useState(false);
- const [elapsed, setElapsed] = useState(0);
- const [duration, setDuration] = useState(totalSec ?? 0);
- const [volume, setVolume] = useState(1);
-
- const isFake = !audioUrl;
- const timerRef = useRef(null);
- const total = duration || totalSec || 60;
-
- const togglePlay = () => {
- if (isFake) {
- if (playing) {
- clearInterval(timerRef.current);
- setPlaying(false);
- } else {
- setPlaying(true);
- timerRef.current = setInterval(() => {
- setElapsed((e) => {
- if (e >= total - 1) {
- clearInterval(timerRef.current);
- setPlaying(false);
- return 0;
- }
- return e + 1;
- });
- }, 1000);
- }
- return;
- }
- const el = audioRef.current;
- if (!el) return;
- playing ? el.pause() : el.play();
- };
-
- const handleSeek = (e) => {
- const rect = e.currentTarget.getBoundingClientRect();
- const ratio = (e.clientX - rect.left) / rect.width;
- const newTime = ratio * total;
- if (!isFake && audioRef.current) {
- audioRef.current.currentTime = newTime;
- }
- setElapsed(newTime);
- };
-
- const handleVolumeChange = (e) => {
- const v = Number(e.target.value);
- setVolume(v);
- if (!isFake && audioRef.current) audioRef.current.volume = v;
- };
-
- useEffect(() => () => clearInterval(timerRef.current), []);
-
- const progress = (elapsed / total) * 100;
-
- return (
-
- {!isFake && (
-
setDuration(e.target.duration)}
- onTimeUpdate={(e) => setElapsed(e.target.currentTime)}
- onPlay={() => setPlaying(true)}
- onPause={() => setPlaying(false)}
- onEnded={() => { setPlaying(false); setElapsed(0); }}
- />
- )}
-
- {playing ? (
-
-
-
-
- ) : (
-
-
-
- )}
-
-
-
-
-
- {fmtTime(elapsed)}
- {fmtTime(total)}
-
-
-
-
-
- );
-};
-
-export default AudioPlayer;
-```
-
-- [ ] **Step 2: CreditsBadge 컴포넌트 생성**
-
-`web-ui/src/pages/music/components/CreditsBadge.jsx` 생성:
-
-```jsx
-import React, { useEffect, useState, useCallback } from 'react';
-import { getMusicCredits } from '../../../api';
-
-const CreditsBadge = () => {
- const [credits, setCredits] = useState(null);
-
- const fetchCredits = useCallback(async () => {
- try {
- const data = await getMusicCredits();
- setCredits(data);
- } catch {}
- }, []);
-
- useEffect(() => {
- fetchCredits();
- const interval = setInterval(fetchCredits, 30000);
- return () => clearInterval(interval);
- }, [fetchCredits]);
-
- if (!credits) return null;
-
- const remaining = credits.credits_left ?? credits.remaining ?? credits.data ?? null;
- if (remaining == null) return null;
-
- const isLow = remaining <= 10;
-
- return (
-
- ⚡
- {remaining}
- credits
-
- );
-};
-
-export default CreditsBadge;
-```
-
-- [ ] **Step 3: LyricsTab 추출**
-
-`web-ui/src/pages/music/components/LyricsTab.jsx` 생성 — 기존 MusicStudio.jsx의 `LyricsTab` 컴포넌트(line 617~847)를 그대로 이동. import 경로를 상대경로로 변경:
-
-```jsx
-import React, { useEffect, useState } from 'react';
-import {
- generateMusicLyrics,
- getSavedLyrics,
- saveLyrics,
- updateLyrics,
- deleteLyrics,
-} from '../../../api';
-
-const LyricsTab = ({ onUseInCreate }) => {
- // ... 기존 LyricsTab 코드 전체 (line 618~847) 그대로 이동 ...
-};
-
-export default LyricsTab;
-```
-
-- [ ] **Step 4: MusicStudio.jsx에서 추출된 컴포넌트 import로 교체**
-
-```jsx
-// MusicStudio.jsx 상단 import 변경
-import AudioPlayer from './components/AudioPlayer';
-import { fmtTime } from './components/AudioPlayer';
-import CreditsBadge from './components/CreditsBadge';
-import LyricsTab from './components/LyricsTab';
-```
-
-기존 인라인 `AudioPlayer`, `LyricsTab`, `fmtTime`, `pad` 정의를 삭제.
-
-헤더 영역의 크레딧 표시(line 1191~1198)를 ` ` 로 교체:
-
-```jsx
-
-
-
-
-```
-
-기존 `credits` 상태 변수와 관련 로직 제거:
-- `const [credits, setCredits] = useState(null);` 삭제
-- `getMusicCredits().then(...)` 호출 삭제
-
-- [ ] **Step 5: 커밋**
-
-```bash
-git add web-ui/src/pages/music/
-git commit -m "refactor(music-lab): 컴포넌트 분할 — AudioPlayer, LyricsTab, CreditsBadge 추출"
-```
-
----
-
-### Task 4: 프론트엔드 — Create 탭 Phase 1 확장 (보컬 성별, negativeTags, weight 슬라이더)
-
-**Files:**
-- Modify: `web-ui/src/pages/music/MusicStudio.jsx`
-- Modify: `web-ui/src/pages/music/MusicStudio.css`
-
-- [ ] **Step 1: 상태 변수 추가**
-
-MusicStudio.jsx의 상태 선언 영역(line 870~) 뒤에 추가:
-
-```jsx
- /* ── Phase 1: 신규 파라미터 ── */
- const [vocalGender, setVocalGender] = useState(null); // "m" | "f" | null
- const [negativeTags, setNegativeTags] = useState('');
- const [styleWeight, setStyleWeight] = useState(50); // UI: 0~100, API: 0~1
- const [audioWeight, setAudioWeight] = useState(50);
-```
-
-- [ ] **Step 2: handleGenerate 페이로드에 신규 파라미터 포함**
-
-기존 payload 조립(line 1063~) 수정:
-
-```jsx
- const payload = {
- provider,
- model,
- title,
- genre,
- moods,
- instruments: instList,
- duration_sec: durSec,
- bpm,
- key: musicalKey,
- scale,
- prompt: prompt || undefined,
- ...(provider === 'suno' ? {
- lyrics: lyrics || undefined,
- instrumental,
- vocal_gender: vocalGender || undefined,
- negative_tags: negativeTags || undefined,
- style_weight: styleWeight !== 50 ? styleWeight / 100 : undefined,
- audio_weight: audioWeight !== 50 ? audioWeight / 100 : undefined,
- } : {}),
- };
-```
-
-- [ ] **Step 3: Step 4 (Parameters) 섹션에 UI 추가**
-
-Key+Scale 그리드(line 1489~1531) 뒤, `` 닫기 전에 추가:
-
-```jsx
- {/* Vocal Gender (Suno only) */}
- {provider === 'suno' && (
-
-
Vocal Gender
-
- {[
- { value: null, label: 'Auto', icon: '🎵' },
- { value: 'm', label: 'Male', icon: '♂' },
- { value: 'f', label: 'Female', icon: '♀' },
- ].map((opt) => (
- setVocalGender(opt.value)}
- >
- {opt.icon}
- {opt.label}
-
- ))}
-
-
- )}
-
- {/* Negative Tags (Suno only) */}
- {provider === 'suno' && (
-
-
Exclude Styles
-
-
- {['screaming', 'autotune', 'distortion', 'whisper', 'falsetto', 'rap'].map((tag) => (
- {
- setNegativeTags((prev) => {
- const tags = prev.split(',').map(t => t.trim()).filter(Boolean);
- if (tags.includes(tag)) return tags.filter(t => t !== tag).join(', ');
- return [...tags, tag].join(', ');
- });
- }}
- >
- {tag}
-
- ))}
-
-
setNegativeTags(e.target.value)}
- />
-
-
- )}
-
- {/* Style Weight / Audio Weight (Suno only) */}
- {provider === 'suno' && (
-
-
-
- Style Weight
- {styleWeight}%
-
-
Prompt ↔ Style 밸런스
-
setStyleWeight(Number(e.target.value))}
- className="ms-bpm-slider"
- aria-label="Style Weight"
- />
-
-
-
- Audio Weight
- {audioWeight}%
-
-
Original ↔ AI 밸런스
-
setAudioWeight(Number(e.target.value))}
- className="ms-bpm-slider"
- aria-label="Audio Weight"
- />
-
-
- )}
-```
-
-- [ ] **Step 4: CSS 스타일 추가**
-
-`web-ui/src/pages/music/MusicStudio.css` 하단에 추가:
-
-```css
-/* ── Phase 1: Credits Badge ─────────────────────────────── */
-.ms-credits-badge {
- display: inline-flex; align-items: center; gap: 6px;
- padding: 6px 14px; border-radius: 20px;
- background: rgba(245, 166, 35, 0.1);
- border: 1px solid rgba(245, 166, 35, 0.25);
- font-family: 'Courier Prime', monospace;
- font-size: 0.85rem; color: var(--ms-accent);
-}
-.ms-credits-badge__icon { font-size: 1rem; }
-.ms-credits-badge__value { font-weight: 700; font-size: 1.1rem; }
-.ms-credits-badge__label { color: var(--ms-muted); font-size: 0.75rem; text-transform: uppercase; }
-.ms-credits-badge.is-low {
- background: rgba(231, 76, 60, 0.15);
- border-color: rgba(231, 76, 60, 0.4);
- color: #e74c3c;
- animation: pulse-badge 1.5s ease-in-out infinite;
-}
-@keyframes pulse-badge {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.6; }
-}
-
-/* ── Phase 1: Vocal Gender Toggle ───────────────────────── */
-.ms-gender-toggle {
- display: flex; gap: 6px;
-}
-.ms-gender-btn {
- flex: 1; padding: 8px 12px; border-radius: 8px;
- background: var(--ms-surface); border: 1px solid var(--ms-line);
- color: var(--ms-muted); font-family: 'Syne', sans-serif;
- font-size: 0.82rem; cursor: pointer; transition: all 0.2s;
- display: flex; align-items: center; gap: 6px; justify-content: center;
-}
-.ms-gender-btn:hover { border-color: var(--ms-accent); color: var(--ms-text); }
-.ms-gender-btn.is-active { background: rgba(245, 166, 35, 0.12); border-color: var(--ms-accent); color: var(--ms-text); }
-.ms-gender-btn.is-active.is-male { background: rgba(74, 158, 255, 0.12); border-color: #4a9eff; color: #4a9eff; }
-.ms-gender-btn.is-active.is-female { background: rgba(255, 107, 157, 0.12); border-color: #ff6b9d; color: #ff6b9d; }
-.ms-gender-btn__icon { font-size: 1.1rem; }
-
-/* ── Phase 1: Negative Tags ─────────────────────────────── */
-.ms-negative-tags { display: flex; flex-direction: column; gap: 8px; }
-.ms-negative-tags__presets { display: flex; flex-wrap: wrap; gap: 6px; }
-.ms-neg-chip {
- padding: 4px 12px; border-radius: 14px;
- background: var(--ms-surface); border: 1px solid var(--ms-line);
- color: var(--ms-muted); font-size: 0.78rem; cursor: pointer;
- font-family: 'Syne', sans-serif; transition: all 0.2s;
-}
-.ms-neg-chip:hover { border-color: #e74c3c; color: var(--ms-text); }
-.ms-neg-chip.is-active {
- background: rgba(231, 76, 60, 0.12); border-color: #e74c3c; color: #e74c3c;
- text-decoration: line-through;
-}
-.ms-negative-tags__input {
- padding: 8px 12px; border-radius: 8px;
- background: var(--ms-surface); border: 1px solid var(--ms-line);
- color: var(--ms-text); font-family: 'Syne', sans-serif; font-size: 0.82rem;
-}
-.ms-negative-tags__input::placeholder { color: var(--ms-dim); }
-
-/* ── Phase 1: Param hint inline ─────────────────────────── */
-.ms-param-hint--inline {
- font-size: 0.72rem; color: var(--ms-dim); margin: 0 0 4px;
- font-family: 'Courier Prime', monospace;
-}
-```
-
-- [ ] **Step 5: 커밋**
-
-```bash
-git add web-ui/src/pages/music/
-git commit -m "feat(music-lab): Phase 1 UI — 보컬 성별, 제외 스타일, weight 슬라이더, 크레딧 배지"
-```
-
----
-
-### Task 5: 프론트엔드 — LibraryCard 더보기 메뉴 + CoverArtModal
-
-**Files:**
-- Modify: `web-ui/src/pages/music/MusicStudio.jsx` (LibraryCard 수정)
-- Create: `web-ui/src/pages/music/components/CoverArtModal.jsx`
-- Modify: `web-ui/src/pages/music/MusicStudio.css`
-- Modify: `web-ui/src/api.js`
-
-- [ ] **Step 1: api.js에 커버 이미지 API 함수 추가**
-
-`web-ui/src/api.js`의 음악 API 섹션(line 313 뒤)에 추가:
-
-```javascript
-// POST /api/music/cover-image body: { suno_task_id, track_id }
-// → { task_id, provider }
-export function generateCoverImage(payload) {
- return apiPost('/api/music/cover-image', payload);
-}
-```
-
-- [ ] **Step 2: CoverArtModal 컴포넌트 생성**
-
-`web-ui/src/pages/music/components/CoverArtModal.jsx` 생성:
-
-```jsx
-import React, { useState } from 'react';
-
-const CoverArtModal = ({ images, onSelect, onClose }) => {
- const [selected, setSelected] = useState(null);
-
- if (!images || images.length === 0) return null;
-
- return (
-
-
e.stopPropagation()}>
-
-
Cover Art 선택
- ✕
-
-
- {images.map((url, idx) => (
-
setSelected(idx)}
- >
-
- Option {idx + 1}
-
- ))}
-
-
- { if (selected !== null) onSelect(images[selected]); }}
- >
- 이 이미지 사용
-
-
- 취소
-
-
-
-
- );
-};
-
-export default CoverArtModal;
-```
-
-- [ ] **Step 3: LibraryCard에 더보기 메뉴 + 커버아트 핸들러 추가**
-
-MusicStudio.jsx의 `LibraryCard` 컴포넌트를 수정. props에 `onCoverArt` 추가:
-
-```jsx
-const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, isGenerating }) => {
- const [menuOpen, setMenuOpen] = useState(false);
- const genre = GENRES.find((g) => g.id === track.genre);
- const totalSec = track.duration_sec ?? null;
- const filename = track.audio_url ? track.audio_url.split('/').pop() : '';
- const hasSunoId = !!track.suno_id;
-
- return (
-
-
-
{genre?.icon ?? '🎵'}
- {track.cover_images?.[0] && (
-
- )}
-
{track.title}
-
-
onPlay(track)}
- aria-label={isPlaying ? '정지' : '재생'}
- >
- {isPlaying ? '■' : '▶'}
-
- {track.audio_url && (
-
↓
- )}
-
onDelete(track.id)}
- aria-label="삭제"
- >
- ✕
-
-
-
-
-
{filename}
-
- {totalSec != null ? fmtTime(totalSec) : '--:--'} · {track.bpm ? `${track.bpm} BPM` : ''} {track.key} {track.scale}
-
-
- {isPlaying && (
-
- )}
-
- {track.provider && (
-
- {track.provider === 'suno' ? '🎙️ Suno' : '🤖 MusicGen'}
-
- )}
- {(track.instruments ?? []).slice(0, 3).map((i) => (
- {i}
- ))}
- {(track.moods ?? []).slice(0, 2).map((m) => (
- {m}
- ))}
-
- {hasSunoId && (
-
-
onExtend(track)} disabled={isGenerating} title="이 곡을 이어서 연장합니다">
- ⏩ Extend
-
-
onVocalRemoval(track)} disabled={isGenerating} title="보컬과 인스트루멘탈을 분리합니다">
- 🎤 Vocal Split
-
- {/* 더보기 메뉴 */}
-
-
setMenuOpen(!menuOpen)}>
- •••
-
- {menuOpen && (
-
- { onCoverArt(track); setMenuOpen(false); }}
- disabled={isGenerating}>
- 🖼 Cover Art
-
-
- )}
-
-
- )}
-
- {track.created_at ? new Date(track.created_at).toLocaleDateString('ko-KR') : ''}
-
-
- );
-};
-```
-
-- [ ] **Step 4: MusicStudio 메인에 커버아트 핸들러 + 모달 추가**
-
-```jsx
-import CoverArtModal from './components/CoverArtModal';
-import { generateCoverImage } from '../../api';
-
-// 상태 추가
-const [coverArtModal, setCoverArtModal] = useState(null); // { trackId, images }
-
-// 핸들러 추가
-const handleCoverArt = async (track) => {
- if (!track.task_id || isGenerating) return;
- setTab('create');
- setIsGenerating(true);
- setTrack(null);
- setGenProgress(0);
- setGenStep('커버 이미지 생성 요청 중…');
- setGenError(null);
- try {
- const res = await generateCoverImage({
- suno_task_id: track.task_id,
- track_id: track.id,
- });
- if (res?.task_id) {
- taskIdRef.current = res.task_id;
- setGenStep('AI가 커버 이미지를 생성하고 있습니다…');
- setGenProgress(5);
- // 커버 이미지용 폴링 — 완료 시 이미지 URL 배열이 audio_url에 JSON으로 들어옴
- clearInterval(pollRef.current);
- pollRef.current = setInterval(async () => {
- try {
- const status = await getMusicStatus(res.task_id);
- setGenProgress(status.progress ?? 0);
- setGenStep(status.message ?? '처리 중…');
- if (status.status === 'succeeded') {
- clearInterval(pollRef.current);
- setIsGenerating(false);
- const images = JSON.parse(status.audio_url || '[]');
- setCoverArtModal({ trackId: track.id, images });
- } else if (status.status === 'failed') {
- clearInterval(pollRef.current);
- setIsGenerating(false);
- setGenError(`커버 이미지 생성 실패: ${status.error ?? '알 수 없는 오류'}`);
- }
- } catch {
- clearInterval(pollRef.current);
- setIsGenerating(false);
- setGenError('커버 이미지 상태 조회 실패');
- }
- }, 3000);
- }
- } catch {
- setIsGenerating(false);
- setGenError('커버 이미지 생성에 실패했습니다');
- }
-};
-
-const handleCoverSelect = (imageUrl) => {
- if (coverArtModal?.trackId) {
- setLibrary((prev) => prev.map((t) =>
- t.id === coverArtModal.trackId
- ? { ...t, cover_images: [imageUrl, ...(coverArtModal.images || []).filter(u => u !== imageUrl)] }
- : t
- ));
- }
- setCoverArtModal(null);
-};
-
-// JSX에 모달 추가 (최하단 닫기 div 전)
-{coverArtModal && (
- setCoverArtModal(null)}
- />
-)}
-
-// Library 컴포넌트에 onCoverArt prop 전달
-
-```
-
-Library 컴포넌트에도 `onCoverArt` prop을 받아서 LibraryCard에 전달하도록 수정.
-
-- [ ] **Step 5: 더보기 메뉴 + 모달 CSS**
-
-```css
-/* ── More Menu ──────────────────────────────────────────── */
-.ms-more-menu { position: relative; }
-.ms-more-menu__dropdown {
- position: absolute; bottom: 100%; right: 0;
- background: var(--ms-surface2); border: 1px solid var(--ms-line);
- border-radius: 8px; padding: 4px; min-width: 160px; z-index: 20;
- box-shadow: 0 8px 24px rgba(0,0,0,0.4);
-}
-.ms-more-menu__dropdown button {
- display: block; width: 100%; padding: 8px 12px; border: none;
- background: none; color: var(--ms-text); font-size: 0.82rem;
- font-family: 'Syne', sans-serif; cursor: pointer; text-align: left;
- border-radius: 6px;
-}
-.ms-more-menu__dropdown button:hover { background: rgba(245,166,35,0.1); }
-.ms-more-menu__dropdown button:disabled { opacity: 0.4; cursor: not-allowed; }
-
-/* ── Modal Overlay ──────────────────────────────────────── */
-.ms-modal-overlay {
- position: fixed; inset: 0; background: rgba(0,0,0,0.7);
- display: flex; align-items: center; justify-content: center; z-index: 100;
-}
-.ms-modal {
- background: var(--ms-surface); border: 1px solid var(--ms-line);
- border-radius: 16px; padding: 24px; max-width: 520px; width: 90%;
- max-height: 90vh; overflow-y: auto;
-}
-.ms-modal__header {
- display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;
-}
-.ms-modal__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.3rem; color: var(--ms-text); }
-.ms-modal__close { background: none; border: none; color: var(--ms-muted); font-size: 1.2rem; cursor: pointer; }
-.ms-modal__actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
-
-/* ── Cover Art Grid ─────────────────────────────────────── */
-.ms-cover-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
-.ms-cover-option {
- border: 2px solid var(--ms-line); border-radius: 12px; overflow: hidden;
- cursor: pointer; background: none; padding: 0; transition: border-color 0.2s;
-}
-.ms-cover-option:hover { border-color: var(--ms-accent); }
-.ms-cover-option.is-selected { border-color: var(--ms-accent); box-shadow: 0 0 12px rgba(245,166,35,0.3); }
-.ms-cover-option__img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
-.ms-cover-option__label {
- display: block; padding: 8px; text-align: center;
- font-family: 'Courier Prime', monospace; font-size: 0.78rem; color: var(--ms-muted);
-}
-
-/* ── Library Card Thumb ─────────────────────────────────── */
-.ms-lib-card__thumb {
- width: 28px; height: 28px; border-radius: 6px; object-fit: cover;
- margin-right: 4px; flex-shrink: 0;
-}
-```
-
-- [ ] **Step 6: 커밋**
-
-```bash
-git add web-ui/src/pages/music/ web-ui/src/api.js
-git commit -m "feat(music-lab): Phase 1 UI — LibraryCard 더보기 메뉴 + CoverArtModal"
-```
-
----
-
-## Phase 2: 후처리 파워업
-
-### Task 6: 백엔드 — WAV 변환 + 12스템 분리 + 타임스탬프 가사 + 스타일 부스트
-
-**Files:**
-- Modify: `music-lab/app/suno_provider.py`
-- Modify: `music-lab/app/main.py`
-
-- [ ] **Step 1: suno_provider.py에 Phase 2 함수 추가**
-
-```python
-# ── WAV 변환 ─────────────────────────────────────────────────────────────────
-
-def run_wav_convert(task_id: str, params: dict) -> None:
- """곡을 WAV 포맷으로 변환."""
- try:
- if not SUNO_API_KEY:
- update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
- return
-
- update_task(task_id, "processing", 5, "WAV 변환 요청 중...")
-
- payload = {
- "taskId": params["suno_task_id"],
- "audioId": params["suno_id"],
- "callBackUrl": "https://example.com/noop",
- }
-
- resp = requests.post(
- f"{SUNO_BASE_URL}/wav/generate",
- headers=_headers(),
- json=payload,
- timeout=30,
- )
-
- if resp.status_code == 409:
- # 이미 WAV 변환됨 — 기존 결과 조회
- body = resp.json()
- wav_url = body.get("data", {}).get("audioWavUrl", "")
- if wav_url:
- update_task(task_id, "succeeded", 100, "WAV 변환 완료 (캐시)", audio_url=wav_url)
- return
-
- if resp.status_code != 200:
- update_task(task_id, "failed", 0, "", error=f"WAV API 오류: {resp.text[:300]}")
- return
-
- body = resp.json()
- if body.get("code") != 200:
- update_task(task_id, "failed", 0, "", error=f"WAV 변환 거부: {body.get('msg', 'unknown')}")
- return
-
- wav_task_id = body.get("data", {}).get("taskId", params["suno_task_id"])
- update_task(task_id, "processing", 15, "WAV 변환 처리 중...")
-
- response = _poll_suno_record(
- "/wav/record-info", wav_task_id, task_id,
- max_attempts=30, interval=5,
- progress_msg_map={"PENDING": "WAV 변환 대기 중...", "GENERATING": "WAV 변환 중..."},
- )
- if not response:
- return
-
- wav_url = ""
- suno_data = response.get("sunoData") or []
- if suno_data and isinstance(suno_data, list):
- wav_url = suno_data[0].get("audioWavUrl", "") if isinstance(suno_data[0], dict) else ""
- if not wav_url:
- wav_url = response.get("audioWavUrl", "")
-
- update_task(task_id, "succeeded", 100, "WAV 변환 완료", audio_url=wav_url)
-
- except Exception as e:
- logger.exception("WAV convert error for task %s", task_id)
- update_task(task_id, "failed", 0, "", error=str(e))
-
-
-# ── 12스템 분리 ──────────────────────────────────────────────────────────────
-
-def run_stem_split(task_id: str, params: dict) -> None:
- """곡을 12개 스템으로 분리 (50 크레딧 소모)."""
- try:
- if not SUNO_API_KEY:
- update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
- return
-
- update_task(task_id, "processing", 5, "12스템 분리 요청 중...")
-
- payload = {
- "taskId": params["suno_task_id"],
- "audioId": params["suno_id"],
- "type": "split_stem",
- "callBackUrl": "https://example.com/noop",
- }
-
- resp = requests.post(
- f"{SUNO_BASE_URL}/vocal-removal/generate",
- headers=_headers(),
- json=payload,
- timeout=30,
- )
-
- if resp.status_code != 200:
- update_task(task_id, "failed", 0, "", error=f"스템 분리 API 오류: {resp.text[:300]}")
- return
-
- body = resp.json()
- if body.get("code") != 200:
- update_task(task_id, "failed", 0, "", error=f"스템 분리 거부: {body.get('msg', 'unknown')}")
- return
-
- stem_task_id = body.get("data", {}).get("taskId", "")
- if not stem_task_id:
- update_task(task_id, "failed", 0, "", error="스템 분리 응답에 taskId 없음")
- return
-
- update_task(task_id, "processing", 15, "12스템 분리 처리 중 (약 2~3분)...")
-
- response = _poll_suno_record(
- "/vocal-removal/record-info", stem_task_id, task_id,
- max_attempts=40, interval=8,
- progress_msg_map={"PENDING": "스템 분리 대기 중...", "GENERATING": "스템 분리 중..."},
- )
- if not response:
- return
-
- suno_data = response.get("sunoData") or []
- stems = {}
- stem_names = ["vocal", "backing_vocals", "drums", "bass", "guitar", "keyboard",
- "strings", "brass", "woodwinds", "percussion", "synth", "fx"]
- for i, item in enumerate(suno_data):
- if isinstance(item, dict):
- name = stem_names[i] if i < len(stem_names) else f"stem_{i}"
- stems[name] = item.get("audioUrl") or item.get("audio_url", "")
-
- update_task(task_id, "succeeded", 100, "12스템 분리 완료",
- audio_url=json.dumps(stems))
-
- except Exception as e:
- logger.exception("Stem split error for task %s", task_id)
- update_task(task_id, "failed", 0, "", error=str(e))
-
-
-# ── 타임스탬프 가사 ──────────────────────────────────────────────────────────
-
-def get_timestamped_lyrics(suno_task_id: str, suno_id: str) -> Optional[dict]:
- """타임스탬프가 포함된 가사 데이터 조회 (동기)."""
- if not SUNO_API_KEY:
- return None
- try:
- resp = requests.post(
- f"{SUNO_BASE_URL}/generate/get-timestamped-lyrics",
- headers=_headers(),
- json={"taskId": suno_task_id, "audioId": suno_id},
- timeout=30,
- )
- if resp.status_code == 200:
- body = resp.json()
- return body.get("data", body)
- except Exception as e:
- logger.warning("Timestamped lyrics error: %s", e)
- return None
-
-
-# ── 스타일 부스트 ────────────────────────────────────────────────────────────
-
-def generate_style_boost(content: str) -> Optional[dict]:
- """AI로 최적 스타일 텍스트 생성 (동기)."""
- if not SUNO_API_KEY:
- return None
- try:
- resp = requests.post(
- f"{SUNO_BASE_URL}/style/generate",
- headers=_headers(),
- json={"content": content},
- timeout=30,
- )
- if resp.status_code == 200:
- body = resp.json()
- return body.get("data", body)
- except Exception as e:
- logger.warning("Style boost error: %s", e)
- return None
-```
-
-- [ ] **Step 2: main.py에 Phase 2 엔드포인트 추가**
-
-import 업데이트:
-
-```python
-from .suno_provider import (
- run_suno_generation, run_suno_extend, run_vocal_removal,
- run_cover_image, run_wav_convert, run_stem_split,
- generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost,
- SUNO_API_KEY, SUNO_MODELS,
-)
-```
-
-엔드포인트 추가:
-
-```python
-# ── WAV 변환 API ────────────────────────────────────────────────────────────
-
-class WavRequest(BaseModel):
- suno_task_id: str
- suno_id: str
- track_id: Optional[int] = None
-
-
-@app.post("/api/music/wav")
-def wav_convert(req: WavRequest, background_tasks: BackgroundTasks):
- """곡을 WAV 포맷으로 변환."""
- if not SUNO_API_KEY:
- raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
- task_id = str(uuid.uuid4())
- params = req.model_dump()
- create_task(task_id, params, provider="suno")
- background_tasks.add_task(run_wav_convert, task_id, params)
- return {"task_id": task_id, "provider": "suno"}
-
-
-# ── 12스템 분리 API ─────────────────────────────────────────────────────────
-
-class StemSplitRequest(BaseModel):
- suno_task_id: str
- suno_id: str
- track_id: Optional[int] = None
-
-
-@app.post("/api/music/stem-split")
-def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks):
- """곡을 12개 스템으로 분리 (50 크레딧). 보컬, 드럼, 베이스, 기타 등."""
- if not SUNO_API_KEY:
- raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
- task_id = str(uuid.uuid4())
- params = req.model_dump()
- create_task(task_id, params, provider="suno")
- background_tasks.add_task(run_stem_split, task_id, params)
- return {"task_id": task_id, "provider": "suno"}
-
-
-# ── 타임스탬프 가사 API ─────────────────────────────────────────────────────
-
-@app.get("/api/music/timestamped-lyrics")
-def timestamped_lyrics(task_id: str, suno_id: str):
- """타임스탬프 가사 조회 (가라오케 스타일 싱크용)."""
- if not SUNO_API_KEY:
- raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
- result = get_timestamped_lyrics(task_id, suno_id)
- if not result:
- raise HTTPException(status_code=502, detail="타임스탬프 가사 조회 실패")
- return result
-
-
-# ── 스타일 부스트 API ───────────────────────────────────────────────────────
-
-class StyleBoostRequest(BaseModel):
- content: str
-
-
-@app.post("/api/music/style-boost")
-def style_boost(req: StyleBoostRequest):
- """AI로 최적 스타일 프롬프트 생성."""
- if not SUNO_API_KEY:
- raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
- result = generate_style_boost(req.content)
- if not result:
- raise HTTPException(status_code=502, detail="스타일 부스트 생성 실패")
- return result
-```
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add music-lab/app/suno_provider.py music-lab/app/main.py
-git commit -m "feat(music-lab): Phase 2 백엔드 — WAV 변환, 12스템 분리, 타임스탬프 가사, 스타일 부스트"
-```
-
----
-
-### Task 7: 프론트엔드 — Phase 2 UI (StemModal, 타임스탬프 가사, 스타일 부스트)
-
-**Files:**
-- Modify: `web-ui/src/api.js`
-- Create: `web-ui/src/pages/music/components/StemModal.jsx`
-- Create: `web-ui/src/pages/music/components/SyncedLyricsPlayer.jsx`
-- Modify: `web-ui/src/pages/music/MusicStudio.jsx`
-- Modify: `web-ui/src/pages/music/MusicStudio.css`
-
-- [ ] **Step 1: api.js Phase 2 함수 추가**
-
-```javascript
-// ── Phase 2 API ─────────────────────────────────────────────────────────────
-
-// POST /api/music/wav body: { suno_task_id, suno_id, track_id }
-export function convertToWav(payload) {
- return apiPost('/api/music/wav', payload);
-}
-
-// POST /api/music/stem-split body: { suno_task_id, suno_id, track_id }
-export function splitStems(payload) {
- return apiPost('/api/music/stem-split', payload);
-}
-
-// GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
-export function getTimestampedLyrics(taskId, sunoId) {
- return apiGet(`/api/music/timestamped-lyrics?task_id=${encodeURIComponent(taskId)}&suno_id=${encodeURIComponent(sunoId)}`);
-}
-
-// POST /api/music/style-boost body: { content }
-export function generateStyleBoost(content) {
- return apiPost('/api/music/style-boost', { content });
-}
-```
-
-- [ ] **Step 2: StemModal 컴포넌트**
-
-`web-ui/src/pages/music/components/StemModal.jsx`:
-
-```jsx
-import React, { useState } from 'react';
-
-const STEM_ICONS = {
- vocal: '🎤', backing_vocals: '🎶', drums: '🥁', bass: '🎸',
- guitar: '🎸', keyboard: '🎹', strings: '🎻', brass: '🎺',
- woodwinds: '🪈', percussion: '🪘', synth: '🎛', fx: '✨',
-};
-
-const StemModal = ({ stems, onClose }) => {
- const [playingStem, setPlayingStem] = useState(null);
-
- if (!stems || Object.keys(stems).length === 0) return null;
-
- return (
-
-
e.stopPropagation()}>
-
-
12 Stems
- 각 스템을 개별 재생 및 다운로드할 수 있습니다
- ✕
-
-
- {Object.entries(stems).map(([name, url]) => {
- if (!url) return null;
- const isPlaying = playingStem === name;
- return (
-
-
{STEM_ICONS[name] || '🎵'}
-
{name.replace(/_/g, ' ')}
-
-
setPlayingStem(isPlaying ? null : name)}
- >
- {isPlaying ? '■' : '▶'}
-
-
↓
-
- {isPlaying && (
-
setPlayingStem(null)} />
- )}
-
- );
- })}
-
-
- 닫기
-
-
-
- );
-};
-
-export default StemModal;
-```
-
-- [ ] **Step 3: SyncedLyricsPlayer 컴포넌트**
-
-`web-ui/src/pages/music/components/SyncedLyricsPlayer.jsx`:
-
-```jsx
-import React, { useEffect, useRef, useState } from 'react';
-
-const SyncedLyricsPlayer = ({ audioUrl, alignedWords, onClose, accentColor }) => {
- const audioRef = useRef(null);
- const [playing, setPlaying] = useState(false);
- const [currentTime, setCurrentTime] = useState(0);
-
- useEffect(() => {
- const el = audioRef.current;
- if (!el) return;
- const handler = () => setCurrentTime(el.currentTime);
- el.addEventListener('timeupdate', handler);
- return () => el.removeEventListener('timeupdate', handler);
- }, []);
-
- if (!alignedWords || alignedWords.length === 0) return null;
-
- return (
-
-
-
Synced Lyrics
- ✕
-
-
setPlaying(true)}
- onPause={() => setPlaying(false)}
- onEnded={() => setPlaying(false)}
- controls
- className="ms-synced-player__audio"
- />
-
- {alignedWords.map((word, idx) => {
- const isActive = currentTime >= word.startS && currentTime < word.endS;
- const isPast = currentTime >= word.endS;
- return (
-
- {word.word}{' '}
-
- );
- })}
-
-
- );
-};
-
-export default SyncedLyricsPlayer;
-```
-
-- [ ] **Step 4: LibraryCard 더보기 메뉴에 Phase 2 액션 추가 + 스타일 부스트 버튼**
-
-MusicStudio.jsx 더보기 메뉴 dropdown에 추가:
-
-```jsx
- { onWavConvert(track); setMenuOpen(false); }}
- disabled={isGenerating}>
- 📀 WAV Download
-
- { onStemSplit(track); setMenuOpen(false); }}
- disabled={isGenerating}>
- 🎛 12 Stems (50cr)
-
- { onSyncedLyrics(track); setMenuOpen(false); }}
- disabled={isGenerating || !track.lyrics}>
- 📝 Synced Lyrics
-
-```
-
-Create 탭 Step 1 (Genre) 제목 옆에 Style Boost 버튼:
-
-```jsx
-
- 01
-
Genre
- 장르를 선택하세요
- {provider === 'suno' && (
-
- {styleBoostLoading ? '생성 중...' : '✨ Style Boost'}
-
- )}
-
-```
-
-핸들러:
-
-```jsx
-const [styleBoostLoading, setStyleBoostLoading] = useState(false);
-
-const handleStyleBoost = async () => {
- if (!genre || styleBoostLoading) return;
- setStyleBoostLoading(true);
- try {
- const content = [
- GENRES.find(g => g.id === genre)?.label,
- ...moods.map(id => MOODS.find(m => m.id === id)?.label).filter(Boolean),
- ].join(', ');
- const result = await generateStyleBoost(content);
- if (result?.result) {
- setPrompt(result.result);
- }
- } catch {}
- finally { setStyleBoostLoading(false); }
-};
-```
-
-- [ ] **Step 5: Phase 2 CSS**
-
-```css
-/* ── Stem Modal ─────────────────────────────────────────── */
-.ms-modal--wide { max-width: 680px; }
-.ms-modal__subtitle { font-size: 0.78rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }
-.ms-stem-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
-.ms-stem-card {
- display: flex; flex-direction: column; align-items: center; gap: 6px;
- padding: 12px 8px; border-radius: 10px;
- background: var(--ms-surface2); border: 1px solid var(--ms-line);
- transition: border-color 0.2s;
-}
-.ms-stem-card.is-playing { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
-.ms-stem-card__icon { font-size: 1.4rem; }
-.ms-stem-card__name {
- font-family: 'Courier Prime', monospace; font-size: 0.72rem;
- color: var(--ms-muted); text-transform: capitalize;
-}
-.ms-stem-card__actions { display: flex; gap: 6px; }
-
-/* ── Synced Lyrics Player ───────────────────────────────── */
-.ms-synced-player {
- background: var(--ms-surface); border: 1px solid var(--ms-line);
- border-radius: 12px; padding: 16px; margin-top: 12px;
-}
-.ms-synced-player__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
-.ms-synced-player__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
-.ms-synced-player__audio { width: 100%; margin-bottom: 12px; }
-.ms-synced-player__lyrics { line-height: 1.8; font-family: 'Syne', sans-serif; font-size: 0.95rem; }
-.ms-synced-word { color: var(--ms-dim); transition: color 0.15s; }
-.ms-synced-word.is-active { color: var(--synced-accent, var(--ms-accent)); font-weight: 600; }
-.ms-synced-word.is-past { color: var(--ms-muted); }
-
-/* ── Style Boost Button ─────────────────────────────────── */
-.ms-style-boost-btn { margin-left: auto; }
-.ms-style-boost-btn.is-loading { opacity: 0.6; }
-```
-
-- [ ] **Step 6: 커밋**
-
-```bash
-git add web-ui/src/pages/music/ web-ui/src/api.js
-git commit -m "feat(music-lab): Phase 2 UI — StemModal, SyncedLyricsPlayer, Style Boost, WAV 변환"
-```
-
----
-
-## Phase 3: 고급 크리에이티브
-
-### Task 8: 백엔드 — Phase 3 엔드포인트 (업로드, 보컬/인스트 추가, 뮤직비디오)
-
-**Files:**
-- Modify: `music-lab/app/suno_provider.py`
-- Modify: `music-lab/app/main.py`
-
-- [ ] **Step 1: suno_provider.py Phase 3 함수 추가**
-
-```python
-# ── 오디오 업로드 + 커버 ─────────────────────────────────────────────────────
-
-def run_upload_cover(task_id: str, params: dict) -> None:
- """외부 오디오를 Suno 스타일로 리메이크."""
- try:
- if not SUNO_API_KEY:
- update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
- return
-
- update_task(task_id, "processing", 5, "AI Cover 요청 중...")
-
- payload = {
- "uploadUrl": params["upload_url"],
- "customMode": params.get("custom_mode", True),
- "instrumental": params.get("instrumental", False),
- "model": params.get("model", "V4"),
- "callBackUrl": "https://example.com/noop",
- }
- for key, api_key in [("prompt", "prompt"), ("style", "style"), ("title", "title"),
- ("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags"),
- ("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
- if params.get(key):
- payload[api_key] = params[key]
-
- resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-cover", headers=_headers(), json=payload, timeout=30)
- if resp.status_code != 200:
- update_task(task_id, "failed", 0, "", error=f"Upload Cover API 오류: {resp.text[:300]}")
- return
- body = resp.json()
- if body.get("code") != 200:
- update_task(task_id, "failed", 0, "", error=f"Upload Cover 거부: {body.get('msg', 'unknown')}")
- return
-
- suno_task_id = body.get("data", {}).get("taskId", "")
- if not suno_task_id:
- update_task(task_id, "failed", 0, "", error="Upload Cover 응답에 taskId 없음")
- return
- update_task(task_id, "processing", 15, "AI Cover 생성 중...")
-
- response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
- if not response:
- return
- completed_tracks = response.get("sunoData") or []
- if not completed_tracks:
- update_task(task_id, "failed", 0, "", error="AI Cover 생성 완료했으나 트랙 없음")
- return
-
- track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
- if track:
- update_task(task_id, "succeeded", 100, "AI Cover 완료", audio_url=track["audio_url"])
-
- except Exception as e:
- logger.exception("Upload cover error for task %s", task_id)
- update_task(task_id, "failed", 0, "", error=str(e))
-
-
-# ── 오디오 업로드 + 확장 ─────────────────────────────────────────────────────
-
-def run_upload_extend(task_id: str, params: dict) -> None:
- """외부 오디오를 이어서 확장."""
- try:
- if not SUNO_API_KEY:
- update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
- return
-
- update_task(task_id, "processing", 5, "Upload Extend 요청 중...")
-
- payload = {
- "uploadUrl": params["upload_url"],
- "defaultParamFlag": params.get("default_param_flag", True),
- "model": params.get("model", "V4"),
- "callBackUrl": "https://example.com/noop",
- }
- for key, api_key in [("prompt", "prompt"), ("style", "style"), ("title", "title"),
- ("continue_at", "continueAt"), ("instrumental", "instrumental"),
- ("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags")]:
- if params.get(key) is not None:
- payload[api_key] = params[key]
-
- resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-extend", headers=_headers(), json=payload, timeout=30)
- if resp.status_code != 200:
- update_task(task_id, "failed", 0, "", error=f"Upload Extend API 오류: {resp.text[:300]}")
- return
- body = resp.json()
- if body.get("code") != 200:
- update_task(task_id, "failed", 0, "", error=f"Upload Extend 거부: {body.get('msg', 'unknown')}")
- return
-
- suno_task_id = body.get("data", {}).get("taskId", "")
- if not suno_task_id:
- update_task(task_id, "failed", 0, "", error="Upload Extend 응답에 taskId 없음")
- return
- update_task(task_id, "processing", 15, "Upload Extend 생성 중...")
-
- response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
- if not response:
- return
- completed_tracks = response.get("sunoData") or []
- if not completed_tracks:
- update_task(task_id, "failed", 0, "", error="Upload Extend 완료했으나 트랙 없음")
- return
-
- track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
- if track:
- update_task(task_id, "succeeded", 100, "Upload Extend 완료", audio_url=track["audio_url"])
-
- except Exception as e:
- logger.exception("Upload extend error for task %s", task_id)
- update_task(task_id, "failed", 0, "", error=str(e))
-
-
-# ── 보컬 추가 ────────────────────────────────────────────────────────────────
-
-def run_add_vocals(task_id: str, params: dict) -> None:
- """인스트루멘탈에 AI 보컬을 추가."""
- try:
- if not SUNO_API_KEY:
- update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
- return
-
- update_task(task_id, "processing", 5, "보컬 추가 요청 중...")
-
- payload = {
- "uploadUrl": params["upload_url"],
- "prompt": params.get("prompt", ""),
- "title": params.get("title", ""),
- "style": params.get("style", ""),
- "negativeTags": params.get("negative_tags", ""),
- "callBackUrl": "https://example.com/noop",
- }
- for key, api_key in [("vocal_gender", "vocalGender"), ("model", "model"),
- ("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
- if params.get(key) is not None:
- payload[api_key] = params[key]
-
- resp = requests.post(f"{SUNO_BASE_URL}/generate/add-vocals", headers=_headers(), json=payload, timeout=30)
- if resp.status_code != 200:
- update_task(task_id, "failed", 0, "", error=f"Add Vocals API 오류: {resp.text[:300]}")
- return
- body = resp.json()
- if body.get("code") != 200:
- update_task(task_id, "failed", 0, "", error=f"Add Vocals 거부: {body.get('msg', 'unknown')}")
- return
-
- suno_task_id = body.get("data", {}).get("taskId", "")
- if not suno_task_id:
- update_task(task_id, "failed", 0, "", error="Add Vocals 응답에 taskId 없음")
- return
- update_task(task_id, "processing", 15, "AI 보컬 생성 중...")
-
- response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
- if not response:
- return
- completed_tracks = response.get("sunoData") or []
- if not completed_tracks:
- update_task(task_id, "failed", 0, "", error="보컬 추가 완료했으나 트랙 없음")
- return
-
- track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
- if track:
- update_task(task_id, "succeeded", 100, "보컬 추가 완료", audio_url=track["audio_url"])
-
- except Exception as e:
- logger.exception("Add vocals error for task %s", task_id)
- update_task(task_id, "failed", 0, "", error=str(e))
-
-
-# ── 인스트루멘탈 추가 ────────────────────────────────────────────────────────
-
-def run_add_instrumental(task_id: str, params: dict) -> None:
- """보컬에 AI 반주를 추가."""
- try:
- if not SUNO_API_KEY:
- update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
- return
-
- update_task(task_id, "processing", 5, "인스트루멘탈 추가 요청 중...")
-
- payload = {
- "uploadUrl": params["upload_url"],
- "title": params.get("title", ""),
- "tags": params.get("tags", ""),
- "negativeTags": params.get("negative_tags", ""),
- "callBackUrl": "https://example.com/noop",
- }
- for key, api_key in [("vocal_gender", "vocalGender"), ("model", "model"),
- ("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
- if params.get(key) is not None:
- payload[api_key] = params[key]
-
- resp = requests.post(f"{SUNO_BASE_URL}/generate/add-instrumental", headers=_headers(), json=payload, timeout=30)
- if resp.status_code != 200:
- update_task(task_id, "failed", 0, "", error=f"Add Instrumental API 오류: {resp.text[:300]}")
- return
- body = resp.json()
- if body.get("code") != 200:
- update_task(task_id, "failed", 0, "", error=f"Add Instrumental 거부: {body.get('msg', 'unknown')}")
- return
-
- suno_task_id = body.get("data", {}).get("taskId", "")
- if not suno_task_id:
- update_task(task_id, "failed", 0, "", error="Add Instrumental 응답에 taskId 없음")
- return
- update_task(task_id, "processing", 15, "AI 반주 생성 중...")
-
- response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
- if not response:
- return
- completed_tracks = response.get("sunoData") or []
- if not completed_tracks:
- update_task(task_id, "failed", 0, "", error="인스트루멘탈 추가 완료했으나 트랙 없음")
- return
-
- track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
- if track:
- update_task(task_id, "succeeded", 100, "인스트루멘탈 추가 완료", audio_url=track["audio_url"])
-
- except Exception as e:
- logger.exception("Add instrumental error for task %s", task_id)
- update_task(task_id, "failed", 0, "", error=str(e))
-
-
-# ── 뮤직비디오 생성 ──────────────────────────────────────────────────────────
-
-def run_video_generate(task_id: str, params: dict) -> None:
- """곡의 뮤직비디오(MP4) 생성."""
- try:
- if not SUNO_API_KEY:
- update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
- return
-
- update_task(task_id, "processing", 5, "뮤직비디오 생성 요청 중...")
-
- payload = {
- "taskId": params["suno_task_id"],
- "audioId": params["suno_id"],
- "callBackUrl": "https://example.com/noop",
- }
- if params.get("author"):
- payload["author"] = params["author"][:50]
- if params.get("domain_name"):
- payload["domainName"] = params["domain_name"][:50]
-
- resp = requests.post(f"{SUNO_BASE_URL}/mp4/generate", headers=_headers(), json=payload, timeout=30)
- if resp.status_code != 200:
- update_task(task_id, "failed", 0, "", error=f"Video API 오류: {resp.text[:300]}")
- return
- body = resp.json()
- if body.get("code") != 200:
- update_task(task_id, "failed", 0, "", error=f"Video 생성 거부: {body.get('msg', 'unknown')}")
- return
-
- video_task_id = body.get("data", {}).get("taskId", params.get("suno_task_id", ""))
- update_task(task_id, "processing", 15, "뮤직비디오 렌더링 중...")
-
- response = _poll_suno_record(
- "/mp4/record-info", video_task_id, task_id,
- max_attempts=60, interval=10,
- progress_msg_map={"PENDING": "비디오 렌더링 대기 중...", "GENERATING": "비디오 렌더링 중..."},
- )
- if not response:
- return
-
- video_url = ""
- suno_data = response.get("sunoData") or []
- if suno_data and isinstance(suno_data, list) and isinstance(suno_data[0], dict):
- video_url = suno_data[0].get("videoUrl") or suno_data[0].get("video_url", "")
- if not video_url:
- video_url = response.get("video_url") or response.get("videoUrl", "")
-
- update_task(task_id, "succeeded", 100, "뮤직비디오 생성 완료", audio_url=video_url)
-
- except Exception as e:
- logger.exception("Video generate error for task %s", task_id)
- update_task(task_id, "failed", 0, "", error=str(e))
-```
-
-- [ ] **Step 2: main.py Phase 3 엔드포인트 추가**
-
-import 업데이트:
-
-```python
-from .suno_provider import (
- run_suno_generation, run_suno_extend, run_vocal_removal,
- run_cover_image, run_wav_convert, run_stem_split,
- run_upload_cover, run_upload_extend, run_add_vocals, run_add_instrumental, run_video_generate,
- generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost,
- SUNO_API_KEY, SUNO_MODELS,
-)
-```
-
-엔드포인트:
-
-```python
-# ── Phase 3: 업로드 + 커버 ──────────────────────────────────────────────────
-
-class UploadCoverRequest(BaseModel):
- upload_url: str
- model: str = "V4"
- custom_mode: bool = True
- instrumental: bool = False
- prompt: str = ""
- style: str = ""
- title: str = ""
- vocal_gender: Optional[str] = None
- negative_tags: Optional[str] = None
- style_weight: Optional[float] = None
- audio_weight: Optional[float] = None
-
-
-@app.post("/api/music/upload-cover")
-def upload_cover(req: UploadCoverRequest, background_tasks: BackgroundTasks):
- """외부 오디오를 Suno 스타일로 리메이크."""
- if not SUNO_API_KEY:
- raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
- task_id = str(uuid.uuid4())
- params = req.model_dump()
- create_task(task_id, params, provider="suno")
- background_tasks.add_task(run_upload_cover, task_id, params)
- return {"task_id": task_id, "provider": "suno"}
-
-
-# ── Phase 3: 업로드 + 확장 ──────────────────────────────────────────────────
-
-class UploadExtendRequest(BaseModel):
- upload_url: str
- model: str = "V4"
- default_param_flag: bool = True
- continue_at: Optional[float] = None
- prompt: str = ""
- style: str = ""
- title: str = ""
- instrumental: bool = False
- vocal_gender: Optional[str] = None
- negative_tags: Optional[str] = None
-
-
-@app.post("/api/music/upload-extend")
-def upload_extend(req: UploadExtendRequest, background_tasks: BackgroundTasks):
- """외부 오디오를 이어서 확장."""
- if not SUNO_API_KEY:
- raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
- task_id = str(uuid.uuid4())
- params = req.model_dump()
- create_task(task_id, params, provider="suno")
- background_tasks.add_task(run_upload_extend, task_id, params)
- return {"task_id": task_id, "provider": "suno"}
-
-
-# ── Phase 3: 보컬 추가 ──────────────────────────────────────────────────────
-
-class AddVocalsRequest(BaseModel):
- upload_url: str
- prompt: str
- title: str
- style: str
- negative_tags: str = ""
- vocal_gender: Optional[str] = None
- model: str = "V4_5PLUS"
- style_weight: Optional[float] = None
- audio_weight: Optional[float] = None
-
-
-@app.post("/api/music/add-vocals")
-def add_vocals(req: AddVocalsRequest, background_tasks: BackgroundTasks):
- """인스트루멘탈에 AI 보컬 추가."""
- if not SUNO_API_KEY:
- raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
- task_id = str(uuid.uuid4())
- params = req.model_dump()
- create_task(task_id, params, provider="suno")
- background_tasks.add_task(run_add_vocals, task_id, params)
- return {"task_id": task_id, "provider": "suno"}
-
-
-# ── Phase 3: 인스트루멘탈 추가 ──────────────────────────────────────────────
-
-class AddInstrumentalRequest(BaseModel):
- upload_url: str
- title: str
- tags: str
- negative_tags: str = ""
- vocal_gender: Optional[str] = None
- model: str = "V4_5PLUS"
- style_weight: Optional[float] = None
- audio_weight: Optional[float] = None
-
-
-@app.post("/api/music/add-instrumental")
-def add_instrumental(req: AddInstrumentalRequest, background_tasks: BackgroundTasks):
- """보컬에 AI 반주 추가."""
- if not SUNO_API_KEY:
- raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
- task_id = str(uuid.uuid4())
- params = req.model_dump()
- create_task(task_id, params, provider="suno")
- background_tasks.add_task(run_add_instrumental, task_id, params)
- return {"task_id": task_id, "provider": "suno"}
-
-
-# ── Phase 3: 뮤직비디오 생성 ────────────────────────────────────────────────
-
-class VideoRequest(BaseModel):
- suno_task_id: str
- suno_id: str
- author: str = ""
- domain_name: str = ""
- track_id: Optional[int] = None
-
-
-@app.post("/api/music/video")
-def video_generate(req: VideoRequest, background_tasks: BackgroundTasks):
- """뮤직비디오(MP4) 생성."""
- if not SUNO_API_KEY:
- raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
- task_id = str(uuid.uuid4())
- params = req.model_dump()
- create_task(task_id, params, provider="suno")
- background_tasks.add_task(run_video_generate, task_id, params)
- return {"task_id": task_id, "provider": "suno"}
-```
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add music-lab/app/suno_provider.py music-lab/app/main.py
-git commit -m "feat(music-lab): Phase 3 백엔드 — 업로드커버, 업로드확장, 보컬추가, 인스트추가, 뮤직비디오"
-```
-
----
-
-### Task 9: 프론트엔드 — Phase 3 UI (RemixTab + 뮤직비디오)
-
-**Files:**
-- Modify: `web-ui/src/api.js`
-- Create: `web-ui/src/pages/music/components/RemixTab.jsx`
-- Modify: `web-ui/src/pages/music/MusicStudio.jsx`
-- Modify: `web-ui/src/pages/music/MusicStudio.css`
-
-- [ ] **Step 1: api.js Phase 3 함수 추가**
-
-```javascript
-// ── Phase 3 API ─────────────────────────────────────────────────────────────
-
-// POST /api/music/upload-cover
-export function uploadAndCover(payload) {
- return apiPost('/api/music/upload-cover', payload);
-}
-
-// POST /api/music/upload-extend
-export function uploadAndExtend(payload) {
- return apiPost('/api/music/upload-extend', payload);
-}
-
-// POST /api/music/add-vocals
-export function addVocals(payload) {
- return apiPost('/api/music/add-vocals', payload);
-}
-
-// POST /api/music/add-instrumental
-export function addInstrumental(payload) {
- return apiPost('/api/music/add-instrumental', payload);
-}
-
-// POST /api/music/video
-export function generateVideo(payload) {
- return apiPost('/api/music/video', payload);
-}
-```
-
-- [ ] **Step 2: RemixTab 컴포넌트**
-
-`web-ui/src/pages/music/components/RemixTab.jsx`:
-
-```jsx
-import React, { useState } from 'react';
-import { uploadAndCover, uploadAndExtend, addVocals, addInstrumental, getMusicStatus } from '../../../api';
-
-const REMIX_ACTIONS = [
- { id: 'cover', label: 'AI Cover', icon: '🎨', desc: '외부 음원을 Suno AI 스타일로 리메이크' },
- { id: 'extend', label: 'Extend', icon: '⏩', desc: '외부 음원을 이어서 확장' },
- { id: 'add-vocals', label: 'Add Vocals', icon: '🎤', desc: '인스트루멘탈에 AI 보컬 입히기' },
- { id: 'add-instrumental', label: 'Add Instrumental', icon: '🎹', desc: '보컬에 AI 반주 입히기' },
-];
-
-const RemixTab = ({ onTaskStarted, model, isGenerating }) => {
- const [uploadUrl, setUploadUrl] = useState('');
- const [activeAction, setActiveAction] = useState(null);
-
- // 각 액션별 파라미터
- const [title, setTitle] = useState('');
- const [style, setStyle] = useState('');
- const [prompt, setPrompt] = useState('');
- const [tags, setTags] = useState('');
- const [negativeTags, setNegativeTags] = useState('');
- const [vocalGender, setVocalGender] = useState(null);
- const [continueAt, setContinueAt] = useState(0);
- const [instrumental, setInstrumental] = useState(false);
-
- const handleSubmit = async () => {
- if (!uploadUrl || !activeAction || isGenerating) return;
-
- let apiCall;
- let payload = {};
-
- switch (activeAction) {
- case 'cover':
- apiCall = uploadAndCover;
- payload = {
- upload_url: uploadUrl, model, custom_mode: true,
- instrumental, prompt, style, title,
- vocal_gender: vocalGender || undefined,
- negative_tags: negativeTags || undefined,
- };
- break;
- case 'extend':
- apiCall = uploadAndExtend;
- payload = {
- upload_url: uploadUrl, model,
- default_param_flag: !!prompt,
- continue_at: continueAt || undefined,
- prompt, style, title, instrumental,
- vocal_gender: vocalGender || undefined,
- negative_tags: negativeTags || undefined,
- };
- break;
- case 'add-vocals':
- apiCall = addVocals;
- payload = {
- upload_url: uploadUrl, prompt, title, style,
- negative_tags: negativeTags,
- vocal_gender: vocalGender || undefined,
- model: 'V4_5PLUS',
- };
- break;
- case 'add-instrumental':
- apiCall = addInstrumental;
- payload = {
- upload_url: uploadUrl, title, tags,
- negative_tags: negativeTags,
- vocal_gender: vocalGender || undefined,
- model: 'V4_5PLUS',
- };
- break;
- default:
- return;
- }
-
- try {
- const res = await apiCall(payload);
- if (res?.task_id) {
- onTaskStarted(res.task_id, `Remix: ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`);
- }
- } catch (e) {
- // 에러는 부모 컴포넌트에서 처리
- }
- };
-
- return (
-
-
-
Remix Studio
-
외부 음원을 AI로 리메이크, 확장, 보컬/반주 추가
-
-
-
- Audio URL
- setUploadUrl(e.target.value)}
- style={{ width: '100%' }}
- />
-
-
-
- {REMIX_ACTIONS.map((action) => (
- setActiveAction(activeAction === action.id ? null : action.id)}
- >
- {action.icon}
- {action.label}
- {action.desc}
-
- ))}
-
-
- {activeAction && (
-
- {/* 공통 파라미터 */}
-
- Title
- setTitle(e.target.value)} placeholder="곡 제목" style={{ width: '100%' }} />
-
-
- {(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
-
- Prompt / Lyrics
-
- )}
-
- {(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
-
- Style
- setStyle(e.target.value)} placeholder="예: Pop, Energetic, Piano" style={{ width: '100%' }} />
-
- )}
-
- {activeAction === 'add-instrumental' && (
-
- Tags (스타일/특성)
- setTags(e.target.value)} placeholder="예: acoustic, warm, dreamy" style={{ width: '100%' }} />
-
- )}
-
- {activeAction === 'extend' && (
-
- Continue At (초)
- setContinueAt(Number(e.target.value))} min={0} style={{ width: '120px' }} />
-
- )}
-
-
- Exclude Styles
- setNegativeTags(e.target.value)} placeholder="제외할 스타일" style={{ width: '100%' }} />
-
-
-
-
Vocal Gender
-
- {[{ value: null, label: 'Auto' }, { value: 'm', label: 'Male' }, { value: 'f', label: 'Female' }].map((opt) => (
- setVocalGender(opt.value)}>
- {opt.label}
-
- ))}
-
-
-
-
- {isGenerating ? 'Processing...' : `Start ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`}
-
-
- )}
-
- );
-};
-
-export default RemixTab;
-```
-
-- [ ] **Step 3: MusicStudio.jsx에 Remix 탭 + Video 액션 통합**
-
-```jsx
-import RemixTab from './components/RemixTab';
-import { generateVideo } from '../../api';
-
-// 탭 네비게이션에 Remix 추가
- setTab('remix')}
->
- 🔄 Remix
-
-
-// 탭 내용에 Remix 추가
-{tab === 'remix' && (
- {
- setTab('create');
- setIsGenerating(true);
- setTrack(null);
- setGenProgress(0);
- setGenStep(`${title} 처리 중…`);
- setGenError(null);
- taskIdRef.current = taskId;
- startPolling(taskId, title);
- }}
- model={model}
- isGenerating={isGenerating}
- />
-)}
-
-// LibraryCard 더보기 메뉴에 Video 추가
- { onVideoGenerate(track); setMenuOpen(false); }}
- disabled={isGenerating}>
- 🎬 Music Video
-
-```
-
-Video 핸들러:
-
-```jsx
-const handleVideoGenerate = async (track) => {
- if (!track.task_id || !track.suno_id || isGenerating) return;
- setTab('create');
- setIsGenerating(true);
- setTrack(null);
- setGenProgress(0);
- setGenStep('뮤직비디오 생성 요청 중…');
- setGenError(null);
- try {
- const res = await generateVideo({
- suno_task_id: track.task_id,
- suno_id: track.suno_id,
- track_id: track.id,
- });
- if (res?.task_id) {
- taskIdRef.current = res.task_id;
- startPolling(res.task_id, `${track.title} (Video)`);
- }
- } catch {
- setIsGenerating(false);
- setGenError('뮤직비디오 생성에 실패했습니다');
- }
-};
-```
-
-- [ ] **Step 4: Phase 3 CSS**
-
-```css
-/* ── Remix Tab ──────────────────────────────────────────── */
-.ms-remix-tab { display: flex; flex-direction: column; gap: 20px; }
-.ms-remix-tab__header { }
-.ms-remix-tab__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.8rem; color: var(--ms-text); }
-.ms-remix-tab__desc { font-size: 0.85rem; color: var(--ms-muted); }
-
-.ms-remix-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
-.ms-remix-card {
- display: flex; flex-direction: column; align-items: center; gap: 6px;
- padding: 20px 12px; border-radius: 12px; cursor: pointer;
- background: var(--ms-surface); border: 1px solid var(--ms-line);
- transition: all 0.2s; text-align: center;
-}
-.ms-remix-card:hover { border-color: var(--ms-accent); background: var(--ms-surface2); }
-.ms-remix-card.is-active { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
-.ms-remix-card__icon { font-size: 2rem; }
-.ms-remix-card__label { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
-.ms-remix-card__desc { font-size: 0.72rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }
-
-.ms-remix-params {
- display: flex; flex-direction: column; gap: 12px;
- padding: 16px; border-radius: 12px;
- background: var(--ms-surface); border: 1px solid var(--ms-line);
-}
-.ms-remix-submit { align-self: flex-start; margin-top: 8px; }
-```
-
-- [ ] **Step 5: 커밋**
-
-```bash
-git add web-ui/src/pages/music/ web-ui/src/api.js
-git commit -m "feat(music-lab): Phase 3 UI — RemixTab + 뮤직비디오 생성"
-```
-
----
-
-### Task 10: 최종 통합 검증 + CLAUDE.md 업데이트
-
-**Files:**
-- Modify: `web-backend/CLAUDE.md`
-
-- [ ] **Step 1: CLAUDE.md music-lab API 목록 업데이트**
-
-`web-backend/CLAUDE.md`의 music-lab API 목록 테이블을 업데이트:
-
-```markdown
-**music-lab API 목록**
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/music/providers` | 사용 가능한 프로바이더 목록 |
-| GET | `/api/music/models` | Suno 모델 목록 (V4~V5.5) |
-| GET | `/api/music/credits` | Suno 크레딧 조회 |
-| POST | `/api/music/generate` | 음악 생성 (provider, model, vocal_gender, negative_tags, style_weight, audio_weight) |
-| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 |
-| POST | `/api/music/lyrics` | Suno AI 가사 생성 |
-| GET | `/api/music/library` | 라이브러리 전체 조회 |
-| POST | `/api/music/library` | 트랙 수동 추가 |
-| DELETE | `/api/music/library/{id}` | 트랙 삭제 |
-| POST | `/api/music/extend` | 곡 연장 |
-| POST | `/api/music/vocal-removal` | 보컬/인스트 분리 (2트랙) |
-| POST | `/api/music/cover-image` | 커버 이미지 2장 생성 |
-| POST | `/api/music/wav` | WAV 고음질 변환 |
-| POST | `/api/music/stem-split` | 12스템 분리 (50cr) |
-| GET | `/api/music/timestamped-lyrics` | 타임스탬프 가사 (가라오케) |
-| POST | `/api/music/style-boost` | AI 스타일 프롬프트 생성 |
-| POST | `/api/music/upload-cover` | 외부 음원 AI Cover |
-| POST | `/api/music/upload-extend` | 외부 음원 확장 |
-| POST | `/api/music/add-vocals` | 인스트에 AI 보컬 추가 |
-| POST | `/api/music/add-instrumental` | 보컬에 AI 반주 추가 |
-| POST | `/api/music/video` | 뮤직비디오 MP4 생성 |
-| GET | `/api/music/lyrics/library` | 저장된 가사 목록 |
-| POST | `/api/music/lyrics/library` | 가사 저장 |
-| PUT | `/api/music/lyrics/library/{id}` | 가사 수정 |
-| DELETE | `/api/music/lyrics/library/{id}` | 가사 삭제 |
-```
-
-- [ ] **Step 2: 전체 빌드 확인**
-
-```bash
-cd web-ui && npm run build
-```
-
-빌드 에러가 있으면 수정.
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add web-backend/CLAUDE.md
-git commit -m "docs: music-lab API 목록 업데이트 — Phase 1~3 신규 엔드포인트 반영"
-```
diff --git a/docs/superpowers/plans/2026-04-11-agent-office.md b/docs/superpowers/plans/2026-04-11-agent-office.md
deleted file mode 100644
index 65f61ff..0000000
--- a/docs/superpowers/plans/2026-04-11-agent-office.md
+++ /dev/null
@@ -1,2961 +0,0 @@
-# Agent Office 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:** 2D 픽셀아트 가상 사무실에서 AI 에이전트(주식, 음악)가 실제 작업을 수행하고, 텔레그램 양방향 연동으로 알림/승인을 처리하는 MVP 구현.
-
-**Architecture:** agent-office 백엔드 서비스(포트 18900)가 기존 stock-lab/music-lab API를 프록시 호출하고 에이전트 FSM을 관리. 프론트엔드는 Canvas 2D로 사무실을 렌더링하고 WebSocket으로 실시간 상태를 수신. 텔레그램 봇이 양방향 알림/승인을 처리.
-
-**Tech Stack:** FastAPI, SQLite, APScheduler, python-telegram-bot, WebSocket, React 18, HTML5 Canvas 2D, Vite
-
-**Design Spec:** `docs/superpowers/specs/2026-04-11-agent-office-design.md`
-
----
-
-## File Structure
-
-### Backend (web-backend/agent-office/)
-
-| File | Responsibility |
-|------|---------------|
-| `agent-office/app/__init__.py` | Package init |
-| `agent-office/app/main.py` | FastAPI app, WebSocket endpoint, REST routes, lifespan (scheduler start) |
-| `agent-office/app/config.py` | Environment variables, service URLs |
-| `agent-office/app/db.py` | SQLite init, CRUD for agent_config, agent_tasks, agent_logs, telegram_state |
-| `agent-office/app/models.py` | Pydantic request/response models |
-| `agent-office/app/websocket_manager.py` | WebSocket connection pool, broadcast |
-| `agent-office/app/service_proxy.py` | HTTP client for stock-lab, music-lab APIs |
-| `agent-office/app/telegram_bot.py` | Telegram Bot API: send messages, handle webhook callbacks |
-| `agent-office/app/scheduler.py` | APScheduler setup, job registration |
-| `agent-office/app/agents/__init__.py` | Package init, agent registry |
-| `agent-office/app/agents/base.py` | BaseAgent FSM (state transitions, idle timer, break logic) |
-| `agent-office/app/agents/stock.py` | StockAgent (news summary, price alerts) |
-| `agent-office/app/agents/music.py` | MusicAgent (compose pipeline with approval) |
-| `agent-office/Dockerfile` | Python 3.12-alpine, uvicorn |
-| `agent-office/requirements.txt` | Dependencies |
-
-### Frontend (web-ui/src/pages/agent-office/)
-
-| File | Responsibility |
-|------|---------------|
-| `agent-office/AgentOffice.jsx` | Main page: Canvas container + React overlay panels |
-| `agent-office/AgentOffice.css` | All styles for office page |
-| `agent-office/canvas/OfficeRenderer.js` | Game loop, layer rendering, click detection |
-| `agent-office/canvas/SpriteSheet.js` | Sprite sheet loader, frame animation |
-| `agent-office/canvas/TileMap.js` | Tile map data + rendering (floor, furniture) |
-| `agent-office/canvas/AgentSprite.js` | Agent character: position, state, movement, animation |
-| `agent-office/components/ChatPanel.jsx` | Agent chat/command panel (click to open) |
-| `agent-office/components/TaskHistory.jsx` | Task history side panel |
-| `agent-office/hooks/useAgentManager.js` | WebSocket connection + agent state management |
-| `agent-office/hooks/useOfficeCanvas.js` | Canvas init, resize, click event binding |
-| `agent-office/assets/office-map.json` | Tile map layout data |
-
-### Infrastructure
-
-| File | Change |
-|------|--------|
-| `docker-compose.yml` | Add agent-office service |
-| `nginx/default.conf` | Add /api/agent-office/ location with WebSocket upgrade |
-| `web-ui/src/routes.jsx` | Add agent-office route |
-| `web-ui/src/pages/effect-lab/EffectLab.jsx` | Add Agent Office to LAB_ITEMS |
-| `web-ui/src/api.js` | Add agent-office API helpers |
-
----
-
-## Task 1: Backend Scaffold — config, db, models
-
-**Files:**
-- Create: `agent-office/app/__init__.py`
-- Create: `agent-office/app/config.py`
-- Create: `agent-office/app/db.py`
-- Create: `agent-office/app/models.py`
-- Create: `agent-office/requirements.txt`
-- Create: `agent-office/Dockerfile`
-- Test: `agent-office/app/test_db.py`
-
-- [ ] **Step 1: Create requirements.txt**
-
-```
-fastapi==0.115.6
-uvicorn[standard]==0.30.6
-requests==2.32.3
-apscheduler==3.10.4
-python-telegram-bot==21.5
-websockets>=12.0
-httpx>=0.27
-```
-
-- [ ] **Step 2: Create Dockerfile**
-
-```dockerfile
-FROM python:3.12-alpine
-ENV PYTHONUNBUFFERED=1
-
-WORKDIR /app
-COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
-
-COPY . .
-
-CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
-```
-
-- [ ] **Step 3: Create `__init__.py`**
-
-```python
-# agent-office/app/__init__.py
-```
-
-Empty file for package init.
-
-- [ ] **Step 4: Create config.py**
-
-```python
-import os
-
-# Service URLs (Docker internal network)
-STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", "http://localhost:18500")
-MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://localhost:18600")
-
-# Telegram
-TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
-TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
-TELEGRAM_WEBHOOK_URL = os.getenv("TELEGRAM_WEBHOOK_URL", "")
-
-# Database
-DB_PATH = os.getenv("AGENT_OFFICE_DB_PATH", "/app/data/agent_office.db")
-
-# CORS
-CORS_ALLOW_ORIGINS = os.getenv(
- "CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
-)
-
-# Idle break threshold (seconds)
-IDLE_BREAK_THRESHOLD = int(os.getenv("IDLE_BREAK_THRESHOLD", "300")) # 5 min
-BREAK_DURATION_MIN = int(os.getenv("BREAK_DURATION_MIN", "60")) # 1 min
-BREAK_DURATION_MAX = int(os.getenv("BREAK_DURATION_MAX", "180")) # 3 min
-```
-
-- [ ] **Step 5: Create models.py**
-
-```python
-from pydantic import BaseModel
-from typing import Optional
-
-class CommandRequest(BaseModel):
- agent: str
- action: str
- params: Optional[dict] = None
-
-class ApprovalRequest(BaseModel):
- agent: str
- task_id: str
- approved: bool
- feedback: Optional[str] = None
-
-class AgentConfigUpdate(BaseModel):
- enabled: Optional[bool] = None
- schedule_config: Optional[dict] = None
- custom_config: Optional[dict] = None
-
-class PriceAlertConfig(BaseModel):
- symbol: str
- name: str
- target_price: float
- direction: str # "above" or "below"
-
-class ComposeCommand(BaseModel):
- prompt: str
- style: Optional[str] = None
- model: Optional[str] = "V4"
- instrumental: Optional[bool] = False
-```
-
-- [ ] **Step 6: Create db.py**
-
-```python
-import os
-import json
-import sqlite3
-import uuid
-from typing import Any, Dict, List, Optional
-
-from .config import DB_PATH
-
-def _conn() -> sqlite3.Connection:
- os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
- conn = sqlite3.connect(DB_PATH)
- conn.row_factory = sqlite3.Row
- conn.execute("PRAGMA journal_mode=WAL")
- return conn
-
-def init_db() -> None:
- with _conn() as conn:
- conn.execute("""
- CREATE TABLE IF NOT EXISTS agent_config (
- agent_id TEXT PRIMARY KEY,
- display_name TEXT NOT NULL,
- enabled INTEGER NOT NULL DEFAULT 1,
- schedule_config TEXT NOT NULL DEFAULT '{}',
- custom_config TEXT NOT NULL DEFAULT '{}',
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
- )
- """)
- conn.execute("""
- CREATE TABLE IF NOT EXISTS agent_tasks (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL,
- task_type TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'pending',
- input_data TEXT NOT NULL DEFAULT '{}',
- result_data TEXT,
- requires_approval INTEGER NOT NULL DEFAULT 0,
- approved_at TEXT,
- approved_via TEXT,
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- completed_at TEXT
- )
- """)
- conn.execute("""
- CREATE INDEX IF NOT EXISTS idx_tasks_agent
- ON agent_tasks(agent_id, created_at DESC)
- """)
- conn.execute("""
- CREATE TABLE IF NOT EXISTS agent_logs (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- agent_id TEXT NOT NULL,
- task_id TEXT,
- level TEXT NOT NULL DEFAULT 'info',
- message TEXT NOT NULL,
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
- )
- """)
- conn.execute("""
- CREATE TABLE IF NOT EXISTS telegram_state (
- callback_id TEXT PRIMARY KEY,
- task_id TEXT NOT NULL,
- agent_id TEXT NOT NULL,
- action TEXT,
- responded INTEGER NOT NULL DEFAULT 0,
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
- )
- """)
- # Seed default agent configs
- for agent_id, name in [("stock", "주식 트레이더"), ("music", "음악 프로듀서")]:
- conn.execute(
- "INSERT OR IGNORE INTO agent_config(agent_id, display_name) VALUES(?,?)",
- (agent_id, name),
- )
-
-# --- agent_config CRUD ---
-
-def get_all_agents() -> List[Dict[str, Any]]:
- with _conn() as conn:
- rows = conn.execute("SELECT * FROM agent_config ORDER BY agent_id").fetchall()
- return [_config_to_dict(r) for r in rows]
-
-def get_agent_config(agent_id: str) -> Optional[Dict[str, Any]]:
- with _conn() as conn:
- r = conn.execute("SELECT * FROM agent_config WHERE agent_id=?", (agent_id,)).fetchone()
- return _config_to_dict(r) if r else None
-
-def update_agent_config(agent_id: str, **kwargs) -> None:
- sets, vals = [], []
- for k in ("enabled", "schedule_config", "custom_config"):
- if k in kwargs and kwargs[k] is not None:
- if k in ("schedule_config", "custom_config"):
- sets.append(f"{k}=?")
- vals.append(json.dumps(kwargs[k]))
- else:
- sets.append(f"{k}=?")
- vals.append(kwargs[k])
- if not sets:
- return
- sets.append("updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')")
- vals.append(agent_id)
- with _conn() as conn:
- conn.execute(f"UPDATE agent_config SET {','.join(sets)} WHERE agent_id=?", vals)
-
-def _config_to_dict(r) -> Dict[str, Any]:
- return {
- "agent_id": r["agent_id"],
- "display_name": r["display_name"],
- "enabled": bool(r["enabled"]),
- "schedule_config": json.loads(r["schedule_config"]),
- "custom_config": json.loads(r["custom_config"]),
- "created_at": r["created_at"],
- "updated_at": r["updated_at"],
- }
-
-# --- agent_tasks CRUD ---
-
-def create_task(agent_id: str, task_type: str, input_data: dict, requires_approval: bool = False) -> str:
- task_id = str(uuid.uuid4())
- status = "pending" if requires_approval else "working"
- with _conn() as conn:
- conn.execute(
- "INSERT INTO agent_tasks(id,agent_id,task_type,status,input_data,requires_approval) VALUES(?,?,?,?,?,?)",
- (task_id, agent_id, task_type, status, json.dumps(input_data), int(requires_approval)),
- )
- return task_id
-
-def update_task_status(task_id: str, status: str, result_data: dict = None) -> None:
- with _conn() as conn:
- if result_data is not None:
- conn.execute(
- "UPDATE agent_tasks SET status=?, result_data=?, completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?",
- (status, json.dumps(result_data), task_id),
- )
- else:
- conn.execute("UPDATE agent_tasks SET status=? WHERE id=?", (status, task_id))
-
-def approve_task(task_id: str, via: str = "web") -> None:
- with _conn() as conn:
- conn.execute(
- "UPDATE agent_tasks SET status='approved', approved_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), approved_via=? WHERE id=?",
- (via, task_id),
- )
-
-def reject_task(task_id: str) -> None:
- with _conn() as conn:
- conn.execute("UPDATE agent_tasks SET status='failed', completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?", (task_id,))
-
-def get_task(task_id: str) -> Optional[Dict[str, Any]]:
- with _conn() as conn:
- r = conn.execute("SELECT * FROM agent_tasks WHERE id=?", (task_id,)).fetchone()
- return _task_to_dict(r) if r else None
-
-def get_agent_tasks(agent_id: str, limit: int = 20) -> List[Dict[str, Any]]:
- with _conn() as conn:
- rows = conn.execute(
- "SELECT * FROM agent_tasks WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
- (agent_id, limit),
- ).fetchall()
- return [_task_to_dict(r) for r in rows]
-
-def get_pending_approvals() -> List[Dict[str, Any]]:
- with _conn() as conn:
- rows = conn.execute(
- "SELECT * FROM agent_tasks WHERE status='pending' AND requires_approval=1 ORDER BY created_at DESC"
- ).fetchall()
- return [_task_to_dict(r) for r in rows]
-
-def _task_to_dict(r) -> Dict[str, Any]:
- return {
- "id": r["id"],
- "agent_id": r["agent_id"],
- "task_type": r["task_type"],
- "status": r["status"],
- "input_data": json.loads(r["input_data"]) if r["input_data"] else {},
- "result_data": json.loads(r["result_data"]) if r["result_data"] else None,
- "requires_approval": bool(r["requires_approval"]),
- "approved_at": r["approved_at"],
- "approved_via": r["approved_via"],
- "created_at": r["created_at"],
- "completed_at": r["completed_at"],
- }
-
-# --- agent_logs ---
-
-def add_log(agent_id: str, message: str, level: str = "info", task_id: str = None) -> None:
- with _conn() as conn:
- conn.execute(
- "INSERT INTO agent_logs(agent_id,task_id,level,message) VALUES(?,?,?,?)",
- (agent_id, task_id, level, message),
- )
-
-def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]:
- with _conn() as conn:
- rows = conn.execute(
- "SELECT * FROM agent_logs WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
- (agent_id, limit),
- ).fetchall()
- return [{"id": r["id"], "agent_id": r["agent_id"], "task_id": r["task_id"],
- "level": r["level"], "message": r["message"], "created_at": r["created_at"]} for r in rows]
-
-# --- telegram_state ---
-
-def save_telegram_callback(callback_id: str, task_id: str, agent_id: str) -> None:
- with _conn() as conn:
- conn.execute(
- "INSERT OR REPLACE INTO telegram_state(callback_id,task_id,agent_id) VALUES(?,?,?)",
- (callback_id, task_id, agent_id),
- )
-
-def get_telegram_callback(callback_id: str) -> Optional[Dict[str, Any]]:
- with _conn() as conn:
- r = conn.execute("SELECT * FROM telegram_state WHERE callback_id=? AND responded=0", (callback_id,)).fetchone()
- if not r:
- return None
- return {"callback_id": r["callback_id"], "task_id": r["task_id"],
- "agent_id": r["agent_id"], "responded": bool(r["responded"])}
-
-def mark_telegram_responded(callback_id: str, action: str) -> None:
- with _conn() as conn:
- conn.execute("UPDATE telegram_state SET responded=1, action=? WHERE callback_id=?", (action, callback_id))
-```
-
-- [ ] **Step 7: Write DB test**
-
-```python
-# agent-office/app/test_db.py
-import os
-import sys
-import tempfile
-
-# Override DB_PATH before importing db
-_tmp = tempfile.mktemp(suffix=".db")
-os.environ["AGENT_OFFICE_DB_PATH"] = _tmp
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
-from app.db import (
- init_db, get_all_agents, get_agent_config, update_agent_config,
- create_task, update_task_status, approve_task, get_task, get_agent_tasks,
- get_pending_approvals, add_log, get_logs,
- save_telegram_callback, get_telegram_callback, mark_telegram_responded,
-)
-
-def test_init_and_seed():
- init_db()
- agents = get_all_agents()
- assert len(agents) == 2
- ids = {a["agent_id"] for a in agents}
- assert ids == {"stock", "music"}
-
-def test_agent_config_update():
- init_db()
- update_agent_config("stock", custom_config={"watch": ["AAPL"]})
- cfg = get_agent_config("stock")
- assert cfg["custom_config"] == {"watch": ["AAPL"]}
-
-def test_task_lifecycle():
- init_db()
- # Create task with approval
- tid = create_task("music", "compose", {"prompt": "test"}, requires_approval=True)
- task = get_task(tid)
- assert task["status"] == "pending"
- assert task["requires_approval"] is True
-
- # Approve
- approve_task(tid, via="telegram")
- task = get_task(tid)
- assert task["status"] == "approved"
- assert task["approved_via"] == "telegram"
-
- # Complete
- update_task_status(tid, "succeeded", {"url": "/media/music/test.mp3"})
- task = get_task(tid)
- assert task["status"] == "succeeded"
- assert task["result_data"]["url"] == "/media/music/test.mp3"
-
-def test_task_no_approval():
- init_db()
- tid = create_task("stock", "news_summary", {"limit": 10})
- task = get_task(tid)
- assert task["status"] == "working"
-
-def test_pending_approvals():
- init_db()
- create_task("music", "compose", {"prompt": "a"}, requires_approval=True)
- create_task("music", "compose", {"prompt": "b"}, requires_approval=True)
- create_task("stock", "news_summary", {})
- pending = get_pending_approvals()
- assert len(pending) == 2
-
-def test_logs():
- init_db()
- add_log("stock", "News fetched", "info", "task-1")
- add_log("stock", "API error", "error")
- logs = get_logs("stock")
- assert len(logs) == 2
- assert logs[0]["level"] == "error" # DESC order
-
-def test_telegram_state():
- init_db()
- save_telegram_callback("cb-1", "task-1", "music")
- cb = get_telegram_callback("cb-1")
- assert cb["task_id"] == "task-1"
- mark_telegram_responded("cb-1", "approve")
- cb = get_telegram_callback("cb-1")
- assert cb is None # responded=1, filtered out
-
-if __name__ == "__main__":
- test_init_and_seed()
- test_agent_config_update()
- test_task_lifecycle()
- test_task_no_approval()
- test_pending_approvals()
- test_logs()
- test_telegram_state()
- print("All DB tests passed!")
- os.unlink(_tmp)
-```
-
-- [ ] **Step 8: Run DB test**
-
-Run: `cd agent-office && python -m app.test_db`
-Expected: "All DB tests passed!"
-
-- [ ] **Step 9: Commit**
-
-```bash
-git add agent-office/
-git commit -m "feat(agent-office): scaffold backend — config, db, models, Dockerfile"
-```
-
----
-
-## Task 2: WebSocket Manager
-
-**Files:**
-- Create: `agent-office/app/websocket_manager.py`
-
-- [ ] **Step 1: Create websocket_manager.py**
-
-```python
-import asyncio
-import json
-from typing import Any, Dict, Set
-from fastapi import WebSocket
-
-class WebSocketManager:
- def __init__(self):
- self._connections: Set[WebSocket] = set()
- self._lock = asyncio.Lock()
-
- async def connect(self, ws: WebSocket) -> None:
- await ws.accept()
- async with self._lock:
- self._connections.add(ws)
-
- async def disconnect(self, ws: WebSocket) -> None:
- async with self._lock:
- self._connections.discard(ws)
-
- async def broadcast(self, message: Dict[str, Any]) -> None:
- payload = json.dumps(message, ensure_ascii=False)
- async with self._lock:
- dead = set()
- for ws in self._connections:
- try:
- await ws.send_text(payload)
- except Exception:
- dead.add(ws)
- self._connections -= dead
-
- async def send_agent_state(self, agent_id: str, state: str, detail: str = "", task_id: str = None) -> None:
- msg = {"type": "agent_state", "agent": agent_id, "state": state, "detail": detail}
- if task_id:
- msg["task_id"] = task_id
- await self.broadcast(msg)
-
- async def send_task_complete(self, agent_id: str, task_id: str, result: dict) -> None:
- await self.broadcast({
- "type": "task_complete", "agent": agent_id,
- "task_id": task_id, "result": result,
- })
-
- async def send_agent_move(self, agent_id: str, target: str) -> None:
- await self.broadcast({"type": "agent_move", "agent": agent_id, "target": target})
-
-ws_manager = WebSocketManager()
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add agent-office/app/websocket_manager.py
-git commit -m "feat(agent-office): WebSocket connection manager with broadcast"
-```
-
----
-
-## Task 3: Service Proxy
-
-**Files:**
-- Create: `agent-office/app/service_proxy.py`
-
-- [ ] **Step 1: Create service_proxy.py**
-
-```python
-import httpx
-from typing import Any, Dict, List, Optional
-
-from .config import STOCK_LAB_URL, MUSIC_LAB_URL
-
-_client = httpx.AsyncClient(timeout=30.0)
-
-# --- Stock Lab ---
-
-async def fetch_stock_news(limit: int = 10, category: str = None) -> List[Dict[str, Any]]:
- params = {"limit": limit}
- if category:
- params["category"] = category
- resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/news", params=params)
- resp.raise_for_status()
- return resp.json()
-
-async def fetch_stock_indices() -> Dict[str, Any]:
- resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/indices")
- resp.raise_for_status()
- return resp.json()
-
-# --- Music Lab ---
-
-async def generate_music(payload: dict) -> Dict[str, Any]:
- resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
- resp.raise_for_status()
- return resp.json()
-
-async def get_music_status(task_id: str) -> Dict[str, Any]:
- resp = await _client.get(f"{MUSIC_LAB_URL}/api/music/status/{task_id}")
- resp.raise_for_status()
- return resp.json()
-
-async def get_music_credits() -> Dict[str, Any]:
- resp = await _client.get(f"{MUSIC_LAB_URL}/api/music/credits")
- resp.raise_for_status()
- return resp.json()
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add agent-office/app/service_proxy.py
-git commit -m "feat(agent-office): service proxy for stock-lab and music-lab APIs"
-```
-
----
-
-## Task 4: BaseAgent FSM
-
-**Files:**
-- Create: `agent-office/app/agents/__init__.py`
-- Create: `agent-office/app/agents/base.py`
-
-- [ ] **Step 1: Create agents/__init__.py**
-
-```python
-from .stock import StockAgent
-from .music import MusicAgent
-
-AGENT_REGISTRY = {}
-
-def init_agents():
- AGENT_REGISTRY["stock"] = StockAgent()
- AGENT_REGISTRY["music"] = MusicAgent()
-
-def get_agent(agent_id: str):
- return AGENT_REGISTRY.get(agent_id)
-
-def get_all_agent_states() -> list:
- return [
- {"agent_id": aid, "state": agent.state, "detail": agent.state_detail}
- for aid, agent in AGENT_REGISTRY.items()
- ]
-```
-
-- [ ] **Step 2: Create agents/base.py**
-
-```python
-import asyncio
-import random
-import time
-from typing import Optional
-
-from ..config import IDLE_BREAK_THRESHOLD, BREAK_DURATION_MIN, BREAK_DURATION_MAX
-from ..db import add_log
-
-VALID_STATES = ("idle", "working", "waiting", "reporting", "break")
-
-class BaseAgent:
- agent_id: str = ""
- display_name: str = ""
- state: str = "idle"
- state_detail: str = ""
- _idle_since: float = 0.0
- _break_until: float = 0.0
- _ws_manager = None
-
- def __init__(self):
- self._idle_since = time.time()
-
- def set_ws_manager(self, manager):
- self._ws_manager = manager
-
- async def transition(self, new_state: str, detail: str = "", task_id: str = None) -> None:
- if new_state not in VALID_STATES:
- return
- old = self.state
- self.state = new_state
- self.state_detail = detail
-
- if new_state == "idle":
- self._idle_since = time.time()
- elif new_state == "break":
- duration = random.randint(BREAK_DURATION_MIN, BREAK_DURATION_MAX)
- self._break_until = time.time() + duration
-
- add_log(self.agent_id, f"State: {old} → {new_state} ({detail})")
-
- if self._ws_manager:
- await self._ws_manager.send_agent_state(self.agent_id, new_state, detail, task_id)
- if new_state == "break":
- await self._ws_manager.send_agent_move(self.agent_id, "break_room")
- elif old == "break" and new_state == "idle":
- await self._ws_manager.send_agent_move(self.agent_id, "desk")
-
- async def check_idle_break(self) -> None:
- now = time.time()
- if self.state == "idle" and (now - self._idle_since) > IDLE_BREAK_THRESHOLD:
- if random.random() < 0.5:
- break_type = random.choice(["커피 타임", "잠깐 산책", "졸고 있음"])
- await self.transition("break", break_type)
- elif self.state == "break" and now > self._break_until:
- await self.transition("idle", "휴식 완료")
-
- async def on_schedule(self) -> None:
- raise NotImplementedError
-
- async def on_command(self, command: str, params: dict) -> dict:
- raise NotImplementedError
-
- async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
- raise NotImplementedError
-
- async def get_status(self) -> dict:
- return {
- "agent_id": self.agent_id,
- "display_name": self.display_name,
- "state": self.state,
- "detail": self.state_detail,
- }
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add agent-office/app/agents/
-git commit -m "feat(agent-office): BaseAgent FSM with idle/break behavior"
-```
-
----
-
-## Task 5: StockAgent
-
-**Files:**
-- Create: `agent-office/app/agents/stock.py`
-
-- [ ] **Step 1: Create stock.py**
-
-```python
-import asyncio
-from typing import Optional
-
-from .base import BaseAgent
-from ..db import create_task, update_task_status, get_agent_config, add_log
-from .. import service_proxy
-
-class StockAgent(BaseAgent):
- agent_id = "stock"
- display_name = "주식 트레이더"
-
- async def on_schedule(self) -> None:
- """매일 08:00 실행 — 뉴스 수집 + 요약 + 텔레그램 전송."""
- if self.state not in ("idle", "break"):
- return
-
- task_id = create_task(self.agent_id, "news_summary", {"limit": 15})
- await self.transition("working", "뉴스 수집 중...", task_id)
-
- try:
- news = await service_proxy.fetch_stock_news(limit=15)
- indices = await service_proxy.fetch_stock_indices()
-
- summary = self._format_news_summary(news, indices)
-
- update_task_status(task_id, "succeeded", {
- "summary": summary,
- "news_count": len(news) if isinstance(news, list) else 0,
- })
-
- await self.transition("reporting", "뉴스 요약 전송 중...")
-
- # Telegram send will be wired in Task 6
- from ..telegram_bot import send_stock_summary
- await send_stock_summary(summary)
-
- await self.transition("idle", "뉴스 요약 완료")
-
- except Exception as e:
- add_log(self.agent_id, f"News summary failed: {e}", "error", task_id)
- update_task_status(task_id, "failed", {"error": str(e)})
- await self.transition("idle", f"오류: {e}")
-
- async def on_command(self, command: str, params: dict) -> dict:
- if command == "fetch_news":
- await self.on_schedule()
- return {"ok": True, "message": "뉴스 수집 시작"}
-
- if command == "add_alert":
- config = get_agent_config(self.agent_id)
- alerts = config["custom_config"].get("alerts", [])
- alerts.append({
- "symbol": params["symbol"],
- "name": params.get("name", params["symbol"]),
- "target_price": params["target_price"],
- "direction": params.get("direction", "above"),
- })
- from ..db import update_agent_config
- update_agent_config(self.agent_id, custom_config={**config["custom_config"], "alerts": alerts})
- return {"ok": True, "message": f"알람 추가: {params['symbol']}"}
-
- if command == "list_alerts":
- config = get_agent_config(self.agent_id)
- alerts = config["custom_config"].get("alerts", [])
- return {"ok": True, "alerts": alerts}
-
- return {"ok": False, "message": f"Unknown command: {command}"}
-
- async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
- pass # Stock agent has no approval-required tasks
-
- def _format_news_summary(self, news, indices) -> str:
- lines = ["📈 [주식 에이전트] 아침 뉴스 요약", "━" * 20]
-
- if isinstance(news, list):
- for item in news[:10]:
- title = item.get("title", "")
- if title:
- lines.append(f"• {title}")
- elif isinstance(news, dict) and "articles" in news:
- for item in news["articles"][:10]:
- title = item.get("title", "")
- if title:
- lines.append(f"• {title}")
-
- if indices:
- lines.append("")
- lines.append("📊 주요 지수")
- if isinstance(indices, dict):
- for key, val in indices.items():
- if isinstance(val, dict):
- name = val.get("name", key)
- price = val.get("price", "")
- change = val.get("change", "")
- lines.append(f"{name}: {price} ({change})")
-
- return "\n".join(lines)
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add agent-office/app/agents/stock.py
-git commit -m "feat(agent-office): StockAgent — news summary, price alerts"
-```
-
----
-
-## Task 6: Telegram Bot
-
-**Files:**
-- Create: `agent-office/app/telegram_bot.py`
-
-- [ ] **Step 1: Create telegram_bot.py**
-
-```python
-import json
-import uuid
-import httpx
-from typing import Optional
-
-from .config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_WEBHOOK_URL
-from .db import save_telegram_callback, get_telegram_callback, mark_telegram_responded
-
-_BASE = "https://api.telegram.org/bot"
-
-def _enabled() -> bool:
- return bool(TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID)
-
-async def _api(method: str, payload: dict) -> dict:
- if not _enabled():
- return {"ok": False, "description": "Telegram not configured"}
- async with httpx.AsyncClient(timeout=10.0) as client:
- resp = await client.post(f"{_BASE}{TELEGRAM_BOT_TOKEN}/{method}", json=payload)
- return resp.json()
-
-async def send_message(text: str, reply_markup: dict = None) -> dict:
- payload = {
- "chat_id": TELEGRAM_CHAT_ID,
- "text": text,
- "parse_mode": "HTML",
- }
- if reply_markup:
- payload["reply_markup"] = reply_markup
- return await _api("sendMessage", payload)
-
-async def send_stock_summary(summary: str) -> dict:
- return await send_message(summary)
-
-async def send_approval_request(agent_id: str, task_id: str, title: str, detail: str) -> dict:
- approve_id = f"approve_{uuid.uuid4().hex[:8]}"
- reject_id = f"reject_{uuid.uuid4().hex[:8]}"
-
- save_telegram_callback(approve_id, task_id, agent_id)
- save_telegram_callback(reject_id, task_id, agent_id)
-
- text = f"{title}\n{'━' * 20}\n{detail}"
- reply_markup = {
- "inline_keyboard": [[
- {"text": "✅ 승인", "callback_data": approve_id},
- {"text": "❌ 거절", "callback_data": reject_id},
- ]]
- }
- return await send_message(text, reply_markup)
-
-async def send_task_result(agent_id: str, title: str, result: str) -> dict:
- text = f"{title}\n{'━' * 20}\n{result}"
- return await send_message(text)
-
-async def handle_webhook(data: dict) -> Optional[dict]:
- """Process incoming Telegram webhook update. Returns action info or None."""
- callback_query = data.get("callback_query")
- if not callback_query:
- return None
-
- callback_id = callback_query.get("data", "")
- cb = get_telegram_callback(callback_id)
- if not cb:
- return None
-
- action = "approve" if callback_id.startswith("approve_") else "reject"
- mark_telegram_responded(callback_id, action)
-
- # Answer callback query to remove loading state
- await _api("answerCallbackQuery", {
- "callback_query_id": callback_query["id"],
- "text": "승인됨 ✅" if action == "approve" else "거절됨 ❌",
- })
-
- return {
- "task_id": cb["task_id"],
- "agent_id": cb["agent_id"],
- "action": action,
- "approved": action == "approve",
- }
-
-async def setup_webhook() -> dict:
- if not _enabled() or not TELEGRAM_WEBHOOK_URL:
- return {"ok": False, "description": "Webhook URL not configured"}
- return await _api("setWebhook", {"url": TELEGRAM_WEBHOOK_URL})
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add agent-office/app/telegram_bot.py
-git commit -m "feat(agent-office): Telegram bot — send messages, approval requests, webhook handler"
-```
-
----
-
-## Task 7: MusicAgent
-
-**Files:**
-- Create: `agent-office/app/agents/music.py`
-
-- [ ] **Step 1: Create music.py**
-
-```python
-import asyncio
-from .base import BaseAgent
-from ..db import create_task, update_task_status, approve_task, reject_task, add_log
-from .. import service_proxy
-from .. import telegram_bot
-
-class MusicAgent(BaseAgent):
- agent_id = "music"
- display_name = "음악 프로듀서"
-
- async def on_schedule(self) -> None:
- pass # Music agent is command-driven, not scheduled
-
- async def on_command(self, command: str, params: dict) -> dict:
- if command == "compose":
- prompt = params.get("prompt", "")
- style = params.get("style", "")
- model = params.get("model", "V4")
- instrumental = params.get("instrumental", False)
-
- if not prompt:
- return {"ok": False, "message": "프롬프트를 입력해주세요"}
-
- task_id = create_task(self.agent_id, "compose", {
- "prompt": prompt, "style": style,
- "model": model, "instrumental": instrumental,
- }, requires_approval=True)
-
- await self.transition("waiting", "프롬프트 승인 대기", task_id)
-
- detail = f"프롬프트: {prompt}"
- if style:
- detail += f"\n스타일: {style}"
- detail += f"\n모델: {model}"
-
- await telegram_bot.send_approval_request(
- self.agent_id, task_id,
- "🎵 [음악 에이전트] 작곡 요청", detail,
- )
-
- return {"ok": True, "task_id": task_id, "message": "승인 대기 중"}
-
- if command == "credits":
- credits = await service_proxy.get_music_credits()
- return {"ok": True, "credits": credits}
-
- return {"ok": False, "message": f"Unknown command: {command}"}
-
- async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
- if not approved:
- reject_task(task_id)
- await self.transition("idle", "작곡 거절됨")
- await telegram_bot.send_task_result(
- self.agent_id, "🎵 [음악 에이전트] 작곡 취소",
- "사용자가 거절했습니다.",
- )
- return
-
- from ..db import get_task
- task = get_task(task_id)
- if not task:
- return
-
- approve_task(task_id, via="telegram")
- await self.transition("working", "작곡 중...", task_id)
-
- try:
- input_data = task["input_data"]
- payload = {
- "provider": "suno",
- "model": input_data.get("model", "V4"),
- "prompt": input_data.get("prompt", ""),
- "style": input_data.get("style", ""),
- "instrumental": input_data.get("instrumental", False),
- "custom_mode": True,
- }
-
- result = await service_proxy.generate_music(payload)
- music_task_id = result.get("task_id")
-
- if not music_task_id:
- raise Exception("music-lab did not return task_id")
-
- # Poll for completion
- for _ in range(60): # max 5 min (60 * 5s)
- await asyncio.sleep(5)
- status = await service_proxy.get_music_status(music_task_id)
- progress = status.get("progress", 0)
- state = status.get("status", "")
-
- if state == "succeeded":
- tracks = status.get("tracks", [])
- update_task_status(task_id, "succeeded", {
- "music_task_id": music_task_id,
- "tracks": tracks,
- })
- await self.transition("reporting", "작곡 완료!")
-
- track_info = ""
- for t in tracks:
- title = t.get("title", "Untitled")
- url = t.get("audio_url", "")
- track_info += f"🎶 {title}\n{url}\n"
-
- await telegram_bot.send_task_result(
- self.agent_id, "🎵 [음악 에이전트] 작곡 완료",
- track_info or "트랙 생성 완료",
- )
- await self.transition("idle", "작곡 완료")
- return
-
- if state == "failed":
- raise Exception(status.get("message", "Generation failed"))
-
- raise Exception("Timeout: 5분 초과")
-
- except Exception as e:
- add_log(self.agent_id, f"Compose failed: {e}", "error", task_id)
- update_task_status(task_id, "failed", {"error": str(e)})
- await self.transition("idle", f"오류: {e}")
- await telegram_bot.send_task_result(
- self.agent_id, "🎵 [음악 에이전트] 작곡 실패",
- f"오류: {e}",
- )
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add agent-office/app/agents/music.py
-git commit -m "feat(agent-office): MusicAgent — compose with approval, polling, telegram notifications"
-```
-
----
-
-## Task 8: Scheduler
-
-**Files:**
-- Create: `agent-office/app/scheduler.py`
-
-- [ ] **Step 1: Create scheduler.py**
-
-```python
-import asyncio
-from apscheduler.schedulers.asyncio import AsyncIOScheduler
-
-from .agents import AGENT_REGISTRY
-
-scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
-
-def _run_async(coro_func):
- """Wrap async agent method for APScheduler."""
- def wrapper():
- loop = asyncio.get_event_loop()
- for agent in AGENT_REGISTRY.values():
- if hasattr(agent, coro_func):
- loop.create_task(getattr(agent, coro_func)())
- return wrapper
-
-async def _check_idle_breaks():
- for agent in AGENT_REGISTRY.values():
- await agent.check_idle_break()
-
-async def _run_stock_schedule():
- agent = AGENT_REGISTRY.get("stock")
- if agent:
- await agent.on_schedule()
-
-def init_scheduler():
- # Stock agent: daily news at 08:00
- scheduler.add_job(_run_stock_schedule, "cron", hour=8, minute=0, id="stock_news")
-
- # Idle break check: every 60 seconds
- scheduler.add_job(_check_idle_breaks, "interval", seconds=60, id="idle_check")
-
- scheduler.start()
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add agent-office/app/scheduler.py
-git commit -m "feat(agent-office): APScheduler — stock news cron, idle break checker"
-```
-
----
-
-## Task 9: FastAPI Main — REST + WebSocket + Lifespan
-
-**Files:**
-- Create: `agent-office/app/main.py`
-
-- [ ] **Step 1: Create main.py**
-
-```python
-import os
-import json
-from fastapi import FastAPI, WebSocket, WebSocketDisconnect
-from fastapi.middleware.cors import CORSMiddleware
-
-from .config import CORS_ALLOW_ORIGINS
-from .db import init_db, get_all_agents, get_agent_config, update_agent_config, get_agent_tasks, get_pending_approvals, get_task, get_logs
-from .models import CommandRequest, ApprovalRequest, AgentConfigUpdate
-from .websocket_manager import ws_manager
-from .agents import init_agents, get_agent, get_all_agent_states, AGENT_REGISTRY
-from .scheduler import init_scheduler
-from . import telegram_bot
-
-app = FastAPI()
-
-_cors_origins = CORS_ALLOW_ORIGINS.split(",")
-app.add_middleware(
- CORSMiddleware,
- allow_origins=[o.strip() for o in _cors_origins],
- allow_credentials=False,
- allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
- allow_headers=["Content-Type"],
-)
-
-@app.on_event("startup")
-async def on_startup():
- init_db()
- os.makedirs("/app/data", exist_ok=True)
- init_agents()
- for agent in AGENT_REGISTRY.values():
- agent.set_ws_manager(ws_manager)
- init_scheduler()
-
-@app.get("/health")
-def health():
- return {"ok": True}
-
-# --- WebSocket ---
-
-@app.websocket("/api/agent-office/ws")
-async def websocket_endpoint(ws: WebSocket):
- await ws_manager.connect(ws)
- # Send initial state
- try:
- await ws.send_text(json.dumps({
- "type": "init",
- "agents": get_all_agent_states(),
- "pending": [t["id"] for t in get_pending_approvals()],
- }, ensure_ascii=False))
- while True:
- data = await ws.receive_text()
- msg = json.loads(data)
- await _handle_ws_message(msg)
- except WebSocketDisconnect:
- pass
- finally:
- await ws_manager.disconnect(ws)
-
-async def _handle_ws_message(msg: dict):
- msg_type = msg.get("type")
- agent_id = msg.get("agent")
- agent = get_agent(agent_id) if agent_id else None
-
- if msg_type == "command" and agent:
- action = msg.get("action", "")
- params = msg.get("params", {})
- result = await agent.on_command(action, params)
- await ws_manager.broadcast({"type": "command_result", "agent": agent_id, "result": result})
-
- elif msg_type == "approval" and agent:
- task_id = msg.get("task_id")
- approved = msg.get("approved", False)
- if task_id:
- await agent.on_approval(task_id, approved)
-
- elif msg_type == "query" and agent:
- status = await agent.get_status()
- await ws_manager.broadcast({"type": "agent_status", "agent": agent_id, "status": status})
-
-# --- REST Endpoints ---
-
-@app.get("/api/agent-office/agents")
-def list_agents():
- return {"agents": get_all_agents()}
-
-@app.get("/api/agent-office/agents/{agent_id}")
-def agent_detail(agent_id: str):
- config = get_agent_config(agent_id)
- if not config:
- return {"error": "Agent not found"}, 404
- agent = get_agent(agent_id)
- state_info = {"state": agent.state, "detail": agent.state_detail} if agent else {}
- return {**config, **state_info}
-
-@app.put("/api/agent-office/agents/{agent_id}")
-def update_agent(agent_id: str, body: AgentConfigUpdate):
- update_agent_config(agent_id, enabled=body.enabled,
- schedule_config=body.schedule_config,
- custom_config=body.custom_config)
- return {"ok": True}
-
-@app.get("/api/agent-office/agents/{agent_id}/tasks")
-def agent_tasks(agent_id: str, limit: int = 20):
- return {"tasks": get_agent_tasks(agent_id, limit)}
-
-@app.get("/api/agent-office/agents/{agent_id}/logs")
-def agent_logs(agent_id: str, limit: int = 50):
- return {"logs": get_logs(agent_id, limit)}
-
-@app.get("/api/agent-office/tasks/pending")
-def pending_tasks():
- return {"tasks": get_pending_approvals()}
-
-@app.get("/api/agent-office/tasks/{task_id}")
-def task_detail(task_id: str):
- task = get_task(task_id)
- if not task:
- return {"error": "Task not found"}, 404
- return task
-
-@app.post("/api/agent-office/command")
-async def send_command(body: CommandRequest):
- agent = get_agent(body.agent)
- if not agent:
- return {"error": f"Agent '{body.agent}' not found"}
- result = await agent.on_command(body.action, body.params or {})
- return result
-
-@app.post("/api/agent-office/approve")
-async def approve(body: ApprovalRequest):
- agent = get_agent(body.agent)
- if not agent:
- return {"error": f"Agent '{body.agent}' not found"}
- await agent.on_approval(body.task_id, body.approved, body.feedback or "")
- return {"ok": True}
-
-# --- Telegram Webhook ---
-
-@app.post("/api/agent-office/telegram/webhook")
-async def telegram_webhook(data: dict):
- result = await telegram_bot.handle_webhook(data)
- if result:
- agent = get_agent(result["agent_id"])
- if agent:
- await agent.on_approval(result["task_id"], result["approved"])
- return {"ok": True}
-
-@app.get("/api/agent-office/states")
-def all_states():
- return {"agents": get_all_agent_states()}
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add agent-office/app/main.py
-git commit -m "feat(agent-office): FastAPI main — REST routes, WebSocket, telegram webhook, lifespan"
-```
-
----
-
-## Task 10: Infrastructure — Docker Compose + Nginx
-
-**Files:**
-- Modify: `docker-compose.yml`
-- Modify: `nginx/default.conf`
-
-- [ ] **Step 1: Add agent-office to docker-compose.yml**
-
-Add the following service block after the existing services (e.g., after `realestate-lab`):
-
-```yaml
- agent-office:
- build:
- context: ./agent-office
- container_name: agent-office
- restart: unless-stopped
- ports:
- - "18900:8000"
- environment:
- - TZ=${TZ:-Asia/Seoul}
- - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
- - STOCK_LAB_URL=http://stock-lab:8000
- - MUSIC_LAB_URL=http://music-lab:8000
- - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
- - TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-}
- volumes:
- - ${RUNTIME_PATH:-.}/data/agent-office:/app/data
- depends_on:
- - stock-lab
- - music-lab
- healthcheck:
- test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
- interval: 30s
- timeout: 5s
- retries: 3
-```
-
-- [ ] **Step 2: Add Nginx location block for agent-office**
-
-Add before the catch-all `/api/` block in `nginx/default.conf`:
-
-```nginx
- # agent-office API + WebSocket
- location /api/agent-office/ {
- resolver 127.0.0.11 valid=10s;
- set $agent_office_backend agent-office:8000;
-
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade";
- proxy_read_timeout 86400s;
- proxy_pass http://$agent_office_backend$request_uri;
- }
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add docker-compose.yml nginx/default.conf
-git commit -m "infra(agent-office): Docker Compose service + Nginx WebSocket proxy"
-```
-
----
-
-## Task 11: Frontend — API Helpers + Route + Lab Entry
-
-**Files:**
-- Modify: `web-ui/src/api.js` — add agent-office helpers
-- Modify: `web-ui/src/routes.jsx` — add route
-- Modify: `web-ui/src/pages/effect-lab/EffectLab.jsx` — add LAB_ITEMS entry
-
-- [ ] **Step 1: Add API helpers to api.js**
-
-Append to the end of `web-ui/src/api.js`:
-
-```javascript
-// ── Agent Office ──────────────────────────────────
-export const getAgents = () => apiGet('/api/agent-office/agents');
-export const getAgentDetail = (id) => apiGet(`/api/agent-office/agents/${id}`);
-export const updateAgentConfig = (id, body) => apiPut(`/api/agent-office/agents/${id}`, body);
-export const getAgentTasks = (id, limit=20) => apiGet(`/api/agent-office/agents/${id}/tasks?limit=${limit}`);
-export const getAgentLogs = (id, limit=50) => apiGet(`/api/agent-office/agents/${id}/logs?limit=${limit}`);
-export const getPendingTasks = () => apiGet('/api/agent-office/tasks/pending');
-export const sendAgentCommand = (agent, action, params={}) => apiPost('/api/agent-office/command', { agent, action, params });
-export const approveAgentTask = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback });
-export const getAgentStates = () => apiGet('/api/agent-office/states');
-```
-
-- [ ] **Step 2: Add route to routes.jsx**
-
-Add to the `appRoutes` array:
-
-```javascript
-{ path: 'agent-office', lazy: () => import('./pages/agent-office/AgentOffice') },
-```
-
-And add to `navLinks` array:
-
-```javascript
-{
- id: 'agent-office',
- label: 'Agent Office',
- path: '/agent-office',
- subtitle: 'AI LAB',
- description: 'AI 에이전트 사무실',
- icon: 🏢 ,
- accent: '#8b5cf6',
-},
-```
-
-- [ ] **Step 3: Add to LAB_ITEMS in EffectLab.jsx**
-
-Add to the `LAB_ITEMS` array:
-
-```javascript
-{
- id: 'agent-office',
- path: '/agent-office',
- title: 'Agent Office',
- category: 'AI · 자동화',
- desc: 'AI 에이전트들이 사무실에서 자동으로 작업하는 가상 오피스',
- tags: ['Canvas 2D', 'WebSocket', 'AI Agent', 'Telegram'],
- accent: '#8b5cf6',
- icon: '🏢',
- status: 'wip',
-},
-```
-
-- [ ] **Step 4: Commit**
-
-```bash
-cd ../web-ui
-git add src/api.js src/routes.jsx src/pages/effect-lab/EffectLab.jsx
-git commit -m "feat(agent-office): API helpers, route, Lab entry"
-```
-
----
-
-## Task 12: Frontend Canvas — SpriteSheet + TileMap
-
-**Files:**
-- Create: `web-ui/src/pages/agent-office/canvas/SpriteSheet.js`
-- Create: `web-ui/src/pages/agent-office/canvas/TileMap.js`
-- Create: `web-ui/src/pages/agent-office/assets/office-map.json`
-
-- [ ] **Step 1: Create office-map.json**
-
-```json
-{
- "tileSize": 32,
- "cols": 20,
- "rows": 14,
- "layers": {
- "floor": [
- [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
- [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
- [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
- [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
- [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
- [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
- [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
- [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
- [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
- [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
- [2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
- [2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
- [2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
- [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
- ]
- },
- "furniture": [
- {"type": "desk", "x": 2, "y": 1, "label": "Stock"},
- {"type": "desk", "x": 7, "y": 1, "label": "Music"},
- {"type": "desk", "x": 12, "y": 1, "label": "Claude"},
- {"type": "desk", "x": 17, "y": 1, "label": "(빈)"},
- {"type": "table", "x": 8, "y": 6, "w": 4, "h": 2, "label": "회의 테이블"},
- {"type": "sofa", "x": 1, "y": 10, "label": "휴게실"},
- {"type": "coffee", "x": 3, "y": 10, "label": "☕"},
- {"type": "desk", "x": 14, "y": 10, "w": 5, "h": 2, "label": "CEO"}
- ],
- "waypoints": {
- "stock_desk": {"x": 2, "y": 2},
- "music_desk": {"x": 7, "y": 2},
- "claude_desk": {"x": 12, "y": 2},
- "meeting_table": {"x": 9, "y": 7},
- "break_room": {"x": 2, "y": 11},
- "ceo_desk": {"x": 16, "y": 11}
- },
- "colors": {
- "1": "#3a3a50",
- "2": "#4a3a2a"
- }
-}
-```
-
-- [ ] **Step 2: Create SpriteSheet.js**
-
-```javascript
-// web-ui/src/pages/agent-office/canvas/SpriteSheet.js
-
-const PIXEL_CHARS = {
- stock: {
- body: '#4488cc',
- accent: '#cc4444', // necktie
- label: '주식',
- hair: '#332222',
- },
- music: {
- body: '#44aa88',
- accent: '#ffaa00', // headphones
- label: '음악',
- hair: '#443322',
- },
- claude: {
- body: '#8855cc',
- accent: '#cc88ff',
- label: 'Claude',
- hair: '#554466',
- },
-};
-
-const ANIM_FRAMES = {
- idle: { frames: 2, speed: 800 }, // ms per frame
- working: { frames: 4, speed: 200 },
- waiting: { frames: 2, speed: 400 },
- break: { frames: 2, speed: 1000 },
- walk: { frames: 4, speed: 150 },
-};
-
-export function drawAgent(ctx, agentId, x, y, state, frameIndex, scale = 2) {
- const char = PIXEL_CHARS[agentId] || PIXEL_CHARS.claude;
- const s = scale;
- const anim = ANIM_FRAMES[state] || ANIM_FRAMES.idle;
- const frame = frameIndex % anim.frames;
-
- ctx.save();
- ctx.translate(x, y);
-
- // Shadow
- ctx.fillStyle = 'rgba(0,0,0,0.2)';
- ctx.fillRect(-4 * s, 14 * s, 8 * s, 2 * s);
-
- // Body
- ctx.fillStyle = char.body;
- ctx.fillRect(-3 * s, 2 * s, 6 * s, 8 * s);
-
- // Head
- ctx.fillStyle = '#ffcc99';
- ctx.fillRect(-3 * s, -4 * s, 6 * s, 6 * s);
-
- // Hair
- ctx.fillStyle = char.hair;
- ctx.fillRect(-3 * s, -5 * s, 6 * s, 2 * s);
-
- // Eyes
- ctx.fillStyle = '#222';
- const eyeOffset = state === 'break' && frame === 1 ? 0 : 1;
- ctx.fillRect(-2 * s, -1 * s, 1 * s, eyeOffset * s);
- ctx.fillRect(1 * s, -1 * s, 1 * s, eyeOffset * s);
-
- // Legs
- ctx.fillStyle = '#335';
- const legSpread = state === 'walk' ? (frame % 2 === 0 ? 1 : -1) : 0;
- ctx.fillRect(-2 * s, 10 * s, 2 * s, 4 * s);
- ctx.fillRect(0 + legSpread * s, 10 * s, 2 * s, 4 * s);
-
- // Accent (agent-specific)
- ctx.fillStyle = char.accent;
- if (agentId === 'stock') {
- // Tie
- ctx.fillRect(0, 2 * s, 1 * s, 5 * s);
- } else if (agentId === 'music') {
- // Headphones
- ctx.fillRect(-4 * s, -4 * s, 1 * s, 4 * s);
- ctx.fillRect(3 * s, -4 * s, 1 * s, 4 * s);
- ctx.fillRect(-4 * s, -5 * s, 8 * s, 1 * s);
- } else if (agentId === 'claude') {
- // AI glow
- ctx.globalAlpha = 0.3 + 0.2 * Math.sin(Date.now() / 500);
- ctx.fillRect(-4 * s, -6 * s, 8 * s, 1 * s);
- ctx.globalAlpha = 1;
- }
-
- // Working animation: typing hands
- if (state === 'working') {
- ctx.fillStyle = '#ffcc99';
- const handY = 6 * s + (frame % 2) * s;
- ctx.fillRect(-4 * s, handY, 1 * s, 2 * s);
- ctx.fillRect(3 * s, handY, 1 * s, 2 * s);
- }
-
- // Waiting wobble
- if (state === 'waiting') {
- const wobble = Math.sin(Date.now() / 200) * s;
- ctx.translate(wobble, 0);
- }
-
- ctx.restore();
-}
-
-export function getAnimSpeed(state) {
- return (ANIM_FRAMES[state] || ANIM_FRAMES.idle).speed;
-}
-
-export function getCharLabel(agentId) {
- return (PIXEL_CHARS[agentId] || {}).label || agentId;
-}
-```
-
-- [ ] **Step 3: Create TileMap.js**
-
-```javascript
-// web-ui/src/pages/agent-office/canvas/TileMap.js
-
-const WALL_COLOR = '#2a2a3a';
-const DESK_COLOR = '#6b5b3a';
-const DESK_TOP = '#8b7b5a';
-const TABLE_COLOR = '#5a4a2a';
-const SOFA_COLOR = '#884444';
-const MONITOR_COLOR = '#224466';
-const MONITOR_SCREEN = '#44aacc';
-const PLANT_POT = '#664422';
-const PLANT_LEAF = '#44aa44';
-
-export function drawTileMap(ctx, mapData, width, height) {
- const { tileSize, cols, rows, layers, furniture, colors } = mapData;
- const scaleX = width / (cols * tileSize);
- const scaleY = height / (rows * tileSize);
- const scale = Math.min(scaleX, scaleY);
-
- const offsetX = (width - cols * tileSize * scale) / 2;
- const offsetY = (height - rows * tileSize * scale) / 2;
-
- ctx.save();
- ctx.translate(offsetX, offsetY);
- ctx.scale(scale, scale);
-
- // Floor tiles
- const floor = layers.floor;
- for (let r = 0; r < rows; r++) {
- for (let c = 0; c < cols; c++) {
- const tile = floor[r][c];
- ctx.fillStyle = colors[String(tile)] || '#3a3a50';
- ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize);
- // Grid line
- ctx.strokeStyle = 'rgba(255,255,255,0.03)';
- ctx.strokeRect(c * tileSize, r * tileSize, tileSize, tileSize);
- }
- }
-
- // Walls (top edge)
- ctx.fillStyle = WALL_COLOR;
- ctx.fillRect(0, 0, cols * tileSize, 4);
-
- // Furniture
- for (const f of furniture) {
- const fx = f.x * tileSize;
- const fy = f.y * tileSize;
- const fw = (f.w || 2) * tileSize;
- const fh = (f.h || 2) * tileSize;
-
- if (f.type === 'desk') {
- _drawDesk(ctx, fx, fy, fw, fh, f.label);
- } else if (f.type === 'table') {
- _drawTable(ctx, fx, fy, fw, fh);
- } else if (f.type === 'sofa') {
- _drawSofa(ctx, fx, fy);
- } else if (f.type === 'coffee') {
- _drawCoffee(ctx, fx, fy);
- }
- }
-
- ctx.restore();
-
- return { scale, offsetX, offsetY, tileSize };
-}
-
-function _drawDesk(ctx, x, y, w, h, label) {
- // Desk surface
- ctx.fillStyle = DESK_COLOR;
- ctx.fillRect(x, y, w, h);
- ctx.fillStyle = DESK_TOP;
- ctx.fillRect(x + 2, y + 2, w - 4, 6);
-
- // Monitor
- const mx = x + w / 2 - 8;
- ctx.fillStyle = MONITOR_COLOR;
- ctx.fillRect(mx, y + 4, 16, 12);
- ctx.fillStyle = MONITOR_SCREEN;
- ctx.fillRect(mx + 2, y + 6, 12, 8);
-
- // Label
- if (label) {
- ctx.fillStyle = 'rgba(255,255,255,0.6)';
- ctx.font = '8px monospace';
- ctx.textAlign = 'center';
- ctx.fillText(label, x + w / 2, y + h + 12);
- }
-}
-
-function _drawTable(ctx, x, y, w, h) {
- ctx.fillStyle = TABLE_COLOR;
- ctx.fillRect(x, y, w, h);
- ctx.fillStyle = '#7a6a4a';
- ctx.fillRect(x + 4, y + 4, w - 8, h - 8);
-}
-
-function _drawSofa(ctx, x, y) {
- ctx.fillStyle = SOFA_COLOR;
- ctx.fillRect(x, y, 48, 32);
- ctx.fillStyle = '#aa5555';
- ctx.fillRect(x + 4, y + 4, 40, 24);
-}
-
-function _drawCoffee(ctx, x, y) {
- ctx.fillStyle = PLANT_POT;
- ctx.fillRect(x + 8, y + 8, 16, 20);
- ctx.fillStyle = '#886644';
- ctx.fillRect(x + 6, y + 6, 20, 4);
-}
-
-export function worldToTile(mapData, renderInfo, canvasX, canvasY) {
- const { scale, offsetX, offsetY, tileSize } = renderInfo;
- const wx = (canvasX - offsetX) / scale;
- const wy = (canvasY - offsetY) / scale;
- return {
- col: Math.floor(wx / tileSize),
- row: Math.floor(wy / tileSize),
- worldX: wx,
- worldY: wy,
- };
-}
-
-export function tileToCanvas(mapData, renderInfo, col, row) {
- const { scale, offsetX, offsetY, tileSize } = renderInfo;
- return {
- x: offsetX + col * tileSize * scale + (tileSize * scale) / 2,
- y: offsetY + row * tileSize * scale + (tileSize * scale) / 2,
- };
-}
-```
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add src/pages/agent-office/canvas/ src/pages/agent-office/assets/
-git commit -m "feat(agent-office): Canvas engine — SpriteSheet, TileMap, office-map data"
-```
-
----
-
-## Task 13: Frontend Canvas — AgentSprite + OfficeRenderer
-
-**Files:**
-- Create: `web-ui/src/pages/agent-office/canvas/AgentSprite.js`
-- Create: `web-ui/src/pages/agent-office/canvas/OfficeRenderer.js`
-
-- [ ] **Step 1: Create AgentSprite.js**
-
-```javascript
-// web-ui/src/pages/agent-office/canvas/AgentSprite.js
-
-import { drawAgent, getAnimSpeed } from './SpriteSheet';
-
-export class AgentSprite {
- constructor(agentId, waypoints) {
- this.agentId = agentId;
- this.waypoints = waypoints;
- this.state = 'idle';
- this.detail = '';
-
- const deskKey = `${agentId}_desk`;
- const desk = waypoints[deskKey] || { x: 5, y: 3 };
- this.x = desk.x;
- this.y = desk.y;
- this.targetX = desk.x;
- this.targetY = desk.y;
- this.deskPos = { x: desk.x, y: desk.y };
-
- this.frameIndex = 0;
- this._lastFrameTime = 0;
- this._moveSpeed = 0.05; // tiles per frame
- }
-
- setState(newState, detail = '') {
- this.state = newState;
- this.detail = detail;
- this.frameIndex = 0;
- }
-
- moveTo(target) {
- const wp = this.waypoints[target];
- if (wp) {
- this.targetX = wp.x;
- this.targetY = wp.y;
- }
- }
-
- moveToDesk() {
- this.targetX = this.deskPos.x;
- this.targetY = this.deskPos.y;
- }
-
- update(now) {
- // Frame animation
- const speed = getAnimSpeed(this.state);
- if (now - this._lastFrameTime > speed) {
- this.frameIndex++;
- this._lastFrameTime = now;
- }
-
- // Movement
- const dx = this.targetX - this.x;
- const dy = this.targetY - this.y;
- const dist = Math.sqrt(dx * dx + dy * dy);
-
- if (dist > 0.1) {
- const step = Math.min(this._moveSpeed, dist);
- this.x += (dx / dist) * step;
- this.y += (dy / dist) * step;
- } else {
- this.x = this.targetX;
- this.y = this.targetY;
- }
- }
-
- draw(ctx, renderInfo) {
- const { scale, offsetX, offsetY, tileSize } = renderInfo;
- const canvasX = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2;
- const canvasY = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2;
-
- const isMoving = Math.abs(this.targetX - this.x) > 0.1 || Math.abs(this.targetY - this.y) > 0.1;
- const drawState = isMoving ? 'walk' : this.state;
-
- drawAgent(ctx, this.agentId, canvasX, canvasY, drawState, this.frameIndex, scale * 1.5);
- }
-
- hitTest(canvasX, canvasY, renderInfo) {
- const { scale, offsetX, offsetY, tileSize } = renderInfo;
- const cx = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2;
- const cy = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2;
- const hitW = 20 * scale;
- const hitH = 30 * scale;
-
- return canvasX >= cx - hitW && canvasX <= cx + hitW &&
- canvasY >= cy - hitH && canvasY <= cy + hitH;
- }
-}
-```
-
-- [ ] **Step 2: Create OfficeRenderer.js**
-
-```javascript
-// web-ui/src/pages/agent-office/canvas/OfficeRenderer.js
-
-import { drawTileMap } from './TileMap';
-import { AgentSprite } from './AgentSprite';
-import { getCharLabel } from './SpriteSheet';
-
-const STATUS_ICONS = {
- idle: null,
- working: null,
- waiting: '❗',
- reporting: '📋',
- break: '☕',
-};
-
-export class OfficeRenderer {
- constructor(canvas, mapData) {
- this.canvas = canvas;
- this.ctx = canvas.getContext('2d');
- this.mapData = mapData;
- this.renderInfo = null;
- this.agents = {};
- this._animId = null;
- this._onClick = null;
-
- // Initialize agents from map waypoints
- const agentIds = ['stock', 'music', 'claude'];
- for (const id of agentIds) {
- this.agents[id] = new AgentSprite(id, mapData.waypoints);
- }
- }
-
- start() {
- this._loop = this._loop.bind(this);
- this._animId = requestAnimationFrame(this._loop);
- }
-
- stop() {
- if (this._animId) {
- cancelAnimationFrame(this._animId);
- this._animId = null;
- }
- }
-
- resize(width, height) {
- this.canvas.width = width;
- this.canvas.height = height;
- }
-
- setOnClick(handler) {
- this._onClick = handler;
- }
-
- handleClick(canvasX, canvasY) {
- if (!this.renderInfo) return null;
-
- for (const [id, sprite] of Object.entries(this.agents)) {
- if (sprite.hitTest(canvasX, canvasY, this.renderInfo)) {
- if (this._onClick) this._onClick(id);
- return id;
- }
- }
- return null;
- }
-
- updateAgentState(agentId, state, detail) {
- const sprite = this.agents[agentId];
- if (sprite) {
- sprite.setState(state, detail);
- if (state === 'idle' || state === 'working' || state === 'waiting') {
- sprite.moveToDesk();
- }
- }
- }
-
- moveAgent(agentId, target) {
- const sprite = this.agents[agentId];
- if (sprite) {
- sprite.moveTo(target);
- }
- }
-
- _loop(timestamp) {
- const { ctx, canvas, mapData } = this;
-
- // Clear
- ctx.clearRect(0, 0, canvas.width, canvas.height);
-
- // Background
- ctx.fillStyle = '#1a1a2e';
- ctx.fillRect(0, 0, canvas.width, canvas.height);
-
- // Draw tilemap
- this.renderInfo = drawTileMap(ctx, mapData, canvas.width, canvas.height);
-
- // Update and draw agents
- const now = Date.now();
- for (const sprite of Object.values(this.agents)) {
- sprite.update(now);
- sprite.draw(ctx, this.renderInfo);
- }
-
- // Draw overlays (bubbles, icons, labels)
- for (const [id, sprite] of Object.entries(this.agents)) {
- this._drawOverlay(ctx, sprite, id);
- }
-
- this._animId = requestAnimationFrame(this._loop);
- }
-
- _drawOverlay(ctx, sprite, agentId) {
- if (!this.renderInfo) return;
- const { scale, offsetX, offsetY, tileSize } = this.renderInfo;
- const cx = offsetX + sprite.x * tileSize * scale + (tileSize * scale) / 2;
- const cy = offsetY + sprite.y * tileSize * scale - 10 * scale;
-
- // Status icon
- const icon = STATUS_ICONS[sprite.state];
- if (icon) {
- ctx.font = `${14 * scale}px serif`;
- ctx.textAlign = 'center';
- ctx.fillText(icon, cx, cy - 15 * scale);
- }
-
- // Name label
- ctx.fillStyle = 'rgba(255,255,255,0.7)';
- ctx.font = `${8 * scale}px monospace`;
- ctx.textAlign = 'center';
- ctx.fillText(getCharLabel(agentId), cx, cy + 30 * scale + 30);
-
- // Detail bubble (working/waiting)
- if (sprite.detail && (sprite.state === 'working' || sprite.state === 'waiting')) {
- const bubbleY = cy - 25 * scale;
- ctx.fillStyle = 'rgba(0,0,0,0.7)';
- const textW = ctx.measureText(sprite.detail).width;
- ctx.fillRect(cx - textW / 2 - 6, bubbleY - 10, textW + 12, 16);
- ctx.fillStyle = '#fff';
- ctx.font = `${7 * scale}px monospace`;
- ctx.fillText(sprite.detail, cx, bubbleY);
- }
- }
-}
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add src/pages/agent-office/canvas/
-git commit -m "feat(agent-office): AgentSprite movement + OfficeRenderer game loop"
-```
-
----
-
-## Task 14: Frontend Hooks — useAgentManager + useOfficeCanvas
-
-**Files:**
-- Create: `web-ui/src/pages/agent-office/hooks/useAgentManager.js`
-- Create: `web-ui/src/pages/agent-office/hooks/useOfficeCanvas.js`
-
-- [ ] **Step 1: Create useAgentManager.js**
-
-```javascript
-// web-ui/src/pages/agent-office/hooks/useAgentManager.js
-
-import { useState, useEffect, useRef, useCallback } from 'react';
-
-export function useAgentManager() {
- const [agents, setAgents] = useState({});
- const [pendingTasks, setPendingTasks] = useState([]);
- const [connected, setConnected] = useState(false);
- const wsRef = useRef(null);
- const reconnectTimer = useRef(null);
-
- const connect = useCallback(() => {
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- const wsUrl = `${protocol}//${window.location.host}/api/agent-office/ws`;
-
- const ws = new WebSocket(wsUrl);
- wsRef.current = ws;
-
- ws.onopen = () => {
- setConnected(true);
- if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
- };
-
- ws.onclose = () => {
- setConnected(false);
- reconnectTimer.current = setTimeout(connect, 3000);
- };
-
- ws.onerror = () => {
- ws.close();
- };
-
- ws.onmessage = (event) => {
- const msg = JSON.parse(event.data);
-
- switch (msg.type) {
- case 'init':
- const agentMap = {};
- for (const a of msg.agents) {
- agentMap[a.agent_id] = { state: a.state, detail: a.detail };
- }
- setAgents(agentMap);
- setPendingTasks(msg.pending || []);
- break;
-
- case 'agent_state':
- setAgents(prev => ({
- ...prev,
- [msg.agent]: { state: msg.state, detail: msg.detail, taskId: msg.task_id },
- }));
- break;
-
- case 'task_complete':
- setAgents(prev => ({
- ...prev,
- [msg.agent]: { ...prev[msg.agent], lastResult: msg.result },
- }));
- setPendingTasks(prev => prev.filter(id => id !== msg.task_id));
- break;
-
- case 'command_result':
- setAgents(prev => ({
- ...prev,
- [msg.agent]: { ...prev[msg.agent], lastCommand: msg.result },
- }));
- break;
-
- default:
- break;
- }
- };
- }, []);
-
- useEffect(() => {
- connect();
- return () => {
- if (wsRef.current) wsRef.current.close();
- if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
- };
- }, [connect]);
-
- const sendCommand = useCallback((agent, action, params = {}) => {
- if (wsRef.current?.readyState === WebSocket.OPEN) {
- wsRef.current.send(JSON.stringify({ type: 'command', agent, action, params }));
- }
- }, []);
-
- const sendApproval = useCallback((agent, taskId, approved) => {
- if (wsRef.current?.readyState === WebSocket.OPEN) {
- wsRef.current.send(JSON.stringify({ type: 'approval', agent, task_id: taskId, approved }));
- }
- }, []);
-
- return { agents, pendingTasks, connected, sendCommand, sendApproval };
-}
-```
-
-- [ ] **Step 2: Create useOfficeCanvas.js**
-
-```javascript
-// web-ui/src/pages/agent-office/hooks/useOfficeCanvas.js
-
-import { useRef, useEffect, useCallback } from 'react';
-import { OfficeRenderer } from '../canvas/OfficeRenderer';
-import officeMap from '../assets/office-map.json';
-
-export function useOfficeCanvas(containerRef, onAgentClick) {
- const rendererRef = useRef(null);
- const canvasRef = useRef(null);
-
- useEffect(() => {
- if (!containerRef.current) return;
-
- const canvas = document.createElement('canvas');
- canvas.style.display = 'block';
- canvas.style.width = '100%';
- canvas.style.height = '100%';
- canvas.style.imageRendering = 'pixelated';
- containerRef.current.appendChild(canvas);
- canvasRef.current = canvas;
-
- const renderer = new OfficeRenderer(canvas, officeMap);
- rendererRef.current = renderer;
-
- const resize = () => {
- const rect = containerRef.current.getBoundingClientRect();
- renderer.resize(rect.width, rect.height);
- };
-
- resize();
- renderer.start();
-
- renderer.setOnClick((agentId) => {
- if (onAgentClick) onAgentClick(agentId);
- });
-
- const handleClick = (e) => {
- const rect = canvas.getBoundingClientRect();
- const x = e.clientX - rect.left;
- const y = e.clientY - rect.top;
- renderer.handleClick(x, y);
- };
-
- canvas.addEventListener('click', handleClick);
- window.addEventListener('resize', resize);
-
- return () => {
- renderer.stop();
- canvas.removeEventListener('click', handleClick);
- window.removeEventListener('resize', resize);
- if (containerRef.current && canvas.parentNode === containerRef.current) {
- containerRef.current.removeChild(canvas);
- }
- };
- }, [containerRef, onAgentClick]);
-
- const updateAgentState = useCallback((agentId, state, detail) => {
- rendererRef.current?.updateAgentState(agentId, state, detail);
- }, []);
-
- const moveAgent = useCallback((agentId, target) => {
- rendererRef.current?.moveAgent(agentId, target);
- }, []);
-
- return { updateAgentState, moveAgent };
-}
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add src/pages/agent-office/hooks/
-git commit -m "feat(agent-office): useAgentManager WebSocket hook + useOfficeCanvas rendering hook"
-```
-
----
-
-## Task 15: Frontend Components — ChatPanel + TaskHistory
-
-**Files:**
-- Create: `web-ui/src/pages/agent-office/components/ChatPanel.jsx`
-- Create: `web-ui/src/pages/agent-office/components/TaskHistory.jsx`
-
-- [ ] **Step 1: Create ChatPanel.jsx**
-
-```jsx
-// web-ui/src/pages/agent-office/components/ChatPanel.jsx
-import React, { useState } from 'react';
-
-const AGENT_COMMANDS = {
- stock: [
- { action: 'fetch_news', label: '뉴스 수집', icon: '📰' },
- { action: 'list_alerts', label: '알람 목록', icon: '🔔' },
- ],
- music: [
- { action: 'compose', label: '작곡 시작', icon: '🎵', needsInput: true },
- { action: 'credits', label: '크레딧 확인', icon: '💳' },
- ],
- claude: [
- { action: 'instruct', label: '지시하기', icon: '💬', needsInput: true },
- ],
-};
-
-const ChatPanel = ({ agentId, agentState, onCommand, onApproval, onClose }) => {
- const [input, setInput] = useState('');
- const [activeCommand, setActiveCommand] = useState(null);
-
- const commands = AGENT_COMMANDS[agentId] || [];
- const state = agentState || {};
-
- const handleSend = () => {
- if (!input.trim() || !activeCommand) return;
- const params = activeCommand === 'compose'
- ? { prompt: input }
- : { message: input };
- onCommand(agentId, activeCommand, params);
- setInput('');
- setActiveCommand(null);
- };
-
- const handleQuickAction = (cmd) => {
- if (cmd.needsInput) {
- setActiveCommand(cmd.action);
- } else {
- onCommand(agentId, cmd.action, {});
- }
- };
-
- return (
-
-
-
- {agentId === 'stock' ? '주식 트레이더' :
- agentId === 'music' ? '음악 프로듀서' : 'Claude AI'}
-
-
- {state.state || 'idle'}
-
- ×
-
-
- {state.detail && (
-
{state.detail}
- )}
-
- {state.state === 'waiting' && state.taskId && (
-
-
승인 대기 중인 작업이 있습니다
-
- onApproval(agentId, state.taskId, true)}>
- ✅ 승인
-
- onApproval(agentId, state.taskId, false)}>
- ❌ 거절
-
-
-
- )}
-
-
- {commands.map(cmd => (
- handleQuickAction(cmd)}>
- {cmd.icon} {cmd.label}
-
- ))}
-
-
- {activeCommand && (
-
- setInput(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && handleSend()}
- placeholder={activeCommand === 'compose' ? '프롬프트 입력...' : '메시지 입력...'}
- autoFocus
- />
- 전송
-
- )}
-
- {state.lastResult && (
-
-
최근 결과
-
{JSON.stringify(state.lastResult, null, 2)}
-
- )}
-
- );
-};
-
-export default ChatPanel;
-```
-
-- [ ] **Step 2: Create TaskHistory.jsx**
-
-```jsx
-// web-ui/src/pages/agent-office/components/TaskHistory.jsx
-import React, { useState, useEffect } from 'react';
-import { getAgentTasks } from '../../../api';
-
-const STATUS_BADGE = {
- pending: { label: '대기', color: '#fbbf24' },
- approved: { label: '승인됨', color: '#60a5fa' },
- working: { label: '진행중', color: '#818cf8' },
- succeeded: { label: '완료', color: '#34d399' },
- failed: { label: '실패', color: '#f87171' },
-};
-
-const TaskHistory = ({ agentId, onClose }) => {
- const [tasks, setTasks] = useState([]);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- if (!agentId) return;
- setLoading(true);
- getAgentTasks(agentId, 30)
- .then(data => setTasks(data.tasks || []))
- .catch(() => setTasks([]))
- .finally(() => setLoading(false));
- }, [agentId]);
-
- return (
-
-
- 작업 이력 — {agentId}
- ×
-
-
- {loading &&
로딩 중...
}
- {!loading && tasks.length === 0 &&
이력 없음
}
- {tasks.map(task => {
- const badge = STATUS_BADGE[task.status] || STATUS_BADGE.pending;
- return (
-
-
- {task.task_type}
-
- {badge.label}
-
-
-
- {task.created_at?.replace('T', ' ').slice(0, 19)}
-
- {task.result_data && (
-
- 결과 보기
- {JSON.stringify(task.result_data, null, 2)}
-
- )}
-
- );
- })}
-
-
- );
-};
-
-export default TaskHistory;
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add src/pages/agent-office/components/
-git commit -m "feat(agent-office): ChatPanel with commands/approval + TaskHistory panel"
-```
-
----
-
-## Task 16: Frontend — AgentOffice Main Page + CSS
-
-**Files:**
-- Create: `web-ui/src/pages/agent-office/AgentOffice.jsx`
-- Create: `web-ui/src/pages/agent-office/AgentOffice.css`
-
-- [ ] **Step 1: Create AgentOffice.jsx**
-
-```jsx
-// web-ui/src/pages/agent-office/AgentOffice.jsx
-import React, { useRef, useState, useCallback, useEffect } from 'react';
-import { useAgentManager } from './hooks/useAgentManager';
-import { useOfficeCanvas } from './hooks/useOfficeCanvas';
-import ChatPanel from './components/ChatPanel';
-import TaskHistory from './components/TaskHistory';
-import './AgentOffice.css';
-
-export function Component() {
- const canvasContainerRef = useRef(null);
- const [selectedAgent, setSelectedAgent] = useState(null);
- const [showHistory, setShowHistory] = useState(null);
-
- const { agents, pendingTasks, connected, sendCommand, sendApproval } = useAgentManager();
-
- const handleAgentClick = useCallback((agentId) => {
- setSelectedAgent(prev => prev === agentId ? null : agentId);
- }, []);
-
- const { updateAgentState, moveAgent } = useOfficeCanvas(canvasContainerRef, handleAgentClick);
-
- // Sync WebSocket state to canvas
- useEffect(() => {
- for (const [id, info] of Object.entries(agents)) {
- updateAgentState(id, info.state, info.detail);
- }
- }, [agents, updateAgentState]);
-
- return (
-
-
-
Agent Office
-
-
- {connected ? 'Connected' : 'Disconnected'}
-
-
-
-
-
-
- {/* Agent indicator bar */}
-
- {Object.entries(agents).map(([id, info]) => (
- handleAgentClick(id)}
- >
-
- {id}
- {info.state === 'waiting' && ! }
-
- ))}
- {pendingTasks.length > 0 && (
- {pendingTasks.length} pending
- )}
-
-
- {/* Panels */}
- {selectedAgent && (
-
setSelectedAgent(null)}
- />
- )}
-
- {showHistory && (
- setShowHistory(null)}
- />
- )}
-
-
- {/* Bottom toolbar */}
-
- {['stock', 'music', 'claude'].map(id => (
- setShowHistory(prev => prev === id ? null : id)}>
- 📋 {id} 이력
-
- ))}
-
-
- );
-}
-```
-
-- [ ] **Step 2: Create AgentOffice.css**
-
-```css
-/* web-ui/src/pages/agent-office/AgentOffice.css */
-
-.ao-page {
- display: flex;
- flex-direction: column;
- height: 100vh;
- background: #0d0d1a;
- color: #e0e0e0;
- font-family: 'Courier New', monospace;
-}
-
-.ao-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 20px;
- background: #1a1a2e;
- border-bottom: 1px solid #2a2a4a;
-}
-
-.ao-title {
- font-size: 1.4rem;
- color: #8b5cf6;
- margin: 0;
- letter-spacing: 2px;
-}
-
-.ao-status {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 0.85rem;
- color: #888;
-}
-
-.ao-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
-}
-.ao-dot--on { background: #34d399; }
-.ao-dot--off { background: #f87171; }
-
-.ao-workspace {
- flex: 1;
- position: relative;
- overflow: hidden;
-}
-
-.ao-canvas-container {
- width: 100%;
- height: 100%;
-}
-
-/* Agent bar */
-.ao-agent-bar {
- position: absolute;
- top: 12px;
- left: 50%;
- transform: translateX(-50%);
- display: flex;
- gap: 8px;
- padding: 6px 12px;
- background: rgba(0, 0, 0, 0.6);
- border-radius: 20px;
- backdrop-filter: blur(8px);
-}
-
-.ao-agent-chip {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 4px 12px;
- border: 1px solid #333;
- border-radius: 12px;
- background: transparent;
- color: #ccc;
- font-size: 0.8rem;
- cursor: pointer;
- font-family: inherit;
-}
-.ao-agent-chip:hover { border-color: #8b5cf6; }
-.ao-agent-chip--selected { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.15); }
-.ao-agent-chip--alert { animation: ao-pulse 1s infinite; }
-
-@keyframes ao-pulse {
- 0%, 100% { border-color: #fbbf24; }
- 50% { border-color: #f59e0b; box-shadow: 0 0 8px rgba(251, 191, 36, 0.4); }
-}
-
-.ao-chip-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
-}
-.ao-chip-dot--idle { background: #666; }
-.ao-chip-dot--working { background: #818cf8; }
-.ao-chip-dot--waiting { background: #fbbf24; }
-.ao-chip-dot--reporting { background: #34d399; }
-.ao-chip-dot--break { background: #a78bfa; }
-
-.ao-chip-badge {
- background: #f87171;
- color: #fff;
- font-size: 0.65rem;
- padding: 0 4px;
- border-radius: 4px;
- font-weight: bold;
-}
-
-.ao-pending-count {
- color: #fbbf24;
- font-size: 0.75rem;
- align-self: center;
-}
-
-/* Chat Panel */
-.ao-chat-panel {
- position: absolute;
- right: 16px;
- top: 60px;
- width: 340px;
- max-height: calc(100% - 80px);
- background: rgba(26, 26, 46, 0.95);
- border: 1px solid #333;
- border-radius: 12px;
- overflow-y: auto;
- backdrop-filter: blur(12px);
-}
-
-.ao-chat-header {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 12px 16px;
- border-bottom: 1px solid #2a2a4a;
-}
-
-.ao-chat-title {
- flex: 1;
- font-weight: bold;
- color: #e0e0e0;
-}
-
-.ao-chat-state {
- font-size: 0.75rem;
- padding: 2px 8px;
- border-radius: 8px;
- text-transform: uppercase;
-}
-.ao-chat-state--idle { background: #333; }
-.ao-chat-state--working { background: #3730a3; }
-.ao-chat-state--waiting { background: #92400e; }
-.ao-chat-state--break { background: #4c1d95; }
-
-.ao-chat-close {
- background: none;
- border: none;
- color: #888;
- font-size: 1.2rem;
- cursor: pointer;
-}
-.ao-chat-close:hover { color: #fff; }
-
-.ao-chat-detail {
- padding: 8px 16px;
- color: #aaa;
- font-size: 0.85rem;
-}
-
-.ao-chat-approval {
- padding: 12px 16px;
- background: rgba(251, 191, 36, 0.1);
- border-top: 1px solid #2a2a4a;
- border-bottom: 1px solid #2a2a4a;
-}
-.ao-chat-approval p {
- margin: 0 0 8px;
- color: #fbbf24;
- font-size: 0.85rem;
-}
-.ao-chat-approval-btns {
- display: flex;
- gap: 8px;
-}
-
-.ao-btn {
- padding: 6px 16px;
- border: none;
- border-radius: 6px;
- font-size: 0.85rem;
- cursor: pointer;
- font-family: inherit;
-}
-.ao-btn--approve { background: #065f46; color: #34d399; }
-.ao-btn--approve:hover { background: #047857; }
-.ao-btn--reject { background: #7f1d1d; color: #f87171; }
-.ao-btn--reject:hover { background: #991b1b; }
-.ao-btn--send { background: #4c1d95; color: #c4b5fd; }
-.ao-btn--send:hover { background: #5b21b6; }
-
-.ao-chat-commands {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- padding: 12px 16px;
-}
-
-.ao-cmd-btn {
- padding: 6px 12px;
- border: 1px solid #333;
- border-radius: 8px;
- background: transparent;
- color: #ccc;
- font-size: 0.8rem;
- cursor: pointer;
- font-family: inherit;
-}
-.ao-cmd-btn:hover { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.1); }
-
-.ao-chat-input-area {
- display: flex;
- gap: 8px;
- padding: 8px 16px 12px;
-}
-.ao-chat-input {
- flex: 1;
- padding: 8px 12px;
- background: #111;
- border: 1px solid #333;
- border-radius: 6px;
- color: #e0e0e0;
- font-size: 0.85rem;
- font-family: inherit;
-}
-.ao-chat-input:focus { border-color: #8b5cf6; outline: none; }
-
-.ao-chat-result {
- padding: 8px 16px;
- border-top: 1px solid #2a2a4a;
-}
-.ao-chat-result h4 {
- margin: 0 0 8px;
- font-size: 0.8rem;
- color: #888;
-}
-.ao-chat-result pre {
- font-size: 0.75rem;
- color: #aaa;
- overflow-x: auto;
- white-space: pre-wrap;
- margin: 0;
-}
-
-/* Task History */
-.ao-history-panel {
- position: absolute;
- left: 16px;
- top: 60px;
- width: 340px;
- max-height: calc(100% - 80px);
- background: rgba(26, 26, 46, 0.95);
- border: 1px solid #333;
- border-radius: 12px;
- overflow-y: auto;
- backdrop-filter: blur(12px);
-}
-
-.ao-history-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 16px;
- border-bottom: 1px solid #2a2a4a;
- font-weight: bold;
-}
-
-.ao-history-list { padding: 8px; }
-.ao-history-empty { text-align: center; color: #666; padding: 20px; }
-
-.ao-history-item {
- padding: 10px 12px;
- border-bottom: 1px solid #1a1a2e;
-}
-.ao-history-item:last-child { border-bottom: none; }
-
-.ao-history-item-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-.ao-history-type { font-size: 0.85rem; color: #ccc; }
-.ao-history-badge {
- font-size: 0.7rem;
- padding: 2px 8px;
- border-radius: 4px;
- color: #fff;
-}
-.ao-history-time {
- font-size: 0.75rem;
- color: #666;
- margin-top: 4px;
-}
-.ao-history-detail {
- margin-top: 6px;
- font-size: 0.75rem;
-}
-.ao-history-detail summary {
- cursor: pointer;
- color: #8b5cf6;
-}
-.ao-history-detail pre {
- color: #aaa;
- white-space: pre-wrap;
- margin: 4px 0 0;
-}
-
-/* Toolbar */
-.ao-toolbar {
- display: flex;
- gap: 8px;
- padding: 8px 20px;
- background: #1a1a2e;
- border-top: 1px solid #2a2a4a;
-}
-
-.ao-tool-btn {
- padding: 6px 14px;
- border: 1px solid #333;
- border-radius: 6px;
- background: transparent;
- color: #aaa;
- font-size: 0.8rem;
- cursor: pointer;
- font-family: inherit;
-}
-.ao-tool-btn:hover { border-color: #8b5cf6; color: #e0e0e0; }
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add src/pages/agent-office/
-git commit -m "feat(agent-office): AgentOffice main page with canvas + overlay panels + CSS"
-```
-
----
-
-## Task 17: CLAUDE.md Updates
-
-**Files:**
-- Modify: `web-backend/CLAUDE.md`
-
-- [ ] **Step 1: Add agent-office to CLAUDE.md**
-
-Add to the Docker services table (section 4):
-```
-| `agent-office` | 18900 | AI 에이전트 사무실 — FSM, 텔레그램, 스케줄러 |
-```
-
-Add to the Nginx routing rules (section 5):
-```
-| `/api/agent-office/` | `agent-office:8000` | Agent Office API + WebSocket |
-```
-
-Add a new subsection to section 9 (서비스별 핵심 정보):
-
-```markdown
-### agent-office (agent-office/)
-- AI 에이전트 가상 사무실 서비스 (에이전트 FSM, 텔레그램 양방향, 스케줄러)
-- 기존 서비스 프록시 호출 (stock-lab, music-lab)
-- DB: `/app/data/agent_office.db` (agent_config, agent_tasks, agent_logs, telegram_state)
-- 파일 구조: `main.py`, `config.py`, `db.py`, `models.py`, `websocket_manager.py`, `service_proxy.py`, `telegram_bot.py`, `scheduler.py`, `agents/base.py`, `agents/stock.py`, `agents/music.py`
-
-**환경변수**
-- `TELEGRAM_BOT_TOKEN`: 텔레그램 Bot API 토큰
-- `TELEGRAM_CHAT_ID`: 알림 수신 채팅 ID
-- `TELEGRAM_WEBHOOK_URL`: 텔레그램 Webhook 수신 URL
-- `STOCK_LAB_URL`: stock-lab 내부 URL (기본 `http://stock-lab:8000`)
-- `MUSIC_LAB_URL`: music-lab 내부 URL (기본 `http://music-lab:8000`)
-
-**에이전트 목록 (MVP)**
-- `stock`: 매일 08:00 뉴스 요약 + 주가 알람 (자동)
-- `music`: 프롬프트 기반 작곡 (승인 필요)
-
-**스케줄러 job**
-- 08:00 매일 — StockAgent 뉴스 수집/요약/텔레그램 전송
-- 60초 간격 — 에이전트 idle break 체크
-
-**agent-office API 목록**
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/agent-office/agents` | 에이전트 목록 |
-| GET | `/api/agent-office/agents/{id}` | 에이전트 상세 (설정+상태) |
-| PUT | `/api/agent-office/agents/{id}` | 에이전트 설정 수정 |
-| GET | `/api/agent-office/agents/{id}/tasks` | 에이전트 작업 이력 |
-| GET | `/api/agent-office/agents/{id}/logs` | 에이전트 로그 |
-| GET | `/api/agent-office/tasks/pending` | 승인 대기 작업 목록 |
-| GET | `/api/agent-office/tasks/{id}` | 작업 상세 |
-| POST | `/api/agent-office/command` | 에이전트 직접 지시 |
-| POST | `/api/agent-office/approve` | 작업 승인/거절 |
-| GET | `/api/agent-office/states` | 전체 에이전트 상태 |
-| WS | `/api/agent-office/ws` | WebSocket 실시간 연결 |
-| POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook 수신 |
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add CLAUDE.md
-git commit -m "docs: add agent-office service to CLAUDE.md"
-```
-
----
-
-## Summary
-
-| Task | Component | Estimated Complexity |
-|------|-----------|---------------------|
-| 1 | Backend scaffold (config, db, models) | Standard |
-| 2 | WebSocket manager | Simple |
-| 3 | Service proxy | Simple |
-| 4 | BaseAgent FSM | Standard |
-| 5 | StockAgent | Standard |
-| 6 | Telegram bot | Standard |
-| 7 | MusicAgent | Standard |
-| 8 | Scheduler | Simple |
-| 9 | FastAPI main (REST + WS) | Complex |
-| 10 | Infrastructure (Docker + Nginx) | Standard |
-| 11 | Frontend API + routing + Lab entry | Simple |
-| 12 | Canvas SpriteSheet + TileMap | Complex |
-| 13 | Canvas AgentSprite + OfficeRenderer | Complex |
-| 14 | Frontend hooks (WebSocket + Canvas) | Standard |
-| 15 | Frontend ChatPanel + TaskHistory | Standard |
-| 16 | Frontend AgentOffice page + CSS | Standard |
-| 17 | CLAUDE.md updates | Simple |
diff --git a/docs/superpowers/plans/2026-04-15-lotto-ai-curator.md b/docs/superpowers/plans/2026-04-15-lotto-ai-curator.md
deleted file mode 100644
index d14e07f..0000000
--- a/docs/superpowers/plans/2026-04-15-lotto-ai-curator.md
+++ /dev/null
@@ -1,1853 +0,0 @@
-# Lotto AI 큐레이터 구현 계획
-
-> **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:** 매주 월요일 07:00 AI 큐레이터가 Claude로 5세트 + 내러티브 브리핑을 자동 생성해 로또 구매 의사결정을 단순화한다.
-
-**Architecture:** lotto-backend은 엔진·저장소(후보 API + briefings DB), agent-office은 `lotto` 에이전트로 Claude 호출·검증·저장. 프론트는 3탭(브리핑/분석/구매)으로 재배치하고 토큰·비용을 표시한다.
-
-**Tech Stack:** Python 3.12 · FastAPI · SQLite · APScheduler · Anthropic Claude (`claude-sonnet-4-5`) · React (Vite) · httpx · pydantic.
-
-**Spec reference:** `docs/superpowers/specs/2026-04-15-lotto-ai-curator-design.md`
-
----
-
-## File Map
-
-### lotto-backend (`backend/app/`)
-- Modify: `db.py` — `lotto_briefings` 테이블 + CRUD
-- Create: `curator_helpers.py` — 후보 dedup, 피처 계산, context builder
-- Create: `routers/__init__.py`
-- Create: `routers/curator.py` — `/curator/candidates`, `/curator/context`
-- Create: `routers/briefing.py` — `/briefing/*`, `/curator/usage`
-- Modify: `main.py` — 라우터 마운트
-
-### agent-office (`agent-office/app/`)
-- Modify: `config.py` — `LOTTO_CURATOR_MODEL`, `LOTTO_BACKEND_URL`
-- Modify: `service_proxy.py` — lotto 엔드포인트 래퍼
-- Create: `curator/__init__.py`
-- Create: `curator/schema.py` — pydantic 응답 + 검증
-- Create: `curator/prompt.py` — system prompt 빌더
-- Create: `curator/pipeline.py` — Claude 호출 + 저장
-- Create: `agents/lotto.py` — LottoAgent
-- Modify: `agents/__init__.py` — 등록
-- Modify: `db.py` — seed에 lotto 추가
-- Modify: `scheduler.py` — 월요일 07:00 job
-- Test: `tests/test_curator_schema.py` — 검증 로직 유닛 테스트
-
-### web-ui (`src/pages/lotto/`)
-- Modify: `../api.js` — briefing / usage 헬퍼
-- Create: `hooks/useBriefing.js`
-- Create: `hooks/useCuratorUsage.js`
-- Create: `components/briefing/BriefingHeader.jsx`
-- Create: `components/briefing/BriefingSummary.jsx`
-- Create: `components/briefing/PickSetCard.jsx`
-- Create: `components/briefing/BriefingEmpty.jsx`
-- Create: `components/briefing/CuratorUsageFooter.jsx`
-- Create: `tabs/BriefingTab.jsx`
-- Create: `tabs/AnalysisTab.jsx`
-- Create: `tabs/PurchaseTab.jsx`
-- Modify: `Functions.jsx` — 탭 라우터로 축소
-
-### docs
-- Modify: `web-backend/CLAUDE.md` — API 표 + 환경변수
-- Modify: `web-ui/CLAUDE.md` — 탭 구조 + API 헬퍼
-
----
-
-# Phase 1 — lotto-backend
-
-## Task 1: `lotto_briefings` 테이블 + CRUD
-
-**Files:**
-- Modify: `backend/app/db.py`
-
-- [ ] **Step 1: `init_db()`에 테이블 추가**
-
-`backend/app/db.py`의 `init_db()` 함수에서 `conn.execute` 호출 맨 아래에 추가 (기존 테이블 생성 블록 뒤):
-
-```python
-conn.execute("""
- CREATE TABLE IF NOT EXISTS lotto_briefings (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- draw_no INTEGER UNIQUE NOT NULL,
- picks TEXT NOT NULL,
- narrative TEXT NOT NULL,
- confidence INTEGER NOT NULL,
- model TEXT NOT NULL,
- tokens_input INTEGER NOT NULL DEFAULT 0,
- tokens_output INTEGER NOT NULL DEFAULT 0,
- cache_read INTEGER NOT NULL DEFAULT 0,
- cache_write INTEGER NOT NULL DEFAULT 0,
- latency_ms INTEGER NOT NULL DEFAULT 0,
- source TEXT NOT NULL DEFAULT 'auto',
- generated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
- )
-""")
-conn.execute("CREATE INDEX IF NOT EXISTS idx_briefings_draw ON lotto_briefings(draw_no DESC)")
-```
-
-- [ ] **Step 2: CRUD 함수 추가**
-
-`backend/app/db.py` 파일 맨 아래에 추가:
-
-```python
-# --- Lotto Briefings ---
-
-def save_briefing(data: Dict[str, Any]) -> int:
- with _conn() as conn:
- cur = conn.execute("""
- INSERT INTO lotto_briefings
- (draw_no, picks, narrative, confidence, model,
- tokens_input, tokens_output, cache_read, cache_write,
- latency_ms, source)
- VALUES (?,?,?,?,?,?,?,?,?,?,?)
- ON CONFLICT(draw_no) DO UPDATE SET
- picks=excluded.picks, narrative=excluded.narrative,
- confidence=excluded.confidence, model=excluded.model,
- tokens_input=excluded.tokens_input,
- tokens_output=excluded.tokens_output,
- cache_read=excluded.cache_read,
- cache_write=excluded.cache_write,
- latency_ms=excluded.latency_ms,
- source=excluded.source,
- generated_at=datetime('now','localtime')
- """, (
- data["draw_no"],
- json.dumps(data["picks"], ensure_ascii=False),
- json.dumps(data["narrative"], ensure_ascii=False),
- int(data["confidence"]),
- data["model"],
- int(data.get("tokens_input", 0)),
- int(data.get("tokens_output", 0)),
- int(data.get("cache_read", 0)),
- int(data.get("cache_write", 0)),
- int(data.get("latency_ms", 0)),
- data.get("source", "auto"),
- ))
- return cur.lastrowid
-
-
-def _briefing_row(r) -> Dict[str, Any]:
- return {
- "id": r["id"],
- "draw_no": r["draw_no"],
- "picks": json.loads(r["picks"]),
- "narrative": json.loads(r["narrative"]),
- "confidence": r["confidence"],
- "model": r["model"],
- "tokens_input": r["tokens_input"],
- "tokens_output": r["tokens_output"],
- "cache_read": r["cache_read"],
- "cache_write": r["cache_write"],
- "latency_ms": r["latency_ms"],
- "source": r["source"],
- "generated_at": r["generated_at"],
- }
-
-
-def get_latest_briefing() -> Optional[Dict[str, Any]]:
- with _conn() as conn:
- r = conn.execute("SELECT * FROM lotto_briefings ORDER BY draw_no DESC LIMIT 1").fetchone()
- return _briefing_row(r) if r else None
-
-
-def get_briefing(draw_no: int) -> Optional[Dict[str, Any]]:
- with _conn() as conn:
- r = conn.execute("SELECT * FROM lotto_briefings WHERE draw_no=?", (draw_no,)).fetchone()
- return _briefing_row(r) if r else None
-
-
-def list_briefings(limit: int = 10) -> List[Dict[str, Any]]:
- with _conn() as conn:
- rows = conn.execute(
- "SELECT * FROM lotto_briefings ORDER BY draw_no DESC LIMIT ?",
- (limit,),
- ).fetchall()
- return [_briefing_row(r) for r in rows]
-
-
-def get_curator_usage(days: int = 30) -> Dict[str, Any]:
- with _conn() as conn:
- r = conn.execute("""
- SELECT COUNT(*) AS calls,
- SUM(tokens_input) AS in_tokens,
- SUM(tokens_output) AS out_tokens,
- SUM(cache_read) AS cache_read,
- SUM(cache_write) AS cache_write,
- AVG(latency_ms) AS avg_latency
- FROM lotto_briefings
- WHERE generated_at >= datetime('now', ?, 'localtime')
- """, (f"-{int(days)} days",)).fetchone()
- cr = int(r["cache_read"] or 0)
- cw = int(r["cache_write"] or 0)
- return {
- "days": days,
- "calls": int(r["calls"] or 0),
- "tokens_input": int(r["in_tokens"] or 0),
- "tokens_output": int(r["out_tokens"] or 0),
- "cache_read": cr,
- "cache_write": cw,
- "cache_hit_rate": round(cr / (cr + cw), 3) if (cr + cw) > 0 else 0.0,
- "avg_latency_ms": round(float(r["avg_latency"] or 0), 1),
- }
-```
-
-- [ ] **Step 3: 확인 — import 누락 체크**
-
-`db.py` 파일 상단에 이미 `import json`, `from typing import ..., Optional` 있는지 확인. 없으면 추가.
-
-- [ ] **Step 4: 컨테이너 재시작하여 테이블 생성 확인**
-
-사용자가 NAS에서 `docker compose restart lotto-backend` 후 `docker exec lotto-backend sqlite3 /app/data/lotto.db ".schema lotto_briefings"` 실행하여 스키마 생성 확인.
-
-- [ ] **Step 5: 커밋**
-
-```bash
-cd web-backend
-git add backend/app/db.py
-git commit -m "feat(lotto): lotto_briefings 테이블 + CRUD 함수"
-```
-
----
-
-## Task 2: `curator_helpers.py` — 후보 dedup + 피처 계산
-
-**Files:**
-- Create: `backend/app/curator_helpers.py`
-
-- [ ] **Step 1: 파일 생성**
-
-```python
-"""큐레이터용 후보 가공 — 여러 엔진 결과를 하나로 병합, 중복 제거, 피처 계산."""
-from typing import Dict, List, Any
-from . import db
-from .recommender import recommend_numbers, recommend_with_heatmap
-from .analyzer import get_statistical_report
-
-
-LOW_HIGH_CUT = 22 # 1~22 저구간, 23~45 고구간
-
-
-def compute_features(numbers: List[int], hot: set, cold: set) -> Dict[str, Any]:
- nums = sorted(numbers)
- odd = sum(1 for n in nums if n % 2 == 1)
- low = sum(1 for n in nums if n <= LOW_HIGH_CUT)
- buckets = [0, 0, 0, 0, 0] # 1-10, 11-20, 21-30, 31-40, 41-45
- for n in nums:
- if n <= 10: buckets[0] += 1
- elif n <= 20: buckets[1] += 1
- elif n <= 30: buckets[2] += 1
- elif n <= 40: buckets[3] += 1
- else: buckets[4] += 1
- consecutive = any(nums[i+1] - nums[i] == 1 for i in range(len(nums) - 1))
- return {
- "odd_count": odd,
- "even_count": 6 - odd,
- "low_count": low,
- "high_count": 6 - low,
- "range_distribution": buckets,
- "has_consecutive": consecutive,
- "hot_number_count": len(set(nums) & hot),
- "cold_number_count": len(set(nums) & cold),
- "sum": sum(nums),
- }
-
-
-def _key(numbers: List[int]) -> str:
- return ",".join(str(n) for n in sorted(numbers))
-
-
-def collect_candidates(n: int, hot: set, cold: set) -> List[Dict[str, Any]]:
- """여러 엔진에서 후보를 모으고 중복을 제거. 최대 n세트 반환.
-
- 우선순위: simulation best_picks → meta → heatmap → statistics
- """
- seen = {}
- sources_order = []
-
- # 1. simulation best_picks
- for row in db.get_best_picks(limit=n):
- numbers = row.get("numbers") or []
- if not numbers:
- continue
- k = _key(numbers)
- if k not in seen:
- seen[k] = {"numbers": sorted(numbers), "source": "simulation"}
- sources_order.append(k)
-
- # 2. meta-strategy (smart)
- try:
- from .generator import generate_smart_recommendation
- meta = generate_smart_recommendation(sets=n)
- for s in meta.get("sets", []):
- numbers = s.get("numbers") or []
- k = _key(numbers)
- if k not in seen and numbers:
- seen[k] = {"numbers": sorted(numbers), "source": "meta"}
- sources_order.append(k)
- except Exception:
- pass
-
- # 3. heatmap
- try:
- hm = recommend_with_heatmap(count=n)
- for numbers in hm:
- k = _key(numbers)
- if k not in seen and numbers:
- seen[k] = {"numbers": sorted(numbers), "source": "heatmap"}
- sources_order.append(k)
- except Exception:
- pass
-
- # 4. statistics
- try:
- st = recommend_numbers(count=n)
- for numbers in st:
- k = _key(numbers)
- if k not in seen and numbers:
- seen[k] = {"numbers": sorted(numbers), "source": "statistics"}
- sources_order.append(k)
- except Exception:
- pass
-
- out = []
- for k in sources_order[:n]:
- item = seen[k]
- item["features"] = compute_features(item["numbers"], hot, cold)
- out.append(item)
- return out
-
-
-def build_context(hot_limit: int = 3, cold_limit: int = 3) -> Dict[str, Any]:
- """주간 맥락 패키지."""
- report = get_statistical_report()
- latest = db.get_latest_draw()
- freq = report.get("frequency", {}) # {number: count} 전체 누적
- # 최근 핫: frequency 상위 중 최근 10회에 많이 나온 수 근사로 freq top 사용
- sorted_freq = sorted(freq.items(), key=lambda x: -x[1])
- hot = [int(k) for k, _ in sorted_freq[:hot_limit]]
-
- # cold: 가장 적게 나온 수
- sorted_cold = sorted(freq.items(), key=lambda x: x[1])
- cold = [int(k) for k, _ in sorted_cold[:cold_limit]]
-
- last_summary = ""
- if latest:
- nums = [latest.get(f"drwtNo{i}") for i in range(1, 7)]
- odd = sum(1 for n in nums if n and n % 2 == 1)
- low = sum(1 for n in nums if n and n <= LOW_HIGH_CUT)
- last_summary = f"{latest['drwNo']}회: {', '.join(str(n) for n in nums)} (홀{odd}짝{6-odd}, 저{low}고{6-low})"
-
- # 최근 구매 성과 — purchase_manager의 최근 3회
- my_perf = []
- try:
- from .purchase_manager import get_recent_performance
- my_perf = get_recent_performance(limit=3)
- except Exception:
- my_perf = []
-
- return {
- "hot_numbers": hot,
- "cold_numbers": cold,
- "last_draw_summary": last_summary,
- "my_recent_performance": my_perf,
- }
-```
-
-- [ ] **Step 2: `purchase_manager.py`에 `get_recent_performance` 없으면 경량 스텁 추가**
-
-`backend/app/purchase_manager.py` 파일 하단에 추가 (이미 있으면 스킵):
-
-```python
-def get_recent_performance(limit: int = 3) -> list:
- """최근 N회차 내 구매 성과 요약. 없으면 빈 리스트."""
- from . import db
- purchases = db.get_purchases(days=None) or []
- by_draw: dict = {}
- for p in purchases:
- d = p.get("draw_no")
- if not d:
- continue
- by_draw.setdefault(d, {"draw_no": d, "purchased_sets": 0, "best_match": 0})
- by_draw[d]["purchased_sets"] += int(p.get("sets") or 1)
- by_draw[d]["best_match"] = max(by_draw[d]["best_match"], int(p.get("correct_count") or 0))
- return sorted(by_draw.values(), key=lambda x: -x["draw_no"])[:limit]
-```
-
-- [ ] **Step 3: 컨테이너 재시작 후 수동 검증**
-
-사용자가 컨테이너 재시작 후 `docker exec -it lotto-backend python -c "from app.curator_helpers import collect_candidates, build_context; ctx=build_context(); cs=collect_candidates(5, set(ctx['hot_numbers']), set(ctx['cold_numbers'])); print(ctx); print(cs[0])"` 실행하여 후보 1건 출력 확인.
-
-- [ ] **Step 4: 커밋**
-
-```bash
-git add backend/app/curator_helpers.py backend/app/purchase_manager.py
-git commit -m "feat(lotto): curator_helpers — 후보 병합·피처·맥락"
-```
-
----
-
-## Task 3: `routers/curator.py` — candidates + context 엔드포인트
-
-**Files:**
-- Create: `backend/app/routers/__init__.py`
-- Create: `backend/app/routers/curator.py`
-
-- [ ] **Step 1: 빈 `__init__.py` 생성**
-
-`backend/app/routers/__init__.py` — 빈 파일.
-
-- [ ] **Step 2: 큐레이터 라우터 작성**
-
-```python
-"""큐레이터 입력 엔드포인트 — agent-office에서만 호출."""
-from fastapi import APIRouter
-from ..curator_helpers import collect_candidates, build_context
-from .. import db
-
-router = APIRouter(prefix="/api/lotto/curator")
-
-
-@router.get("/candidates")
-def candidates(n: int = 20):
- ctx = build_context()
- hot = set(ctx["hot_numbers"])
- cold = set(ctx["cold_numbers"])
- latest = db.get_latest_draw()
- draw_no = (latest["drwNo"] + 1) if latest else 0
- items = collect_candidates(n, hot, cold)
- return {"draw_no": draw_no, "candidates": items}
-
-
-@router.get("/context")
-def context():
- latest = db.get_latest_draw()
- draw_no = (latest["drwNo"] + 1) if latest else 0
- return {"draw_no": draw_no, **build_context()}
-```
-
-- [ ] **Step 3: 커밋 (main.py 마운트는 Task 5에서)**
-
-```bash
-git add backend/app/routers/__init__.py backend/app/routers/curator.py
-git commit -m "feat(lotto): curator candidates/context 라우터"
-```
-
----
-
-## Task 4: `routers/briefing.py` — briefing CRUD + 사용량
-
-**Files:**
-- Create: `backend/app/routers/briefing.py`
-
-- [ ] **Step 1: 라우터 작성**
-
-```python
-"""브리핑 저장/조회 + 큐레이터 사용량 엔드포인트."""
-from typing import Any, Dict, List, Optional
-from fastapi import APIRouter, HTTPException
-from pydantic import BaseModel, Field
-from .. import db
-
-router = APIRouter(prefix="/api/lotto")
-
-
-class BriefingRequest(BaseModel):
- draw_no: int
- picks: List[Dict[str, Any]]
- narrative: Dict[str, Any]
- confidence: int = Field(ge=0, le=100)
- model: str
- tokens_input: int = 0
- tokens_output: int = 0
- cache_read: int = 0
- cache_write: int = 0
- latency_ms: int = 0
- source: str = "auto"
-
-
-@router.post("/briefing", status_code=201)
-def save_briefing(body: BriefingRequest):
- bid = db.save_briefing(body.model_dump())
- return {"ok": True, "id": bid}
-
-
-@router.get("/briefing/latest")
-def latest():
- b = db.get_latest_briefing()
- if not b:
- raise HTTPException(404, "no briefing yet")
- return b
-
-
-@router.get("/briefing/{draw_no}")
-def get_one(draw_no: int):
- b = db.get_briefing(draw_no)
- if not b:
- raise HTTPException(404, f"no briefing for draw {draw_no}")
- return b
-
-
-@router.get("/briefing")
-def history(limit: int = 10):
- return {"briefings": db.list_briefings(limit)}
-
-
-@router.get("/curator/usage")
-def usage(days: int = 30):
- return db.get_curator_usage(days)
-```
-
-주의: `FastAPI` 라우팅 순서상 `/briefing/latest`는 `/briefing/{draw_no}`보다 먼저 등록되어야 하는데, 위 코드는 `latest()` 함수 정의가 먼저이므로 FastAPI가 정상 매칭한다. (FastAPI는 선언 순서 기준.)
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add backend/app/routers/briefing.py
-git commit -m "feat(lotto): briefing CRUD + 큐레이터 사용량 라우터"
-```
-
----
-
-## Task 5: `main.py`에 라우터 마운트
-
-**Files:**
-- Modify: `backend/app/main.py`
-
-- [ ] **Step 1: 라우터 import 추가**
-
-`backend/app/main.py` 최상단 import 블록(예: line 1~45)에 추가:
-
-```python
-from .routers import curator as curator_router
-from .routers import briefing as briefing_router
-```
-
-- [ ] **Step 2: `app = FastAPI(...)` 직후에 라우터 등록**
-
-app 인스턴스 생성 직후(CORS 미들웨어 추가 부근):
-
-```python
-app.include_router(curator_router.router)
-app.include_router(briefing_router.router)
-```
-
-- [ ] **Step 3: 컨테이너 재시작 후 수동 검증**
-
-사용자가 NAS에서:
-```
-curl http://localhost:18000/api/lotto/curator/candidates?n=5
-curl http://localhost:18000/api/lotto/curator/context
-curl http://localhost:18000/api/lotto/curator/usage
-```
-각각 200 응답 확인. `/briefing/latest`는 404 (아직 데이터 없음) 정상.
-
-- [ ] **Step 4: 커밋**
-
-```bash
-git add backend/app/main.py
-git commit -m "feat(lotto): curator/briefing 라우터 마운트"
-```
-
----
-
-## Task 6: nginx 프록시 규칙 확인
-
-**Files:**
-- Possibly modify: `nginx/default.conf`
-
-- [ ] **Step 1: 기존 `/api/lotto/` 프록시가 전체 prefix를 커버하는지 확인**
-
-`nginx/default.conf`에서 `location /api/` → `lotto-backend:8000` 이미 있으면 추가 작업 없음 (대부분의 경우 그렇다).
-
-신규 경로(`/api/lotto/curator`, `/api/lotto/briefing`)는 prefix 매칭에 자연히 포함되므로 수정 불필요. 확인만 하고 Task 종료.
-
-- [ ] **Step 2: 커밋 없음 (변경 없으면 스킵)**
-
----
-
-# Phase 2 — agent-office
-
-## Task 7: config에 큐레이터 환경변수 추가
-
-**Files:**
-- Modify: `agent-office/app/config.py`
-
-- [ ] **Step 1: 환경변수 추가**
-
-`agent-office/app/config.py` 하단에 추가:
-
-```python
-# Lotto Curator
-LOTTO_BACKEND_URL = os.getenv("LOTTO_BACKEND_URL", "http://lotto-backend:8000")
-LOTTO_CURATOR_MODEL = os.getenv("LOTTO_CURATOR_MODEL", "claude-sonnet-4-5")
-```
-
-- [ ] **Step 2: `.env.example`에 샘플 추가 (있으면)**
-
-파일 없으면 스킵. 있으면:
-```
-LOTTO_CURATOR_MODEL=claude-sonnet-4-5
-```
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add agent-office/app/config.py
-git commit -m "feat(agent-office): lotto 큐레이터 환경변수"
-```
-
----
-
-## Task 8: `service_proxy.py`에 lotto 메서드 추가
-
-**Files:**
-- Modify: `agent-office/app/service_proxy.py`
-
-- [ ] **Step 1: 메서드 추가**
-
-`service_proxy.py` 파일 하단에 추가:
-
-```python
-# --- lotto-backend ---
-
-async def lotto_candidates(n: int = 20) -> Dict[str, Any]:
- from .config import LOTTO_BACKEND_URL
- resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/curator/candidates", params={"n": n})
- resp.raise_for_status()
- return resp.json()
-
-
-async def lotto_context() -> Dict[str, Any]:
- from .config import LOTTO_BACKEND_URL
- resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/curator/context")
- resp.raise_for_status()
- return resp.json()
-
-
-async def lotto_save_briefing(payload: dict) -> Dict[str, Any]:
- from .config import LOTTO_BACKEND_URL
- resp = await _client.post(f"{LOTTO_BACKEND_URL}/api/lotto/briefing", json=payload)
- resp.raise_for_status()
- return resp.json()
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add agent-office/app/service_proxy.py
-git commit -m "feat(agent-office): service_proxy lotto 메서드"
-```
-
----
-
-## Task 9: `curator/schema.py` — 응답 검증 (TDD)
-
-**Files:**
-- Create: `agent-office/app/curator/__init__.py` (빈 파일)
-- Create: `agent-office/app/curator/schema.py`
-- Create: `agent-office/tests/test_curator_schema.py`
-
-- [ ] **Step 1: 빈 `__init__.py` 생성**
-
-- [ ] **Step 2: 실패하는 테스트 먼저**
-
-`agent-office/tests/test_curator_schema.py`:
-
-```python
-import pytest
-from app.curator.schema import validate_response, CuratorOutput
-
-
-CANDIDATE_NUMBERS = [
- [1, 2, 3, 4, 5, 6],
- [7, 8, 9, 10, 11, 12],
- [13, 14, 15, 16, 17, 18],
- [19, 20, 21, 22, 23, 24],
- [25, 26, 27, 28, 29, 30],
- [31, 32, 33, 34, 35, 36],
-]
-
-
-def _valid_payload():
- return {
- "picks": [
- {"numbers": s, "risk_tag": "안정", "reason": "test"}
- for s in CANDIDATE_NUMBERS[:5]
- ],
- "narrative": {
- "headline": "h", "summary_3lines": ["a", "b", "c"],
- "hot_cold_comment": "hc", "warnings": "",
- },
- "confidence": 80,
- }
-
-
-def test_valid_payload_passes():
- result = validate_response(_valid_payload(), CANDIDATE_NUMBERS)
- assert isinstance(result, CuratorOutput)
- assert len(result.picks) == 5
-
-
-def test_rejects_number_out_of_candidates():
- bad = _valid_payload()
- bad["picks"][0]["numbers"] = [99, 2, 3, 4, 5, 6] # 99 not in range and not in candidates
- with pytest.raises(ValueError, match="not in candidates"):
- validate_response(bad, CANDIDATE_NUMBERS)
-
-
-def test_rejects_wrong_pick_count():
- bad = _valid_payload()
- bad["picks"] = bad["picks"][:3]
- with pytest.raises(ValueError, match="exactly 5"):
- validate_response(bad, CANDIDATE_NUMBERS)
-
-
-def test_rejects_duplicate_numbers_within_set():
- bad = _valid_payload()
- bad["picks"][0]["numbers"] = [1, 1, 2, 3, 4, 5]
- with pytest.raises(ValueError):
- validate_response(bad, CANDIDATE_NUMBERS)
-
-
-def test_rejects_invalid_risk_tag():
- bad = _valid_payload()
- bad["picks"][0]["risk_tag"] = "미친"
- with pytest.raises(ValueError):
- validate_response(bad, CANDIDATE_NUMBERS)
-```
-
-- [ ] **Step 3: 테스트 실패 확인**
-
-```
-cd agent-office
-pytest tests/test_curator_schema.py -v
-```
-Expected: ModuleNotFoundError or ImportError for `app.curator.schema`.
-
-- [ ] **Step 4: 구현 작성**
-
-`agent-office/app/curator/schema.py`:
-
-```python
-from typing import List, Literal
-from pydantic import BaseModel, Field, field_validator
-
-
-class Pick(BaseModel):
- numbers: List[int] = Field(min_length=6, max_length=6)
- risk_tag: Literal["안정", "균형", "공격"]
- reason: str = Field(max_length=80)
-
- @field_validator("numbers")
- @classmethod
- def _check_numbers(cls, v):
- if len(set(v)) != 6:
- raise ValueError("numbers must be 6 unique integers")
- if any(n < 1 or n > 45 for n in v):
- raise ValueError("numbers must be within 1..45")
- return sorted(v)
-
-
-class Narrative(BaseModel):
- headline: str
- summary_3lines: List[str] = Field(min_length=3, max_length=3)
- hot_cold_comment: str = ""
- warnings: str = ""
-
-
-class CuratorOutput(BaseModel):
- picks: List[Pick]
- narrative: Narrative
- confidence: int = Field(ge=0, le=100)
-
-
-def validate_response(data: dict, candidate_numbers: List[List[int]]) -> CuratorOutput:
- out = CuratorOutput.model_validate(data)
- if len(out.picks) != 5:
- raise ValueError("picks must have exactly 5 sets")
- candidate_set = {tuple(sorted(c)) for c in candidate_numbers}
- for p in out.picks:
- if tuple(p.numbers) not in candidate_set:
- raise ValueError(f"pick {p.numbers} not in candidates")
- return out
-```
-
-- [ ] **Step 5: 테스트 통과 확인**
-
-```
-pytest tests/test_curator_schema.py -v
-```
-Expected: 5 passed.
-
-- [ ] **Step 6: 커밋**
-
-```bash
-git add agent-office/app/curator/__init__.py agent-office/app/curator/schema.py agent-office/tests/test_curator_schema.py
-git commit -m "feat(agent-office): 큐레이터 응답 검증 스키마 + 테스트"
-```
-
----
-
-## Task 10: `curator/prompt.py` — 시스템 프롬프트
-
-**Files:**
-- Create: `agent-office/app/curator/prompt.py`
-
-- [ ] **Step 1: 프롬프트 빌더 작성**
-
-```python
-"""큐레이터 system/user 프롬프트. system은 정적이므로 캐시 대상."""
-import json
-
-
-SYSTEM_PROMPT = """당신은 로또 번호 큐레이터입니다. 주어진 후보 20세트 중 5세트를 다음 규칙으로 선별합니다.
-
-선별 규칙:
-- 5세트의 리스크 분포는 안정 2 · 균형 2 · 공격 1 을 권장(유연 ±1).
-- 홀짝 비율, 저/고 구간, 연속번호 포함 여부가 세트끼리 겹치지 않도록 다양성을 확보.
-- hot_number_count=0 이고 cold_number_count=0 인 '중립형' 세트를 최소 1개 포함.
-- 후보에 없는 번호 조합은 절대 사용 금지. numbers 필드는 반드시 candidates 중 하나와 정확히 일치해야 함.
-- 각 세트 reason은 한국어 40자 이내 한 줄. 해당 세트의 features 값과 context 값만 근거로.
-
-narrative 규칙:
-- headline: 한 줄, 이번 주 추첨 전망 요약.
-- summary_3lines: 정확히 3개 항목의 배열.
-- hot_cold_comment: hot/cold 번호에 대한 한 줄 논평.
-- warnings: 특별한 주의사항 없으면 빈 문자열.
-
-출력은 반드시 JSON 하나, 그 외 어떤 텍스트도 금지. 스키마:
-{
- "picks": [
- {"numbers":[int,int,int,int,int,int], "risk_tag":"안정"|"균형"|"공격", "reason": str}
- ],
- "narrative": {
- "headline": str,
- "summary_3lines": [str, str, str],
- "hot_cold_comment": str,
- "warnings": str
- },
- "confidence": int (0~100)
-}
-"""
-
-
-def build_user_message(draw_no: int, candidates: list, context: dict) -> str:
- payload = {
- "draw_no": draw_no,
- "context": context,
- "candidates": candidates,
- }
- return (
- f"이번 회차: {draw_no}\n"
- f"아래 데이터로 5세트를 큐레이션하고 위 스키마로만 응답하세요.\n\n"
- f"```json\n{json.dumps(payload, ensure_ascii=False)}\n```"
- )
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add agent-office/app/curator/prompt.py
-git commit -m "feat(agent-office): 큐레이터 system 프롬프트"
-```
-
----
-
-## Task 11: `curator/pipeline.py` — Claude 호출 + 저장
-
-**Files:**
-- Create: `agent-office/app/curator/pipeline.py`
-
-- [ ] **Step 1: 파일 작성**
-
-```python
-"""큐레이터 파이프라인 — fetch → claude → validate → save."""
-import json
-import time
-from typing import Any, Dict
-
-import httpx
-
-from ..config import ANTHROPIC_API_KEY, LOTTO_CURATOR_MODEL
-from .. import service_proxy
-from .prompt import SYSTEM_PROMPT, build_user_message
-from .schema import validate_response
-
-
-API_URL = "https://api.anthropic.com/v1/messages"
-
-
-class CuratorError(Exception):
- pass
-
-
-async def _call_claude(user_text: str, feedback: str = "") -> tuple[dict, dict]:
- if not ANTHROPIC_API_KEY:
- raise CuratorError("ANTHROPIC_API_KEY missing")
- headers = {
- "x-api-key": ANTHROPIC_API_KEY,
- "anthropic-version": "2023-06-01",
- "anthropic-beta": "prompt-caching-2024-07-31",
- "content-type": "application/json",
- }
- system_blocks = [{
- "type": "text",
- "text": SYSTEM_PROMPT,
- "cache_control": {"type": "ephemeral"},
- }]
- if feedback:
- user_text = f"이전 응답이 다음 이유로 거절됨: {feedback}\n올바른 스키마로 다시 응답.\n\n{user_text}"
- payload = {
- "model": LOTTO_CURATOR_MODEL,
- "max_tokens": 4096,
- "system": system_blocks,
- "messages": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
- }
- started = time.monotonic()
- async with httpx.AsyncClient(timeout=120) as client:
- r = await client.post(API_URL, headers=headers, json=payload)
- r.raise_for_status()
- resp = r.json()
- latency_ms = int((time.monotonic() - started) * 1000)
-
- text = "".join(
- b.get("text", "") for b in resp.get("content", []) if b.get("type") == "text"
- ).strip()
- # ```json … ``` 래핑 제거
- if text.startswith("```"):
- text = text.strip("`")
- if text.startswith("json"):
- text = text[4:]
- text = text.strip()
- parsed = json.loads(text)
-
- usage = resp.get("usage", {}) or {}
- return parsed, {
- "input": int(usage.get("input_tokens", 0) or 0),
- "output": int(usage.get("output_tokens", 0) or 0),
- "cache_read": int(usage.get("cache_read_input_tokens", 0) or 0),
- "cache_write": int(usage.get("cache_creation_input_tokens", 0) or 0),
- "latency_ms": latency_ms,
- }
-
-
-async def curate_weekly(source: str = "auto") -> Dict[str, Any]:
- """후보+맥락 수집 → Claude → 검증 → lotto-backend 저장."""
- cand_resp = await service_proxy.lotto_candidates(n=20)
- draw_no = cand_resp["draw_no"]
- candidates = cand_resp["candidates"]
- context = await service_proxy.lotto_context()
-
- user_text = build_user_message(draw_no, candidates, {
- "hot_numbers": context.get("hot_numbers", []),
- "cold_numbers": context.get("cold_numbers", []),
- "last_draw_summary": context.get("last_draw_summary", ""),
- "my_recent_performance": context.get("my_recent_performance", []),
- })
-
- candidate_numbers = [c["numbers"] for c in candidates]
-
- usage_total = {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0, "latency_ms": 0}
- last_error = None
- validated = None
-
- for attempt in (0, 1): # 최대 2회
- try:
- raw, usage = await _call_claude(user_text, feedback=last_error or "")
- for k in usage_total:
- usage_total[k] += usage[k]
- validated = validate_response(raw, candidate_numbers)
- break
- except Exception as e:
- last_error = f"{type(e).__name__}: {e}"
-
- if validated is None:
- raise CuratorError(f"schema validation failed after retry: {last_error}")
-
- payload = {
- "draw_no": draw_no,
- "picks": [p.model_dump() for p in validated.picks],
- "narrative": validated.narrative.model_dump(),
- "confidence": validated.confidence,
- "model": LOTTO_CURATOR_MODEL,
- "tokens_input": usage_total["input"],
- "tokens_output": usage_total["output"],
- "cache_read": usage_total["cache_read"],
- "cache_write": usage_total["cache_write"],
- "latency_ms": usage_total["latency_ms"],
- "source": source,
- }
- await service_proxy.lotto_save_briefing(payload)
- return {
- "ok": True,
- "draw_no": draw_no,
- "confidence": validated.confidence,
- "tokens": {"input": usage_total["input"], "output": usage_total["output"]},
- }
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add agent-office/app/curator/pipeline.py
-git commit -m "feat(agent-office): 큐레이터 파이프라인(fetch→claude→validate→save)"
-```
-
----
-
-## Task 12: `agents/lotto.py` — LottoAgent + 등록
-
-**Files:**
-- Create: `agent-office/app/agents/lotto.py`
-- Modify: `agent-office/app/agents/__init__.py`
-- Modify: `agent-office/app/db.py` — seed에 lotto 추가
-- Modify: `agent-office/app/telegram/agent_registry.py` — lotto 메타 등록
-
-- [ ] **Step 1: LottoAgent 작성**
-
-`agent-office/app/agents/lotto.py`:
-
-```python
-from .base import BaseAgent
-from ..db import create_task, update_task_status, add_log
-from ..curator.pipeline import curate_weekly, CuratorError
-
-
-class LottoAgent(BaseAgent):
- agent_id = "lotto"
- display_name = "로또 큐레이터"
-
- async def on_schedule(self) -> None:
- if self.state not in ("idle", "break"):
- return
- await self._run(source="auto")
-
- async def on_command(self, action: str, params: dict) -> dict:
- if action in ("curate_now", "curate_weekly"):
- return await self._run(source="manual")
- if action == "status":
- return {"ok": True, "message": f"{self.state}: {self.state_detail}"}
- return {"ok": False, "message": f"unknown action: {action}"}
-
- async def _run(self, source: str) -> dict:
- task_id = create_task(self.agent_id, "curate_weekly", {"source": source})
- await self.transition("working", "후보 수집 및 AI 큐레이션 중...", task_id)
- try:
- result = await curate_weekly(source=source)
- update_task_status(task_id, "succeeded", result_data=result)
- await self.transition("reporting", f"#{result['draw_no']} 브리핑 저장 완료")
- add_log(self.agent_id, f"큐레이션 완료: #{result['draw_no']} conf={result['confidence']}", task_id=task_id)
- await self.transition("idle", "대기 중")
- return {"ok": True, **result}
- except CuratorError as e:
- update_task_status(task_id, "failed", result_data={"error": str(e)})
- add_log(self.agent_id, f"큐레이션 실패: {e}", level="error", task_id=task_id)
- await self.transition("idle", "오류")
- return {"ok": False, "message": str(e)}
- except Exception as e:
- update_task_status(task_id, "failed", result_data={"error": str(e)})
- add_log(self.agent_id, f"큐레이션 예외: {e}", level="error", task_id=task_id)
- await self.transition("idle", "오류")
- return {"ok": False, "message": f"{type(e).__name__}: {e}"}
-```
-
-- [ ] **Step 2: 레지스트리에 등록**
-
-`agent-office/app/agents/__init__.py`에서 기존 `stock`, `music`, `blog`, `realestate` 등록 패턴 찾아 동일 방식으로 lotto 추가. 예:
-
-```python
-from .lotto import LottoAgent
-...
-# init_agents() 내부
-AGENT_REGISTRY["lotto"] = LottoAgent()
-```
-
-- [ ] **Step 3: DB seed에 lotto 추가**
-
-`agent-office/app/db.py` `init_db()` 내부의 seed 리스트에 추가:
-
-```python
-for agent_id, name in [
- ("stock", "주식 트레이더"),
- ("music", "음악 프로듀서"),
- ("blog", "블로그 마케터"),
- ("realestate", "청약 애널리스트"),
- ("lotto", "로또 큐레이터"), # ← 추가
-]:
-```
-
-- [ ] **Step 4: 텔레그램 agent_registry에 lotto 메타 추가**
-
-`agent-office/app/telegram/agent_registry.py`의 `AGENT_META` 딕셔너리에 추가 (이모지는 🎱):
-
-```python
-"lotto": {"emoji": "🎱", "display_name": "로또 큐레이터"},
-```
-
-- [ ] **Step 5: 커밋**
-
-```bash
-git add agent-office/app/agents/lotto.py agent-office/app/agents/__init__.py agent-office/app/db.py agent-office/app/telegram/agent_registry.py
-git commit -m "feat(agent-office): LottoAgent 등록 + seed + 텔레그램 메타"
-```
-
----
-
-## Task 13: 월요일 07:00 스케줄러 추가
-
-**Files:**
-- Modify: `agent-office/app/scheduler.py`
-
-- [ ] **Step 1: 스케줄 함수 + job 추가**
-
-```python
-async def _run_lotto_schedule():
- agent = AGENT_REGISTRY.get("lotto")
- if agent:
- await agent.on_schedule()
-```
-
-`init_scheduler()` 내부에 추가:
-
-```python
-scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate")
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add agent-office/app/scheduler.py
-git commit -m "feat(agent-office): lotto 큐레이터 월요일 07:00 스케줄"
-```
-
----
-
-## Task 14: 통합 수동 검증
-
-- [ ] **Step 1: 사용자가 NAS에서 컨테이너 재빌드**
-
-```
-cd /volume1/docker/webpage
-docker compose up -d --build agent-office lotto-backend
-```
-
-- [ ] **Step 2: 큐레이터 수동 트리거**
-
-```
-curl -X POST http://localhost:18900/api/agent-office/command \
- -H "Content-Type: application/json" \
- -d '{"agent":"lotto","action":"curate_now","params":{}}'
-```
-
-Expected: `{"ok": true, "draw_no": , "confidence": 0-100, "tokens": {...}}`
-
-- [ ] **Step 3: 저장 확인**
-
-```
-curl http://localhost:18000/api/lotto/briefing/latest
-curl http://localhost:18000/api/lotto/curator/usage
-```
-
-각각 200 + 예상 구조. 실패 시 `docker logs agent-office --tail 100`으로 디버깅.
-
-- [ ] **Step 4: 문제 없으면 다음 Phase로**
-
----
-
-# Phase 3 — Frontend (web-ui)
-
-## Task 15: api.js 헬퍼
-
-**Files:**
-- Modify: `web-ui/src/api.js`
-
-- [ ] **Step 1: 헬퍼 함수 추가**
-
-`web-ui/src/api.js` 파일 하단에 추가:
-
-```javascript
-// --- Lotto Briefing ---
-
-export async function getLatestBriefing() {
- const r = await fetch('/api/lotto/briefing/latest');
- if (r.status === 404) return null;
- if (!r.ok) throw new Error(`briefing fetch failed: ${r.status}`);
- return r.json();
-}
-
-export async function getCuratorUsage(days = 30) {
- const r = await fetch(`/api/lotto/curator/usage?days=${days}`);
- if (!r.ok) throw new Error(`usage fetch failed: ${r.status}`);
- return r.json();
-}
-
-export async function triggerLottoCurate() {
- const r = await fetch('/api/agent-office/command', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ agent: 'lotto', action: 'curate_now', params: {} }),
- });
- if (!r.ok) throw new Error(`curate trigger failed: ${r.status}`);
- return r.json();
-}
-```
-
-- [ ] **Step 2: 커밋 (web-ui 레포)**
-
-```bash
-cd web-ui
-git add src/api.js
-git commit -m "feat(lotto): 브리핑·큐레이터 API 헬퍼"
-```
-
----
-
-## Task 16: `useBriefing.js` + `useCuratorUsage.js` 훅
-
-**Files:**
-- Create: `web-ui/src/pages/lotto/hooks/useBriefing.js`
-- Create: `web-ui/src/pages/lotto/hooks/useCuratorUsage.js`
-
-- [ ] **Step 1: useBriefing 작성**
-
-```javascript
-import { useState, useEffect, useCallback, useRef } from 'react';
-import { getLatestBriefing, triggerLottoCurate } from '../../../api';
-
-export default function useBriefing() {
- const [briefing, setBriefing] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState('');
- const [regenerating, setRegenerating] = useState(false);
- const pollingRef = useRef(null);
-
- const load = useCallback(async () => {
- setLoading(true); setError('');
- try {
- const data = await getLatestBriefing();
- setBriefing(data);
- } catch (e) {
- setError(e.message);
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => { load(); }, [load]);
-
- const regenerate = useCallback(async () => {
- setRegenerating(true); setError('');
- try {
- const prevGen = briefing?.generated_at;
- await triggerLottoCurate();
- // 3초 간격으로 최대 40회(2분) 폴링, generated_at이 바뀌면 종료
- let attempts = 0;
- pollingRef.current = setInterval(async () => {
- attempts += 1;
- try {
- const data = await getLatestBriefing();
- if (data && data.generated_at !== prevGen) {
- setBriefing(data);
- setRegenerating(false);
- clearInterval(pollingRef.current);
- }
- } catch {}
- if (attempts >= 40) {
- clearInterval(pollingRef.current);
- setRegenerating(false);
- setError('재생성 타임아웃 (2분)');
- }
- }, 3000);
- } catch (e) {
- setError(e.message);
- setRegenerating(false);
- }
- }, [briefing?.generated_at]);
-
- useEffect(() => () => { if (pollingRef.current) clearInterval(pollingRef.current); }, []);
-
- return { briefing, loading, error, regenerating, reload: load, regenerate };
-}
-```
-
-- [ ] **Step 2: useCuratorUsage 작성**
-
-```javascript
-import { useState, useEffect } from 'react';
-import { getCuratorUsage } from '../../../api';
-
-export default function useCuratorUsage(days = 30) {
- const [usage, setUsage] = useState(null);
- const [error, setError] = useState('');
-
- useEffect(() => {
- let alive = true;
- getCuratorUsage(days)
- .then(d => { if (alive) setUsage(d); })
- .catch(e => { if (alive) setError(e.message); });
- return () => { alive = false; };
- }, [days]);
-
- return { usage, error };
-}
-```
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add src/pages/lotto/hooks/useBriefing.js src/pages/lotto/hooks/useCuratorUsage.js
-git commit -m "feat(lotto): useBriefing·useCuratorUsage 훅"
-```
-
----
-
-## Task 17: 브리핑 컴포넌트
-
-**Files:**
-- Create: `web-ui/src/pages/lotto/components/briefing/BriefingHeader.jsx`
-- Create: `web-ui/src/pages/lotto/components/briefing/BriefingSummary.jsx`
-- Create: `web-ui/src/pages/lotto/components/briefing/PickSetCard.jsx`
-- Create: `web-ui/src/pages/lotto/components/briefing/BriefingEmpty.jsx`
-- Create: `web-ui/src/pages/lotto/components/briefing/CuratorUsageFooter.jsx`
-
-- [ ] **Step 1: 단가 상수 모듈**
-
-`web-ui/src/pages/lotto/components/briefing/pricing.js`:
-
-```javascript
-// Sonnet 4.5 단가 (per 1M tokens)
-const IN_PER_M = 3.00;
-const OUT_PER_M = 15.00;
-const CACHE_READ_PER_M = 0.30;
-const CACHE_WRITE_PER_M = 3.75;
-
-export function estimateCost({ tokens_input = 0, tokens_output = 0, cache_read = 0, cache_write = 0 }) {
- const usd =
- (tokens_input / 1_000_000) * IN_PER_M +
- (tokens_output / 1_000_000) * OUT_PER_M +
- (cache_read / 1_000_000) * CACHE_READ_PER_M +
- (cache_write / 1_000_000) * CACHE_WRITE_PER_M;
- return usd;
-}
-
-export function fmtUsd(usd) {
- if (usd < 0.01) return `$${usd.toFixed(4)}`;
- return `$${usd.toFixed(3)}`;
-}
-
-export function fmtTokens(n) {
- if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
- return String(n);
-}
-```
-
-- [ ] **Step 2: BriefingHeader.jsx**
-
-```jsx
-import { estimateCost, fmtUsd, fmtTokens } from './pricing';
-
-export default function BriefingHeader({ briefing, regenerating, onRegenerate }) {
- const cost = estimateCost(briefing);
- const genDate = new Date(briefing.generated_at).toLocaleString('ko-KR');
- return (
-
-
-
🗓 #{briefing.draw_no}회 브리핑
-
- {regenerating ? '⏳ 생성 중...' : '🔄 다시 생성'}
-
-
-
- {genDate}
-
- 신뢰도 {briefing.confidence} /100
-
-
- {fmtTokens(briefing.tokens_input)} in · {fmtTokens(briefing.tokens_output)} out · {fmtUsd(cost)}
-
-
-
-
- );
-}
-```
-
-- [ ] **Step 3: BriefingSummary.jsx**
-
-```jsx
-export default function BriefingSummary({ narrative }) {
- return (
-
-
{narrative.headline}
-
- {narrative.summary_3lines.map((line, i) => {line} )}
-
- {narrative.hot_cold_comment && (
-
🔥❄️ {narrative.hot_cold_comment}
- )}
- {narrative.warnings && (
-
⚠️ {narrative.warnings}
- )}
-
- );
-}
-```
-
-- [ ] **Step 4: PickSetCard.jsx**
-
-```jsx
-const RISK_BADGE = { '안정': '🟢', '균형': '🟡', '공격': '🔴' };
-
-export default function PickSetCard({ pick, index }) {
- return (
-
-
- Set {index + 1}
- {RISK_BADGE[pick.risk_tag] || '⚪'} {pick.risk_tag}
-
-
- {pick.numbers.map(n => (
- {n}
- ))}
-
-
{pick.reason}
-
- );
-}
-```
-
-- [ ] **Step 5: BriefingEmpty.jsx**
-
-```jsx
-export default function BriefingEmpty({ regenerating, onRegenerate, error }) {
- return (
-
-
아직 이번 주 브리핑이 없습니다.
-
매주 월요일 07:00에 자동 생성됩니다.
-
- {regenerating ? '⏳ 생성 중...' : '지금 생성'}
-
- {error &&
⚠️ {error}
}
-
- );
-}
-```
-
-- [ ] **Step 6: CuratorUsageFooter.jsx**
-
-```jsx
-import useCuratorUsage from '../../hooks/useCuratorUsage';
-import { estimateCost, fmtUsd, fmtTokens } from './pricing';
-
-export default function CuratorUsageFooter() {
- const { usage } = useCuratorUsage(30);
- if (!usage) return null;
- const cost = estimateCost(usage);
- return (
-
- 최근 30일 큐레이터:
- {usage.calls}회 호출
- {fmtTokens(usage.tokens_input + usage.tokens_output)} tokens
- {fmtUsd(cost)}
- 캐시 {(usage.cache_hit_rate * 100).toFixed(0)}%
-
- );
-}
-```
-
-- [ ] **Step 7: CSS 추가 (`Lotto.css`)**
-
-`Lotto.css` 하단에 추가 (디자인 토큰은 기존 스타일과 맞춤):
-
-```css
-.briefing-header { padding: 16px; border-radius: 12px; background: rgba(129,140,248,0.08); margin-bottom: 16px; }
-.briefing-header-row { display: flex; justify-content: space-between; align-items: center; }
-.briefing-meta { display: flex; gap: 12px; color: #94a3b8; font-size: 0.85rem; margin-top: 4px; flex-wrap: wrap; }
-.briefing-confidence strong { color: #e2e8f0; }
-.briefing-tokens { font-family: monospace; }
-.briefing-confidence-bar { height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; margin-top: 8px; overflow: hidden; }
-.briefing-confidence-bar > div { height: 100%; background: linear-gradient(90deg, #818cf8, #34d399); transition: width .3s; }
-.briefing-summary { padding: 12px 16px; background: rgba(0,0,0,0.2); border-radius: 10px; margin-bottom: 16px; }
-.briefing-summary h3 { margin: 0 0 8px; }
-.briefing-3lines { margin: 0; padding-left: 20px; }
-.briefing-hotcold { color: #fbbf24; margin-top: 8px; }
-.briefing-warning { color: #f87171; margin-top: 8px; }
-.pick-card { padding: 12px; border-radius: 10px; background: rgba(255,255,255,0.04); border-left: 3px solid #64748b; margin-bottom: 8px; }
-.pick-card--안정 { border-left-color: #34d399; }
-.pick-card--균형 { border-left-color: #fbbf24; }
-.pick-card--공격 { border-left-color: #f87171; }
-.pick-card-header { display: flex; justify-content: space-between; font-size: 0.85rem; color: #94a3b8; margin-bottom: 6px; }
-.pick-card-balls { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
-.ball { width: 32px; height: 32px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold; color: #fff; }
-.ball--1 { background: #fbbf24; } .ball--2 { background: #60a5fa; } .ball--3 { background: #f87171; }
-.ball--4 { background: #94a3b8; } .ball--5 { background: #34d399; }
-.pick-card-reason { margin: 0; font-size: 0.85rem; color: #cbd5e1; }
-.briefing-empty { text-align: center; padding: 40px 20px; color: #94a3b8; }
-.briefing-empty button { margin-top: 12px; padding: 8px 20px; }
-.briefing-error { color: #f87171; margin-top: 8px; }
-.curator-usage-footer { display: flex; gap: 12px; padding: 10px 14px; background: rgba(0,0,0,0.25); border-radius: 8px; font-size: 0.8rem; color: #94a3b8; margin-top: 24px; flex-wrap: wrap; font-family: monospace; }
-@media (max-width: 768px) {
- .briefing-meta { font-size: 0.75rem; }
- .briefing-tokens { width: 100%; }
- .pick-card-balls { justify-content: center; }
-}
-```
-
-- [ ] **Step 8: 커밋**
-
-```bash
-git add src/pages/lotto/components/briefing/ src/pages/lotto/Lotto.css
-git commit -m "feat(lotto): 브리핑 컴포넌트 + CSS"
-```
-
----
-
-## Task 18: 탭 컴포넌트 + Functions.jsx 리팩토링
-
-**Files:**
-- Create: `web-ui/src/pages/lotto/tabs/BriefingTab.jsx`
-- Create: `web-ui/src/pages/lotto/tabs/AnalysisTab.jsx`
-- Create: `web-ui/src/pages/lotto/tabs/PurchaseTab.jsx`
-- Modify: `web-ui/src/pages/lotto/Functions.jsx`
-
-- [ ] **Step 1: BriefingTab.jsx 작성**
-
-```jsx
-import useBriefing from '../hooks/useBriefing';
-import BriefingHeader from '../components/briefing/BriefingHeader';
-import BriefingSummary from '../components/briefing/BriefingSummary';
-import PickSetCard from '../components/briefing/PickSetCard';
-import BriefingEmpty from '../components/briefing/BriefingEmpty';
-import CuratorUsageFooter from '../components/briefing/CuratorUsageFooter';
-
-export default function BriefingTab() {
- const { briefing, loading, error, regenerating, regenerate } = useBriefing();
-
- if (loading) return ;
- if (!briefing) return ;
-
- return (
-
-
-
-
-
이번 주 5세트
- {briefing.picks.map((p, i) =>
)}
-
-
-
- );
-}
-```
-
-- [ ] **Step 2: AnalysisTab.jsx — 기존 분석 패널 이동**
-
-현재 `Functions.jsx`에서 `FrequencyChart`, `MetricBlock`, `PersonalAnalysisPanel`, `ReportPanel` 사용하는 JSX를 그대로 가져와 `AnalysisTab.jsx`로 이동. 데이터 훅(`useLottoData`)도 AnalysisTab 안에서 호출. 원본 Functions.jsx에서 해당 JSX는 지운다.
-
-파일이 너무 커지면 AnalysisTab에서 바로 import 하여 렌더링만 담당:
-
-```jsx
-import useLottoData from '../hooks/useLottoData';
-import FrequencyChart from '../components/FrequencyChart';
-import MetricBlock from '../components/MetricBlock';
-import PersonalAnalysisPanel from '../components/PersonalAnalysisPanel';
-import ReportPanel from '../components/ReportPanel';
-
-export default function AnalysisTab() {
- const data = useLottoData();
- if (data.loading) return 로딩...
;
- if (data.error) return {data.error}
;
- return (
-
-
-
-
- {/* 기존 Functions.jsx의 MetricBlock 호출부를 여기로 이동 */}
-
-
-
- );
-}
-```
-
-**주의:** `useLottoData`가 현재 반환하는 필드(`report`, `personalAnalysis`, `stats` 등) 이름은 실제 훅 구현을 확인해 정확히 맞춘다. 변경 시 Functions.jsx에서 쓰던 prop 이름을 그대로 가져간다.
-
-- [ ] **Step 3: PurchaseTab.jsx**
-
-```jsx
-import usePurchases from '../hooks/usePurchases';
-import PurchasePanel from '../components/PurchasePanel';
-import PerformanceBanner from '../components/PerformanceBanner';
-
-export default function PurchaseTab() {
- const purchases = usePurchases();
- return (
-
- );
-}
-```
-
-훅 반환 필드 이름은 실제 `usePurchases` 구현 확인 후 맞춘다.
-
-- [ ] **Step 4: Functions.jsx 리팩토링 — 탭 라우터만**
-
-```jsx
-import { useState } from 'react';
-import BriefingTab from './tabs/BriefingTab';
-import AnalysisTab from './tabs/AnalysisTab';
-import PurchaseTab from './tabs/PurchaseTab';
-
-const TABS = [
- { id: 'briefing', label: '🗓 이번 주 브리핑' },
- { id: 'analysis', label: '📊 분석·통계' },
- { id: 'purchase', label: '💰 구매·성과' },
-];
-
-export default function Functions() {
- const [tab, setTab] = useState('briefing');
- return (
-
-
- {TABS.map(t => (
- setTab(t.id)}
- >{t.label}
- ))}
-
-
- {tab === 'briefing' &&
}
- {tab === 'analysis' &&
}
- {tab === 'purchase' &&
}
-
-
- );
-}
-```
-
-- [ ] **Step 5: 탭 스타일 CSS 추가 (`Lotto.css`)**
-
-```css
-.lotto-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid rgba(255,255,255,0.1); }
-.lotto-tabs button { padding: 8px 16px; background: transparent; border: none; color: #94a3b8; cursor: pointer; border-bottom: 2px solid transparent; }
-.lotto-tabs button.active { color: #e2e8f0; border-bottom-color: #818cf8; }
-.lotto-tab-body { padding-top: 8px; }
-@media (max-width: 768px) {
- .lotto-tabs { overflow-x: auto; }
- .lotto-tabs button { white-space: nowrap; }
-}
-```
-
-- [ ] **Step 6: 로컬 dev 서버로 확인**
-
-```
-cd web-ui && npm run dev
-```
-브라우저에서 http://localhost:3007 → 로또 페이지 → 3개 탭 전환 동작 확인. 브리핑이 없으면 BriefingEmpty 표시 확인.
-
-- [ ] **Step 7: 커밋**
-
-```bash
-git add src/pages/lotto/tabs/ src/pages/lotto/Functions.jsx src/pages/lotto/Lotto.css
-git commit -m "feat(lotto): 3탭 구조 재배치(브리핑/분석/구매)"
-```
-
----
-
-# Phase 4 — 정리 + 문서
-
-## Task 19: 미사용 프론트 컴포넌트 제거
-
-**Files:**
-- Delete candidates: `components/CombinedRecommendPanel.jsx`, `components/ConfidenceRing.jsx`
-
-- [ ] **Step 1: 참조 여부 grep**
-
-```
-cd web-ui/src
-grep -r "CombinedRecommendPanel" --include="*.jsx" --include="*.js"
-grep -r "ConfidenceRing" --include="*.jsx" --include="*.js"
-```
-
-- [ ] **Step 2: 참조 없으면 삭제**
-
-참조가 0건이면 해당 파일 삭제. 참조가 남아있으면 해당 호출부도 정리 후 삭제.
-
-```
-git rm src/pages/lotto/components/CombinedRecommendPanel.jsx
-git rm src/pages/lotto/components/ConfidenceRing.jsx
-```
-
-- [ ] **Step 3: 빌드 확인**
-
-```
-npm run build
-```
-에러 없이 통과.
-
-- [ ] **Step 4: 커밋**
-
-```bash
-git commit -m "chore(lotto): 브리핑 탭이 대체 — 미사용 컴포넌트 제거"
-```
-
----
-
-## Task 20: 미사용 백엔드 코드/테이블 분석
-
-**Files:**
-- Modify: `backend/app/db.py` (drop 대상 테이블)
-- Possibly modify: `backend/app/main.py`, `backend/app/analyzer.py`
-
-- [ ] **Step 1: `weekly_reports` 테이블 참조 조사**
-
-```
-cd web-backend/backend
-grep -rn "weekly_reports" .
-```
-
-큐레이터 브리핑이 역할을 대체하므로, 참조가 `init_db`의 CREATE + `analyzer.generate_weekly_report`만이면 드롭 후보. 관련 엔드포인트(`/api/lotto/report/*`)가 프론트에서 여전히 쓰이는지 web-ui에서 grep:
-
-```
-cd web-ui/src && grep -rn "lotto/report" .
-```
-
-여전히 쓰이면 유지. 안 쓰이면 제거 대상.
-
-- [ ] **Step 2: 판단 결과 기록**
-
-`docs/superpowers/plans/2026-04-15-lotto-ai-curator.md` 하단 또는 PR 설명에 분석 결과 한 단락:
-
-- `weekly_reports` 테이블: 사용처 [X개] → [유지|드롭]
-- `/api/lotto/report/*` 엔드포인트: [유지|제거]
-- `analyzer.generate_weekly_report`: [유지|제거]
-- `simulation_candidates` 테이블: 사용처 [X개]
-
-- [ ] **Step 3: 드롭 결정된 테이블만 init 제거 + 마이그레이션**
-
-드롭 대상이 결정되면 `db.py`의 CREATE 문 삭제 + 기존 DB에서 `DROP TABLE IF EXISTS`를 `init_db` 상단에 일회성 실행:
-
-```python
-# 정리(2026-04-15): 큐레이터 브리핑이 대체
-conn.execute("DROP TABLE IF EXISTS weekly_reports")
-```
-
-(NAS에서 실제 파일에 반영되었는지 확인 후, 다음 배포에서 해당 라인을 지워도 되지만 유지해도 무해.)
-
-- [ ] **Step 4: 삭제된 함수/엔드포인트 실제 삭제**
-
-`main.py`의 해당 라우트, `analyzer.py`의 함수 삭제. 프론트에서 쓰지 않는지 다시 확인.
-
-- [ ] **Step 5: 컨테이너 재시작 + 스모크 테스트**
-
-```
-docker compose restart lotto-backend
-curl http://localhost:18000/api/lotto/latest
-curl http://localhost:18000/api/lotto/briefing/latest
-```
-
-기본 동작 정상 확인.
-
-- [ ] **Step 6: 커밋**
-
-```bash
-git add backend/app/db.py backend/app/main.py backend/app/analyzer.py
-git commit -m "chore(lotto): weekly_reports 및 미사용 로직 제거 (브리핑이 대체)"
-```
-
----
-
-## Task 21: CLAUDE.md 업데이트
-
-**Files:**
-- Modify: `web-backend/CLAUDE.md`
-- Modify: `web-ui/CLAUDE.md` (있으면)
-
-- [ ] **Step 1: lotto-lab API 표에 신규 엔드포인트 추가**
-
-`web-backend/CLAUDE.md`의 lotto-lab API 섹션에 추가:
-
-```
-| GET | /api/lotto/curator/candidates | 큐레이터용 후보 N세트 + 피처 |
-| GET | /api/lotto/curator/context | 주간 맥락(핫/콜드·직전 회차) |
-| GET | /api/lotto/curator/usage | 큐레이터 토큰·비용 집계 |
-| POST | /api/lotto/briefing | AI 브리핑 저장 |
-| GET | /api/lotto/briefing/latest | 최신 브리핑 |
-| GET | /api/lotto/briefing/{draw_no} | 특정 회차 브리핑 |
-| GET | /api/lotto/briefing | 브리핑 이력 |
-```
-
-lotto.db 테이블 표에 `lotto_briefings` 추가, 제거한 테이블은 표에서 삭제.
-
-- [ ] **Step 2: agent-office 섹션에 lotto 에이전트 추가**
-
-환경변수 + 스케줄러 + API 항목 추가:
-
-```
-**환경변수 추가**
-- `LOTTO_BACKEND_URL`: 기본 `http://lotto-backend:8000`
-- `LOTTO_CURATOR_MODEL`: 기본 `claude-sonnet-4-5`
-
-**스케줄러 job 추가**
-- 매주 월요일 07:00 — 로또 큐레이터 브리핑 (`lotto_curate`)
-```
-
-- [ ] **Step 3: web-ui CLAUDE.md 업데이트 (있으면)**
-
-API 헬퍼 3개(`getLatestBriefing`, `getCuratorUsage`, `triggerLottoCurate`) + 로또 페이지 3탭 구조 설명 추가.
-
-- [ ] **Step 4: 커밋**
-
-```bash
-# web-backend
-git add CLAUDE.md
-git commit -m "docs: lotto 큐레이터 API·테이블·스케줄 반영"
-
-# web-ui (있으면)
-git add CLAUDE.md
-git commit -m "docs: 로또 페이지 3탭 구조 + 브리핑 API 반영"
-```
-
----
-
-## Task 22: 최종 배포 + 모니터링
-
-- [ ] **Step 1: 백엔드 배포**
-
-```
-cd web-backend && git push
-```
-Gitea Webhook → deployer 자동 배포. `docker logs -f webpage-deployer` 로 배포 진행 확인.
-
-- [ ] **Step 2: 프론트 배포**
-
-```
-cd web-ui && npm run release:nas
-```
-
-- [ ] **Step 3: 운영 스모크 테스트**
-
-브라우저에서 실서비스 접속 → 로또 페이지 3탭 전환 → "지금 생성" 클릭해 브리핑 1회 수동 생성 → 5세트·근거·토큰·비용 표시 정상 확인.
-
-- [ ] **Step 4: 월요일 07:00 첫 자동 실행 대기 (D+?)**
-
-다음 월요일까지 대기, 자동 생성 결과를 DB와 UI에서 확인. 문제 발견 시 `docker logs agent-office`로 디버깅.
-
----
-
-# 완료 기준
-
-- [x] 월요일 07:00 스케줄러가 자동으로 `curate_weekly` 태스크 생성·실행
-- [x] Claude 응답이 candidates 외 번호를 쓰면 검증에서 차단 (테스트로 증명)
-- [x] 로또 페이지 첫 진입에 5세트·3줄 요약이 표시되고, 토큰·비용·캐시 히트율이 보인다
-- [x] 기존 Functions.jsx 460줄 → 탭 라우터 ~50줄로 축소
-- [x] 미사용 컴포넌트·테이블이 grep 검증 후 제거됨
-- [x] CLAUDE.md에 새 API·스케줄·환경변수 반영
diff --git a/docs/superpowers/plans/2026-04-23-responsive-web-design.md b/docs/superpowers/plans/2026-04-23-responsive-web-design.md
deleted file mode 100644
index 6ba395e..0000000
--- a/docs/superpowers/plans/2026-04-23-responsive-web-design.md
+++ /dev/null
@@ -1,2392 +0,0 @@
-# 반응형 웹 UI/UX 전면 개선 구현 계획
-
-> **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:** 14개 뷰 전체에 모바일 반응형 + 풀 모바일 UX 패턴(바텀네비, 스와이프, 풀다운 리프레시, FAB, 바텀시트) 적용
-
-**Architecture:** 공통 모바일 인프라(컴포넌트 5개 + 훅 2개)를 먼저 구축한 뒤, 주요 4개 페이지 → 나머지 페이지 순으로 적용. 기존 사이드바 네비게이션은 모바일에서 바텀 네비게이션으로 대체.
-
-**Tech Stack:** React 18, Vite, react-swipeable, 커스텀 CSS (디자인 토큰 기반)
-
-**Spec:** `docs/superpowers/specs/2026-04-23-responsive-web-design.md`
-
-**작업 대상 리포지토리:** `C:\Users\jaeoh\Desktop\workspace\web-ui` (프론트엔드 별도 Git)
-
----
-
-## File Structure
-
-### 신규 생성 파일
-
-| 파일 | 역할 |
-|------|------|
-| `src/hooks/useIsMobile.js` | 768px 이하 감지 훅 (matchMedia) |
-| `src/hooks/useSwipe.js` | react-swipeable 래핑 훅 |
-| `src/components/BottomNav.jsx` | 모바일 하단 네비게이션 |
-| `src/components/BottomNav.css` | 바텀네비 스타일 |
-| `src/components/PullToRefresh.jsx` | 풀다운 새로고침 래퍼 |
-| `src/components/PullToRefresh.css` | 풀다운 스타일 |
-| `src/components/SwipeableView.jsx` | 좌우 스와이프 탭 전환 |
-| `src/components/SwipeableView.css` | 스와이프 뷰 스타일 |
-| `src/components/FAB.jsx` | 플로팅 액션 버튼 |
-| `src/components/FAB.css` | FAB 스타일 |
-| `src/components/MobileSheet.jsx` | 바텀시트 모달 |
-| `src/components/MobileSheet.css` | 바텀시트 스타일 |
-
-### 수정 파일
-
-| 파일 | 수정 내용 |
-|------|----------|
-| `index.html` | viewport-fit=cover 추가 |
-| `src/index.css` | breakpoint CSS 변수, safe-area 변수 |
-| `src/App.jsx` | BottomNav 조건부 렌더링 |
-| `src/App.css` | 앱 셸 모바일 레이아웃 (padding-bottom 등) |
-| `src/components/Navbar.jsx` | 모바일 사이드바/햄버거 제거 |
-| `src/components/Navbar.css` | 모바일 미디어쿼리 정리 |
-| `src/pages/home/Home.jsx` | SwipeableView, PullToRefresh 적용 |
-| `src/pages/home/Home.css` | breakpoint 통일 + 모바일 레이아웃 |
-| `src/pages/lotto/Lotto.jsx` (또는 Functions.jsx) | 탭 스와이프, FAB 적용 |
-| `src/pages/lotto/Lotto.css` | breakpoint 통일 + 모바일 레이아웃 |
-| `src/pages/stock/Stock.jsx` | 필터 칩, FAB 적용 |
-| `src/pages/stock/Stock.css` | breakpoint 통일 (예외 유지) + 모바일 |
-| `src/pages/stock/StockTrade.jsx` | 카드형 리스트, FAB 적용 |
-| `src/pages/travel/Travel.jsx` | 풀스크린 뷰어, PullToRefresh |
-| `src/pages/travel/Travel.css` | breakpoint 통일 + 모바일 |
-| `src/pages/blog/Blog.jsx` | FAB, PullToRefresh |
-| `src/pages/blog/Blog.css` | breakpoint 통일 |
-| `src/pages/blog-marketing/BlogMarketing.jsx` | FAB, PullToRefresh |
-| `src/pages/blog-marketing/BlogMarketing.css` | 모바일 개선 |
-| `src/pages/subscription/Subscription.jsx` | FAB, MobileSheet |
-| `src/pages/subscription/Subscription.css` | breakpoint 통일 |
-| `src/pages/music/MusicStudio.jsx` | FAB, PullToRefresh |
-| `src/pages/music/MusicStudio.css` | breakpoint 통일 |
-| `src/pages/todo/Todo.jsx` | SwipeableView, FAB, MobileSheet |
-| `src/pages/todo/Todo.css` | 스와이프 탭 |
-| `src/pages/agent-office/AgentOffice.jsx` | MobileSheet |
-| `src/pages/agent-office/AgentOffice.css` | 모바일 개선 |
-| `src/pages/effect-lab/EffectLab.css` | 모바일 개선 |
-| `src/pages/effect-lab/DayCalc.css` | 모바일 개선 |
-| `src/pages/effect-lab/SwordStream.css` | 모바일 미디어쿼리 추가 |
-| `package.json` | react-swipeable 의존성 추가 |
-
----
-
-## Phase 1a: Breakpoint 정리
-
-### Task 1: viewport-fit 및 글로벌 CSS 변수 추가
-
-**Files:**
-- Modify: `index.html:6`
-- Modify: `src/index.css:15-105`
-
-- [ ] **Step 1: index.html에 viewport-fit=cover 추가**
-
-```html
-
-
-
-
-
-```
-
-- [ ] **Step 2: index.css에 breakpoint 및 safe-area CSS 변수 추가**
-
-`src/index.css`의 `:root` 블록(line 15) 안에 layout tokens 섹션(line 73-74) 뒤에 추가:
-
-```css
- /* ── Layout ── */
- --sidebar-w: 240px;
- --topbar-h: 56px;
- --bottom-nav-h: 64px;
- --safe-area-bottom: env(safe-area-inset-bottom, 0px);
-```
-
-- [ ] **Step 3: 모바일 body 스타일에 safe-area 패딩 추가**
-
-`src/index.css` line 239-244의 모바일 미디어쿼리를 확장:
-
-```css
-@media (max-width: 768px) {
- body {
- overflow: auto;
- background-attachment: scroll;
- padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom));
- }
-}
-```
-
-- [ ] **Step 4: 개발 서버 실행 후 데스크톱/모바일 확인**
-
-Run: `cd C:\Users\jaeoh\Desktop\workspace\web-ui && npm run dev`
-
-DevTools에서 375px, 768px, 1024px 뷰포트로 확인. 기존 레이아웃에 변화 없어야 함.
-
-- [ ] **Step 5: 커밋**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-git add index.html src/index.css
-git commit -m "feat: viewport-fit=cover 및 모바일 CSS 변수 추가"
-```
-
----
-
-### Task 2: Breakpoint 통일 — Home, Lotto, Travel, Blog
-
-**Files:**
-- Modify: `src/pages/home/Home.css:730` (960px → 1024px)
-- Modify: `src/pages/lotto/Lotto.css:1077` (900px → 768px)
-- Modify: `src/pages/travel/Travel.css:969` (900px → 768px)
-- Modify: `src/pages/blog/Blog.css:454` (900px → 768px)
-
-- [ ] **Step 1: Home.css — 960px → 1024px로 변경**
-
-`src/pages/home/Home.css` line 730:
-
-```css
-/* 기존 */
-@media (max-width: 960px) {
-
-/* 변경 */
-@media (max-width: 1024px) {
-```
-
-- [ ] **Step 2: Lotto.css — 900px → 768px로 변경**
-
-`src/pages/lotto/Lotto.css` line 1077:
-
-```css
-/* 기존 */
-@media (max-width: 900px) {
-
-/* 변경 */
-@media (max-width: 768px) {
-```
-
-주의: 이 블록 내의 스타일이 기존 768px 블록(line 1159)과 충돌하지 않는지 확인. 충돌 시 두 블록을 병합한다.
-
-- [ ] **Step 3: Travel.css — 900px → 768px로 변경**
-
-`src/pages/travel/Travel.css` line 969:
-
-```css
-/* 기존 */
-@media (max-width: 900px) {
-
-/* 변경 */
-@media (max-width: 768px) {
-```
-
-기존 640px 블록(line 975)과 겹치지 않는지 확인.
-
-- [ ] **Step 4: Blog.css — 900px → 768px로 변경**
-
-`src/pages/blog/Blog.css` line 454:
-
-```css
-/* 기존 */
-@media (max-width: 900px) {
-
-/* 변경 */
-@media (max-width: 768px) {
-```
-
-기존 768px 블록(line 504)과 병합 필요 시 병합.
-
-- [ ] **Step 5: 각 페이지를 DevTools 768px/1024px에서 확인**
-
-각 페이지가 기존과 동일하게 렌더링되는지 확인. 특히:
-- Home: 히어로 그리드 전환 시점
-- Lotto: 헤더/분석 카드 1컬럼 전환 시점
-- Travel: 헤더 레이아웃 전환 시점
-- Blog: 사이드 목록 오버레이 전환 시점
-
-- [ ] **Step 6: 커밋**
-
-```bash
-git add src/pages/home/Home.css src/pages/lotto/Lotto.css src/pages/travel/Travel.css src/pages/blog/Blog.css
-git commit -m "refactor: Home/Lotto/Travel/Blog breakpoint 표준화 (960→1024, 900→768)"
-```
-
----
-
-### Task 3: Breakpoint 통일 — Subscription, MusicStudio, Lotto(640px)
-
-**Files:**
-- Modify: `src/pages/subscription/Subscription.css:1142` (1100px → 1024px)
-- Modify: `src/pages/subscription/Subscription.css:1146` (900px → 768px)
-- Modify: `src/pages/music/MusicStudio.css:320` (960px → 1024px)
-- Modify: `src/pages/lotto/Lotto.css:1111,1462` (640px → 480px)
-- Modify: `src/pages/music/MusicStudio.css:490,640,1699` (640px → 480px)
-- Modify: `src/pages/travel/Travel.css:349,975` (640px → 480px)
-- Modify: `src/pages/blog-marketing/BlogMarketing.css:128` (640px → 480px)
-
-- [ ] **Step 1: Subscription.css — 1100px → 1024px, 900px → 768px**
-
-```css
-/* line 1142: 1100px → 1024px */
-@media (max-width: 1024px) {
-
-/* line 1146: 900px → 768px */
-@media (max-width: 768px) {
-```
-
-기존 768px 블록(line 1154)과 병합 필요 시 병합.
-
-- [ ] **Step 2: MusicStudio.css — 960px → 1024px**
-
-`src/pages/music/MusicStudio.css` line 320:
-
-```css
-/* 기존 */
-@media (max-width: 960px) {
-
-/* 변경 */
-@media (max-width: 1024px) {
-```
-
-- [ ] **Step 3: 640px → 480px 일괄 변경**
-
-각 파일의 640px 미디어쿼리를 480px로 변경:
-
-- `Lotto.css` line 1111, 1462
-- `MusicStudio.css` line 490, 640, 1699
-- `Travel.css` line 349, 975
-- `BlogMarketing.css` line 128
-
-각 파일에서:
-
-```css
-/* 기존 */
-@media (max-width: 640px) {
-
-/* 변경 */
-@media (max-width: 480px) {
-```
-
-- [ ] **Step 4: 각 페이지 480px/768px/1024px에서 확인**
-
-- [ ] **Step 5: 커밋**
-
-```bash
-git add src/pages/subscription/Subscription.css src/pages/music/MusicStudio.css src/pages/lotto/Lotto.css src/pages/travel/Travel.css src/pages/blog-marketing/BlogMarketing.css
-git commit -m "refactor: Subscription/Music/Lotto/Travel/BlogMarketing breakpoint 표준화"
-```
-
----
-
-### Task 4: RealEstate.css breakpoint 통일 (routes.jsx 미등록이지만 CSS는 존재)
-
-**Files:**
-- Modify: `src/pages/realestate/RealEstate.css:955,961`
-
-- [ ] **Step 1: RealEstate.css — 1100px → 1024px, 900px → 768px**
-
-```css
-/* line 955 */
-@media (max-width: 1024px) {
-
-/* line 961 */
-@media (max-width: 768px) {
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/realestate/RealEstate.css
-git commit -m "refactor: RealEstate breakpoint 표준화 (1100→1024, 900→768)"
-```
-
-> Note: Stock.css의 420px/520px/700px은 spec에 따라 예외로 유지.
-
----
-
-## Phase 1b: 공통 컴포넌트 & 앱 셸
-
-### Task 5: react-swipeable 설치 + useIsMobile 훅
-
-**Files:**
-- Modify: `package.json`
-- Create: `src/hooks/useIsMobile.js`
-
-- [ ] **Step 1: react-swipeable 설치**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-npm install react-swipeable
-```
-
-- [ ] **Step 2: useIsMobile 훅 작성**
-
-```jsx
-// src/hooks/useIsMobile.js
-import { useState, useEffect } from 'react';
-
-const MOBILE_BREAKPOINT = 768;
-
-export function useIsMobile() {
- const [isMobile, setIsMobile] = useState(
- () => window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches
- );
-
- useEffect(() => {
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
- const handler = (e) => setIsMobile(e.matches);
- mql.addEventListener('change', handler);
- return () => mql.removeEventListener('change', handler);
- }, []);
-
- return isMobile;
-}
-```
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add package.json package-lock.json src/hooks/useIsMobile.js
-git commit -m "feat: react-swipeable 설치 + useIsMobile 훅 추가"
-```
-
----
-
-### Task 6: useSwipe 훅
-
-**Files:**
-- Create: `src/hooks/useSwipe.js`
-
-- [ ] **Step 1: useSwipe 훅 작성**
-
-```jsx
-// src/hooks/useSwipe.js
-import { useSwipeable } from 'react-swipeable';
-
-/**
- * 스와이프 방향 감지 훅
- * @param {Object} options
- * @param {Function} options.onSwipedLeft - 왼쪽 스와이프 콜백
- * @param {Function} options.onSwipedRight - 오른쪽 스와이프 콜백
- * @param {number} options.threshold - 스와이프 감지 최소 거리 (기본 50px)
- * @returns {Object} swipeHandlers - DOM 요소에 spread할 핸들러
- */
-export function useSwipe({ onSwipedLeft, onSwipedRight, threshold = 50 } = {}) {
- const handlers = useSwipeable({
- onSwipedLeft,
- onSwipedRight,
- delta: threshold,
- trackMouse: false,
- preventScrollOnSwipe: true,
- });
-
- return handlers;
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/hooks/useSwipe.js
-git commit -m "feat: useSwipe 훅 추가 (react-swipeable 기반)"
-```
-
----
-
-### Task 7: BottomNav 컴포넌트
-
-**Files:**
-- Create: `src/components/BottomNav.jsx`
-- Create: `src/components/BottomNav.css`
-
-- [ ] **Step 1: BottomNav.css 작성**
-
-```css
-/* src/components/BottomNav.css */
-.bottom-nav {
- display: none;
- position: fixed;
- bottom: 0;
- left: 0;
- right: 0;
- height: var(--bottom-nav-h, 64px);
- padding-bottom: var(--safe-area-bottom, 0px);
- background: var(--bg-secondary);
- border-top: 1px solid var(--border-line);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
- z-index: 300;
-}
-
-@media (max-width: 768px) {
- .bottom-nav {
- display: flex;
- align-items: center;
- justify-content: space-around;
- }
-}
-
-.bottom-nav__item {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 2px;
- min-width: 48px;
- min-height: 48px;
- padding: 6px 12px;
- background: none;
- border: none;
- color: var(--text-dim);
- font-size: 10px;
- font-family: var(--font-body);
- cursor: pointer;
- transition: color 0.2s var(--ease-out);
- -webkit-tap-highlight-color: transparent;
- text-decoration: none;
-}
-
-.bottom-nav__item:hover,
-.bottom-nav__item.is-active {
- color: var(--neon-cyan);
-}
-
-.bottom-nav__item.is-active .bottom-nav__icon {
- filter: drop-shadow(0 0 6px var(--neon-cyan));
-}
-
-.bottom-nav__icon {
- width: 22px;
- height: 22px;
- transition: filter 0.2s var(--ease-out);
-}
-
-.bottom-nav__label {
- font-size: 10px;
- line-height: 1;
- white-space: nowrap;
-}
-
-/* 더보기 오버레이 */
-.bottom-nav__more-overlay {
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.6);
- backdrop-filter: blur(8px);
- -webkit-backdrop-filter: blur(8px);
- z-index: 299;
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.25s var(--ease-out);
-}
-
-.bottom-nav__more-overlay.is-visible {
- opacity: 1;
- pointer-events: auto;
-}
-
-.bottom-nav__more-panel {
- position: fixed;
- bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom, 0px));
- left: 0;
- right: 0;
- background: var(--bg-secondary);
- border-top: 1px solid var(--border-line);
- border-radius: var(--radius-lg) var(--radius-lg) 0 0;
- padding: 16px;
- transform: translateY(100%);
- transition: transform 0.3s var(--ease-out);
- z-index: 301;
-}
-
-.bottom-nav__more-panel.is-visible {
- transform: translateY(0);
-}
-
-.bottom-nav__more-grid {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 8px;
-}
-
-.bottom-nav__more-item {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 6px;
- padding: 12px 8px;
- background: var(--surface);
- border: 1px solid var(--border-line);
- border-radius: var(--radius-sm);
- color: var(--text-default);
- font-size: 11px;
- text-decoration: none;
- transition: background 0.2s, border-color 0.2s;
-}
-
-.bottom-nav__more-item:hover,
-.bottom-nav__more-item.is-active {
- background: var(--surface-raised);
- border-color: var(--neon-cyan-dim);
- color: var(--neon-cyan);
-}
-
-.bottom-nav__more-icon {
- width: 24px;
- height: 24px;
-}
-
-@media (prefers-reduced-motion: reduce) {
- .bottom-nav__more-panel,
- .bottom-nav__more-overlay {
- transition: none;
- }
-}
-```
-
-- [ ] **Step 2: BottomNav.jsx 작성**
-
-```jsx
-// src/components/BottomNav.jsx
-import { useState, useCallback } from 'react';
-import { NavLink, useLocation } from 'react-router-dom';
-import { navLinks } from '../routes';
-import './BottomNav.css';
-
-const PRIMARY_PATHS = ['/', '/lotto', '/stock', '/travel'];
-
-export default function BottomNav() {
- const [moreOpen, setMoreOpen] = useState(false);
- const location = useLocation();
-
- const toggleMore = useCallback(() => setMoreOpen(v => !v), []);
- const closeMore = useCallback(() => setMoreOpen(false), []);
-
- const primaryLinks = navLinks.filter(l => PRIMARY_PATHS.includes(l.path));
- const moreLinks = navLinks.filter(l => !PRIMARY_PATHS.includes(l.path));
-
- const isMoreActive = moreLinks.some(l => location.pathname.startsWith(l.path));
-
- return (
- <>
-
-
-
- {moreLinks.map(link => (
-
- `bottom-nav__more-item ${isActive ? 'is-active' : ''}`
- }
- onClick={closeMore}
- >
- {link.icon}
- {link.label}
-
- ))}
-
-
-
-
- {primaryLinks.map(link => (
-
- `bottom-nav__item ${isActive ? 'is-active' : ''}`
- }
- >
- {link.icon}
- {link.label}
-
- ))}
-
-
-
-
-
-
-
-
- 더보기
-
-
- >
- );
-}
-```
-
-- [ ] **Step 3: 데스크톱에서 바텀네비가 숨겨지는지 확인**
-
-DevTools에서 1024px → 바텀네비 `display: none`, 768px 이하 → `display: flex` 확인.
-
-- [ ] **Step 4: 커밋**
-
-```bash
-git add src/components/BottomNav.jsx src/components/BottomNav.css
-git commit -m "feat: BottomNav 모바일 하단 네비게이션 컴포넌트"
-```
-
----
-
-### Task 8: PullToRefresh 컴포넌트
-
-**Files:**
-- Create: `src/components/PullToRefresh.jsx`
-- Create: `src/components/PullToRefresh.css`
-
-- [ ] **Step 1: PullToRefresh.css 작성**
-
-```css
-/* src/components/PullToRefresh.css */
-.pull-to-refresh {
- position: relative;
- overscroll-behavior-y: contain;
-}
-
-.pull-to-refresh__indicator {
- position: absolute;
- top: -48px;
- left: 50%;
- transform: translateX(-50%);
- width: 36px;
- height: 36px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 50%;
- background: var(--surface-card);
- border: 1px solid var(--border-line);
- box-shadow: var(--shadow-md);
- transition: transform 0.2s var(--ease-out), opacity 0.2s;
- opacity: 0;
- z-index: 10;
-}
-
-.pull-to-refresh__indicator.is-pulling {
- opacity: 1;
-}
-
-.pull-to-refresh__indicator.is-refreshing {
- opacity: 1;
- transform: translateX(-50%) translateY(56px);
-}
-
-.pull-to-refresh__spinner {
- width: 20px;
- height: 20px;
- border: 2px solid var(--border-line);
- border-top-color: var(--neon-cyan);
- border-radius: 50%;
- animation: ptr-spin 0.8s linear infinite;
-}
-
-.pull-to-refresh__arrow {
- width: 18px;
- height: 18px;
- color: var(--neon-cyan);
- transition: transform 0.2s;
-}
-
-.pull-to-refresh__arrow.is-ready {
- transform: rotate(180deg);
-}
-
-@keyframes ptr-spin {
- to { transform: rotate(360deg); }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .pull-to-refresh__spinner {
- animation: none;
- border-top-color: var(--neon-cyan);
- opacity: 0.7;
- }
- .pull-to-refresh__indicator {
- transition: none;
- }
-}
-```
-
-- [ ] **Step 2: PullToRefresh.jsx 작성**
-
-```jsx
-// src/components/PullToRefresh.jsx
-import { useState, useRef, useCallback } from 'react';
-import { useIsMobile } from '../hooks/useIsMobile';
-import './PullToRefresh.css';
-
-const THRESHOLD = 60;
-const MAX_PULL = 120;
-
-export default function PullToRefresh({ onRefresh, children, className = '' }) {
- const isMobile = useIsMobile();
- const [pullDistance, setPullDistance] = useState(0);
- const [refreshing, setRefreshing] = useState(false);
- const startY = useRef(0);
- const containerRef = useRef(null);
-
- const handleTouchStart = useCallback((e) => {
- if (containerRef.current?.scrollTop === 0) {
- startY.current = e.touches[0].clientY;
- }
- }, []);
-
- const handleTouchMove = useCallback((e) => {
- if (!startY.current || refreshing) return;
- const diff = e.touches[0].clientY - startY.current;
- if (diff > 0 && containerRef.current?.scrollTop === 0) {
- const distance = Math.min(diff * 0.5, MAX_PULL);
- setPullDistance(distance);
- }
- }, [refreshing]);
-
- const handleTouchEnd = useCallback(async () => {
- if (pullDistance >= THRESHOLD && onRefresh) {
- setRefreshing(true);
- try {
- await onRefresh();
- } finally {
- setRefreshing(false);
- }
- }
- setPullDistance(0);
- startY.current = 0;
- }, [pullDistance, onRefresh]);
-
- if (!isMobile) {
- return {children}
;
- }
-
- const isPulling = pullDistance > 10;
- const isReady = pullDistance >= THRESHOLD;
-
- return (
-
-
- {refreshing ? (
-
- ) : (
-
-
-
- )}
-
-
- {children}
-
-
- );
-}
-```
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add src/components/PullToRefresh.jsx src/components/PullToRefresh.css
-git commit -m "feat: PullToRefresh 풀다운 새로고침 컴포넌트"
-```
-
----
-
-### Task 9: SwipeableView 컴포넌트
-
-**Files:**
-- Create: `src/components/SwipeableView.jsx`
-- Create: `src/components/SwipeableView.css`
-
-- [ ] **Step 1: SwipeableView.css 작성**
-
-```css
-/* src/components/SwipeableView.css */
-.swipeable-view {
- overflow: hidden;
- position: relative;
-}
-
-.swipeable-view__track {
- display: flex;
- transition: transform 0.3s var(--ease-out);
- will-change: transform;
-}
-
-.swipeable-view__track.is-swiping {
- transition: none;
-}
-
-.swipeable-view__panel {
- flex: 0 0 100%;
- min-width: 0;
- overflow-y: auto;
-}
-
-.swipeable-view__tabs {
- display: flex;
- gap: 4px;
- padding: 4px;
- background: var(--surface);
- border-radius: var(--radius-md);
- border: 1px solid var(--border-line);
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- scrollbar-width: none;
-}
-
-.swipeable-view__tabs::-webkit-scrollbar {
- display: none;
-}
-
-.swipeable-view__tab {
- flex: 1;
- min-width: fit-content;
- padding: 8px 16px;
- background: none;
- border: none;
- border-radius: var(--radius-sm);
- color: var(--text-dim);
- font-family: var(--font-body);
- font-size: 13px;
- font-weight: 500;
- cursor: pointer;
- white-space: nowrap;
- transition: background 0.2s, color 0.2s;
-}
-
-.swipeable-view__tab.is-active {
- background: var(--surface-raised);
- color: var(--neon-cyan);
-}
-
-@media (prefers-reduced-motion: reduce) {
- .swipeable-view__track {
- transition: none;
- }
-}
-```
-
-- [ ] **Step 2: SwipeableView.jsx 작성**
-
-```jsx
-// src/components/SwipeableView.jsx
-import { useState, useRef, useCallback } from 'react';
-import { useSwipeable } from 'react-swipeable';
-import { useIsMobile } from '../hooks/useIsMobile';
-import './SwipeableView.css';
-
-/**
- * @param {Object} props
- * @param {Array<{key: string, label: string, content: React.ReactNode}>} props.tabs
- * @param {number} props.activeIndex - 외부 제어용 (optional)
- * @param {Function} props.onTabChange - 탭 변경 콜백 (optional)
- * @param {boolean} props.showTabs - 탭 바 표시 여부 (기본 true)
- */
-export default function SwipeableView({ tabs, activeIndex: controlledIndex, onTabChange, showTabs = true }) {
- const [internalIndex, setInternalIndex] = useState(0);
- const [isSwiping, setIsSwiping] = useState(false);
- const [swipeOffset, setSwipeOffset] = useState(0);
- const isMobile = useIsMobile();
-
- const activeIndex = controlledIndex ?? internalIndex;
- const setActiveIndex = useCallback((idx) => {
- const clamped = Math.max(0, Math.min(idx, tabs.length - 1));
- setInternalIndex(clamped);
- onTabChange?.(clamped);
- }, [tabs.length, onTabChange]);
-
- const handlers = useSwipeable({
- onSwiping: (e) => {
- if (!isMobile) return;
- setIsSwiping(true);
- setSwipeOffset(e.deltaX);
- },
- onSwipedLeft: () => {
- if (!isMobile) return;
- setIsSwiping(false);
- setSwipeOffset(0);
- if (activeIndex < tabs.length - 1) setActiveIndex(activeIndex + 1);
- },
- onSwipedRight: () => {
- if (!isMobile) return;
- setIsSwiping(false);
- setSwipeOffset(0);
- if (activeIndex > 0) setActiveIndex(activeIndex - 1);
- },
- onSwiped: () => {
- setIsSwiping(false);
- setSwipeOffset(0);
- },
- delta: 30,
- trackMouse: false,
- preventScrollOnSwipe: true,
- });
-
- const translateX = isSwiping
- ? -(activeIndex * 100) + (swipeOffset / (window.innerWidth || 1)) * 100
- : -(activeIndex * 100);
-
- return (
-
- {showTabs && (
-
- {tabs.map((tab, i) => (
- setActiveIndex(i)}
- >
- {tab.label}
-
- ))}
-
- )}
-
-
-
- {tabs.map((tab) => (
-
- {tab.content}
-
- ))}
-
-
-
- );
-}
-```
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add src/components/SwipeableView.jsx src/components/SwipeableView.css
-git commit -m "feat: SwipeableView 스와이프 탭 전환 컴포넌트"
-```
-
----
-
-### Task 10: FAB 컴포넌트
-
-**Files:**
-- Create: `src/components/FAB.jsx`
-- Create: `src/components/FAB.css`
-
-- [ ] **Step 1: FAB.css 작성**
-
-```css
-/* src/components/FAB.css */
-.fab {
- display: none;
- position: fixed;
- right: 20px;
- bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
- width: 56px;
- height: 56px;
- border-radius: 50%;
- background: var(--gradient-accent);
- border: none;
- color: var(--bg);
- font-size: 24px;
- cursor: pointer;
- box-shadow: 0 4px 20px rgba(0, 255, 255, 0.3);
- z-index: 250;
- transition: transform 0.2s var(--ease-out), box-shadow 0.2s;
- -webkit-tap-highlight-color: transparent;
- align-items: center;
- justify-content: center;
-}
-
-@media (max-width: 768px) {
- .fab {
- display: flex;
- }
-}
-
-.fab:active {
- transform: scale(0.92);
-}
-
-.fab:hover {
- box-shadow: 0 6px 28px rgba(0, 255, 255, 0.45);
-}
-
-.fab__icon {
- width: 24px;
- height: 24px;
-}
-
-/* FAB + 미니플레이어 공존 시 (뮤직 페이지) */
-.fab--above-player {
- bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 56px + 16px);
-}
-
-@media (prefers-reduced-motion: reduce) {
- .fab {
- transition: none;
- }
-}
-```
-
-- [ ] **Step 2: FAB.jsx 작성**
-
-```jsx
-// src/components/FAB.jsx
-import { useIsMobile } from '../hooks/useIsMobile';
-import './FAB.css';
-
-/**
- * @param {Object} props
- * @param {Function} props.onClick
- * @param {React.ReactNode} props.icon - 아이콘 (기본: + 아이콘)
- * @param {string} props.label - 접근성 라벨
- * @param {string} props.className - 추가 클래스 (e.g. 'fab--above-player')
- */
-export default function FAB({ onClick, icon, label, className = '' }) {
- const isMobile = useIsMobile();
- if (!isMobile) return null;
-
- return (
-
- {icon || (
-
-
-
-
- )}
-
- );
-}
-```
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add src/components/FAB.jsx src/components/FAB.css
-git commit -m "feat: FAB 플로팅 액션 버튼 컴포넌트"
-```
-
----
-
-### Task 11: MobileSheet 컴포넌트
-
-**Files:**
-- Create: `src/components/MobileSheet.jsx`
-- Create: `src/components/MobileSheet.css`
-
-- [ ] **Step 1: MobileSheet.css 작성**
-
-```css
-/* src/components/MobileSheet.css */
-.mobile-sheet__backdrop {
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.6);
- backdrop-filter: blur(4px);
- -webkit-backdrop-filter: blur(4px);
- z-index: 400;
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.25s var(--ease-out);
-}
-
-.mobile-sheet__backdrop.is-visible {
- opacity: 1;
- pointer-events: auto;
-}
-
-.mobile-sheet {
- position: fixed;
- bottom: 0;
- left: 0;
- right: 0;
- max-height: 90vh;
- background: var(--bg-secondary);
- border-top: 1px solid var(--border-line);
- border-radius: var(--radius-xl) var(--radius-xl) 0 0;
- z-index: 401;
- transform: translateY(100%);
- transition: transform 0.3s var(--ease-out);
- display: flex;
- flex-direction: column;
- touch-action: none;
-}
-
-.mobile-sheet.is-visible {
- transform: translateY(0);
-}
-
-.mobile-sheet.snap-half {
- max-height: 50vh;
-}
-
-.mobile-sheet__handle {
- display: flex;
- justify-content: center;
- padding: 12px 0 8px;
- cursor: grab;
- flex-shrink: 0;
-}
-
-.mobile-sheet__handle-bar {
- width: 36px;
- height: 4px;
- background: var(--text-muted);
- border-radius: 2px;
-}
-
-.mobile-sheet__header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 20px 12px;
- border-bottom: 1px solid var(--border-line);
- flex-shrink: 0;
-}
-
-.mobile-sheet__title {
- font-family: var(--font-display);
- font-size: 16px;
- font-weight: 600;
- color: var(--text-bright);
-}
-
-.mobile-sheet__close {
- background: none;
- border: none;
- color: var(--text-dim);
- cursor: pointer;
- padding: 8px;
- min-width: 44px;
- min-height: 44px;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.mobile-sheet__body {
- flex: 1;
- overflow-y: auto;
- padding: 16px 20px;
- padding-bottom: calc(16px + var(--safe-area-bottom, 0px));
- overscroll-behavior: contain;
-}
-
-@media (prefers-reduced-motion: reduce) {
- .mobile-sheet,
- .mobile-sheet__backdrop {
- transition: none;
- }
-}
-```
-
-- [ ] **Step 2: MobileSheet.jsx 작성**
-
-```jsx
-// src/components/MobileSheet.jsx
-import { useEffect, useCallback, useRef } from 'react';
-import './MobileSheet.css';
-
-/**
- * @param {Object} props
- * @param {boolean} props.open
- * @param {Function} props.onClose
- * @param {string} props.title
- * @param {string} props.snap - 'full' | 'half' (기본 'full')
- * @param {React.ReactNode} props.children
- */
-export default function MobileSheet({ open, onClose, title, snap = 'full', children }) {
- const sheetRef = useRef(null);
- const startY = useRef(0);
- const currentY = useRef(0);
-
- useEffect(() => {
- if (open) {
- document.body.style.overflow = 'hidden';
- } else {
- document.body.style.overflow = '';
- }
- return () => { document.body.style.overflow = ''; };
- }, [open]);
-
- const handleTouchStart = useCallback((e) => {
- startY.current = e.touches[0].clientY;
- }, []);
-
- const handleTouchMove = useCallback((e) => {
- currentY.current = e.touches[0].clientY;
- const diff = currentY.current - startY.current;
- if (diff > 0 && sheetRef.current) {
- sheetRef.current.style.transform = `translateY(${diff}px)`;
- sheetRef.current.style.transition = 'none';
- }
- }, []);
-
- const handleTouchEnd = useCallback(() => {
- const diff = currentY.current - startY.current;
- if (sheetRef.current) {
- sheetRef.current.style.transition = '';
- sheetRef.current.style.transform = '';
- }
- if (diff > 100) {
- onClose();
- }
- startY.current = 0;
- currentY.current = 0;
- }, [onClose]);
-
- return (
- <>
-
-
-
- {title && (
-
- {title}
-
-
-
-
-
-
-
- )}
-
- {children}
-
-
- >
- );
-}
-```
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add src/components/MobileSheet.jsx src/components/MobileSheet.css
-git commit -m "feat: MobileSheet 바텀시트 모달 컴포넌트"
-```
-
----
-
-### Task 12: 앱 셸 수정 — BottomNav 통합 + 사이드바 모바일 제거
-
-**Files:**
-- Modify: `src/App.jsx`
-- Modify: `src/App.css:45-49,62-66,472-493`
-- Modify: `src/components/Navbar.jsx:7-16,20-40`
-- Modify: `src/components/Navbar.css:335-359`
-
-- [ ] **Step 1: App.jsx에 BottomNav 추가**
-
-`src/App.jsx`를 수정:
-
-```jsx
-import { Suspense } from 'react';
-import { Outlet, useLocation } from 'react-router-dom';
-import Navbar from './components/Navbar';
-import BottomNav from './components/BottomNav';
-import PageHeader from './components/PageHeader';
-import Loading from './components/Loading';
-import { useIsMobile } from './hooks/useIsMobile';
-import './App.css';
-
-export default function App() {
- const isMobile = useIsMobile();
-
- return (
-
- );
-}
-```
-
-- [ ] **Step 2: App.css 모바일 레이아웃 수정**
-
-`src/App.css`에서 line 62-66의 모바일 미디어쿼리를 수정:
-
-```css
-/* 기존 768px 미디어쿼리에 padding-bottom 추가 */
-@media (max-width: 768px) {
- .site-main {
- padding: 16px;
- padding-bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
- }
-}
-```
-
-- [ ] **Step 3: Navbar.jsx에서 모바일 햄버거/오버레이 제거**
-
-`src/components/Navbar.jsx`를 수정 — 모바일 토글/오버레이 관련 코드를 useIsMobile로 조건부 처리:
-
-```jsx
-import { useState, useEffect } from 'react';
-import { NavLink, useLocation } from 'react-router-dom';
-import { navLinks } from '../routes';
-import { useIsMobile } from '../hooks/useIsMobile';
-import './Navbar.css';
-
-export default function Navbar() {
- const [menuOpen, setMenuOpen] = useState(false);
- const closeMenu = () => setMenuOpen(false);
- const location = useLocation();
- const isMobile = useIsMobile();
-
- useEffect(() => {
- if (menuOpen) {
- document.body.style.overflow = 'hidden';
- } else {
- document.body.style.overflow = '';
- }
- return () => { document.body.style.overflow = ''; };
- }, [menuOpen]);
-
- useEffect(() => {
- closeMenu();
- }, [location.pathname]);
-
- // 모바일에서는 사이드바를 렌더링하지 않음 (BottomNav가 대체)
- if (isMobile) return null;
-
- return (
-
- );
-}
-```
-
-> Note: 기존 Navbar.jsx의 정확한 JSX 구조(link.icon, link.subtitle 등)는 실제 파일을 읽어서 맞춰야 함. 위 코드는 탐색 결과 기반 근사치.
-
-- [ ] **Step 4: Navbar.css 모바일 미디어쿼리 정리**
-
-모바일 관련 미디어쿼리(line 335-359)에서 `.sidebar-toggle`, `.sidebar__overlay` 스타일은 더 이상 사용되지 않으므로 제거하거나 유지해도 무방 (컴포넌트가 렌더링되지 않으므로).
-
-정리를 위해 제거 권장:
-
-```css
-/* 기존 (lines 335-359) — 전체 삭제 가능 */
-/* @media (max-width: 768px) { ... } */
-/* @media (min-width: 769px) { ... } */
-```
-
-대신 데스크톱 전용 사이드바만 유지:
-
-```css
-/* Navbar.css 말미 — 사이드바는 데스크톱 전용 */
-@media (max-width: 768px) {
- .sidebar {
- display: none;
- }
-}
-```
-
-- [ ] **Step 5: 데스크톱/모바일에서 확인**
-
-- 데스크톱: 사이드바 정상 표시, 바텀네비 숨김
-- 모바일 (768px 이하): 사이드바 완전 숨김, 바텀네비 표시
-- 더보기 메뉴 열기/닫기 동작
-- 네비게이션 링크 클릭 시 라우팅 정상 동작
-
-- [ ] **Step 6: 커밋**
-
-```bash
-git add src/App.jsx src/App.css src/components/Navbar.jsx src/components/Navbar.css
-git commit -m "feat: 앱 셸 모바일 레이아웃 — BottomNav 통합 + 사이드바 조건부 렌더링"
-```
-
----
-
-## Phase 2: 주요 페이지 적용
-
-### Task 13: 홈 페이지 모바일 개선
-
-**Files:**
-- Modify: `src/pages/home/Home.jsx`
-- Modify: `src/pages/home/Home.css`
-
-- [ ] **Step 1: Home.css 모바일 레이아웃 보강**
-
-기존 768px 미디어쿼리(line 740)에 다음을 추가/수정:
-
-```css
-@media (max-width: 768px) {
- /* 기존 스타일 유지하면서 추가 */
- .home__hero-grid {
- grid-template-columns: 1fr;
- }
-
- .home__nav-grid {
- grid-template-columns: 1fr 1fr;
- gap: 10px;
- }
-
- .home__nav-card {
- min-height: 80px;
- }
-
- .home__todo-board {
- /* 스와이프 탭으로 대체되므로 3컬럼 그리드 숨김 */
- display: none;
- }
-
- .home__todo-swipe {
- display: block;
- }
-
- .home__blog-grid {
- grid-template-columns: 1fr;
- }
-
- .home__profile {
- margin-top: 16px;
- }
-}
-
-/* 데스크톱에서는 스와이프 뷰 숨김 */
-.home__todo-swipe {
- display: none;
-}
-```
-
-- [ ] **Step 2: Home.jsx에 SwipeableView + PullToRefresh 적용**
-
-TODO 보드 영역에 모바일 전용 SwipeableView 추가:
-
-```jsx
-import { useIsMobile } from '../../hooks/useIsMobile';
-import SwipeableView from '../../components/SwipeableView';
-import PullToRefresh from '../../components/PullToRefresh';
-
-// 컴포넌트 내부:
-const isMobile = useIsMobile();
-
-// 기존 TODO 칸반 보드 아래에 추가:
-{isMobile && (
-
- },
- { key: 'progress', label: '진행중', content: },
- { key: 'done', label: '완료', content: },
- ]}
- />
-
-)}
-```
-
-블로그 포스트 영역을 PullToRefresh로 래핑:
-
-```jsx
-
- {/* 기존 블로그 포스트 그리드 */}
-
-```
-
-> Note: 실제 변수명/함수명은 Home.jsx를 읽어서 매칭해야 함.
-
-- [ ] **Step 3: 모바일 375px에서 확인**
-
-- 히어로: 1컬럼 스택
-- 네비 카드: 2컬럼
-- TODO: 스와이프 탭 동작
-- 블로그: 1컬럼 + 풀다운 리프레시
-
-- [ ] **Step 4: 커밋**
-
-```bash
-git add src/pages/home/Home.jsx src/pages/home/Home.css
-git commit -m "feat(home): 모바일 반응형 — 스와이프 TODO + 풀다운 리프레시"
-```
-
----
-
-### Task 14: 로또 페이지 모바일 개선
-
-**Files:**
-- Modify: `src/pages/lotto/Lotto.jsx` (또는 `Functions.jsx` — 3탭 구조 파일)
-- Modify: `src/pages/lotto/Lotto.css`
-
-- [ ] **Step 1: Lotto.css 모바일 추가 스타일**
-
-기존 768px 미디어쿼리에 추가:
-
-```css
-@media (max-width: 768px) {
- /* 기존 스타일 유지 */
-
- /* 구매 이력 테이블 가로 스크롤 */
- .purchase-table-wrapper {
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- }
-
- .lotto-ball {
- width: 32px;
- height: 32px;
- font-size: 13px;
- }
-
- /* 전략 차트 축소 */
- .strategy-chart-wrapper {
- overflow-x: auto;
- }
-}
-```
-
-- [ ] **Step 2: Lotto.jsx/Functions.jsx에 SwipeableView + FAB 적용**
-
-3탭 구조에 SwipeableView 래핑:
-
-```jsx
-import SwipeableView from '../../components/SwipeableView';
-import FAB from '../../components/FAB';
-import PullToRefresh from '../../components/PullToRefresh';
-import { useIsMobile } from '../../hooks/useIsMobile';
-
-// 탭 영역:
-const isMobile = useIsMobile();
-
-// 모바일에서 기존 탭 컨텐츠를 SwipeableView로 래핑
-{isMobile ? (
-
- },
- { key: 'analysis', label: '분석', content: },
- { key: 'purchase', label: '구매', content: },
- ]}
- activeIndex={activeTab}
- onTabChange={setActiveTab}
- />
-
-) : (
- /* 기존 데스크톱 탭 구조 유지 */
-)}
-
-// FAB
- { /* 빠른 추천 로직 */ }}
- label="추천받기"
- icon={... }
-/>
-```
-
-- [ ] **Step 3: 모바일에서 확인**
-
-- 3탭 스와이프 전환 동작
-- 번호 볼 크기 축소
-- 구매 이력 테이블 가로 스크롤
-- FAB 위치 (바텀네비 위)
-
-- [ ] **Step 4: 커밋**
-
-```bash
-git add src/pages/lotto/ src/pages/lotto/Lotto.css
-git commit -m "feat(lotto): 모바일 반응형 — 스와이프 탭 + FAB + 볼 축소"
-```
-
----
-
-### Task 15: 주식 페이지 모바일 개선 (Stock + StockTrade)
-
-**Files:**
-- Modify: `src/pages/stock/Stock.jsx`
-- Modify: `src/pages/stock/StockTrade.jsx`
-- Modify: `src/pages/stock/Stock.css`
-
-- [ ] **Step 1: Stock.css 모바일 보강**
-
-기존 768px 미디어쿼리에 추가:
-
-```css
-@media (max-width: 768px) {
- /* 기존 스타일 유지 */
-
- /* 필터 가로 스크롤 칩 */
- .stock-filter-row {
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- flex-wrap: nowrap;
- gap: 8px;
- padding-bottom: 4px;
- }
-
- .stock-filter-row > * {
- flex-shrink: 0;
- }
-
- /* 지표 카드 캐러셀 */
- .stock-indices-grid {
- display: flex;
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- gap: 12px;
- padding-bottom: 8px;
- scroll-snap-type: x mandatory;
- }
-
- .stock-indices-grid > * {
- flex: 0 0 240px;
- scroll-snap-align: start;
- }
-
- /* 뉴스 1컬럼 */
- .stock-news-grid {
- grid-template-columns: 1fr;
- }
-}
-```
-
-> Note: 420px/520px/700px breakpoint는 예외로 유지 (spec 규정).
-
-- [ ] **Step 2: Stock.jsx에 FAB + PullToRefresh 적용**
-
-```jsx
-import FAB from '../../components/FAB';
-import PullToRefresh from '../../components/PullToRefresh';
-
-// PullToRefresh 래핑
-
- {/* 기존 Stock 콘텐츠 */}
-
-
-// FAB
- { /* 종목 추가 모달 */ }} label="종목 추가" />
-```
-
-- [ ] **Step 3: StockTrade.jsx 모바일 처리**
-
-포트폴리오 테이블을 모바일에서 카드형으로 전환:
-
-```jsx
-import { useIsMobile } from '../../hooks/useIsMobile';
-import FAB from '../../components/FAB';
-import MobileSheet from '../../components/MobileSheet';
-
-const isMobile = useIsMobile();
-
-// 포트폴리오 영역
-{isMobile ? (
-
- {portfolio.map(item => (
-
openDetail(item)}>
-
{item.name}
-
{item.currentPrice}
-
0}>
- {item.profitRate}%
-
-
- ))}
-
-) : (
- /* 기존 테이블 */
-)}
-
-// FAB
- { /* 매도 기록 */ }} label="매도 기록" />
-
-// 상세 바텀시트
- setDetailOpen(false)} title="종목 상세">
- {/* 상세 내용 */}
-
-```
-
-- [ ] **Step 4: Stock.css에 포트폴리오 카드 스타일 추가**
-
-```css
-/* 모바일 포트폴리오 카드 */
-.stock-portfolio-cards {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.stock-portfolio-card {
- display: grid;
- grid-template-columns: 1fr auto auto;
- align-items: center;
- gap: 12px;
- padding: 14px 16px;
- background: var(--surface-card);
- border: 1px solid var(--border-line);
- border-radius: var(--radius-sm);
- cursor: pointer;
-}
-
-.stock-portfolio-card__name {
- font-weight: 600;
- color: var(--text-bright);
-}
-
-.stock-portfolio-card__profit[data-positive="true"] {
- color: #4caf50;
-}
-
-.stock-portfolio-card__profit[data-positive="false"] {
- color: #f44336;
-}
-```
-
-- [ ] **Step 5: Stock + StockTrade 모바일 확인 (함께 검증)**
-
-- Stock: 필터 칩 가로스크롤, 지표 카드 캐러셀, 뉴스 1컬럼
-- StockTrade: 포트폴리오 카드형, 매도이력 스크롤, FAB
-
-- [ ] **Step 6: 커밋**
-
-```bash
-git add src/pages/stock/
-git commit -m "feat(stock): 모바일 반응형 — 카드형 포트폴리오 + 캐러셀 지표 + FAB"
-```
-
----
-
-### Task 16: 여행 페이지 모바일 개선
-
-**Files:**
-- Modify: `src/pages/travel/Travel.jsx`
-- Modify: `src/pages/travel/Travel.css`
-
-- [ ] **Step 1: Travel.css 모바일 보강**
-
-기존 768px 미디어쿼리에 추가:
-
-```css
-@media (max-width: 768px) {
- /* 기존 스타일 유지 */
-
- /* 지도 높이 축소 */
- .travel-map-container {
- height: 35vh;
- }
-
- /* 사진 그리드 2컬럼 */
- .travel-photo-grid {
- grid-template-columns: 1fr 1fr;
- }
-
- /* 라이트박스 풀스크린 */
- .travel-lightbox {
- border-radius: 0;
- max-width: 100vw;
- max-height: 100vh;
- width: 100vw;
- height: 100vh;
- }
-
- .travel-lightbox__image {
- object-fit: contain;
- width: 100%;
- height: 100%;
- }
-}
-
-@media (max-width: 480px) {
- .travel-photo-grid {
- grid-template-columns: 1fr;
- }
-}
-```
-
-- [ ] **Step 2: Travel.jsx에 PullToRefresh + 라이트박스 스와이프 적용**
-
-```jsx
-import PullToRefresh from '../../components/PullToRefresh';
-import { useSwipe } from '../../hooks/useSwipe';
-import { useIsMobile } from '../../hooks/useIsMobile';
-
-// 사진 목록 PullToRefresh 래핑
-
- {/* 기존 사진 그리드 */}
-
-
-// 라이트박스에 스와이프 네비게이션 추가
-const lightboxSwipe = useSwipe({
- onSwipedLeft: () => nextPhoto(),
- onSwipedRight: () => prevPhoto(),
-});
-
-// 라이트박스 내부:
-
-
-
-```
-
-- [ ] **Step 3: 모바일 확인**
-
-- 지도 높이 35vh
-- 사진 2컬럼/1컬럼
-- 라이트박스 풀스크린 + 스와이프 넘기기
-- 풀다운 리프레시
-
-- [ ] **Step 4: 커밋**
-
-```bash
-git add src/pages/travel/
-git commit -m "feat(travel): 모바일 반응형 — 풀스크린 라이트박스 + 스와이프 + 지도 축소"
-```
-
----
-
-## Phase 3: 나머지 페이지 확장
-
-### Task 17: 블로그 + 블로그 마케팅 모바일 개선
-
-**Files:**
-- Modify: `src/pages/blog/Blog.jsx`
-- Modify: `src/pages/blog/Blog.css`
-- Modify: `src/pages/blog-marketing/BlogMarketing.jsx`
-- Modify: `src/pages/blog-marketing/BlogMarketing.css`
-
-- [ ] **Step 1: Blog.css 모바일 보강**
-
-```css
-@media (max-width: 768px) {
- /* 기존 스타일 유지 */
-
- /* 태그 필터 가로 스크롤 칩 */
- .blog-tags {
- display: flex;
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- gap: 8px;
- flex-wrap: nowrap;
- padding-bottom: 4px;
- }
-
- .blog-tags > * {
- flex-shrink: 0;
- }
-
- /* 에디터 풀 너비 */
- .blog-editor {
- width: 100%;
- }
-}
-```
-
-- [ ] **Step 2: Blog.jsx에 FAB + PullToRefresh 적용**
-
-```jsx
-import FAB from '../../components/FAB';
-import PullToRefresh from '../../components/PullToRefresh';
-
-
- {/* 기존 블로그 콘텐츠 */}
-
-
- { /* 글 쓰기 모달 */ }} label="글 쓰기" />
-```
-
-- [ ] **Step 3: BlogMarketing.css 모바일 보강**
-
-기존 480px 미디어쿼리(원래 640px → 표준화됨)에 추가:
-
-```css
-@media (max-width: 768px) {
- .blog-marketing__pipeline-table {
- display: none;
- }
-
- .blog-marketing__pipeline-cards {
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
-}
-
-/* 데스크톱에서 카드 숨김 */
-.blog-marketing__pipeline-cards {
- display: none;
-}
-```
-
-- [ ] **Step 4: BlogMarketing.jsx에 FAB + PullToRefresh + 카드 뷰 적용**
-
-```jsx
-import FAB from '../../components/FAB';
-import PullToRefresh from '../../components/PullToRefresh';
-import { useIsMobile } from '../../hooks/useIsMobile';
-
-
- {/* 기존 콘텐츠 */}
-
-
- { /* 키워드 분석 시작 */ }} label="키워드 분석" />
-```
-
-- [ ] **Step 5: 커밋**
-
-```bash
-git add src/pages/blog/ src/pages/blog-marketing/
-git commit -m "feat(blog): 모바일 반응형 — FAB + 풀다운 리프레시 + 칩 필터"
-```
-
----
-
-### Task 18: 부동산 청약 모바일 개선
-
-**Files:**
-- Modify: `src/pages/subscription/Subscription.jsx`
-- Modify: `src/pages/subscription/Subscription.css`
-
-- [ ] **Step 1: Subscription.css 모바일 보강**
-
-```css
-@media (max-width: 768px) {
- /* 기존 스타일 유지 */
-
- /* 필터를 바텀시트로 대체하므로 인라인 필터 숨김 */
- .subscription__filter-inline {
- display: none;
- }
-
- .subscription__filter-trigger {
- display: flex;
- }
-
- /* 공고 카드 1컬럼 */
- .subscription__list {
- grid-template-columns: 1fr;
- }
-}
-
-/* 데스크톱에서 필터 트리거 숨김 */
-.subscription__filter-trigger {
- display: none;
-}
-```
-
-- [ ] **Step 2: Subscription.jsx에 FAB + MobileSheet 필터 적용**
-
-```jsx
-import FAB from '../../components/FAB';
-import MobileSheet from '../../components/MobileSheet';
-import PullToRefresh from '../../components/PullToRefresh';
-import { useIsMobile } from '../../hooks/useIsMobile';
-
-const [filterSheetOpen, setFilterSheetOpen] = useState(false);
-const isMobile = useIsMobile();
-
-// 모바일 필터 트리거 버튼
-{isMobile && (
- setFilterSheetOpen(true)}>
- 필터
-
-)}
-
-// 필터 바텀시트
- setFilterSheetOpen(false)} title="필터">
- {/* 기존 필터 폼 컴포넌트 */}
-
-
-
- {/* 기존 콘텐츠 */}
-
-
- { /* 공고 등록 */ }} label="공고 등록" />
-```
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add src/pages/subscription/
-git commit -m "feat(subscription): 모바일 반응형 — 바텀시트 필터 + FAB"
-```
-
----
-
-### Task 19: 뮤직 스튜디오 모바일 개선
-
-**Files:**
-- Modify: `src/pages/music/MusicStudio.jsx`
-- Modify: `src/pages/music/MusicStudio.css`
-
-- [ ] **Step 1: MusicStudio.css 모바일 보강**
-
-```css
-@media (max-width: 768px) {
- /* 라이브러리 1컬럼 */
- .music-library-grid {
- grid-template-columns: 1fr;
- }
-
- /* 레이더 위젯 중앙 */
- .music-radar {
- margin: 0 auto;
- }
-
- /* 미니 플레이어 고정 */
- .music-mini-player {
- position: fixed;
- bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px));
- left: 0;
- right: 0;
- height: 56px;
- z-index: 250;
- background: var(--bg-secondary);
- border-top: 1px solid var(--border-line);
- display: flex;
- align-items: center;
- padding: 0 16px;
- gap: 12px;
- }
-
- /* 미니 플레이어 존재 시 콘텐츠 여백 */
- .music-content--with-player {
- padding-bottom: calc(var(--bottom-nav-h) + 56px + var(--safe-area-bottom, 0px) + 16px);
- }
-}
-```
-
-- [ ] **Step 2: MusicStudio.jsx에 FAB + PullToRefresh 적용**
-
-```jsx
-import FAB from '../../components/FAB';
-import PullToRefresh from '../../components/PullToRefresh';
-
-
- {/* 기존 콘텐츠 */}
-
-
- { /* 음악 생성 모달 */ }}
- label="음악 생성"
- className={isPlaying ? 'fab--above-player' : ''}
-/>
-```
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add src/pages/music/
-git commit -m "feat(music): 모바일 반응형 — 미니 플레이어 + FAB + 1컬럼 라이브러리"
-```
-
----
-
-### Task 20: TODO 모바일 개선
-
-**Files:**
-- Modify: `src/pages/todo/Todo.jsx`
-- Modify: `src/pages/todo/Todo.css`
-
-- [ ] **Step 1: Todo.css 모바일 보강**
-
-```css
-@media (max-width: 768px) {
- /* 기존 스타일 유지 */
-
- /* 칸반 보드 숨김 (스와이프로 대체) */
- .todo-board {
- display: none;
- }
-
- .todo-swipe-board {
- display: block;
- }
-}
-
-/* 데스크톱에서 스와이프 보드 숨김 */
-.todo-swipe-board {
- display: none;
-}
-```
-
-- [ ] **Step 2: Todo.jsx에 SwipeableView + FAB + MobileSheet 적용**
-
-```jsx
-import SwipeableView from '../../components/SwipeableView';
-import FAB from '../../components/FAB';
-import MobileSheet from '../../components/MobileSheet';
-import { useIsMobile } from '../../hooks/useIsMobile';
-
-const [addSheetOpen, setAddSheetOpen] = useState(false);
-const isMobile = useIsMobile();
-
-// 모바일 스와이프 보드
-{isMobile && (
-
- },
- { key: 'progress', label: '진행중', content: },
- { key: 'done', label: '완료', content: },
- ]}
- />
-
-)}
-
-// FAB → 바텀시트 입력
- setAddSheetOpen(true)} label="할일 추가" />
-
- setAddSheetOpen(false)} title="할일 추가">
- {/* 기존 입력 폼 */}
-
-```
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add src/pages/todo/
-git commit -m "feat(todo): 모바일 반응형 — 스와이프 칸반 + FAB + 바텀시트 입력"
-```
-
----
-
-### Task 21: 에이전트 오피스 모바일 개선
-
-**Files:**
-- Modify: `src/pages/agent-office/AgentOffice.jsx`
-- Modify: `src/pages/agent-office/AgentOffice.css`
-
-- [ ] **Step 1: AgentOffice.css 모바일 보강**
-
-기존 768px 미디어쿼리에 추가:
-
-```css
-@media (max-width: 768px) {
- /* 기존 스타일 유지 */
-
- /* 캔버스 풀스크린 */
- .agent-office__canvas {
- width: 100%;
- height: 50vh;
- touch-action: pan-x pan-y;
- }
-
- /* 명령 입력 하단 고정 */
- .agent-office__command {
- position: fixed;
- bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px));
- left: 0;
- right: 0;
- padding: 8px 16px;
- background: var(--bg-secondary);
- border-top: 1px solid var(--border-line);
- z-index: 200;
- }
-}
-```
-
-- [ ] **Step 2: AgentOffice.jsx에 MobileSheet 적용**
-
-```jsx
-import MobileSheet from '../../components/MobileSheet';
-import { useIsMobile } from '../../hooks/useIsMobile';
-
-const [agentSheetOpen, setAgentSheetOpen] = useState(false);
-const [selectedAgent, setSelectedAgent] = useState(null);
-const isMobile = useIsMobile();
-
-// 에이전트 클릭 시 바텀시트
-const handleAgentClick = (agent) => {
- if (isMobile) {
- setSelectedAgent(agent);
- setAgentSheetOpen(true);
- }
- // 데스크톱은 기존 사이드 패널 동작
-};
-
- setAgentSheetOpen(false)} title={selectedAgent?.name}>
- {/* 에이전트 상세 + 로그 */}
-
-```
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add src/pages/agent-office/
-git commit -m "feat(agent-office): 모바일 반응형 — 바텀시트 에이전트 상세 + 하단 명령 입력"
-```
-
----
-
-### Task 22: 이펙트 랩 모바일 개선
-
-**Files:**
-- Modify: `src/pages/effect-lab/EffectLab.css`
-- Modify: `src/pages/effect-lab/DayCalc.css`
-- Modify: `src/pages/effect-lab/SwordStream.css`
-
-- [ ] **Step 1: EffectLab.css — 기존 768px 이미 1컬럼, 추가 수정 불필요**
-
-확인만 수행. line 183의 기존 미디어쿼리가 충분한지 검증.
-
-- [ ] **Step 2: DayCalc.css — 기존 768px 이미 적용됨, 확인**
-
-line 417의 기존 미디어쿼리가 충분한지 검증.
-
-- [ ] **Step 3: SwordStream.css — 모바일 미디어쿼리 추가**
-
-SwordStream은 미디어쿼리가 없음 (83줄). 모바일 대응 추가:
-
-```css
-@media (max-width: 768px) {
- .sword-stream {
- touch-action: none; /* 터치 인터랙션 유지 */
- }
-
- .sword-stream__controls {
- position: fixed;
- bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 8px);
- left: 8px;
- right: 8px;
- padding: 8px;
- background: rgba(0, 0, 0, 0.7);
- border-radius: var(--radius-sm);
- backdrop-filter: blur(8px);
- }
-}
-```
-
-> Note: 실제 클래스명은 SwordStream.css를 읽어서 매칭해야 함.
-
-- [ ] **Step 4: 커밋**
-
-```bash
-git add src/pages/effect-lab/
-git commit -m "feat(effect-lab): SwordStream 모바일 터치 대응 + 오버레이 컨트롤"
-```
-
----
-
-## Phase 4: 검증
-
-### Task 23: 전체 뷰 모바일 UI 검증
-
-**대상 뷰포트:**
-- 360px (Galaxy S)
-- 390px (iPhone 14)
-- 768px (iPad)
-- 1024px (데스크톱)
-
-- [ ] **Step 1: 개발 서버 실행**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui && npm run dev
-```
-
-- [ ] **Step 2: 각 페이지별 4개 뷰포트 확인**
-
-DevTools에서 각 뷰포트 크기로 전환하며 확인:
-
-| 페이지 | 360px | 390px | 768px | 1024px |
-|--------|-------|-------|-------|--------|
-| Home | ☐ | ☐ | ☐ | ☐ |
-| Lotto | ☐ | ☐ | ☐ | ☐ |
-| Stock | ☐ | ☐ | ☐ | ☐ |
-| StockTrade | ☐ | ☐ | ☐ | ☐ |
-| Travel | ☐ | ☐ | ☐ | ☐ |
-| Blog | ☐ | ☐ | ☐ | ☐ |
-| BlogMarketing | ☐ | ☐ | ☐ | ☐ |
-| Subscription | ☐ | ☐ | ☐ | ☐ |
-| Music | ☐ | ☐ | ☐ | ☐ |
-| Todo | ☐ | ☐ | ☐ | ☐ |
-| AgentOffice | ☐ | ☐ | ☐ | ☐ |
-| EffectLab | ☐ | ☐ | ☐ | ☐ |
-| DayCalc | ☐ | ☐ | ☐ | ☐ |
-| SwordStream | ☐ | ☐ | ☐ | ☐ |
-
-**확인 항목:**
-- UI 짤림 없음
-- 터치 타겟 44×44px 이상
-- FAB가 바텀네비 위에 올바르게 위치
-- 바텀네비 더보기 메뉴 동작
-- 스와이프 동작 (Lotto, Todo, Home TODO)
-- 풀다운 리프레시 동작
-- 바텀시트 열기/닫기/드래그 닫기
-- 가로 스크롤 테이블/캐러셀
-
-- [ ] **Step 3: prefers-reduced-motion 확인**
-
-DevTools → Rendering → Emulate CSS media feature `prefers-reduced-motion: reduce`
-
-모든 애니메이션이 비활성화되는지 확인:
-- 바텀네비 더보기 패널 전환
-- 바텀시트 슬라이드
-- 풀다운 리프레시 스피너
-- 스와이프 전환
-
-- [ ] **Step 4: 발견된 문제 수정 + 커밋**
-
-```bash
-git add -A
-git commit -m "fix: 모바일 UI 검증 후 수정"
-```
-
-- [ ] **Step 5: 데스크톱 회귀 확인**
-
-1024px 이상에서 모든 페이지가 기존과 동일하게 동작하는지 확인:
-- 사이드바 정상 표시
-- 바텀네비 숨김
-- FAB 숨김
-- 기존 레이아웃 유지
diff --git a/docs/superpowers/plans/2026-04-24-travel-gallery-redesign.md b/docs/superpowers/plans/2026-04-24-travel-gallery-redesign.md
deleted file mode 100644
index 6bc8b67..0000000
--- a/docs/superpowers/plans/2026-04-24-travel-gallery-redesign.md
+++ /dev/null
@@ -1,2665 +0,0 @@
-# Travel Gallery Redesign 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:** Travel 여행 기록 갤러리를 앨범 카드 기반 진입 + Masonry 그리드 + HERO 확대 라이트박스로 리디자인한다.
-
-**Architecture:** 현재 모놀리식 Travel.jsx(1,024줄)를 `useTravelData` 훅 + 7개 컴포넌트로 분리하며 점진적으로 리팩토링한다. 기존 API 호출/캐싱/페이지네이션 로직을 훅으로 추출하여 재활용하고, UI 레이어를 새로 구성한다. 백엔드 API 변경 없음.
-
-**Tech Stack:** React 18, Leaflet + react-leaflet, react-swipeable, CSS columns (Masonry), IntersectionObserver, SwipeableView (기존 컴포넌트)
-
----
-
-## File Structure
-
-```
-src/pages/travel/
-├── Travel.jsx # 메인 컨테이너 (리팩토링) — 미니맵 + 앨범 카드 리스트 + 오버레이 상태
-├── Travel.css # 전체 레이아웃 + CSS 변수 (리팩토링)
-├── AlbumCard.jsx # (신규) 여행지 앨범 카드
-├── AlbumCard.css # (신규)
-├── AlbumDetail.jsx # (신규) 앨범 상세 오버레이 (탭 + Masonry + 진입/이탈 애니메이션)
-├── AlbumDetail.css # (신규)
-├── MasonryGrid.jsx # (신규) CSS columns Masonry + 무한스크롤 + 스크롤 리빌
-├── MasonryGrid.css # (신규)
-├── HeroLightbox.jsx # (신규) shared element transition 라이트박스
-├── HeroLightbox.css # (신규)
-├── MiniMap.jsx # (신규) Leaflet 미니맵 (기존 MapLayer 로직 추출)
-├── MiniMap.css # (신규)
-├── VideoTab.jsx # (신규) 영상 탭 플레이스홀더
-├── VideoTab.css # (신규)
-└── useTravelData.js # (신규) API 호출 + 캐싱 + 앨범 그룹핑 + 페이지네이션 훅
-```
-
----
-
-### Task 1: useTravelData 훅 추출
-
-기존 Travel.jsx에서 API 호출, 캐싱, 페이지네이션 로직을 커스텀 훅으로 추출한다.
-
-**Files:**
-- Create: `src/pages/travel/useTravelData.js`
-
-- [ ] **Step 1: useTravelData 훅 파일 생성**
-
-```js
-// src/pages/travel/useTravelData.js
-import { useCallback, useEffect, useRef, useState } from 'react';
-
-const PAGE_SIZE = 20;
-
-const normalizePhotos = (items = []) =>
- items
- .map((item) => {
- if (typeof item === 'string') return { src: item, title: '' };
- if (!item) return null;
- return {
- src: item.thumb || item.url || item.path || item.src || '',
- title: item.title || item.name || item.file || '',
- original: item.url || item.path || item.src || '',
- file: item.file || '',
- album: item.album || '',
- };
- })
- .filter((item) => item && item.src);
-
-const hasSummaryInfo = (payload) =>
- payload &&
- (Object.prototype.hasOwnProperty.call(payload, 'total') ||
- Object.prototype.hasOwnProperty.call(payload, 'matched_albums'));
-
-export function useTravelData() {
- const [regions, setRegions] = useState(null);
- const [albums, setAlbums] = useState([]);
- const [selectedRegion, setSelectedRegion] = useState(null);
- const [photos, setPhotos] = useState([]);
- const [photoSummary, setPhotoSummary] = useState(null);
- const [loading, setLoading] = useState(false);
- const [loadingMore, setLoadingMore] = useState(false);
- const [loadingAlbums, setLoadingAlbums] = useState(false);
- const [error, setError] = useState('');
- const [page, setPage] = useState(1);
- const [hasNext, setHasNext] = useState(true);
-
- const cacheRef = useRef(new Map());
- const albumCacheRef = useRef(new Map());
- const cacheTtlMs = 10 * 60 * 1000;
-
- // ── Load GeoJSON regions ──
- useEffect(() => {
- const controller = new AbortController();
- (async () => {
- try {
- const res = await fetch('/api/travel/regions', { signal: controller.signal });
- if (!res.ok) throw new Error(`지역 정보 로딩 실패 (${res.status})`);
- setRegions(await res.json());
- } catch (err) {
- if (err?.name !== 'AbortError') setError(err?.message ?? String(err));
- }
- })();
- return () => controller.abort();
- }, []);
-
- // ── Build album list from regions GeoJSON ──
- useEffect(() => {
- if (!regions?.features) return;
- const controller = new AbortController();
-
- (async () => {
- setLoadingAlbums(true);
- const albumList = [];
-
- for (const feature of regions.features) {
- const regionId = feature.properties?.id;
- const regionName = feature.properties?.name || regionId;
- if (!regionId) continue;
-
- // Check album cache
- const cached = albumCacheRef.current.get(regionId);
- if (cached && Date.now() - cached.timestamp < cacheTtlMs) {
- albumList.push(...cached.albums);
- continue;
- }
-
- try {
- const res = await fetch(
- `/api/travel/photos?region=${encodeURIComponent(regionId)}&page=1&size=${PAGE_SIZE}`,
- { signal: controller.signal }
- );
- if (!res.ok) continue;
- const json = await res.json();
- const meta = Array.isArray(json) ? {} : json ?? {};
- const items = Array.isArray(json) ? json : json.items ?? [];
- const matchedAlbums = meta.matched_albums ?? [];
-
- const regionAlbums = matchedAlbums.map((a, idx) => ({
- id: `${regionId}__${a.album}`,
- name: a.album,
- region: regionId,
- regionName,
- photoCount: a.count,
- coverThumb: items.length > 0
- ? (items.find(i => i.album === a.album)?.thumb || items[0]?.thumb || '')
- : '',
- }));
-
- albumCacheRef.current.set(regionId, {
- timestamp: Date.now(),
- albums: regionAlbums,
- });
- albumList.push(...regionAlbums);
- } catch (err) {
- if (err?.name === 'AbortError') return;
- }
- }
-
- setAlbums(albumList);
- setLoadingAlbums(false);
- })();
-
- return () => controller.abort();
- }, [regions, cacheTtlMs]);
-
- // ── Load photos for a specific album (region + album filter) ──
- const loadAlbumPhotos = useCallback(async (regionId, albumName) => {
- const cacheKey = `${regionId}__${albumName}`;
- const cached = cacheRef.current.get(cacheKey);
- if (cached && Date.now() - cached.timestamp < cacheTtlMs) {
- setPhotos(cached.items);
- setPhotoSummary(cached.summary ?? null);
- setPage(cached.page ?? 1);
- setHasNext(cached.hasNext ?? true);
- setLoading(false);
- setLoadingMore(false);
- setError('');
- return;
- }
-
- setLoading(true);
- setLoadingMore(false);
- setError('');
- setPhotos([]);
- setPhotoSummary(null);
- setPage(1);
- setHasNext(true);
-
- try {
- const res = await fetch(
- `/api/travel/photos?region=${encodeURIComponent(regionId)}&page=1&size=${PAGE_SIZE}`
- );
- if (!res.ok) throw new Error(`사진 로딩 실패 (${res.status})`);
- const json = await res.json();
- const items = Array.isArray(json) ? json : json.items ?? [];
- const meta = Array.isArray(json) ? {} : json ?? {};
-
- // Filter by album name
- const albumItems = items.filter((i) => i.album === albumName);
- const normalized = normalizePhotos(albumItems);
-
- // For albums, we need all photos — fetch remaining pages if album has more
- const allNormalized = normalizePhotos(items);
- const totalForRegion = meta.total ?? allNormalized.length;
- const nextHasNext = typeof meta.has_next === 'boolean' ? meta.has_next : allNormalized.length >= PAGE_SIZE;
-
- const summary = hasSummaryInfo(meta)
- ? { total: meta.total, albums: meta.matched_albums ?? [] }
- : null;
-
- // We filter all items to this album — but pagination is region-level
- // So we store the region-level data and filter in display
- setPhotos(normalized);
- setPhotoSummary(summary);
- setHasNext(nextHasNext);
- setPage(2);
-
- cacheRef.current.set(cacheKey, {
- timestamp: Date.now(),
- items: normalized,
- page: 2,
- hasNext: nextHasNext,
- summary,
- regionId,
- albumName,
- });
- } catch (err) {
- setError(err?.message ?? String(err));
- setPhotos([]);
- setPhotoSummary(null);
- } finally {
- setLoading(false);
- }
- }, [cacheTtlMs]);
-
- // ── Load more photos ──
- const loadMorePhotos = useCallback(async (regionId, albumName) => {
- if (loading || loadingMore || !hasNext) return;
- setLoadingMore(true);
- setError('');
- try {
- const res = await fetch(
- `/api/travel/photos?region=${encodeURIComponent(regionId)}&page=${page}&size=${PAGE_SIZE}`
- );
- if (!res.ok) throw new Error(`사진 로딩 실패 (${res.status})`);
- const json = await res.json();
- const items = Array.isArray(json) ? json : json.items ?? [];
- const meta = Array.isArray(json) ? {} : json ?? {};
-
- const albumItems = items.filter((i) => i.album === albumName);
- const normalized = normalizePhotos(albumItems);
- const nextHasNext = typeof meta.has_next === 'boolean'
- ? meta.has_next
- : typeof meta.hasNext === 'boolean' ? meta.hasNext : items.length >= PAGE_SIZE;
-
- setPhotos((prev) => {
- const merged = [...prev, ...normalized];
- const cacheKey = `${regionId}__${albumName}`;
- cacheRef.current.set(cacheKey, {
- timestamp: Date.now(),
- items: merged,
- page: page + 1,
- hasNext: nextHasNext,
- summary: photoSummary,
- regionId,
- albumName,
- });
- return merged;
- });
- setHasNext(nextHasNext);
- setPage((p) => p + 1);
- } catch (err) {
- setError(err?.message ?? String(err));
- } finally {
- setLoadingMore(false);
- }
- }, [hasNext, loading, loadingMore, page, photoSummary]);
-
- // ── Reload (pull-to-refresh) ──
- const reloadAlbumPhotos = useCallback(async (regionId, albumName) => {
- const cacheKey = `${regionId}__${albumName}`;
- cacheRef.current.delete(cacheKey);
- albumCacheRef.current.delete(regionId);
- await loadAlbumPhotos(regionId, albumName);
- }, [loadAlbumPhotos]);
-
- // ── Filter albums by region ──
- const getFilteredAlbums = useCallback((regionId) => {
- if (!regionId) return albums;
- return albums.filter((a) => a.region === regionId);
- }, [albums]);
-
- return {
- regions,
- albums,
- selectedRegion,
- setSelectedRegion,
- photos,
- photoSummary,
- loading,
- loadingMore,
- loadingAlbums,
- error,
- hasNext,
- loadAlbumPhotos,
- loadMorePhotos,
- reloadAlbumPhotos,
- getFilteredAlbums,
- };
-}
-```
-
-- [ ] **Step 2: 빌드 확인**
-
-Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1 | tail -5`
-Expected: 빌드 성공 (새 파일은 아직 import되지 않으므로 기존과 동일)
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add src/pages/travel/useTravelData.js
-git commit -m "feat(travel): useTravelData 훅 추출 — API/캐싱/페이지네이션 로직 분리"
-```
-
----
-
-### Task 2: MiniMap 컴포넌트 추출
-
-기존 Travel.jsx의 MapLayer + MapContainer 로직을 MiniMap으로 추출한다.
-
-**Files:**
-- Create: `src/pages/travel/MiniMap.jsx`
-- Create: `src/pages/travel/MiniMap.css`
-
-- [ ] **Step 1: MiniMap.jsx 생성**
-
-```jsx
-// src/pages/travel/MiniMap.jsx
-import { useState } from 'react';
-import { GeoJSON, MapContainer, TileLayer, useMap } from 'react-leaflet';
-import 'leaflet/dist/leaflet.css';
-import './MiniMap.css';
-
-const REGION_PALETTE = {
- japan: '#e05c4b', korea: '#d64f6e', china: '#c84b3a',
- europe: '#5b8fc4', france: '#6f8fc4', italy: '#78a46e',
- spain: '#c4844a', sea: '#4aad8b', thailand: '#4aad8b',
- vietnam: '#5faa78', bali: '#7aac5a', indonesia: '#8aaa4a',
- america: '#b4885c', usa: '#b4885c', canada: '#6a9890',
- africa: '#c47c3c', middle: '#c4a24a', dubai: '#c4a24a',
- default: '#c8905e',
-};
-
-export const getRegionAccent = (regionId = '') => {
- const id = regionId.toLowerCase();
- for (const [key, color] of Object.entries(REGION_PALETTE)) {
- if (key !== 'default' && id.includes(key)) return color;
- }
- return REGION_PALETTE.default;
-};
-
-function MapLayer({ geojson, selectedRegionId, onSelectRegion }) {
- const map = useMap();
- if (!geojson) return null;
-
- return (
- {
- const isSelected = feature?.properties?.id === selectedRegionId;
- const accent = getRegionAccent(feature?.properties?.id || '');
- return {
- color: isSelected ? accent : 'rgba(200,160,100,0.4)',
- weight: isSelected ? 2 : 1,
- fillColor: isSelected ? accent : 'rgba(200,144,94,0.15)',
- fillOpacity: isSelected ? 0.25 : 0.12,
- };
- }}
- onEachFeature={(feature, layer) => {
- const name = feature?.properties?.name || feature?.properties?.id || '';
- if (name) layer.bindTooltip(name, { sticky: true, className: 'minimap-tooltip' });
- layer.on('click', () => {
- if (!feature?.properties?.id) return;
- map.fitBounds(layer.getBounds(), { padding: [40, 40], animate: true });
- onSelectRegion({
- id: feature.properties.id,
- name: feature.properties.name || feature.properties.id,
- });
- });
- }}
- />
- );
-}
-
-export default function MiniMap({ geojson, selectedRegionId, onSelectRegion, onClearRegion }) {
- const [collapsed, setCollapsed] = useState(false);
-
- return (
-
-
-
setCollapsed((c) => !c)}
- aria-label={collapsed ? '지도 펼치기' : '지도 접기'}
- >
-
-
-
- {collapsed ? 'MAP' : 'MAP'}
-
- {selectedRegionId && (
-
- 전체 보기
-
- )}
-
-
-
-
-
-
-
-
- {!selectedRegionId && (
-
- CLICK A REGION
-
- )}
-
-
- );
-}
-```
-
-- [ ] **Step 2: MiniMap.css 생성**
-
-```css
-/* src/pages/travel/MiniMap.css */
-
-.minimap {
- display: flex;
- flex-direction: column;
- gap: 0;
-}
-
-.minimap__toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 8px 0;
-}
-
-.minimap__toggle {
- display: flex;
- align-items: center;
- gap: 6px;
- background: none;
- border: none;
- color: var(--tv-muted);
- font-family: var(--tv-mono);
- font-size: 9px;
- letter-spacing: 0.2em;
- text-transform: uppercase;
- cursor: pointer;
- padding: 6px 10px;
- border-radius: 6px;
- transition: color 0.2s ease, background 0.2s ease;
-}
-
-.minimap__toggle:hover {
- color: var(--tv-text);
- background: rgba(232, 221, 208, 0.06);
-}
-
-.minimap__clear {
- background: none;
- border: 1px solid var(--tv-line-bright);
- color: var(--tv-muted);
- font-family: var(--tv-mono);
- font-size: 9px;
- letter-spacing: 0.14em;
- text-transform: uppercase;
- cursor: pointer;
- padding: 5px 12px;
- border-radius: 999px;
- transition: color 0.2s ease, border-color 0.2s ease;
-}
-
-.minimap__clear:hover {
- color: var(--tv-text);
- border-color: var(--tv-text);
-}
-
-.minimap__container {
- position: relative;
- border-radius: var(--tv-r-lg, 22px);
- overflow: hidden;
- border: 1px solid var(--tv-line-bright);
- box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
- height: 200px;
- transition: height 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease, border-color 0.35s ease;
-}
-
-.minimap__container.is-collapsed {
- height: 0;
- opacity: 0;
- border-color: transparent;
- pointer-events: none;
-}
-
-.minimap__leaflet {
- width: 100%;
- height: 100%;
-}
-
-.minimap__hint {
- position: absolute;
- bottom: 12px;
- left: 50%;
- transform: translateX(-50%);
- background: rgba(15, 12, 9, 0.85);
- border: 1px solid rgba(232, 221, 208, 0.2);
- border-radius: 999px;
- padding: 7px 18px;
- pointer-events: none;
- z-index: 500;
-}
-
-.minimap__hint span {
- font-family: var(--tv-mono);
- font-size: 9px;
- letter-spacing: 0.24em;
- color: var(--tv-muted);
-}
-
-/* Leaflet tooltip override */
-.minimap-tooltip {
- font-family: var(--tv-mono) !important;
- font-size: 10px !important;
- letter-spacing: 0.12em !important;
- text-transform: uppercase !important;
- background: rgba(15, 12, 9, 0.92) !important;
- border: 1px solid rgba(232, 221, 208, 0.2) !important;
- border-radius: 6px !important;
- color: #e8ddd0 !important;
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5) !important;
-}
-
-.minimap-tooltip::before {
- border-top-color: rgba(232, 221, 208, 0.15) !important;
-}
-
-@media (max-width: 768px) {
- .minimap__container {
- height: 150px;
- }
-
- .minimap__container.is-collapsed {
- height: 0;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .minimap__container {
- transition: none;
- }
-}
-```
-
-- [ ] **Step 3: 빌드 확인**
-
-Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1 | tail -5`
-Expected: 빌드 성공
-
-- [ ] **Step 4: 커밋**
-
-```bash
-git add src/pages/travel/MiniMap.jsx src/pages/travel/MiniMap.css
-git commit -m "feat(travel): MiniMap 컴포넌트 추출 — 접기/펼치기 + 전체보기 버튼"
-```
-
----
-
-### Task 3: AlbumCard 컴포넌트
-
-여행지 앨범 카드. 대표 사진 배경 + 앨범명 + 사진 수 뱃지.
-
-**Files:**
-- Create: `src/pages/travel/AlbumCard.jsx`
-- Create: `src/pages/travel/AlbumCard.css`
-
-- [ ] **Step 1: AlbumCard.jsx 생성**
-
-```jsx
-// src/pages/travel/AlbumCard.jsx
-import { useRef } from 'react';
-import { getRegionAccent } from './MiniMap';
-import './AlbumCard.css';
-
-export default function AlbumCard({ album, onClick }) {
- const cardRef = useRef(null);
- const accent = getRegionAccent(album.region);
-
- const handleClick = () => {
- const rect = cardRef.current?.getBoundingClientRect();
- onClick(album, rect);
- };
-
- return (
- e.key === 'Enter' && handleClick()}
- aria-label={`${album.name} — ${album.photoCount}장`}
- >
- {album.coverThumb && (
-
- )}
-
-
-
{album.name}
-
- {album.regionName}
- {album.photoCount} photos
-
-
-
- );
-}
-```
-
-- [ ] **Step 2: AlbumCard.css 생성**
-
-```css
-/* src/pages/travel/AlbumCard.css */
-
-.album-card {
- position: relative;
- height: 240px;
- border-radius: 12px;
- overflow: hidden;
- cursor: pointer;
- background: var(--tv-surface, #1a1510);
- border: 1px solid rgba(245, 230, 200, 0.08);
- transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease;
-}
-
-.album-card:hover {
- transform: scale(1.03);
- box-shadow: 0 0 20px rgba(var(--album-accent-rgb, 200, 144, 94), 0.15);
-}
-
-.album-card:focus-visible {
- outline: 2px solid var(--album-accent, #c8905e);
- outline-offset: 2px;
-}
-
-.album-card__cover {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
- object-fit: cover;
- transition: transform 0.6s cubic-bezier(0.25, 0, 0, 1);
-}
-
-.album-card:hover .album-card__cover {
- transform: scale(1.06);
-}
-
-.album-card__gradient {
- position: absolute;
- inset: 0;
- background: linear-gradient(transparent 50%, rgba(15, 12, 9, 0.85) 100%);
- pointer-events: none;
-}
-
-.album-card__info {
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- padding: 16px;
- z-index: 1;
-}
-
-.album-card__name {
- font-family: var(--tv-serif, 'Cormorant Garamond', Georgia, serif);
- font-size: 24px;
- font-weight: 600;
- color: #e8ddd0;
- margin: 0 0 6px;
- letter-spacing: -0.01em;
-}
-
-.album-card__meta {
- display: flex;
- align-items: center;
- gap: 10px;
-}
-
-.album-card__region {
- font-family: var(--tv-mono, 'Space Mono', monospace);
- font-size: 9px;
- text-transform: uppercase;
- letter-spacing: 0.18em;
- color: var(--album-accent, #c8905e);
-}
-
-.album-card__count {
- font-family: var(--tv-mono, 'Space Mono', monospace);
- font-size: 11px;
- letter-spacing: 0.12em;
- color: rgba(232, 221, 208, 0.45);
- background: rgba(15, 12, 9, 0.7);
- padding: 2px 8px;
- border-radius: 4px;
-}
-
-/* Grid layout - set by parent */
-.album-card-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 12px;
-}
-
-@media (max-width: 1024px) {
- .album-card-grid {
- grid-template-columns: repeat(2, 1fr);
- }
-}
-
-@media (max-width: 768px) {
- .album-card-grid {
- grid-template-columns: 1fr;
- }
-
- .album-card {
- height: 200px;
- }
-
- .album-card__name {
- font-size: 18px;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .album-card {
- transition: none;
- }
-
- .album-card__cover {
- transition: none;
- }
-
- .album-card:hover {
- transform: none;
- }
-
- .album-card:hover .album-card__cover {
- transform: none;
- }
-}
-```
-
-- [ ] **Step 3: 빌드 확인**
-
-Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1 | tail -5`
-Expected: 빌드 성공
-
-- [ ] **Step 4: 커밋**
-
-```bash
-git add src/pages/travel/AlbumCard.jsx src/pages/travel/AlbumCard.css
-git commit -m "feat(travel): AlbumCard 컴포넌트 — 대표사진 + 그라디언트 + 메타정보"
-```
-
----
-
-### Task 4: MasonryGrid 컴포넌트
-
-CSS columns 기반 Masonry 레이아웃 + 무한스크롤 + 스크롤 리빌.
-
-**Files:**
-- Create: `src/pages/travel/MasonryGrid.jsx`
-- Create: `src/pages/travel/MasonryGrid.css`
-
-- [ ] **Step 1: MasonryGrid.jsx 생성**
-
-```jsx
-// src/pages/travel/MasonryGrid.jsx
-import { useEffect, useRef } from 'react';
-import './MasonryGrid.css';
-
-const getPhotoLabel = (photo) => {
- if (!photo) return '';
- if (photo.title) return photo.title;
- if (photo.file) return photo.file;
- if (!photo.src) return '';
- const parts = photo.src.split('/');
- return parts[parts.length - 1];
-};
-
-export default function MasonryGrid({
- photos,
- onSelectPhoto,
- onLoadMore,
- hasNext,
- isLoadingMore,
- regionAccent,
-}) {
- const sentinelRef = useRef(null);
- const gridRef = useRef(null);
- const revealObserverRef = useRef(null);
-
- // Infinite scroll sentinel
- useEffect(() => {
- const sentinel = sentinelRef.current;
- if (!sentinel || !onLoadMore) return;
- const io = new IntersectionObserver(
- ([entry]) => {
- if (entry.isIntersecting && !isLoadingMore && hasNext) onLoadMore();
- },
- { rootMargin: '300px' }
- );
- io.observe(sentinel);
- return () => io.disconnect();
- }, [hasNext, isLoadingMore, onLoadMore]);
-
- // Scroll-reveal observer
- useEffect(() => {
- revealObserverRef.current = new IntersectionObserver(
- (entries) => {
- entries.forEach((entry) => {
- if (!entry.isIntersecting) return;
- entry.target.dataset.revealed = 'true';
- revealObserverRef.current?.unobserve(entry.target);
- });
- },
- { rootMargin: '120px', threshold: 0.05 }
- );
- return () => revealObserverRef.current?.disconnect();
- }, []);
-
- useEffect(() => {
- const observer = revealObserverRef.current;
- const grid = gridRef.current;
- if (!observer || !grid) return;
- const cards = grid.querySelectorAll('.masonry-item:not([data-revealed="true"])');
- cards.forEach((c) => observer.observe(c));
- const fallback = setTimeout(() => {
- grid.querySelectorAll('.masonry-item:not([data-revealed="true"])')
- .forEach((c) => (c.dataset.revealed = 'true'));
- }, 600);
- return () => {
- clearTimeout(fallback);
- cards.forEach((c) => observer.unobserve(c));
- };
- }, [photos.length]);
-
- return (
- <>
-
- {photos.map((photo, index) => {
- const label = getPhotoLabel(photo);
- return (
-
onSelectPhoto(index, e)}
- role="button"
- tabIndex={0}
- onKeyDown={(e) => e.key === 'Enter' && onSelectPhoto(index, e)}
- aria-label={label || `Photo ${index + 1}`}
- >
- {
- if (photo.original && e.currentTarget.src !== photo.original) {
- e.currentTarget.src = photo.original;
- }
- }}
- />
-
- {label}
-
-
- );
- })}
-
-
-
- {isLoadingMore && (
-
-
-
-
-
- )}
- {!hasNext && photos.length > 0 && (
-
- — {photos.length} frames developed —
-
- )}
-
- >
- );
-}
-```
-
-- [ ] **Step 2: MasonryGrid.css 생성**
-
-```css
-/* src/pages/travel/MasonryGrid.css */
-
-.masonry-grid {
- column-count: 4;
- column-gap: 8px;
-}
-
-.masonry-item {
- break-inside: avoid;
- margin-bottom: 8px;
- position: relative;
- overflow: hidden;
- border-radius: 4px;
- cursor: zoom-in;
- background: var(--tv-surface, #1a1510);
-
- /* Scroll-reveal */
- opacity: 0;
- transform: translateY(20px);
- transition: opacity 0.5s ease, transform 0.5s ease;
- transition-delay: var(--reveal-delay, 0ms);
-}
-
-.masonry-item[data-revealed='true'] {
- opacity: 1;
- transform: translateY(0);
-}
-
-.masonry-item img {
- width: 100%;
- height: auto;
- display: block;
- transition: filter 0.3s ease;
-}
-
-.masonry-item:hover img {
- filter: brightness(1.08);
-}
-
-.masonry-item__overlay {
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- padding: 8px 10px;
- background: linear-gradient(transparent, rgba(15, 12, 9, 0.7));
- opacity: 0;
- transition: opacity 0.25s ease;
-}
-
-.masonry-item:hover .masonry-item__overlay {
- opacity: 1;
-}
-
-.masonry-item__label {
- font-family: var(--tv-mono, 'Space Mono', monospace);
- font-size: 9px;
- color: rgba(232, 221, 208, 0.8);
- letter-spacing: 0.06em;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- display: block;
-}
-
-.masonry-item:focus-visible {
- outline: 2px solid var(--tv-accent, #c8905e);
- outline-offset: 2px;
-}
-
-/* Footer */
-.masonry-footer {
- display: flex;
- justify-content: center;
- align-items: center;
- padding: 24px 0 8px;
- min-height: 48px;
-}
-
-.masonry-loading {
- display: flex;
- gap: 8px;
-}
-
-.masonry-loading__dot {
- width: 5px;
- height: 5px;
- border-radius: 50%;
- background: var(--tv-accent, #c8905e);
- animation: masonry-pulse 1.2s ease-in-out infinite;
-}
-
-.masonry-loading__dot:nth-child(2) { animation-delay: 0.2s; }
-.masonry-loading__dot:nth-child(3) { animation-delay: 0.4s; }
-
-@keyframes masonry-pulse {
- 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
- 40% { transform: scale(1); opacity: 1; }
-}
-
-.masonry-end {
- font-family: var(--tv-mono, 'Space Mono', monospace);
- font-size: 10px;
- letter-spacing: 0.22em;
- color: var(--tv-dim, rgba(232, 221, 208, 0.25));
- text-transform: uppercase;
- margin: 0;
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.masonry-end span {
- color: var(--tv-line-bright, rgba(232, 221, 208, 0.22));
-}
-
-/* Responsive */
-@media (max-width: 1024px) {
- .masonry-grid {
- column-count: 3;
- }
-}
-
-@media (max-width: 768px) {
- .masonry-grid {
- column-count: 2;
- }
-}
-
-/* Reduced motion */
-@media (prefers-reduced-motion: reduce) {
- .masonry-item {
- opacity: 1 !important;
- transform: none !important;
- transition: none !important;
- }
-
- .masonry-item img {
- transition: none;
- }
-
- .masonry-loading__dot {
- animation: none;
- }
-}
-```
-
-- [ ] **Step 3: 빌드 확인**
-
-Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1 | tail -5`
-Expected: 빌드 성공
-
-- [ ] **Step 4: 커밋**
-
-```bash
-git add src/pages/travel/MasonryGrid.jsx src/pages/travel/MasonryGrid.css
-git commit -m "feat(travel): MasonryGrid 컴포넌트 — CSS columns Masonry + 무한스크롤"
-```
-
----
-
-### Task 5: VideoTab 플레이스홀더
-
-영상 탭 UI 셸. 백엔드 동영상 API 완성 시 내부만 교체.
-
-**Files:**
-- Create: `src/pages/travel/VideoTab.jsx`
-- Create: `src/pages/travel/VideoTab.css`
-
-- [ ] **Step 1: VideoTab.jsx 생성**
-
-```jsx
-// src/pages/travel/VideoTab.jsx
-import './VideoTab.css';
-
-export default function VideoTab() {
- return (
-
-
-
영상 기능 준비 중
-
여행 영상을 감상할 수 있는 기능이 곧 추가됩니다.
-
- );
-}
-```
-
-- [ ] **Step 2: VideoTab.css 생성**
-
-```css
-/* src/pages/travel/VideoTab.css */
-
-.video-tab {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 16px;
- padding: 64px 24px;
- text-align: center;
- min-height: 300px;
-}
-
-.video-tab__icon {
- color: var(--tv-dim, rgba(232, 221, 208, 0.25));
- opacity: 0.6;
-}
-
-.video-tab__title {
- font-family: var(--tv-serif, 'Cormorant Garamond', Georgia, serif);
- font-size: 20px;
- font-weight: 600;
- color: var(--tv-text, #e8ddd0);
- margin: 0;
-}
-
-.video-tab__desc {
- font-family: var(--tv-mono, 'Space Mono', monospace);
- font-size: 11px;
- letter-spacing: 0.1em;
- color: var(--tv-muted, rgba(232, 221, 208, 0.45));
- margin: 0;
- max-width: 280px;
-}
-```
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add src/pages/travel/VideoTab.jsx src/pages/travel/VideoTab.css
-git commit -m "feat(travel): VideoTab 플레이스홀더 — 영상 탭 UI 셸"
-```
-
----
-
-### Task 6: HeroLightbox 컴포넌트
-
-Shared element transition 라이트박스. 사진이 제자리에서 풀스크린으로 확대되는 전환.
-
-**Files:**
-- Create: `src/pages/travel/HeroLightbox.jsx`
-- Create: `src/pages/travel/HeroLightbox.css`
-
-- [ ] **Step 1: HeroLightbox.jsx 생성**
-
-```jsx
-// src/pages/travel/HeroLightbox.jsx
-import { useCallback, useEffect, useRef, useState } from 'react';
-import { useSwipeable } from 'react-swipeable';
-import { useIsMobile } from '../../hooks/useIsMobile';
-import { getRegionAccent } from './MiniMap';
-import './HeroLightbox.css';
-
-const THUMB_STRIP_LIMIT = 36;
-
-const getPhotoLabel = (photo) => {
- if (!photo) return '';
- return photo.title || photo.file || '';
-};
-
-const getStripRange = (length, center) => {
- if (length <= THUMB_STRIP_LIMIT) return [0, length];
- const half = Math.floor(THUMB_STRIP_LIMIT / 2);
- let start = Math.max(0, center - half);
- let end = start + THUMB_STRIP_LIMIT;
- if (end > length) { end = length; start = end - THUMB_STRIP_LIMIT; }
- return [start, end];
-};
-
-export default function HeroLightbox({
- photos,
- selectedIndex,
- albumName,
- regionId,
- sourceRect,
- hasNext,
- loadingMore,
- onClose,
- onNavigate,
- onLoadMore,
-}) {
- const isMobile = useIsMobile();
- const [animPhase, setAnimPhase] = useState('enter'); // enter | open | exit
- const [slideDir, setSlideDir] = useState('next');
- const [slideToken, setSlideToken] = useState(0);
- const overlayRef = useRef(null);
- const heroRef = useRef(null);
- const thumbStripRef = useRef(null);
- const pendingAdvanceRef = useRef(null);
- const swipeYRef = useRef(0);
-
- const photo = photos[selectedIndex];
- const accent = getRegionAccent(regionId);
- const [stripStart, stripEnd] = getStripRange(photos.length, selectedIndex);
-
- // ── Enter animation ──
- useEffect(() => {
- if (!sourceRect) {
- setAnimPhase('open');
- return;
- }
- // Force layout then animate
- requestAnimationFrame(() => {
- requestAnimationFrame(() => setAnimPhase('open'));
- });
- }, [sourceRect]);
-
- // ── Body scroll lock ──
- useEffect(() => {
- const prev = document.body.style.overflow;
- document.body.style.overflow = 'hidden';
- return () => { document.body.style.overflow = prev; };
- }, []);
-
- // ── Close handler ──
- const handleClose = useCallback(() => {
- setAnimPhase('exit');
- const duration = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 350;
- setTimeout(() => onClose(), duration);
- }, [onClose]);
-
- // ── Navigation ──
- const goPrev = useCallback(() => {
- if (selectedIndex <= 0) return;
- setSlideDir('prev');
- setSlideToken((t) => t + 1);
- onNavigate(selectedIndex - 1);
- }, [selectedIndex, onNavigate]);
-
- const goNext = useCallback(() => {
- if (selectedIndex < photos.length - 1) {
- setSlideDir('next');
- setSlideToken((t) => t + 1);
- onNavigate(selectedIndex + 1);
- return;
- }
- if (hasNext && !loadingMore) {
- pendingAdvanceRef.current = 'next';
- onLoadMore?.();
- }
- }, [selectedIndex, photos.length, hasNext, loadingMore, onNavigate, onLoadMore]);
-
- // Advance after load
- useEffect(() => {
- if (pendingAdvanceRef.current !== 'next') return;
- if (selectedIndex < photos.length - 1) {
- setSlideDir('next');
- setSlideToken((t) => t + 1);
- onNavigate(selectedIndex + 1);
- pendingAdvanceRef.current = null;
- }
- if (!hasNext && selectedIndex >= photos.length - 1) pendingAdvanceRef.current = null;
- }, [hasNext, photos.length, selectedIndex, onNavigate]);
-
- // ── Keyboard ──
- useEffect(() => {
- const onKey = (e) => {
- if (e.key === 'Escape') handleClose();
- if (e.key === 'ArrowLeft') goPrev();
- if (e.key === 'ArrowRight') goNext();
- };
- window.addEventListener('keydown', onKey);
- return () => window.removeEventListener('keydown', onKey);
- }, [handleClose, goPrev, goNext]);
-
- // ── Swipe handlers ──
- const swipeHandlers = useSwipeable({
- onSwipedLeft: () => goNext(),
- onSwipedRight: () => goPrev(),
- onSwipedDown: ({ deltaY }) => {
- if (Math.abs(deltaY) > 100) handleClose();
- },
- trackMouse: false,
- trackTouch: true,
- delta: 40,
- });
-
- // ── Auto-center active thumb ──
- useEffect(() => {
- const strip = thumbStripRef.current;
- if (!strip) return;
- const thumb = strip.querySelector(`[data-thumb-index="${selectedIndex}"]`);
- if (!thumb) return;
- const sr = strip.getBoundingClientRect();
- const tr = thumb.getBoundingClientRect();
- const target = tr.left - sr.left + strip.scrollLeft + tr.width / 2 - sr.width / 2;
- strip.scrollTo({ left: target, behavior: 'smooth' });
- }, [selectedIndex, stripStart, stripEnd]);
-
- // ── Source rect styles for transition ──
- const enterStyle = sourceRect && animPhase === 'enter' ? {
- position: 'fixed',
- top: sourceRect.top,
- left: sourceRect.left,
- width: sourceRect.width,
- height: sourceRect.height,
- borderRadius: '4px',
- transition: 'none',
- } : {};
-
- return (
-
-
e.stopPropagation()}
- {...(isMobile ? swipeHandlers : {})}
- >
- {/* Close button */}
-
-
-
-
-
-
- {/* Counter */}
-
-
- {selectedIndex + 1}
-
- /
- {photos.length}
-
-
- {/* Photo stage */}
-
- {!isMobile && (
-
-
-
-
-
- )}
-
-
-
{
- if (photo?.original && e.currentTarget.src !== photo.original)
- e.currentTarget.src = photo.original;
- }}
- />
-
-
- {!isMobile && (
-
- {loadingMore && hasNext && selectedIndex === photos.length - 1 ? (
-
- ) : (
-
-
-
- )}
-
- )}
-
-
- {/* Meta */}
- {(photo?.album || photo?.file) && (
-
- {photo.album}{photo.file ? · {photo.file} : null}
-
- )}
-
- {/* Thumbnail strip */}
-
- {photos.slice(stripStart, stripEnd).map((p, idx) => {
- const realIndex = stripStart + idx;
- return (
-
{
- setSlideDir(realIndex > selectedIndex ? 'next' : 'prev');
- setSlideToken((t) => t + 1);
- onNavigate(realIndex);
- }}
- aria-label={getPhotoLabel(p)}
- >
- {
- if (p.original && e.currentTarget.src !== p.original)
- e.currentTarget.src = p.original;
- }}
- />
-
- );
- })}
-
-
-
- );
-}
-```
-
-- [ ] **Step 2: HeroLightbox.css 생성**
-
-```css
-/* src/pages/travel/HeroLightbox.css */
-
-.hero-lightbox {
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0);
- z-index: 3000;
- display: grid;
- place-items: center;
- transition: background 0.35s ease;
-}
-
-.hero-lightbox--open,
-.hero-lightbox--exit {
- background: rgba(0, 0, 0, 0.95);
-}
-
-.hero-lightbox--exit {
- background: rgba(0, 0, 0, 0);
- pointer-events: none;
-}
-
-.hero-lightbox__inner {
- position: relative;
- width: min(1280px, 98vw);
- max-height: 100dvh;
- display: flex;
- flex-direction: column;
- gap: 0;
- opacity: 0;
- transition: opacity 0.35s ease;
-}
-
-.hero-lightbox--open .hero-lightbox__inner {
- opacity: 1;
-}
-
-.hero-lightbox--exit .hero-lightbox__inner {
- opacity: 0;
-}
-
-/* Close button */
-.hero-lightbox__close {
- position: absolute;
- top: 16px;
- right: 16px;
- width: 40px;
- height: 40px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 50%;
- border: 1px solid rgba(232, 221, 208, 0.18);
- background: rgba(15, 12, 9, 0.8);
- color: #e8ddd0;
- cursor: pointer;
- z-index: 10;
- transition: border-color 0.2s ease;
-}
-
-.hero-lightbox__close:hover {
- border-color: rgba(232, 221, 208, 0.5);
-}
-
-/* Counter */
-.hero-lightbox__counter {
- position: absolute;
- top: 20px;
- left: 20px;
- display: flex;
- align-items: baseline;
- gap: 4px;
- font-family: var(--tv-mono, 'Space Mono', monospace);
- z-index: 10;
-}
-
-.hero-lightbox__counter-current {
- font-size: 18px;
- font-weight: 400;
- line-height: 1;
-}
-
-.hero-lightbox__counter-sep {
- font-size: 12px;
- color: rgba(232, 221, 208, 0.22);
-}
-
-.hero-lightbox__counter-total {
- font-size: 12px;
- color: rgba(232, 221, 208, 0.45);
-}
-
-/* Stage */
-.hero-lightbox__stage {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 0;
- min-height: 0;
- padding: 56px 0 12px;
-}
-
-.hero-lightbox__frame {
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- flex: 1;
- max-height: calc(100vh - 200px);
- overflow: hidden;
-}
-
-.hero-lightbox__photo {
- max-width: 100%;
- max-height: calc(100vh - 200px);
- object-fit: contain;
- display: block;
- border-radius: 4px;
-}
-
-.hero-lightbox__photo.slide-next {
- animation: hero-slide-right 280ms cubic-bezier(0.25, 0, 0.25, 1) forwards;
-}
-
-.hero-lightbox__photo.slide-prev {
- animation: hero-slide-left 280ms cubic-bezier(0.25, 0, 0.25, 1) forwards;
-}
-
-@keyframes hero-slide-right {
- from { opacity: 0; transform: translateX(24px) scale(0.98); }
- to { opacity: 1; transform: translateX(0) scale(1); }
-}
-
-@keyframes hero-slide-left {
- from { opacity: 0; transform: translateX(-24px) scale(0.98); }
- to { opacity: 1; transform: translateX(0) scale(1); }
-}
-
-/* Arrows */
-.hero-lightbox__arrow {
- width: 44px;
- height: 44px;
- border-radius: 12px;
- border: 1px solid rgba(232, 221, 208, 0.18);
- background: rgba(15, 12, 9, 0.85);
- color: #e8ddd0;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- margin: 0 12px;
- transition: border-color 0.2s ease, transform 0.2s ease;
-}
-
-.hero-lightbox__arrow:hover {
- border-color: rgba(232, 221, 208, 0.45);
- transform: scale(1.05);
-}
-
-.hero-lightbox__arrow:disabled {
- opacity: 0.25;
- cursor: not-allowed;
- transform: none;
-}
-
-.hero-lightbox__spinner {
- width: 18px;
- height: 18px;
- border-radius: 50%;
- border: 2px solid rgba(232, 221, 208, 0.25);
- border-top-color: var(--lb-accent, #c8905e);
- animation: hero-spin 0.7s linear infinite;
-}
-
-@keyframes hero-spin {
- to { transform: rotate(360deg); }
-}
-
-/* Meta */
-.hero-lightbox__meta {
- padding: 8px 20px;
- font-family: var(--tv-serif, 'Cormorant Garamond', Georgia, serif);
- font-size: 14px;
- color: #f5e6c8;
- margin: 0;
- text-align: center;
-}
-
-.hero-lightbox__meta span {
- color: rgba(232, 221, 208, 0.45);
- font-family: var(--tv-mono, 'Space Mono', monospace);
- font-size: 10px;
- letter-spacing: 0.1em;
-}
-
-/* Thumbnail strip */
-.hero-lightbox__strip {
- display: flex;
- gap: 4px;
- padding: 8px 20px;
- overflow-x: auto;
- scrollbar-width: none;
- justify-content: center;
-}
-
-.hero-lightbox__strip::-webkit-scrollbar {
- display: none;
-}
-
-.hero-lightbox__thumb {
- width: 52px;
- height: 52px;
- border-radius: 4px;
- border: 2px solid transparent;
- background: var(--tv-surface, #1a1510);
- padding: 0;
- cursor: pointer;
- flex-shrink: 0;
- overflow: hidden;
- transition: border-color 0.2s ease;
-}
-
-.hero-lightbox__thumb img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- display: block;
- filter: saturate(0.7);
- transition: filter 0.2s ease;
-}
-
-.hero-lightbox__thumb:hover img,
-.hero-lightbox__thumb.is-active img {
- filter: saturate(1);
-}
-
-.hero-lightbox__thumb.is-active {
- border-color: #f5e6c8;
-}
-
-/* Mobile */
-@media (max-width: 768px) {
- .hero-lightbox__inner {
- width: 100vw;
- max-height: 100dvh;
- }
-
- .hero-lightbox__frame {
- max-height: calc(100dvh - 160px);
- }
-
- .hero-lightbox__photo {
- max-height: calc(100dvh - 160px);
- }
-
- .hero-lightbox__thumb {
- width: 44px;
- height: 44px;
- }
-}
-
-/* Reduced motion */
-@media (prefers-reduced-motion: reduce) {
- .hero-lightbox,
- .hero-lightbox__inner,
- .hero-lightbox__close,
- .hero-lightbox__arrow,
- .hero-lightbox__thumb {
- transition: none !important;
- }
-
- .hero-lightbox__photo.slide-next,
- .hero-lightbox__photo.slide-prev {
- animation: none !important;
- opacity: 1;
- }
-
- .hero-lightbox__spinner {
- animation: none;
- }
-}
-```
-
-- [ ] **Step 3: 빌드 확인**
-
-Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1 | tail -5`
-Expected: 빌드 성공
-
-- [ ] **Step 4: 커밋**
-
-```bash
-git add src/pages/travel/HeroLightbox.jsx src/pages/travel/HeroLightbox.css
-git commit -m "feat(travel): HeroLightbox — shared element transition + 스와이프 탐색"
-```
-
----
-
-### Task 7: AlbumDetail 오버레이 컴포넌트
-
-앨범 상세 — 진입/이탈 애니메이션, 사진/영상 탭, MasonryGrid + HeroLightbox 통합.
-
-**Files:**
-- Create: `src/pages/travel/AlbumDetail.jsx`
-- Create: `src/pages/travel/AlbumDetail.css`
-
-- [ ] **Step 1: AlbumDetail.jsx 생성**
-
-```jsx
-// src/pages/travel/AlbumDetail.jsx
-import { useCallback, useEffect, useRef, useState } from 'react';
-import SwipeableView from '../../components/SwipeableView';
-import { useIsMobile } from '../../hooks/useIsMobile';
-import PullToRefresh from '../../components/PullToRefresh';
-import MasonryGrid from './MasonryGrid';
-import HeroLightbox from './HeroLightbox';
-import VideoTab from './VideoTab';
-import { getRegionAccent } from './MiniMap';
-import './AlbumDetail.css';
-
-export default function AlbumDetail({
- album,
- sourceRect,
- photos,
- photoSummary,
- loading,
- loadingMore,
- hasNext,
- error,
- onClose,
- onLoadMore,
- onReload,
-}) {
- const isMobile = useIsMobile();
- const [animPhase, setAnimPhase] = useState('enter'); // enter | open | exit
- const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null);
- const [photoSourceRect, setPhotoSourceRect] = useState(null);
- const overlayRef = useRef(null);
- const accent = getRegionAccent(album.region);
-
- // ── Entry animation ──
- useEffect(() => {
- requestAnimationFrame(() => {
- requestAnimationFrame(() => setAnimPhase('open'));
- });
- }, []);
-
- // ── Body scroll lock ──
- useEffect(() => {
- if (selectedPhotoIndex != null) return; // lightbox handles its own
- const prev = document.body.style.overflow;
- document.body.style.overflow = 'hidden';
- return () => { document.body.style.overflow = prev; };
- }, [selectedPhotoIndex]);
-
- // ── Close handler ──
- const handleClose = useCallback(() => {
- setAnimPhase('exit');
- const duration = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400;
- setTimeout(() => onClose(), duration);
- }, [onClose]);
-
- // ── ESC key ──
- useEffect(() => {
- const onKey = (e) => {
- if (e.key === 'Escape' && selectedPhotoIndex == null) handleClose();
- };
- window.addEventListener('keydown', onKey);
- return () => window.removeEventListener('keydown', onKey);
- }, [handleClose, selectedPhotoIndex]);
-
- // ── Photo selection (capture source rect) ──
- const handleSelectPhoto = useCallback((index, event) => {
- const target = event?.currentTarget;
- const rect = target?.getBoundingClientRect?.() ?? null;
- setPhotoSourceRect(rect);
- setSelectedPhotoIndex(index);
- }, []);
-
- // ── Tab definition ──
- const tabs = [
- {
- key: 'photos',
- label: `사진 (${photos.length}${hasNext ? '+' : ''})`,
- content: (
-
- {loading && (
-
-
-
- )}
- {error &&
{error}
}
- {!loading && !error && photos.length === 0 && (
-
이 앨범에는 아직 사진이 없습니다.
- )}
- {!loading && !error && photos.length > 0 && (
-
-
-
- )}
-
- ),
- },
- {
- key: 'videos',
- label: '영상',
- content: ,
- },
- ];
-
- return (
- <>
-
- {/* Header */}
-
-
- {/* Tabs */}
-
-
-
-
-
- {/* Lightbox */}
- {selectedPhotoIndex != null && (
- setSelectedPhotoIndex(null)}
- onNavigate={setSelectedPhotoIndex}
- onLoadMore={onLoadMore}
- />
- )}
- >
- );
-}
-```
-
-- [ ] **Step 2: AlbumDetail.css 생성**
-
-```css
-/* src/pages/travel/AlbumDetail.css */
-
-.album-detail {
- position: fixed;
- inset: 0;
- z-index: 2000;
- background: var(--tv-bg, #0f0c09);
- display: flex;
- flex-direction: column;
- overflow: hidden;
-
- /* Enter animation */
- opacity: 0;
- transform: scale(0.95);
- transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
-}
-
-.album-detail--open {
- opacity: 1;
- transform: scale(1);
-}
-
-.album-detail--exit {
- opacity: 0;
- transform: scale(0.95);
- pointer-events: none;
-}
-
-/* Header */
-.album-detail__header {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 16px 20px;
- border-bottom: 1px solid var(--tv-line, rgba(232, 221, 208, 0.1));
- flex-shrink: 0;
-}
-
-.album-detail__back {
- width: 36px;
- height: 36px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 50%;
- border: 1px solid var(--tv-line-bright, rgba(232, 221, 208, 0.22));
- background: none;
- color: var(--tv-text, #e8ddd0);
- cursor: pointer;
- flex-shrink: 0;
- transition: border-color 0.2s ease;
-}
-
-.album-detail__back:hover {
- border-color: var(--tv-text, #e8ddd0);
-}
-
-.album-detail__title-group {
- flex: 1;
- min-width: 0;
- display: flex;
- align-items: baseline;
- gap: 10px;
-}
-
-.album-detail__title {
- font-family: var(--tv-serif, 'Cormorant Garamond', Georgia, serif);
- font-size: 22px;
- font-weight: 600;
- color: var(--tv-text, #e8ddd0);
- margin: 0;
- letter-spacing: -0.01em;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.album-detail__region-badge {
- font-family: var(--tv-mono, 'Space Mono', monospace);
- font-size: 9px;
- text-transform: uppercase;
- letter-spacing: 0.18em;
- flex-shrink: 0;
-}
-
-.album-detail__count {
- font-family: var(--tv-mono, 'Space Mono', monospace);
- font-size: 11px;
- color: var(--tv-muted, rgba(232, 221, 208, 0.45));
- letter-spacing: 0.12em;
- flex-shrink: 0;
-}
-
-/* Body */
-.album-detail__body {
- flex: 1;
- overflow-y: auto;
- padding: 0 20px 20px;
- padding-bottom: calc(20px + var(--bottom-nav-h, 0px) + var(--safe-area-bottom, 0px));
-}
-
-/* Loading state */
-.album-detail__loading {
- display: flex;
- justify-content: center;
- gap: 8px;
- padding: 48px 0;
-}
-
-.album-detail__loading span {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--detail-accent, #c8905e);
- animation: album-pulse 1.2s ease-in-out infinite;
-}
-
-.album-detail__loading span:nth-child(2) { animation-delay: 0.2s; }
-.album-detail__loading span:nth-child(3) { animation-delay: 0.4s; }
-
-@keyframes album-pulse {
- 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
- 40% { transform: scale(1); opacity: 1; }
-}
-
-.album-detail__error {
- font-family: var(--tv-mono, 'Space Mono', monospace);
- font-size: 11px;
- color: #f2a09a;
- border: 1px solid rgba(242, 160, 154, 0.3);
- border-radius: 10px;
- padding: 12px 16px;
- background: rgba(242, 160, 154, 0.06);
- letter-spacing: 0.08em;
-}
-
-.album-detail__empty {
- font-family: var(--tv-mono, 'Space Mono', monospace);
- font-size: 11px;
- letter-spacing: 0.16em;
- color: var(--tv-muted, rgba(232, 221, 208, 0.45));
- text-align: center;
- padding: 48px 0;
-}
-
-.album-detail__photo-content {
- padding-top: 8px;
-}
-
-/* Mobile */
-@media (max-width: 768px) {
- .album-detail__header {
- padding: 12px 16px;
- }
-
- .album-detail__title {
- font-size: 18px;
- }
-
- .album-detail__body {
- padding: 0 16px 16px;
- padding-bottom: calc(16px + var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px));
- }
-}
-
-/* Reduced motion */
-@media (prefers-reduced-motion: reduce) {
- .album-detail {
- transition: none !important;
- opacity: 1;
- transform: none;
- }
-
- .album-detail--enter {
- opacity: 1;
- transform: none;
- }
-
- .album-detail--exit {
- opacity: 0;
- }
-
- .album-detail__back {
- transition: none;
- }
-
- .album-detail__loading span {
- animation: none;
- }
-}
-```
-
-- [ ] **Step 3: 빌드 확인**
-
-Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1 | tail -5`
-Expected: 빌드 성공
-
-- [ ] **Step 4: 커밋**
-
-```bash
-git add src/pages/travel/AlbumDetail.jsx src/pages/travel/AlbumDetail.css
-git commit -m "feat(travel): AlbumDetail 오버레이 — 사진/영상 탭 + 진입/이탈 애니메이션"
-```
-
----
-
-### Task 8: Travel.jsx 메인 컴포넌트 리팩토링
-
-기존 Travel.jsx를 새 컴포넌트들을 사용하도록 완전 교체.
-
-**Files:**
-- Modify: `src/pages/travel/Travel.jsx` (전면 교체)
-
-- [ ] **Step 1: Travel.jsx를 새 구조로 교체**
-
-```jsx
-// src/pages/travel/Travel.jsx
-import React, { useCallback, useMemo, useState } from 'react';
-import 'leaflet/dist/leaflet.css';
-import './Travel.css';
-import { useTravelData } from './useTravelData';
-import MiniMap, { getRegionAccent } from './MiniMap';
-import AlbumCard from './AlbumCard';
-import AlbumDetail from './AlbumDetail';
-
-const Travel = () => {
- const {
- regions,
- albums,
- selectedRegion,
- setSelectedRegion,
- photos,
- photoSummary,
- loading,
- loadingMore,
- loadingAlbums,
- error,
- hasNext,
- loadAlbumPhotos,
- loadMorePhotos,
- reloadAlbumPhotos,
- getFilteredAlbums,
- } = useTravelData();
-
- const [selectedAlbum, setSelectedAlbum] = useState(null);
- const [albumSourceRect, setAlbumSourceRect] = useState(null);
-
- const regionAccent = getRegionAccent(selectedRegion?.id || '');
- const filteredAlbums = useMemo(
- () => getFilteredAlbums(selectedRegion?.id),
- [getFilteredAlbums, selectedRegion?.id]
- );
-
- // ── Album open/close ──
- const handleOpenAlbum = useCallback((album, rect) => {
- setAlbumSourceRect(rect);
- setSelectedAlbum(album);
- loadAlbumPhotos(album.region, album.name);
- }, [loadAlbumPhotos]);
-
- const handleCloseAlbum = useCallback(() => {
- setSelectedAlbum(null);
- setAlbumSourceRect(null);
- }, []);
-
- const handleLoadMore = useCallback(() => {
- if (!selectedAlbum) return;
- loadMorePhotos(selectedAlbum.region, selectedAlbum.name);
- }, [loadMorePhotos, selectedAlbum]);
-
- const handleReload = useCallback(async () => {
- if (!selectedAlbum) return;
- await reloadAlbumPhotos(selectedAlbum.region, selectedAlbum.name);
- }, [reloadAlbumPhotos, selectedAlbum]);
-
- return (
-
- {/* ── Header ── */}
-
-
- {/* ── MiniMap ── */}
-
setSelectedRegion(null)}
- />
-
- {/* ── Album Card List ── */}
-
- {loadingAlbums && (
-
- )}
-
- {!loadingAlbums && filteredAlbums.length === 0 && (
-
- {selectedRegion ? '이 지역에는 앨범이 없습니다.' : '여행 앨범을 불러오는 중…'}
-
- )}
-
- {!loadingAlbums && filteredAlbums.length > 0 && (
-
- {filteredAlbums.map((album) => (
-
- ))}
-
- )}
-
-
- {/* ── Album Detail Overlay ── */}
- {selectedAlbum && (
-
- )}
-
- );
-};
-
-export default Travel;
-```
-
-- [ ] **Step 2: Travel.css 리팩토링**
-
-기존 Travel.css에서 사용되지 않는 photo-mosaic, photo-card, lightbox, filmstrip 스타일을 제거하고, 앨범 카드 리스트용 레이아웃만 남긴다.
-
-기존 Travel.css를 아래 내용으로 전면 교체한다:
-
-```css
-/* ═══════════════════════════════════════════════════
- Travel — "Dark Room" Editorial Photo Archive
- Fonts: Cormorant Garamond (display) · Space Mono (mono)
-═══════════════════════════════════════════════════ */
-
-@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,300;1,400&family=Space+Mono:ital@0;1&display=swap');
-
-/* ── CSS tokens ──────────────────────────────────────── */
-.travel {
- --tv-bg: #0f0c09;
- --tv-surface: #1a1510;
- --tv-surface-2: #221c14;
- --tv-line: rgba(232, 221, 208, 0.1);
- --tv-line-bright: rgba(232, 221, 208, 0.22);
- --tv-text: #e8ddd0;
- --tv-muted: rgba(232, 221, 208, 0.45);
- --tv-dim: rgba(232, 221, 208, 0.25);
- --tv-accent: var(--region-accent, #c8905e);
- --tv-serif: 'Cormorant Garamond', Georgia, serif;
- --tv-mono: 'Space Mono', 'Courier New', monospace;
- --tv-r-sm: 10px;
- --tv-r-md: 16px;
- --tv-r-lg: 22px;
-
- display: grid;
- gap: 40px;
- color: var(--tv-text);
- font-family: var(--tv-serif);
-}
-
-/* ═══════════════════════════════════════════════════
- HEADER — editorial masthead
-═══════════════════════════════════════════════════ */
-.tv-header {
- display: grid;
- grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
- gap: 32px;
- align-items: end;
- padding-bottom: 28px;
- border-bottom: 1px solid var(--tv-line-bright);
-}
-
-.tv-header__meta {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 14px;
-}
-
-.tv-header__issue,
-.tv-header__tagline {
- font-family: var(--tv-mono);
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.22em;
- color: var(--tv-accent);
-}
-
-.tv-header__divider { color: var(--tv-line-bright); }
-
-.tv-header__title {
- font-family: var(--tv-serif);
- font-weight: 300;
- line-height: 0.9;
- margin: 0 0 18px;
- font-size: clamp(52px, 8vw, 88px);
- letter-spacing: -0.02em;
- display: flex;
- flex-direction: column;
-}
-
-.tv-header__title-main { color: var(--tv-text); }
-
-.tv-header__title-italic {
- font-style: italic;
- font-weight: 300;
- color: var(--tv-accent);
- margin-left: 0.12em;
-}
-
-.tv-header__desc {
- margin: 0;
- color: var(--tv-muted);
- font-size: 14px;
- line-height: 1.75;
- font-family: var(--tv-serif);
- font-style: italic;
- max-width: 420px;
-}
-
-/* Active region info */
-.tv-header__active-region {
- display: flex;
- align-items: flex-start;
- gap: 14px;
- padding: 18px 20px;
- border: 1px solid rgba(var(--tv-accent-rgb, 200, 144, 94), 0.28);
- border-radius: var(--tv-r-md);
- background: rgba(255, 255, 255, 0.03);
-}
-
-.tv-header__region-indicator {
- width: 3px;
- height: 52px;
- border-radius: 999px;
- background: var(--accent, var(--tv-accent));
- flex-shrink: 0;
- margin-top: 2px;
-}
-
-.tv-header__region-label {
- font-family: var(--tv-mono);
- font-size: 9px;
- text-transform: uppercase;
- letter-spacing: 0.2em;
- color: var(--tv-dim);
- margin: 0 0 4px;
-}
-
-.tv-header__region-name {
- font-family: var(--tv-serif);
- font-size: 28px;
- font-weight: 600;
- color: var(--tv-text);
- margin: 0 0 4px;
- letter-spacing: -0.01em;
-}
-
-.tv-header__region-count {
- font-family: var(--tv-mono);
- font-size: 10px;
- color: var(--tv-muted);
- letter-spacing: 0.14em;
- margin: 0;
-}
-
-/* Hint */
-.tv-header__hint {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 10px;
- justify-content: center;
- padding: 24px;
- text-align: center;
- border: 1px dashed var(--tv-line-bright);
- border-radius: var(--tv-r-md);
-}
-
-.tv-header__hint-icon {
- color: var(--tv-dim);
- opacity: 0.6;
-}
-
-.tv-header__hint-text {
- font-family: var(--tv-mono);
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.18em;
- color: var(--tv-muted);
- margin: 0;
-}
-
-/* ═══════════════════════════════════════════════════
- ALBUMS SECTION
-═══════════════════════════════════════════════════ */
-.tv-albums {
- min-height: 200px;
-}
-
-/* ── Loading / Error states ──────────────────────── */
-.tv-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 12px;
- color: var(--tv-muted);
- font-family: var(--tv-mono);
- font-size: 11px;
- letter-spacing: 0.1em;
- padding: 48px 0;
-}
-
-.tv-state__loader {
- display: flex;
- gap: 8px;
-}
-
-.tv-state__loader span {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--tv-accent);
- animation: tv-pulse 1.2s ease-in-out infinite;
-}
-
-.tv-state__loader span:nth-child(2) { animation-delay: 0.2s; }
-.tv-state__loader span:nth-child(3) { animation-delay: 0.4s; }
-
-@keyframes tv-pulse {
- 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
- 40% { transform: scale(1); opacity: 1; }
-}
-
-.tv-state--empty {
- font-family: var(--tv-mono);
- font-size: 11px;
- letter-spacing: 0.16em;
- text-align: center;
-}
-
-/* ═══════════════════════════════════════════════════
- RESPONSIVE
-═══════════════════════════════════════════════════ */
-@media (max-width: 768px) {
- .tv-header {
- grid-template-columns: 1fr;
- }
-
- .travel {
- gap: 28px;
- }
-}
-
-@media (max-width: 480px) {
- .travel {
- gap: 20px;
- }
-
- .tv-header {
- gap: 20px;
- padding-bottom: 20px;
- }
-
- .tv-header__title {
- font-size: clamp(40px, 12vw, 60px);
- }
-}
-
-/* ═══════════════════════════════════════════════════
- REDUCED MOTION
-═══════════════════════════════════════════════════ */
-@media (prefers-reduced-motion: reduce) {
- .tv-state__loader span {
- animation: none;
- }
-}
-```
-
-- [ ] **Step 3: 개발 서버에서 확인**
-
-Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1 | tail -5`
-Expected: 빌드 성공, 에러 없음
-
-- [ ] **Step 4: 커밋**
-
-```bash
-git add src/pages/travel/Travel.jsx src/pages/travel/Travel.css
-git commit -m "refactor(travel): Travel.jsx 리팩토링 — 컴포넌트 분리 + 앨범 카드 기반 UI"
-```
-
----
-
-### Task 9: 통합 테스트 및 빌드 검증
-
-모든 컴포넌트가 올바르게 연결되는지 빌드로 검증한다.
-
-**Files:**
-- 없음 (검증만)
-
-- [ ] **Step 1: Vite 빌드 실행**
-
-Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1`
-Expected: 빌드 성공, 경고 없음
-
-- [ ] **Step 2: import 누락 확인**
-
-Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && grep -rn "from.*Travel" src/pages/travel/ --include="*.jsx" --include="*.js"`
-Expected: 모든 import 경로가 올바른지 확인
-
-- [ ] **Step 3: 사용하지 않는 파일 정리 확인**
-
-기존 Travel.jsx에 있던 인라인 컴포넌트(PhotoCard, PhotoMosaic, MapLayer, FilmStrip, Lightbox)가 모두 새 파일로 대체되었는지 확인.
-
-Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && grep -n "PhotoCard\|PhotoMosaic\|MapLayer\|FilmStrip\|Lightbox" src/pages/travel/Travel.jsx`
-Expected: 해당 이름이 Travel.jsx에 남아있지 않음
-
-- [ ] **Step 4: 빌드 성공 확인 후 커밋 (필요 시)**
-
-수정 사항이 있으면 커밋:
-```bash
-git add -A src/pages/travel/
-git commit -m "fix(travel): 통합 빌드 검증 — import 경로 수정 및 정리"
-```
-
----
-
-### Task 10: 최종 UI 검증
-
-개발 서버를 실행하고 실제 브라우저에서 모든 플로우를 검증한다.
-
-**Files:**
-- 없음 (검증만, 필요 시 수정)
-
-- [ ] **Step 1: 개발 서버 실행**
-
-Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite --port 3007 &`
-Expected: http://localhost:3007 에서 서비스 시작
-
-- [ ] **Step 2: 검증 체크리스트**
-
-브라우저에서 http://localhost:3007 의 Travel 페이지 접근 후:
-
-1. **메인 화면**: 헤더 + 미니맵 + 앨범 카드 리스트가 표시되는지
-2. **미니맵**: 접기/펼치기 토글, 지역 클릭 시 앨범 필터링, "전체 보기" 버튼
-3. **앨범 카드**: 대표 사진, 앨범명, 사진 수 뱃지, 호버 효과
-4. **앨범 진입**: 카드 클릭 시 AlbumDetail 오버레이, 진입 애니메이션
-5. **사진/영상 탭**: SwipeableView 탭 전환, 영상 탭 플레이스홀더
-6. **Masonry 그리드**: CSS columns 레이아웃, 원본 비율 유지, 스크롤 리빌
-7. **무한 스크롤**: 스크롤 하단 도달 시 추가 로드
-8. **라이트박스**: 사진 클릭 시 풀스크린, 좌우 탐색, 썸네일 스트립
-9. **뒤로가기**: ESC 키, 뒤로가기 버튼으로 앨범 닫기
-10. **반응형**: 768px 이하에서 1열 카드, 2열 Masonry, 미니맵 150px
-
-- [ ] **Step 3: 발견된 이슈 수정 후 커밋**
-
-```bash
-git add -A src/pages/travel/
-git commit -m "fix(travel): UI 검증 후 수정"
-```
diff --git a/docs/superpowers/plans/2026-04-24-travel-proxy-perf.md b/docs/superpowers/plans/2026-04-24-travel-proxy-perf.md
deleted file mode 100644
index d1206d1..0000000
--- a/docs/superpowers/plans/2026-04-24-travel-proxy-perf.md
+++ /dev/null
@@ -1,681 +0,0 @@
-# Travel-Proxy 성능 개선 구현 계획
-
-> **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:** travel-proxy의 os.scandir 기반 아키텍처를 SQLite 인덱스 DB로 전환하고, 앨범 커버 지정 + 썸네일 사전 생성을 지원한다.
-
-**Architecture:** 기존 main.py의 스캔/캐시/썸네일 로직을 db.py(스키마+쿼리)와 indexer.py(동기화+썸네일)로 분리. main.py는 라우트만 담당. DB 경로는 `/data/thumbs/travel.db`.
-
-**Tech Stack:** Python 3.12, FastAPI, SQLite (표준 라이브러리 sqlite3), Pillow
-
----
-
-### 파일 구조
-
-| 파일 | 역할 | 상태 |
-|------|------|------|
-| `travel-proxy/app/db.py` | SQLite 스키마 정의, 커넥션 헬퍼, 쿼리 함수 | 신규 |
-| `travel-proxy/app/indexer.py` | 폴더 스캔 → DB 동기화 + 썸네일 일괄 생성 | 신규 |
-| `travel-proxy/app/main.py` | FastAPI 라우트 (기존 수정 + 신규 추가) | 수정 |
-
----
-
-### Task 1: db.py — SQLite 스키마 및 쿼리 헬퍼
-
-**Files:**
-- Create: `travel-proxy/app/db.py`
-
-- [ ] **Step 1: db.py 파일 생성**
-
-```python
-import os
-import sqlite3
-from typing import Any, Dict, List, Optional
-
-DB_PATH = os.getenv("TRAVEL_DB_PATH", "/data/thumbs/travel.db")
-
-
-def _conn() -> sqlite3.Connection:
- os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
- conn = sqlite3.connect(DB_PATH)
- conn.row_factory = sqlite3.Row
- conn.execute("PRAGMA journal_mode=WAL")
- return conn
-
-
-def init_db() -> None:
- with _conn() as conn:
- conn.execute("""
- CREATE TABLE IF NOT EXISTS photos (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- album TEXT NOT NULL,
- filename TEXT NOT NULL,
- mtime REAL NOT NULL,
- has_thumb INTEGER DEFAULT 0,
- indexed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- UNIQUE(album, filename)
- )
- """)
- conn.execute("CREATE INDEX IF NOT EXISTS idx_photos_album ON photos(album)")
-
- conn.execute("""
- CREATE TABLE IF NOT EXISTS album_covers (
- album TEXT PRIMARY KEY,
- filename TEXT NOT NULL,
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
- )
- """)
-
-
-def get_photos_by_region(albums: List[str], page: int, size: int) -> Dict[str, Any]:
- """region에 속한 앨범들의 사진을 페이지네이션하여 반환."""
- if not albums:
- return {"items": [], "total": 0, "has_next": False, "matched_albums": []}
-
- placeholders = ",".join("?" for _ in albums)
-
- with _conn() as conn:
- # 앨범별 사진 수
- rows = conn.execute(
- f"SELECT album, COUNT(*) as cnt FROM photos WHERE album IN ({placeholders}) GROUP BY album",
- albums,
- ).fetchall()
- matched_albums = [{"album": r["album"], "count": r["cnt"]} for r in rows]
-
- # 전체 수
- total_row = conn.execute(
- f"SELECT COUNT(*) as cnt FROM photos WHERE album IN ({placeholders})",
- albums,
- ).fetchone()
- total = total_row["cnt"]
-
- # 페이지네이션
- offset = (page - 1) * size
- items = conn.execute(
- f"""SELECT album, filename, mtime FROM photos
- WHERE album IN ({placeholders})
- ORDER BY album, filename
- LIMIT ? OFFSET ?""",
- [*albums, size, offset],
- ).fetchall()
-
- return {
- "items": [dict(r) for r in items],
- "total": total,
- "has_next": (offset + size) < total,
- "matched_albums": matched_albums,
- }
-
-
-def get_all_albums() -> List[Dict[str, Any]]:
- """전체 앨범 목록 + 사진 수 + 커버 정보."""
- with _conn() as conn:
- rows = conn.execute("""
- SELECT p.album, COUNT(*) as count,
- COALESCE(c.filename, MIN(p.filename)) as cover_filename
- FROM photos p
- LEFT JOIN album_covers c ON p.album = c.album
- GROUP BY p.album
- ORDER BY p.album
- """).fetchall()
- return [dict(r) for r in rows]
-
-
-def set_album_cover(album: str, filename: str) -> bool:
- """앨범 커버 지정. 해당 photo가 존재하면 True, 없으면 False."""
- with _conn() as conn:
- exists = conn.execute(
- "SELECT 1 FROM photos WHERE album = ? AND filename = ?",
- (album, filename),
- ).fetchone()
- if not exists:
- return False
-
- conn.execute(
- """INSERT INTO album_covers (album, filename, updated_at)
- VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
- ON CONFLICT(album) DO UPDATE SET
- filename = excluded.filename,
- updated_at = excluded.updated_at""",
- (album, filename),
- )
- return True
-
-
-def get_album_cover(album: str) -> Optional[str]:
- """앨범 커버 파일명 반환. 미지정 시 None."""
- with _conn() as conn:
- row = conn.execute(
- "SELECT filename FROM album_covers WHERE album = ?",
- (album,),
- ).fetchone()
- return row["filename"] if row else None
-
-
-def upsert_photo(album: str, filename: str, mtime: float) -> str:
- """사진 upsert. 반환: 'added' | 'updated' | 'unchanged'."""
- with _conn() as conn:
- existing = conn.execute(
- "SELECT mtime, has_thumb FROM photos WHERE album = ? AND filename = ?",
- (album, filename),
- ).fetchone()
-
- if not existing:
- conn.execute(
- "INSERT INTO photos (album, filename, mtime, has_thumb) VALUES (?, ?, ?, 0)",
- (album, filename, mtime),
- )
- return "added"
- elif existing["mtime"] != mtime:
- conn.execute(
- "UPDATE photos SET mtime = ?, has_thumb = 0 WHERE album = ? AND filename = ?",
- (mtime, album, filename),
- )
- return "updated"
- return "unchanged"
-
-
-def remove_missing_photos(album: str, existing_filenames: set) -> int:
- """폴더에 없는 사진을 DB에서 제거. 제거 수 반환."""
- with _conn() as conn:
- db_rows = conn.execute(
- "SELECT filename FROM photos WHERE album = ?", (album,)
- ).fetchall()
- db_filenames = {r["filename"] for r in db_rows}
- to_remove = db_filenames - existing_filenames
-
- if to_remove:
- placeholders = ",".join("?" for _ in to_remove)
- conn.execute(
- f"DELETE FROM photos WHERE album = ? AND filename IN ({placeholders})",
- [album, *to_remove],
- )
- # 삭제된 파일이 커버였으면 커버도 제거
- conn.execute(
- f"DELETE FROM album_covers WHERE album = ? AND filename IN ({placeholders})",
- [album, *to_remove],
- )
- return len(to_remove)
-
-
-def get_photos_without_thumb() -> List[Dict[str, str]]:
- """썸네일 미생성 사진 목록."""
- with _conn() as conn:
- rows = conn.execute(
- "SELECT album, filename FROM photos WHERE has_thumb = 0"
- ).fetchall()
- return [dict(r) for r in rows]
-
-
-def mark_thumb_done(album: str, filename: str) -> None:
- """썸네일 생성 완료 표시."""
- with _conn() as conn:
- conn.execute(
- "UPDATE photos SET has_thumb = 1 WHERE album = ? AND filename = ?",
- (album, filename),
- )
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add travel-proxy/app/db.py
-git commit -m "feat(travel-proxy): db.py — SQLite 스키마 + 쿼리 헬퍼"
-```
-
----
-
-### Task 2: indexer.py — 폴더 동기화 + 썸네일 일괄 생성
-
-**Files:**
-- Create: `travel-proxy/app/indexer.py`
-
-- [ ] **Step 1: indexer.py 파일 생성**
-
-기존 main.py의 `ensure_thumb` 로직(라인 105-144)과 `scan_album` 로직(라인 146-166)을 기반으로 작성. `IMAGE_EXT`, `THUMB_SIZE`, 경로 상수는 main.py에서 import.
-
-```python
-import os
-import time
-import json
-import logging
-from pathlib import Path
-from typing import Any, Dict, List, Set
-
-from PIL import Image
-
-from . import db
-
-logger = logging.getLogger(__name__)
-
-IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
-THUMB_SIZE = (480, 480)
-
-
-def _scan_folder(folder: Path) -> List[Dict[str, Any]]:
- """폴더 내 이미지 파일 목록 수집 (os.scandir)."""
- if not folder.exists():
- return []
- items = []
- with os.scandir(folder) as entries:
- for entry in entries:
- if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT:
- items.append({
- "filename": entry.name,
- "mtime": entry.stat().st_mtime,
- })
- return items
-
-
-def _generate_thumb(src: Path, dest: Path) -> bool:
- """원본에서 480x480 썸네일 생성. 성공 시 True."""
- dest.parent.mkdir(parents=True, exist_ok=True)
- tmp = dest.with_name(dest.stem + ".tmp" + dest.suffix)
- try:
- with Image.open(src) as im:
- im.thumbnail(THUMB_SIZE)
- ext = dest.suffix.lower()
- if ext in (".jpg", ".jpeg"):
- fmt = "JPEG"
- elif ext == ".png":
- fmt = "PNG"
- elif ext == ".webp":
- fmt = "WEBP"
- else:
- fmt = (im.format or "").upper() or "JPEG"
- im.save(tmp, format=fmt, quality=85, optimize=True)
- tmp.replace(dest)
- return True
- except Exception as e:
- logger.warning("Thumb generation failed: %s → %s", src, e)
- try:
- if tmp.exists():
- tmp.unlink()
- except Exception:
- pass
- return False
-
-
-def sync(
- travel_root: Path,
- thumb_root: Path,
- region_map_path: Path,
-) -> Dict[str, Any]:
- """
- 폴더 스캔 → DB 동기화 + 썸네일 일괄 생성.
-
- Returns:
- {"added": int, "removed": int, "thumbs_generated": int, "duration_sec": float}
- """
- start = time.time()
-
- # 1. region_map.json에서 전체 앨범 폴더 수집
- with open(region_map_path, "r", encoding="utf-8") as f:
- region_map = json.load(f)
-
- all_albums: Set[str] = set()
- for v in region_map.values():
- if isinstance(v, list):
- all_albums.update(v)
- elif isinstance(v, dict) and isinstance(v.get("albums"), list):
- all_albums.update(v["albums"])
-
- # 2. 각 앨범 폴더 스캔 → DB 동기화
- added = 0
- removed = 0
-
- for album in sorted(all_albums):
- folder = travel_root / album
- items = _scan_folder(folder)
- existing_filenames = set()
-
- for item in items:
- existing_filenames.add(item["filename"])
- result = db.upsert_photo(album, item["filename"], item["mtime"])
- if result == "added":
- added += 1
-
- removed += db.remove_missing_photos(album, existing_filenames)
-
- # 3. 썸네일 미생성 분 일괄 생성
- no_thumb = db.get_photos_without_thumb()
- thumbs_generated = 0
-
- for photo in no_thumb:
- src = travel_root / photo["album"] / photo["filename"]
- dest = thumb_root / photo["album"] / photo["filename"]
- if _generate_thumb(src, dest):
- db.mark_thumb_done(photo["album"], photo["filename"])
- thumbs_generated += 1
-
- duration = round(time.time() - start, 2)
- logger.info(
- "Sync complete: added=%d removed=%d thumbs=%d duration=%.2fs",
- added, removed, thumbs_generated, duration,
- )
-
- return {
- "added": added,
- "removed": removed,
- "thumbs_generated": thumbs_generated,
- "duration_sec": duration,
- }
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add travel-proxy/app/indexer.py
-git commit -m "feat(travel-proxy): indexer.py — 폴더 동기화 + 썸네일 일괄 생성"
-```
-
----
-
-### Task 3: main.py 리팩토링 — DB 기반 photos API + 캐시 제거
-
-**Files:**
-- Modify: `travel-proxy/app/main.py`
-
-이 Task에서 main.py의 메모리 캐시, `scan_album()`, 기존 `photos()` 라우트를 DB 기반으로 교체한다.
-
-- [ ] **Step 1: main.py를 DB 기반으로 재작성**
-
-main.py 전체를 아래로 교체:
-
-```python
-import os
-import json
-import logging
-from pathlib import Path
-from typing import Any, List
-
-from fastapi import FastAPI, HTTPException, Query
-from fastapi.responses import FileResponse
-from pydantic import BaseModel
-from PIL import Image
-
-from .db import init_db, get_photos_by_region, get_all_albums, set_album_cover, get_album_cover
-from .indexer import sync
-
-logger = logging.getLogger(__name__)
-
-app = FastAPI()
-
-# -----------------------------
-# Env / Paths
-# -----------------------------
-ROOT = Path(os.getenv("TRAVEL_ROOT", "/data/travel")).resolve()
-MEDIA_BASE = os.getenv("TRAVEL_MEDIA_BASE", "/media/travel")
-
-META_DIR = ROOT / "_meta"
-REGION_MAP_PATH = META_DIR / "region_map.json"
-REGIONS_GEOJSON_PATH = META_DIR / "regions.geojson"
-
-THUMB_ROOT = Path(os.getenv("TRAVEL_THUMB_ROOT", "/data/thumbs")).resolve()
-THUMB_SIZE = (480, 480)
-
-THUMB_ROOT.mkdir(parents=True, exist_ok=True)
-
-IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
-
-# -----------------------------
-# DB init
-# -----------------------------
-init_db()
-
-# -----------------------------
-# Helpers
-# -----------------------------
-def _read_json(path: Path) -> Any:
- if not path.exists():
- raise HTTPException(500, f"Missing required file: {path}")
- with open(path, "r", encoding="utf-8") as f:
- return json.load(f)
-
-
-def load_region_map() -> dict:
- return _read_json(REGION_MAP_PATH)
-
-
-def load_regions_geojson() -> dict:
- return _read_json(REGIONS_GEOJSON_PATH)
-
-
-def _get_albums_for_region(region: str, region_map: dict) -> List[str]:
- if region not in region_map:
- raise HTTPException(400, "Unknown region")
- v = region_map[region]
- if isinstance(v, list):
- return v
- if isinstance(v, dict) and isinstance(v.get("albums"), list):
- return v["albums"]
- raise HTTPException(500, "Invalid region_map format")
-
-
-def _ensure_thumb_fallback(src: Path, album: str) -> Path:
- """온디맨드 썸네일 폴백 (sync 누락 분 대응)."""
- out = THUMB_ROOT / album / src.name
- if out.exists():
- return out
- out.parent.mkdir(parents=True, exist_ok=True)
- tmp = out.with_name(out.stem + ".tmp" + out.suffix)
- try:
- with Image.open(src) as im:
- im.thumbnail(THUMB_SIZE)
- ext = out.suffix.lower()
- if ext in (".jpg", ".jpeg"):
- fmt = "JPEG"
- elif ext == ".png":
- fmt = "PNG"
- elif ext == ".webp":
- fmt = "WEBP"
- else:
- fmt = (im.format or "").upper() or "JPEG"
- im.save(tmp, format=fmt, quality=85, optimize=True)
- tmp.replace(out)
- return out
- finally:
- try:
- if tmp.exists():
- tmp.unlink()
- except Exception:
- pass
-
-
-# -----------------------------
-# Models
-# -----------------------------
-class CoverRequest(BaseModel):
- filename: str
-
-
-# -----------------------------
-# Routes
-# -----------------------------
-@app.get("/health")
-def health():
- return {"status": "healthy", "service": "travel-proxy"}
-
-
-@app.get("/api/travel/regions")
-def regions():
- return load_regions_geojson()
-
-
-@app.get("/api/travel/photos")
-def photos(
- region: str = Query(...),
- page: int = Query(1, ge=1),
- size: int = Query(20, ge=1, le=100),
-):
- region_map = load_region_map()
- albums = _get_albums_for_region(region, region_map)
- result = get_photos_by_region(albums, page, size)
-
- # URL 조합 (DB에는 경로를 저장하지 않음)
- items = []
- for row in result["items"]:
- items.append({
- "album": row["album"],
- "file": row["filename"],
- "url": f"{MEDIA_BASE}/{row['album']}/{row['filename']}",
- "thumb": f"{MEDIA_BASE}/.thumb/{row['album']}/{row['filename']}",
- "mtime": row["mtime"],
- })
-
- return {
- "region": region,
- "page": page,
- "size": size,
- "total": result["total"],
- "has_next": result["has_next"],
- "items": items,
- "matched_albums": result["matched_albums"],
- }
-
-
-@app.post("/api/travel/sync")
-def sync_endpoint():
- result = sync(
- travel_root=ROOT,
- thumb_root=THUMB_ROOT,
- region_map_path=REGION_MAP_PATH,
- )
- return result
-
-
-@app.get("/api/travel/albums")
-def albums_list():
- rows = get_all_albums()
- result = []
- for r in rows:
- cover = r["cover_filename"]
- result.append({
- "album": r["album"],
- "count": r["count"],
- "cover_url": f"{MEDIA_BASE}/{r['album']}/{cover}",
- "cover_thumb": f"{MEDIA_BASE}/.thumb/{r['album']}/{cover}",
- })
- return result
-
-
-@app.put("/api/travel/albums/{album}/cover")
-def set_cover(album: str, body: CoverRequest):
- ok = set_album_cover(album, body.filename)
- if not ok:
- raise HTTPException(404, f"Photo not found: {album}/{body.filename}")
- return {
- "album": album,
- "filename": body.filename,
- "cover_url": f"{MEDIA_BASE}/{album}/{body.filename}",
- "cover_thumb": f"{MEDIA_BASE}/.thumb/{album}/{body.filename}",
- }
-
-
-@app.get("/media/travel/.thumb/{album}/{filename}")
-def get_thumb(album: str, filename: str):
- if ".." in album or ".." in filename:
- raise HTTPException(400, "Invalid path")
- src = (ROOT / album / filename).resolve()
- if not str(src).startswith(str(ROOT)):
- raise HTTPException(403, "Access denied")
- if not src.exists() or not src.is_file():
- raise HTTPException(404, "Source not found")
- p = _ensure_thumb_fallback(src, album)
- if not p.exists() or not p.is_file():
- raise HTTPException(404, "Thumbnail not found")
- return FileResponse(str(p))
-
-
-@app.get("/api/version")
-def version():
- return {"version": os.getenv("APP_VERSION", "dev")}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add travel-proxy/app/main.py
-git commit -m "refactor(travel-proxy): main.py DB 기반 전환 — 메모리 캐시 제거 + 신규 API"
-```
-
----
-
-### Task 4: 통합 검증
-
-**Files:**
-- 없음 (기존 파일 검증만)
-
-- [ ] **Step 1: import 구조 확인**
-
-travel-proxy/app/ 디렉토리에 `__init__.py`가 필요한지 확인. FastAPI uvicorn 실행 명령이 `app.main:app`이므로 패키지 import가 동작하려면 `__init__.py`가 필요.
-
-```bash
-ls travel-proxy/app/
-```
-
-`__init__.py`가 없으면 생성:
-
-```python
-# travel-proxy/app/__init__.py
-```
-
-- [ ] **Step 2: Dockerfile 확인**
-
-현재 Dockerfile의 `COPY app /app/app` 라인이 db.py, indexer.py를 포함하는지 확인. 디렉토리 단위 복사이므로 추가 파일은 자동 포함됨. 변경 불필요.
-
-- [ ] **Step 3: docker-compose.yml 환경변수 확인**
-
-`TRAVEL_DB_PATH` 환경변수를 docker-compose.yml에 추가:
-
-```yaml
-# docker-compose.yml의 travel-proxy 서비스 environment에 추가
-- TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db}
-```
-
-- [ ] **Step 4: photos 응답 호환성 검증**
-
-기존 응답 필드와 비교:
-- `region` ✓
-- `page`, `size` ✓
-- `total`, `has_next` ✓
-- `items[].album`, `items[].file`, `items[].url`, `items[].thumb`, `items[].mtime` ✓
-- `matched_albums` — 기존에는 `photos()` 응답에 없었으나 캐시 데이터에 포함. DB 버전은 항상 포함.
-
-- [ ] **Step 5: 커밋 (변경 있을 시)**
-
-```bash
-git add travel-proxy/app/__init__.py docker-compose.yml
-git commit -m "chore(travel-proxy): __init__.py + TRAVEL_DB_PATH 환경변수 추가"
-```
-
----
-
-### Task 5: CLAUDE.md 업데이트
-
-**Files:**
-- Modify: `CLAUDE.md`
-
-- [ ] **Step 1: travel-proxy 섹션에 DB 정보 추가**
-
-CLAUDE.md의 travel-proxy 섹션에 아래 내용 추가:
-
-- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
-- 파일 구조에 `db.py`, `indexer.py` 추가
-
-API 목록 테이블에 신규 API 3개 추가:
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
-| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
-| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
-
-`POST /api/travel/reload` 제거 표기.
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add CLAUDE.md
-git commit -m "docs: CLAUDE.md travel-proxy DB·API 업데이트"
-```
diff --git a/docs/superpowers/plans/2026-04-27-agent-office-v2.md b/docs/superpowers/plans/2026-04-27-agent-office-v2.md
deleted file mode 100644
index 20c03da..0000000
--- a/docs/superpowers/plans/2026-04-27-agent-office-v2.md
+++ /dev/null
@@ -1,3163 +0,0 @@
-# Agent Office v2 — Pixel Office UX 대규모 업데이트 구현 계획
-
-> **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:** 대시보드 칼럼 중심 UI를 전체 화면 픽셀 오피스 캔버스 중심으로 전환하여 가상 오피스 몰입감 제공
-
-**Architecture:** Canvas 2D 게임 루프 기반 렌더링 엔진 + BFS 경로 탐색 이동 시스템 + 3테마 프리셋. 에이전트 클릭 시 320px 사이드 패널(4탭)로 상세 정보 표시. 기존 백엔드 WebSocket 프로토콜 100% 호환.
-
-**Tech Stack:** React (기존), Canvas 2D API, requestAnimationFrame 게임 루프, BFS 경로 탐색, CSS transitions/transforms
-
-**Spec:** `docs/superpowers/specs/2026-04-27-agent-office-v2-design.md`
-
-**작업 대상 저장소:** `web-ui` (프론트엔드) — `C:\Users\jaeoh\Desktop\workspace\web-ui\`
-
----
-
-## Phase 1: 캔버스 엔진 기초
-
-### Task 1: 테마 데이터 정의
-
-**Files:**
-- Create: `src/pages/agent-office/canvas/themes.js`
-
-- [ ] **Step 1: 테마 데이터 파일 생성**
-
-```javascript
-// src/pages/agent-office/canvas/themes.js
-
-export const THEMES = {
- modern: {
- name: 'Modern',
- wall: { color: '#1a1a2e', border: '#333', accent: '#8b5cf6' },
- floor: { color1: '#2a2a3e', color2: '#323248', grid: 'rgba(255,255,255,0.03)' },
- furniture: { desk: '#3a3a5a', chair: '#4c1d95', monitor: '#5555aa', monitorScreen: '#1a3a5a', shelf: '#2a2a4e', table: '#3a3a5a', sofa: '#2a2a4e', coffee: '#3a3a2a' },
- decor: { plant: '#22c55e', pot: '#4a3a2a', lamp: '#fbbf24', ledStrip: '#8b5cf6' },
- lighting: { ambient: 'rgba(139,92,246,0.05)', glow: 'rgba(139,92,246,0.15)' },
- text: { primary: '#ffffff', secondary: '#aaaaaa', label: '#888888' },
- ui: { panelBg: '#111111', headerBg: '#1a1a2e', border: '#333333', accent: '#8b5cf6' }
- },
- retro: {
- name: 'Retro',
- wall: { color: '#2a1a0a', border: '#6a4a2a', accent: '#cc8844' },
- floor: { color1: '#4a3a1a', color2: '#3a2a10', grid: 'rgba(255,255,255,0.02)' },
- furniture: { desk: '#6a4a1a', chair: '#8a5a2a', monitor: '#555555', monitorScreen: '#1a3a1a', shelf: '#5a3a1a', table: '#5a4a2a', sofa: '#5a3a2a', coffee: '#4a3a1a' },
- decor: { plant: '#44aa44', pot: '#6a4a2a', lamp: '#ffcc66', brick: '#8a5a2a' },
- lighting: { ambient: 'rgba(255,200,100,0.05)', glow: 'rgba(255,200,100,0.2)' },
- text: { primary: '#ffe0b0', secondary: '#aa8866', label: '#887766' },
- ui: { panelBg: '#1a1008', headerBg: '#2a1a0a', border: '#4a3a2a', accent: '#cc8844' }
- },
- minimal: {
- name: 'Minimal',
- wall: { color: '#fafafa', border: '#dddddd', accent: '#3b82f6' },
- floor: { color1: '#e8e8e8', color2: '#f0f0f0', grid: 'rgba(0,0,0,0.04)' },
- furniture: { desk: '#ffffff', chair: '#e0e0e0', monitor: '#333333', monitorScreen: '#e0e8f0', shelf: '#f5f5f5', table: '#ffffff', sofa: '#e8e8e8', coffee: '#f0f0f0' },
- decor: { plant: '#86efac', pot: '#ffffff', lamp: '#fbbf24', window: '#e0f0ff' },
- lighting: { ambient: 'rgba(59,130,246,0.03)', glow: 'rgba(255,255,255,0.1)' },
- text: { primary: '#1a1a1a', secondary: '#666666', label: '#999999' },
- ui: { panelBg: '#ffffff', headerBg: '#fafafa', border: '#e0e0e0', accent: '#3b82f6' }
- }
-};
-
-export function getTheme(name) {
- return THEMES[name] || THEMES.modern;
-}
-
-export function getThemeNames() {
- return Object.entries(THEMES).map(([key, val]) => ({ key, name: val.name }));
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/canvas/themes.js
-git commit -m "feat(agent-office): add theme data definitions (modern/retro/minimal)"
-```
-
----
-
-### Task 2: 오피스 맵 데이터 확장 (32x20)
-
-**Files:**
-- Rewrite: `src/pages/agent-office/assets/office-map.json`
-
-- [ ] **Step 1: 32x20 맵 데이터 작성**
-
-```json
-{
- "cols": 32,
- "rows": 20,
- "tileSize": 32,
- "floor": [
- [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
- [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
- [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
- ],
- "furniture": [
- {"type": "desk_monitor", "col": 3, "row": 3, "agent": "stock", "monitors": 3},
- {"type": "desk_monitor", "col": 10, "row": 3, "agent": "music", "monitors": 1, "accent": "instrument"},
- {"type": "desk_monitor", "col": 17, "row": 3, "agent": "blog", "monitors": 2, "accent": "papers"},
- {"type": "desk_monitor", "col": 24, "row": 3, "agent": "realestate", "monitors": 2, "accent": "briefcase"},
- {"type": "desk_monitor", "col": 14, "row": 7, "agent": "lotto", "monitors": 1, "accent": "dice"},
- {"type": "meeting_table","col": 13, "row": 11,"width": 6, "height": 2},
- {"type": "sofa", "col": 2, "row": 17},
- {"type": "coffee_machine","col": 5, "row": 16},
- {"type": "bookshelf", "col": 27, "row": 16, "height": 3},
- {"type": "plant", "col": 1, "row": 1},
- {"type": "plant", "col": 30, "row": 1},
- {"type": "plant", "col": 1, "row": 14},
- {"type": "plant", "col": 30, "row": 14},
- {"type": "water_cooler", "col": 8, "row": 17}
- ],
- "waypoints": {
- "desk_stock": {"col": 3, "row": 4},
- "desk_music": {"col": 10, "row": 4},
- "desk_blog": {"col": 17, "row": 4},
- "desk_realestate": {"col": 24, "row": 4},
- "desk_lotto": {"col": 14, "row": 8},
- "meeting": {"col": 16, "row": 13},
- "break_room": {"col": 4, "row": 17},
- "coffee": {"col": 6, "row": 17},
- "water_cooler": {"col": 8, "row": 18}
- },
- "blocked": [
- [3,3],[4,3],[5,3],
- [10,3],[11,3],
- [17,3],[18,3],[19,3],
- [24,3],[25,3],[26,3],
- [14,7],[15,7],
- [13,11],[14,11],[15,11],[16,11],[17,11],[18,11],
- [13,12],[14,12],[15,12],[16,12],[17,12],[18,12],
- [2,17],[3,17],
- [5,16],[6,16],
- [27,16],[27,17],[27,18],
- [8,17]
- ],
- "tileTypes": {
- "0": "wall",
- "1": "floor",
- "2": "floor_break"
- }
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/assets/office-map.json
-git commit -m "feat(agent-office): expand office map to 32x20 with 5 agents and break room"
-```
-
----
-
-### Task 3: BFS 경로 탐색 엔진
-
-**Files:**
-- Create: `src/pages/agent-office/canvas/Pathfinder.js`
-
-- [ ] **Step 1: Pathfinder 모듈 작성**
-
-```javascript
-// src/pages/agent-office/canvas/Pathfinder.js
-
-/**
- * BFS 4방향 경로 탐색 (대각선 없음)
- * blocked 타일과 벽 타일을 회피하여 최단 경로 반환
- */
-export class Pathfinder {
- constructor(cols, rows) {
- this.cols = cols;
- this.rows = rows;
- this.blocked = new Set();
- }
-
- /** blocked 타일 세팅 (wall + furniture footprint) */
- setBlocked(blockedList) {
- this.blocked.clear();
- for (const [col, row] of blockedList) {
- this.blocked.add(`${col},${row}`);
- }
- }
-
- /** wall 타일도 blocked로 추가 (floor 배열에서 0인 셀) */
- setWalls(floorGrid) {
- for (let r = 0; r < this.rows; r++) {
- for (let c = 0; c < this.cols; c++) {
- if (floorGrid[r][c] === 0) {
- this.blocked.add(`${c},${r}`);
- }
- }
- }
- }
-
- isBlocked(col, row) {
- if (col < 0 || col >= this.cols || row < 0 || row >= this.rows) return true;
- return this.blocked.has(`${col},${row}`);
- }
-
- /**
- * BFS 최단 경로
- * @returns {Array<{col, row}>} 시작점 제외, 도착점 포함 경로. 경로 없으면 빈 배열.
- */
- findPath(startCol, startRow, goalCol, goalRow) {
- if (startCol === goalCol && startRow === goalRow) return [];
-
- const key = (c, r) => `${c},${r}`;
- const startKey = key(startCol, startRow);
- const goalKey = key(goalCol, goalRow);
-
- const queue = [{ col: startCol, row: startRow }];
- const visited = new Set([startKey]);
- const parent = new Map();
-
- const dirs = [
- { dc: 0, dr: -1 }, // up
- { dc: 0, dr: 1 }, // down
- { dc: -1, dr: 0 }, // left
- { dc: 1, dr: 0 } // right
- ];
-
- while (queue.length > 0) {
- const current = queue.shift();
-
- for (const { dc, dr } of dirs) {
- const nc = current.col + dc;
- const nr = current.row + dr;
- const nk = key(nc, nr);
-
- if (visited.has(nk) || this.isBlocked(nc, nr)) continue;
- // 골 지점은 blocked여도 이동 가능 (에이전트가 자기 자리에 앉으려면)
- if (nk !== goalKey && this.blocked.has(nk)) continue;
-
- visited.add(nk);
- parent.set(nk, key(current.col, current.row));
- queue.push({ col: nc, row: nr });
-
- if (nc === goalCol && nr === goalRow) {
- return this._reconstructPath(parent, startKey, goalKey);
- }
- }
- }
-
- return []; // 경로 없음
- }
-
- _reconstructPath(parent, startKey, goalKey) {
- const path = [];
- let current = goalKey;
- while (current !== startKey) {
- const [c, r] = current.split(',').map(Number);
- path.unshift({ col: c, row: r });
- current = parent.get(current);
- }
- return path;
- }
-
- /** idle 배회용: start 주변 반경 내 랜덤 walkable 타일 */
- getRandomNearbyFloor(col, row, radius = 4) {
- const candidates = [];
- for (let dr = -radius; dr <= radius; dr++) {
- for (let dc = -radius; dc <= radius; dc++) {
- const nc = col + dc;
- const nr = row + dr;
- if (nc === col && nr === row) continue;
- if (!this.isBlocked(nc, nr)) {
- candidates.push({ col: nc, row: nr });
- }
- }
- }
- if (candidates.length === 0) return null;
- return candidates[Math.floor(Math.random() * candidates.length)];
- }
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/canvas/Pathfinder.js
-git commit -m "feat(agent-office): add BFS pathfinder for agent movement"
-```
-
----
-
-### Task 4: 타일맵 렌더러 재작성 (테마 지원)
-
-**Files:**
-- Rewrite: `src/pages/agent-office/canvas/TileMap.js`
-
-- [ ] **Step 1: TileMap 재작성**
-
-```javascript
-// src/pages/agent-office/canvas/TileMap.js
-
-/**
- * 타일맵 렌더러 — 바닥, 벽, 그리드를 테마 팔레트로 렌더링
- * 가구는 FurnitureRenderer가 별도 처리
- */
-export class TileMap {
- constructor(mapData) {
- this.cols = mapData.cols;
- this.rows = mapData.rows;
- this.tileSize = mapData.tileSize;
- this.floor = mapData.floor;
- this.tileTypes = mapData.tileTypes;
- }
-
- /**
- * 바닥 + 벽 렌더링
- * @param {CanvasRenderingContext2D} ctx
- * @param {object} theme - themes.js 에서 가져온 테마 객체
- * @param {number} scale - 줌 레벨
- * @param {number} offsetX - 패닝 X 오프셋
- * @param {number} offsetY - 패닝 Y 오프셋
- */
- render(ctx, theme, scale, offsetX, offsetY) {
- const ts = this.tileSize * scale;
-
- for (let r = 0; r < this.rows; r++) {
- for (let c = 0; c < this.cols; c++) {
- const tileType = this.floor[r][c];
- const x = c * ts + offsetX;
- const y = r * ts + offsetY;
-
- // 화면 밖이면 스킵
- if (x + ts < 0 || y + ts < 0 || x > ctx.canvas.width || y > ctx.canvas.height) continue;
-
- if (tileType === 0) {
- // 벽
- ctx.fillStyle = theme.wall.color;
- ctx.fillRect(x, y, ts, ts);
- // 벽 하단 경계선
- ctx.fillStyle = theme.wall.border;
- ctx.fillRect(x, y + ts - scale, ts, scale);
- } else {
- // 바닥
- const isBreak = this.tileTypes[String(tileType)] === 'floor_break';
- ctx.fillStyle = isBreak ? theme.floor.color2 : theme.floor.color1;
- ctx.fillRect(x, y, ts, ts);
-
- // 체커보드 패턴
- if ((r + c) % 2 === 0) {
- ctx.fillStyle = theme.floor.grid;
- ctx.fillRect(x, y, ts, ts);
- }
-
- // 그리드 선
- ctx.strokeStyle = theme.floor.grid;
- ctx.lineWidth = scale * 0.5;
- ctx.strokeRect(x, y, ts, ts);
- }
- }
- }
- }
-
- /** 화면 좌표 → 타일 좌표 변환 */
- screenToTile(screenX, screenY, scale, offsetX, offsetY) {
- const ts = this.tileSize * scale;
- const col = Math.floor((screenX - offsetX) / ts);
- const row = Math.floor((screenY - offsetY) / ts);
- return { col, row };
- }
-
- /** 타일 좌표 → 화면 좌표 (타일 중앙) */
- tileToScreen(col, row, scale, offsetX, offsetY) {
- const ts = this.tileSize * scale;
- return {
- x: col * ts + offsetX + ts / 2,
- y: row * ts + offsetY + ts / 2
- };
- }
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/canvas/TileMap.js
-git commit -m "refactor(agent-office): rewrite TileMap with theme support and viewport culling"
-```
-
----
-
-### Task 5: 가구 렌더러 (테마 기반 프로시저럴)
-
-**Files:**
-- Create: `src/pages/agent-office/canvas/FurnitureRenderer.js`
-
-- [ ] **Step 1: FurnitureRenderer 작성**
-
-```javascript
-// src/pages/agent-office/canvas/FurnitureRenderer.js
-
-/**
- * 가구 프로시저럴 렌더러 — 테마 팔레트 기반
- * 각 가구 타입별 draw 함수, Y-sort를 위한 zY 반환
- */
-export class FurnitureRenderer {
- constructor(furnitureList, tileSize) {
- this.furnitureList = furnitureList;
- this.tileSize = tileSize;
- }
-
- /**
- * 모든 가구를 Y-sort 순서로 반환 (에이전트와 함께 정렬하기 위함)
- * @returns {Array<{type, col, row, zY, draw: Function}>}
- */
- getRenderables(theme, scale, offsetX, offsetY) {
- const ts = this.tileSize * scale;
- return this.furnitureList.map(f => ({
- ...f,
- zY: f.row,
- draw: (ctx) => this._drawFurniture(ctx, f, theme, ts, offsetX, offsetY)
- }));
- }
-
- _drawFurniture(ctx, f, theme, ts, ox, oy) {
- const x = f.col * ts + ox;
- const y = f.row * ts + oy;
-
- switch (f.type) {
- case 'desk_monitor': this._drawDesk(ctx, f, theme, ts, x, y); break;
- case 'meeting_table': this._drawMeetingTable(ctx, f, theme, ts, x, y); break;
- case 'sofa': this._drawSofa(ctx, theme, ts, x, y); break;
- case 'coffee_machine':this._drawCoffeeMachine(ctx, theme, ts, x, y); break;
- case 'bookshelf': this._drawBookshelf(ctx, f, theme, ts, x, y); break;
- case 'plant': this._drawPlant(ctx, theme, ts, x, y); break;
- case 'water_cooler': this._drawWaterCooler(ctx, theme, ts, x, y); break;
- }
- }
-
- _drawDesk(ctx, f, theme, ts, x, y) {
- // 책상 상판
- const dw = ts * 2;
- const dh = ts * 0.6;
- ctx.fillStyle = theme.furniture.desk;
- ctx.fillRect(x, y + ts * 0.2, dw, dh);
- // 책상 다리
- ctx.fillStyle = theme.wall.border;
- ctx.fillRect(x + ts * 0.1, y + dh + ts * 0.2, ts * 0.15, ts * 0.3);
- ctx.fillRect(x + dw - ts * 0.25, y + dh + ts * 0.2, ts * 0.15, ts * 0.3);
-
- // 모니터들
- const monCount = f.monitors || 1;
- const monW = ts * 0.5;
- const monH = ts * 0.4;
- const totalW = monCount * monW + (monCount - 1) * ts * 0.1;
- let monX = x + (dw - totalW) / 2;
-
- for (let i = 0; i < monCount; i++) {
- // 모니터 프레임
- ctx.fillStyle = theme.furniture.monitor;
- ctx.fillRect(monX, y - monH + ts * 0.2, monW, monH);
- // 화면
- ctx.fillStyle = theme.furniture.monitorScreen;
- ctx.fillRect(monX + ts * 0.05, y - monH + ts * 0.25, monW - ts * 0.1, monH - ts * 0.1);
- // 모니터 받침대
- ctx.fillStyle = theme.furniture.monitor;
- ctx.fillRect(monX + monW * 0.35, y + ts * 0.2 - ts * 0.05, monW * 0.3, ts * 0.08);
- monX += monW + ts * 0.1;
- }
-
- // 의자 (책상 아래)
- ctx.fillStyle = theme.furniture.chair;
- ctx.fillRect(x + dw * 0.35, y + ts, dw * 0.3, ts * 0.5);
- ctx.fillRect(x + dw * 0.3, y + ts * 0.8, dw * 0.4, ts * 0.25);
-
- // 에이전트별 악센트 소품
- if (f.accent === 'instrument') {
- // 음표 모양
- ctx.fillStyle = theme.ui.accent;
- ctx.fillRect(x + dw + ts * 0.2, y + ts * 0.3, ts * 0.1, ts * 0.5);
- ctx.beginPath();
- ctx.arc(x + dw + ts * 0.2, y + ts * 0.8, ts * 0.15, 0, Math.PI * 2);
- ctx.fill();
- } else if (f.accent === 'papers') {
- // 서류 더미
- ctx.fillStyle = '#ffffff';
- ctx.fillRect(x + dw + ts * 0.1, y + ts * 0.3, ts * 0.35, ts * 0.45);
- ctx.fillStyle = theme.text.label;
- for (let i = 0; i < 3; i++) {
- ctx.fillRect(x + dw + ts * 0.15, y + ts * 0.38 + i * ts * 0.1, ts * 0.25, ts * 0.02);
- }
- } else if (f.accent === 'briefcase') {
- ctx.fillStyle = '#8B4513';
- ctx.fillRect(x + dw + ts * 0.1, y + ts * 0.5, ts * 0.4, ts * 0.3);
- ctx.fillStyle = '#D4A06A';
- ctx.fillRect(x + dw + ts * 0.2, y + ts * 0.45, ts * 0.2, ts * 0.08);
- } else if (f.accent === 'dice') {
- ctx.fillStyle = '#ef4444';
- ctx.fillRect(x + dw + ts * 0.15, y + ts * 0.4, ts * 0.3, ts * 0.3);
- ctx.fillStyle = '#ffffff';
- ctx.beginPath();
- ctx.arc(x + dw + ts * 0.3, y + ts * 0.55, ts * 0.05, 0, Math.PI * 2);
- ctx.fill();
- }
- }
-
- _drawMeetingTable(ctx, f, theme, ts, x, y) {
- const w = (f.width || 4) * ts;
- const h = (f.height || 2) * ts;
- // 테이블 상판
- ctx.fillStyle = theme.furniture.table;
- ctx.fillRect(x + ts * 0.1, y + ts * 0.1, w - ts * 0.2, h - ts * 0.2);
- // 테이블 그림자
- ctx.fillStyle = 'rgba(0,0,0,0.15)';
- ctx.fillRect(x + ts * 0.15, y + h - ts * 0.1, w - ts * 0.25, ts * 0.1);
- // 의자들 (상하 4개씩)
- for (let i = 0; i < 4; i++) {
- const cx = x + ts * 0.5 + i * (w - ts) / 3;
- ctx.fillStyle = theme.furniture.chair;
- ctx.fillRect(cx, y - ts * 0.3, ts * 0.4, ts * 0.35);
- ctx.fillRect(cx, y + h - ts * 0.05, ts * 0.4, ts * 0.35);
- }
- }
-
- _drawSofa(ctx, theme, ts, x, y) {
- ctx.fillStyle = theme.furniture.sofa;
- ctx.fillRect(x, y, ts * 2, ts * 0.8);
- // 등받이
- ctx.fillStyle = theme.furniture.sofa;
- ctx.fillRect(x, y - ts * 0.3, ts * 2, ts * 0.35);
- // 쿠션 구분선
- ctx.strokeStyle = theme.wall.border;
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(x + ts, y);
- ctx.lineTo(x + ts, y + ts * 0.8);
- ctx.stroke();
- }
-
- _drawCoffeeMachine(ctx, theme, ts, x, y) {
- ctx.fillStyle = theme.furniture.coffee;
- ctx.fillRect(x + ts * 0.15, y, ts * 0.7, ts * 0.8);
- // 디스펜서
- ctx.fillStyle = theme.furniture.monitor;
- ctx.fillRect(x + ts * 0.25, y + ts * 0.15, ts * 0.5, ts * 0.3);
- // 커피 잔
- ctx.fillStyle = '#ffffff';
- ctx.fillRect(x + ts * 0.3, y + ts * 0.55, ts * 0.2, ts * 0.15);
- // 스팀
- ctx.strokeStyle = 'rgba(255,255,255,0.3)';
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(x + ts * 0.4, y + ts * 0.5);
- ctx.quadraticCurveTo(x + ts * 0.45, y + ts * 0.35, x + ts * 0.4, y + ts * 0.2);
- ctx.stroke();
- }
-
- _drawBookshelf(ctx, f, theme, ts, x, y) {
- const h = (f.height || 3) * ts;
- ctx.fillStyle = theme.furniture.shelf;
- ctx.fillRect(x, y, ts * 0.9, h);
- // 선반 및 책
- const bookColors = ['#aa4444', '#4444aa', '#44aa44', '#aaaa44', '#aa44aa', '#44aaaa'];
- const shelfCount = f.height || 3;
- for (let i = 0; i < shelfCount; i++) {
- const sy = y + i * ts + ts * 0.1;
- // 선반 판
- ctx.fillStyle = theme.furniture.shelf;
- ctx.fillRect(x, sy + ts * 0.7, ts * 0.9, ts * 0.05);
- // 책들
- for (let b = 0; b < 4; b++) {
- ctx.fillStyle = bookColors[(i * 4 + b) % bookColors.length];
- ctx.fillRect(x + ts * 0.05 + b * ts * 0.2, sy + ts * 0.1, ts * 0.15, ts * 0.6);
- }
- }
- }
-
- _drawPlant(ctx, theme, ts, x, y) {
- // 화분
- ctx.fillStyle = theme.decor.pot;
- ctx.fillRect(x + ts * 0.25, y + ts * 0.6, ts * 0.5, ts * 0.35);
- ctx.fillRect(x + ts * 0.2, y + ts * 0.55, ts * 0.6, ts * 0.1);
- // 잎
- ctx.fillStyle = theme.decor.plant;
- ctx.beginPath();
- ctx.ellipse(x + ts * 0.5, y + ts * 0.35, ts * 0.3, ts * 0.25, 0, 0, Math.PI * 2);
- ctx.fill();
- ctx.beginPath();
- ctx.ellipse(x + ts * 0.35, y + ts * 0.25, ts * 0.15, ts * 0.2, -0.3, 0, Math.PI * 2);
- ctx.fill();
- ctx.beginPath();
- ctx.ellipse(x + ts * 0.65, y + ts * 0.25, ts * 0.15, ts * 0.2, 0.3, 0, Math.PI * 2);
- ctx.fill();
- }
-
- _drawWaterCooler(ctx, theme, ts, x, y) {
- // 본체
- ctx.fillStyle = theme.furniture.shelf;
- ctx.fillRect(x + ts * 0.2, y + ts * 0.3, ts * 0.6, ts * 0.6);
- // 물통
- ctx.fillStyle = 'rgba(100,180,255,0.5)';
- ctx.fillRect(x + ts * 0.3, y, ts * 0.4, ts * 0.35);
- ctx.fillStyle = 'rgba(100,180,255,0.3)';
- ctx.beginPath();
- ctx.arc(x + ts * 0.5, y, ts * 0.2, 0, Math.PI * 2);
- ctx.fill();
- }
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/canvas/FurnitureRenderer.js
-git commit -m "feat(agent-office): add procedural furniture renderer with theme support"
-```
-
----
-
-### Task 6: 게임 루프 + 줌/팬 시스템 (OfficeRenderer 재작성)
-
-**Files:**
-- Rewrite: `src/pages/agent-office/canvas/OfficeRenderer.js`
-
-- [ ] **Step 1: OfficeRenderer 재작성**
-
-```javascript
-// src/pages/agent-office/canvas/OfficeRenderer.js
-
-import mapData from '../assets/office-map.json';
-import { TileMap } from './TileMap.js';
-import { FurnitureRenderer } from './FurnitureRenderer.js';
-import { Pathfinder } from './Pathfinder.js';
-import { AgentSprite } from './AgentSprite.js';
-import { OverlayRenderer } from './OverlayRenderer.js';
-import { getTheme } from './themes.js';
-
-const AGENT_META = {
- stock: { displayName: '주식 트레이더', emoji: '📈', color: '#4488cc' },
- music: { displayName: '음악 프로듀서', emoji: '🎵', color: '#44aa88' },
- blog: { displayName: '블로그 마케터', emoji: '✍️', color: '#d97706' },
- realestate: { displayName: '청약 애널리스트', emoji: '🏢', color: '#c026d3' },
- lotto: { displayName: '로또 큐레이터', emoji: '🎱', color: '#ef4444' }
-};
-
-export class OfficeRenderer {
- constructor(canvas) {
- this.canvas = canvas;
- this.ctx = canvas.getContext('2d');
-
- // 맵 & 렌더러
- this.tileMap = new TileMap(mapData);
- this.furnitureRenderer = new FurnitureRenderer(mapData.furniture, mapData.tileSize);
- this.pathfinder = new Pathfinder(mapData.cols, mapData.rows);
- this.overlayRenderer = new OverlayRenderer();
-
- // blocked 타일 설정
- this.pathfinder.setWalls(mapData.floor);
- this.pathfinder.setBlocked(mapData.blocked);
-
- // 테마 & 뷰포트
- this.theme = getTheme(localStorage.getItem('agent-office-theme') || 'modern');
- this.zoom = 2;
- this.panX = 0;
- this.panY = 0;
- this._isPanning = false;
- this._panStart = { x: 0, y: 0 };
-
- // 에이전트
- this.agents = new Map();
- this._initAgents();
-
- // 게임 루프
- this._lastTime = 0;
- this._animId = null;
-
- // 이벤트
- this._setupInputHandlers();
- }
-
- _initAgents() {
- for (const [id, meta] of Object.entries(AGENT_META)) {
- const waypoint = mapData.waypoints[`desk_${id}`];
- if (!waypoint) continue;
- const sprite = new AgentSprite(id, meta, waypoint.col, waypoint.row, this.pathfinder);
- sprite.deskCol = waypoint.col;
- sprite.deskRow = waypoint.row;
- this.agents.set(id, sprite);
- }
- }
-
- /** 줌/팬/클릭 이벤트 핸들러 */
- _setupInputHandlers() {
- // 마우스 휠 줌
- this.canvas.addEventListener('wheel', (e) => {
- e.preventDefault();
- const oldZoom = this.zoom;
- if (e.deltaY < 0) {
- this.zoom = Math.min(this.zoom + 0.5, 4);
- } else {
- this.zoom = Math.max(this.zoom - 0.5, 1);
- }
- // 마우스 위치 기준 줌
- if (this.zoom !== oldZoom) {
- const rect = this.canvas.getBoundingClientRect();
- const mx = e.clientX - rect.left;
- const my = e.clientY - rect.top;
- const ratio = this.zoom / oldZoom;
- this.panX = mx - (mx - this.panX) * ratio;
- this.panY = my - (my - this.panY) * ratio;
- }
- }, { passive: false });
-
- // 마우스 드래그 패닝
- this.canvas.addEventListener('mousedown', (e) => {
- if (e.button === 0) {
- this._isPanning = true;
- this._panStart = { x: e.clientX - this.panX, y: e.clientY - this.panY };
- }
- });
- window.addEventListener('mousemove', (e) => {
- if (this._isPanning) {
- this.panX = e.clientX - this._panStart.x;
- this.panY = e.clientY - this._panStart.y;
- }
- });
- window.addEventListener('mouseup', () => {
- this._isPanning = false;
- });
-
- // 터치 (모바일)
- let lastTouchDist = 0;
- let lastTouchCenter = { x: 0, y: 0 };
- this.canvas.addEventListener('touchstart', (e) => {
- if (e.touches.length === 1) {
- this._isPanning = true;
- this._panStart = { x: e.touches[0].clientX - this.panX, y: e.touches[0].clientY - this.panY };
- } else if (e.touches.length === 2) {
- this._isPanning = false;
- const dx = e.touches[0].clientX - e.touches[1].clientX;
- const dy = e.touches[0].clientY - e.touches[1].clientY;
- lastTouchDist = Math.hypot(dx, dy);
- lastTouchCenter = {
- x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
- y: (e.touches[0].clientY + e.touches[1].clientY) / 2
- };
- }
- }, { passive: false });
- this.canvas.addEventListener('touchmove', (e) => {
- e.preventDefault();
- if (e.touches.length === 1 && this._isPanning) {
- this.panX = e.touches[0].clientX - this._panStart.x;
- this.panY = e.touches[0].clientY - this._panStart.y;
- } else if (e.touches.length === 2) {
- const dx = e.touches[0].clientX - e.touches[1].clientX;
- const dy = e.touches[0].clientY - e.touches[1].clientY;
- const dist = Math.hypot(dx, dy);
- const oldZoom = this.zoom;
- this.zoom = Math.min(4, Math.max(1, this.zoom * (dist / lastTouchDist)));
- lastTouchDist = dist;
- const rect = this.canvas.getBoundingClientRect();
- const cx = lastTouchCenter.x - rect.left;
- const cy = lastTouchCenter.y - rect.top;
- const ratio = this.zoom / oldZoom;
- this.panX = cx - (cx - this.panX) * ratio;
- this.panY = cy - (cy - this.panY) * ratio;
- }
- }, { passive: false });
- this.canvas.addEventListener('touchend', () => {
- this._isPanning = false;
- });
- }
-
- /** 클릭 히트 테스트 — AgentOffice에서 호출 */
- hitTest(clientX, clientY) {
- const rect = this.canvas.getBoundingClientRect();
- const screenX = clientX - rect.left;
- const screenY = clientY - rect.top;
- const { col, row } = this.tileMap.screenToTile(screenX, screenY, this.zoom, this.panX, this.panY);
-
- // 에이전트 히트 (역순, 최상위 우선)
- for (const [id, sprite] of [...this.agents.entries()].reverse()) {
- const dx = Math.abs(sprite.x - col);
- const dy = Math.abs(sprite.y - row);
- if (dx < 1.2 && dy < 1.5) {
- return { type: 'agent', id };
- }
- }
- return { type: 'empty' };
- }
-
- /** 에이전트 상태 업데이트 (WebSocket에서 호출) */
- updateAgentState(agentId, state, detail) {
- const sprite = this.agents.get(agentId);
- if (!sprite) return;
- sprite.onStateChange(state, detail, mapData.waypoints);
- }
-
- /** 에이전트 알림 배지 설정 */
- setAgentNotification(agentId, count) {
- const sprite = this.agents.get(agentId);
- if (sprite) sprite.notificationCount = count;
- }
-
- /** 테마 변경 */
- setTheme(themeName) {
- this.theme = getTheme(themeName);
- localStorage.setItem('agent-office-theme', themeName);
- }
-
- /** 줌 레벨 설정 */
- setZoom(level) {
- const cx = this.canvas.width / 2;
- const cy = this.canvas.height / 2;
- const oldZoom = this.zoom;
- this.zoom = Math.min(4, Math.max(1, level));
- const ratio = this.zoom / oldZoom;
- this.panX = cx - (cx - this.panX) * ratio;
- this.panY = cy - (cy - this.panY) * ratio;
- }
-
- /** 카메라를 맵 중앙에 맞추기 */
- centerCamera() {
- const mapW = mapData.cols * mapData.tileSize * this.zoom;
- const mapH = mapData.rows * mapData.tileSize * this.zoom;
- this.panX = (this.canvas.width - mapW) / 2;
- this.panY = (this.canvas.height - mapH) / 2;
- }
-
- /** 게임 루프 시작 */
- start() {
- this.centerCamera();
- this._lastTime = performance.now();
- this._loop(this._lastTime);
- }
-
- /** 게임 루프 중지 */
- stop() {
- if (this._animId) {
- cancelAnimationFrame(this._animId);
- this._animId = null;
- }
- }
-
- _loop(timestamp) {
- const dt = Math.min((timestamp - this._lastTime) / 1000, 0.1); // cap dt to avoid spiral
- this._lastTime = timestamp;
-
- this._update(dt);
- this._render();
-
- this._animId = requestAnimationFrame((t) => this._loop(t));
- }
-
- _update(dt) {
- for (const sprite of this.agents.values()) {
- sprite.update(dt);
- }
- }
-
- _render() {
- const ctx = this.ctx;
- const dpr = window.devicePixelRatio || 1;
-
- // 캔버스 크기 조정
- const displayW = this.canvas.clientWidth;
- const displayH = this.canvas.clientHeight;
- if (this.canvas.width !== displayW * dpr || this.canvas.height !== displayH * dpr) {
- this.canvas.width = displayW * dpr;
- this.canvas.height = displayH * dpr;
- ctx.scale(dpr, dpr);
- }
-
- ctx.imageSmoothingEnabled = false;
- ctx.clearRect(0, 0, displayW, displayH);
-
- // 배경
- ctx.fillStyle = this.theme.wall.color;
- ctx.fillRect(0, 0, displayW, displayH);
-
- // 1. 타일맵 (바닥 + 벽)
- this.tileMap.render(ctx, this.theme, this.zoom, this.panX, this.panY);
-
- // 2. Y-sorted: 가구 + 에이전트
- const renderables = [];
-
- // 가구
- const furnitureItems = this.furnitureRenderer.getRenderables(this.theme, this.zoom, this.panX, this.panY);
- renderables.push(...furnitureItems);
-
- // 에이전트
- for (const sprite of this.agents.values()) {
- renderables.push({
- zY: sprite.y,
- draw: (ctx2) => sprite.draw(ctx2, this.zoom, this.panX, this.panY, mapData.tileSize)
- });
- }
-
- // Y좌표 정렬
- renderables.sort((a, b) => a.zY - b.zY);
- for (const item of renderables) {
- item.draw(ctx);
- }
-
- // 3. 오버레이 (항상 최상위)
- for (const sprite of this.agents.values()) {
- this.overlayRenderer.draw(ctx, sprite, this.theme, this.zoom, this.panX, this.panY, mapData.tileSize);
- }
- }
-
- /** 리사이즈 처리 */
- resize() {
- // 다음 프레임에서 자동 조정됨 (_render에서 크기 체크)
- }
-
- destroy() {
- this.stop();
- // 이벤트 리스너는 canvas와 함께 GC됨
- }
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/canvas/OfficeRenderer.js
-git commit -m "refactor(agent-office): rewrite OfficeRenderer with game loop, zoom/pan, Y-sorting"
-```
-
----
-
-## Phase 2: 에이전트 캐릭터 시스템
-
-### Task 7: 프로시저럴 스프라이트 고도화 (16x32px)
-
-**Files:**
-- Rewrite: `src/pages/agent-office/canvas/SpriteSheet.js` → renamed to `ProceduralSprite.js`
-- Create: `src/pages/agent-office/canvas/ProceduralSprite.js`
-
-- [ ] **Step 1: ProceduralSprite 작성 (16x32 해상도, 4방향, 5상태)**
-
-```javascript
-// src/pages/agent-office/canvas/ProceduralSprite.js
-
-/**
- * 프로시저럴 픽셀 캐릭터 렌더러 (16×32px 기본 해상도)
- * Phase 1: 코드로 캐릭터를 그림
- * Phase 2: SpriteLoader가 PNG 스프라이트로 대체
- */
-
-const AGENT_COLORS = {
- stock: { body: '#4488cc', hair: '#2255aa', accent: '#66aaee' },
- music: { body: '#44aa88', hair: '#228866', accent: '#66ccaa' },
- blog: { body: '#d97706', hair: '#b45e04', accent: '#f59e0b' },
- realestate: { body: '#c026d3', hair: '#9b1dab', accent: '#e044f0' },
- lotto: { body: '#ef4444', hair: '#cc2222', accent: '#ff6666' }
-};
-
-/** 애니메이션 프레임 설정 */
-const ANIM_CONFIG = {
- idle: { frames: 2, speed: 0.8 },
- walk: { frames: 4, speed: 0.15, cycle: [0, 1, 2, 1] },
- type: { frames: 2, speed: 0.3 },
- wait: { frames: 2, speed: 0.5 },
- break_anim:{ frames: 2, speed: 1.0 }
-};
-
-export class ProceduralSprite {
- /**
- * 캐릭터 1프레임 렌더링
- * @param {CanvasRenderingContext2D} ctx
- * @param {string} agentId
- * @param {string} state - idle|walk|type|wait|break_anim
- * @param {string} direction - down|up|right|left
- * @param {number} frame - 현재 애니메이션 프레임 인덱스
- * @param {number} x - 캔버스 X 좌표 (캐릭터 중앙 하단)
- * @param {number} y - 캔버스 Y 좌표 (캐릭터 중앙 하단)
- * @param {number} scale - 렌더링 스케일
- */
- static draw(ctx, agentId, state, direction, frame, x, y, scale) {
- const colors = AGENT_COLORS[agentId] || AGENT_COLORS.stock;
- const px = scale; // 1 pixel = scale 크기
- const w = 16 * px;
- const h = 32 * px;
- const bx = x - w / 2; // 좌상단 기준
- const by = y - h;
-
- ctx.save();
-
- // 좌우 반전 (left = right 플립)
- if (direction === 'left') {
- ctx.translate(x, 0);
- ctx.scale(-1, 1);
- ctx.translate(-x, 0);
- }
-
- // 그림자
- ctx.fillStyle = 'rgba(0,0,0,0.2)';
- ctx.beginPath();
- ctx.ellipse(x, y, w * 0.35, px * 2, 0, 0, Math.PI * 2);
- ctx.fill();
-
- // 상태별 오프셋
- let bodyOffsetY = 0;
- let legSpread = 0;
- let armAngle = 0;
-
- if (state === 'walk') {
- const walkFrame = ANIM_CONFIG.walk.cycle[frame % 4];
- legSpread = (walkFrame - 1) * px * 2;
- bodyOffsetY = walkFrame === 1 ? -px : 0;
- } else if (state === 'type') {
- armAngle = frame % 2 === 0 ? 1 : -1;
- bodyOffsetY = frame % 2 === 0 ? 0 : -px * 0.5;
- } else if (state === 'wait') {
- bodyOffsetY = Math.sin(frame * Math.PI) * px;
- } else if (state === 'idle') {
- bodyOffsetY = frame % 2 === 0 ? 0 : -px * 0.5;
- } else if (state === 'break_anim') {
- bodyOffsetY = frame % 2 === 0 ? 0 : px * 0.5; // 졸기
- }
-
- const by2 = by + bodyOffsetY;
-
- // 다리
- ctx.fillStyle = '#2a2a3e';
- // 왼쪽 다리
- ctx.fillRect(bx + px * 4 - legSpread, by2 + px * 24, px * 3, px * 8);
- // 오른쪽 다리
- ctx.fillRect(bx + px * 9 + legSpread, by2 + px * 24, px * 3, px * 8);
- // 신발
- ctx.fillStyle = '#333';
- ctx.fillRect(bx + px * 3 - legSpread, by2 + px * 30, px * 5, px * 2);
- ctx.fillRect(bx + px * 8 + legSpread, by2 + px * 30, px * 5, px * 2);
-
- // 몸통
- ctx.fillStyle = colors.body;
- ctx.fillRect(bx + px * 3, by2 + px * 12, px * 10, px * 13);
-
- // 팔
- if (state === 'type') {
- // 타이핑: 팔 앞으로 뻗음
- ctx.fillStyle = colors.body;
- ctx.fillRect(bx + px * 1, by2 + px * 13, px * 3, px * 8 + armAngle * px);
- ctx.fillRect(bx + px * 12, by2 + px * 13, px * 3, px * 8 - armAngle * px);
- // 손
- ctx.fillStyle = '#ffcc99';
- ctx.fillRect(bx + px * 1, by2 + px * 20 + armAngle * px, px * 3, px * 3);
- ctx.fillRect(bx + px * 12, by2 + px * 20 - armAngle * px, px * 3, px * 3);
- } else {
- // 기본 팔
- ctx.fillStyle = colors.body;
- ctx.fillRect(bx + px * 1, by2 + px * 13, px * 3, px * 10);
- ctx.fillRect(bx + px * 12, by2 + px * 13, px * 3, px * 10);
- // 손
- ctx.fillStyle = '#ffcc99';
- ctx.fillRect(bx + px * 1, by2 + px * 22, px * 3, px * 3);
- ctx.fillRect(bx + px * 12, by2 + px * 22, px * 3, px * 3);
- }
-
- // 머리
- ctx.fillStyle = '#ffcc99'; // 피부색
- ctx.fillRect(bx + px * 4, by2 + px * 2, px * 8, px * 10);
-
- // 머리카락
- ctx.fillStyle = colors.hair;
- ctx.fillRect(bx + px * 3, by2 + px * 1, px * 10, px * 4);
- if (direction === 'down' || direction === 'left' || direction === 'right') {
- // 앞머리
- ctx.fillRect(bx + px * 4, by2 + px * 3, px * 2, px * 2);
- }
-
- // 눈
- if (direction !== 'up') {
- ctx.fillStyle = '#222';
- if (state === 'break_anim' && frame % 2 === 1) {
- // 졸기: 눈 감음
- ctx.fillRect(bx + px * 5, by2 + px * 6, px * 2, px);
- ctx.fillRect(bx + px * 9, by2 + px * 6, px * 2, px);
- } else {
- ctx.fillRect(bx + px * 5, by2 + px * 5, px * 2, px * 2);
- ctx.fillRect(bx + px * 9, by2 + px * 5, px * 2, px * 2);
- }
- }
-
- // break 소품: 커피잔
- if (state === 'break_anim') {
- ctx.fillStyle = '#ffffff';
- ctx.fillRect(bx + px * 14, by2 + px * 16, px * 3, px * 4);
- ctx.fillStyle = '#8B4513';
- ctx.fillRect(bx + px * 14.5, by2 + px * 16.5, px * 2, px * 2);
- }
-
- ctx.restore();
- }
-
- static getAnimConfig(state) {
- const mapped = state === 'working' ? 'type'
- : state === 'waiting' ? 'wait'
- : state === 'reporting' ? 'type'
- : state === 'break' ? 'break_anim'
- : state === 'walk' ? 'walk'
- : 'idle';
- return { ...(ANIM_CONFIG[mapped] || ANIM_CONFIG.idle), mapped };
- }
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/canvas/ProceduralSprite.js
-git commit -m "feat(agent-office): add 16x32 procedural sprite with 5 states and 4 directions"
-```
-
----
-
-### Task 8: AgentSprite 재작성 (BFS 이동 + 배회)
-
-**Files:**
-- Rewrite: `src/pages/agent-office/canvas/AgentSprite.js`
-
-- [ ] **Step 1: AgentSprite 재작성**
-
-```javascript
-// src/pages/agent-office/canvas/AgentSprite.js
-
-import { ProceduralSprite } from './ProceduralSprite.js';
-
-const WALK_SPEED = 3; // tiles per second
-const WANDER_DELAY_MIN = 3;
-const WANDER_DELAY_MAX = 8;
-const WANDER_LIMIT_MIN = 3;
-const WANDER_LIMIT_MAX = 6;
-const REST_DELAY_MIN = 2;
-const REST_DELAY_MAX = 20;
-
-export class AgentSprite {
- constructor(id, meta, col, row, pathfinder) {
- this.id = id;
- this.meta = meta;
- this.pathfinder = pathfinder;
-
- // 위치 (타일 좌표, 실수)
- this.x = col;
- this.y = row;
- this.deskCol = col;
- this.deskRow = row;
-
- // 상태
- this.state = 'idle'; // FSM 상태 (from backend)
- this.detail = '';
- this.notificationCount = 0;
-
- // 애니메이션
- this.animState = 'idle'; // 렌더링용 상태
- this.direction = 'down';
- this.animFrame = 0;
- this.animTimer = 0;
-
- // 이동
- this.path = []; // BFS 경로 [{col, row}, ...]
- this.moveProgress = 0; // 0~1 현재 타일 → 다음 타일
- this.moveFrom = { col, row };
- this.moveTo_target = null;
-
- // 배회
- this._wandering = false;
- this._wanderTimer = 0;
- this._wanderCount = 0;
- this._wanderLimit = 0;
- this._restTimer = 0;
- this._isResting = false;
- this._isAtDesk = true;
- }
-
- /** 매 프레임 호출 */
- update(dt) {
- // 이동 처리
- if (this.path.length > 0) {
- this._updateMovement(dt);
- } else if (this._wandering) {
- this._updateWander(dt);
- }
-
- // 애니메이션 프레임 업데이트
- this._updateAnimation(dt);
- }
-
- _updateMovement(dt) {
- this.animState = 'walk';
- this.moveProgress += WALK_SPEED * dt;
-
- if (this.moveProgress >= 1) {
- // 현재 구간 완료
- const arrived = this.path.shift();
- this.x = arrived.col;
- this.y = arrived.row;
- this.moveFrom = { col: arrived.col, row: arrived.row };
- this.moveProgress = 0;
-
- if (this.path.length === 0) {
- // 최종 목적지 도착
- this._onArrival();
- } else {
- // 다음 구간의 방향 설정
- this._updateDirection(this.path[0]);
- }
- } else {
- // 보간
- const next = this.path[0];
- this.x = this.moveFrom.col + (next.col - this.moveFrom.col) * this.moveProgress;
- this.y = this.moveFrom.row + (next.row - this.moveFrom.row) * this.moveProgress;
- }
- }
-
- _onArrival() {
- const atDesk = Math.abs(this.x - this.deskCol) < 0.5 && Math.abs(this.y - this.deskRow) < 0.5;
- this._isAtDesk = atDesk;
-
- if (this.state === 'working' || this.state === 'reporting') {
- this.animState = 'type';
- this.direction = 'up'; // 모니터를 바라봄
- } else if (this.state === 'waiting') {
- this.animState = 'wait';
- } else if (this.state === 'break') {
- this.animState = 'break_anim';
- } else {
- // idle 도착 — 배회 계속 또는 자리에서 쉬기
- if (this._wandering && this._wanderCount < this._wanderLimit) {
- // 다음 배회 타이머 설정
- this._wanderTimer = WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN);
- } else if (this._wandering) {
- // 배회 끝, 휴식
- this._wandering = false;
- this._isResting = true;
- this._restTimer = REST_DELAY_MIN + Math.random() * (REST_DELAY_MAX - REST_DELAY_MIN);
- }
- this.animState = 'idle';
- }
- }
-
- _updateWander(dt) {
- if (this._isResting) {
- this._restTimer -= dt;
- if (this._restTimer <= 0) {
- this._isResting = false;
- this._startWandering();
- }
- return;
- }
-
- this._wanderTimer -= dt;
- if (this._wanderTimer <= 0) {
- // 랜덤 인접 타일로 이동
- const target = this.pathfinder.getRandomNearbyFloor(
- Math.round(this.x), Math.round(this.y), 4
- );
- if (target) {
- const path = this.pathfinder.findPath(
- Math.round(this.x), Math.round(this.y), target.col, target.row
- );
- if (path.length > 0 && path.length <= 6) {
- this.path = path;
- this.moveFrom = { col: Math.round(this.x), row: Math.round(this.y) };
- this.moveProgress = 0;
- this._updateDirection(path[0]);
- this._wanderCount++;
- }
- }
- // 실패해도 타이머 리셋
- this._wanderTimer = WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN);
- }
- }
-
- _updateDirection(nextTile) {
- const dx = nextTile.col - Math.round(this.x);
- const dy = nextTile.row - Math.round(this.y);
- if (Math.abs(dx) > Math.abs(dy)) {
- this.direction = dx > 0 ? 'right' : 'left';
- } else {
- this.direction = dy > 0 ? 'down' : 'up';
- }
- }
-
- _updateAnimation(dt) {
- const config = ProceduralSprite.getAnimConfig(
- this.animState === 'walk' ? 'walk' : this.state
- );
- this.animTimer += dt;
- if (this.animTimer >= config.speed) {
- this.animTimer = 0;
- this.animFrame = (this.animFrame + 1) % config.frames;
- }
- }
-
- /** 백엔드 상태 변경 시 호출 */
- onStateChange(newState, detail, waypoints) {
- const prevState = this.state;
- this.state = newState;
- this.detail = detail || '';
-
- // 배회 중단
- this._wandering = false;
- this._isResting = false;
-
- switch (newState) {
- case 'working':
- case 'reporting':
- case 'waiting':
- // 자리에 없으면 자리로 이동
- if (!this._isAtDesk) {
- this._moveToDesk();
- } else {
- this.animState = newState === 'waiting' ? 'wait' : 'type';
- this.direction = 'up';
- }
- break;
-
- case 'break': {
- // 휴게실로 이동
- const breakWp = waypoints.break_room || waypoints.coffee;
- if (breakWp) {
- this._navigateTo(breakWp.col, breakWp.row);
- }
- break;
- }
-
- case 'idle':
- if (prevState === 'break') {
- // 휴게실에서 자리로 복귀
- this._moveToDesk();
- }
- // 복귀 후 배회 시작 (도착 콜백에서 처리)
- this._startWanderingAfterDelay(3);
- break;
- }
- }
-
- _moveToDesk() {
- this._navigateTo(this.deskCol, this.deskRow);
- }
-
- _navigateTo(goalCol, goalRow) {
- const startCol = Math.round(this.x);
- const startRow = Math.round(this.y);
- const path = this.pathfinder.findPath(startCol, startRow, goalCol, goalRow);
- if (path.length > 0) {
- this.path = path;
- this.moveFrom = { col: startCol, row: startRow };
- this.moveProgress = 0;
- this._updateDirection(path[0]);
- }
- }
-
- _startWanderingAfterDelay(delay) {
- this._wandering = true;
- this._wanderCount = 0;
- this._wanderLimit = WANDER_LIMIT_MIN + Math.floor(Math.random() * (WANDER_LIMIT_MAX - WANDER_LIMIT_MIN));
- this._wanderTimer = delay;
- this._isResting = false;
- }
-
- _startWandering() {
- this._startWanderingAfterDelay(WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN));
- }
-
- isAtDesk() {
- return this._isAtDesk;
- }
-
- /** 렌더링 */
- draw(ctx, zoom, panX, panY, tileSize) {
- const ts = tileSize * zoom;
- const screenX = this.x * ts + panX + ts / 2;
- const screenY = this.y * ts + panY + ts;
- const spriteScale = zoom * 1.5; // 캐릭터 약간 크게
-
- ProceduralSprite.draw(
- ctx, this.id,
- this.animState === 'walk' ? 'walk' : this.state,
- this.direction, this.animFrame,
- screenX, screenY, spriteScale
- );
- }
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/canvas/AgentSprite.js
-git commit -m "refactor(agent-office): rewrite AgentSprite with BFS movement and idle wandering"
-```
-
----
-
-### Task 9: SpriteLoader (Phase 2 준비, 폴백 지원)
-
-**Files:**
-- Create: `src/pages/agent-office/canvas/SpriteLoader.js`
-
-- [ ] **Step 1: SpriteLoader 작성**
-
-```javascript
-// src/pages/agent-office/canvas/SpriteLoader.js
-
-import { ProceduralSprite } from './ProceduralSprite.js';
-
-/**
- * 스프라이트 로더 — PNG 스프라이트시트가 있으면 사용, 없으면 프로시저럴 폴백
- *
- * 스프라이트시트 규격 (Phase 2):
- * - 프레임 크기: 16×32px
- * - 행: 방향 (0=down, 1=up, 2=right)
- * - 열: 상태별 프레임 (idle 2 | walk 4 | type 2 | wait 2 | break 2 = 12열)
- */
-export class SpriteLoader {
- constructor() {
- this.sprites = new Map(); // agentId → { image: Image, loaded: boolean }
- }
-
- /** PNG 스프라이트시트 로드 시도 */
- async tryLoad(agentId, url) {
- return new Promise((resolve) => {
- const img = new Image();
- img.onload = () => {
- this.sprites.set(agentId, { image: img, loaded: true });
- resolve(true);
- };
- img.onerror = () => {
- resolve(false); // 폴백 사용
- };
- img.src = url;
- });
- }
-
- hasSprite(agentId) {
- return this.sprites.has(agentId) && this.sprites.get(agentId).loaded;
- }
-
- /**
- * 에이전트 1프레임 그리기 (스프라이트 또는 프로시저럴)
- */
- draw(ctx, agentId, state, direction, frame, x, y, scale) {
- if (this.hasSprite(agentId)) {
- this._drawFromSheet(ctx, agentId, state, direction, frame, x, y, scale);
- } else {
- ProceduralSprite.draw(ctx, agentId, state, direction, frame, x, y, scale);
- }
- }
-
- _drawFromSheet(ctx, agentId, state, direction, frame, x, y, scale) {
- const { image } = this.sprites.get(agentId);
- const frameW = 16;
- const frameH = 32;
-
- // 방향 → 행
- const dirRow = direction === 'up' ? 1 : direction === 'right' || direction === 'left' ? 2 : 0;
-
- // 상태 → 열 오프셋
- const stateOffsets = { idle: 0, walk: 2, type: 6, wait: 8, break_anim: 10 };
- const mappedState = state === 'working' ? 'type' : state === 'waiting' ? 'wait'
- : state === 'reporting' ? 'type' : state === 'break' ? 'break_anim'
- : state === 'walk' ? 'walk' : 'idle';
- const colOffset = stateOffsets[mappedState] || 0;
-
- const srcX = (colOffset + frame) * frameW;
- const srcY = dirRow * frameH;
- const destW = frameW * scale;
- const destH = frameH * scale;
-
- ctx.save();
- if (direction === 'left') {
- ctx.translate(x, 0);
- ctx.scale(-1, 1);
- ctx.translate(-x, 0);
- }
- ctx.drawImage(image, srcX, srcY, frameW, frameH, x - destW / 2, y - destH, destW, destH);
- ctx.restore();
- }
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/canvas/SpriteLoader.js
-git commit -m "feat(agent-office): add SpriteLoader with procedural fallback for Phase 2"
-```
-
----
-
-## Phase 3: 오버레이 시스템
-
-### Task 10: 오버레이 렌더러 (이름, 배지, 말풍선)
-
-**Files:**
-- Create: `src/pages/agent-office/canvas/OverlayRenderer.js`
-
-- [ ] **Step 1: OverlayRenderer 작성**
-
-```javascript
-// src/pages/agent-office/canvas/OverlayRenderer.js
-
-/**
- * 캔버스 위 오버레이 렌더링:
- * - 이름 라벨 (항상)
- * - 상태 배지 (항상)
- * - 말풍선 (waiting 상태에서만)
- * - 알림 배지 (notification > 0 일 때)
- */
-
-const STATE_BADGE = {
- idle: { text: 'idle', bg: '#374151', fg: '#9ca3af' },
- working: { text: 'working', bg: '#1e3a5f', fg: '#60a5fa' },
- waiting: { text: 'waiting', bg: '#92400e', fg: '#fbbf24' },
- reporting: { text: 'reporting', bg: '#1e3a5f', fg: '#60a5fa' },
- break: { text: 'break', bg: '#065f46', fg: '#34d399' }
-};
-
-export class OverlayRenderer {
- constructor() {
- this._bubbleAlpha = new Map(); // agentId → alpha (fade in/out)
- }
-
- draw(ctx, sprite, theme, zoom, panX, panY, tileSize) {
- const ts = tileSize * zoom;
- const centerX = sprite.x * ts + panX + ts / 2;
- const topY = sprite.y * ts + panY - ts * 0.3;
-
- const fontSize = Math.max(10, 11 * zoom / 2);
- const smallFontSize = Math.max(8, 9 * zoom / 2);
-
- // 1. 이름 라벨
- ctx.font = `bold ${fontSize}px 'Courier New', monospace`;
- ctx.textAlign = 'center';
- ctx.fillStyle = sprite.meta.color;
- ctx.fillText(sprite.meta.displayName, centerX, topY + ts * 1.85);
-
- // 2. 상태 배지
- const badge = STATE_BADGE[sprite.state] || STATE_BADGE.idle;
- const badgeText = badge.text;
- ctx.font = `${smallFontSize}px 'Courier New', monospace`;
- const badgeW = ctx.measureText(badgeText).width + 8;
- const badgeH = smallFontSize + 4;
- const badgeX = centerX - badgeW / 2;
- const badgeY = topY + ts * 1.9;
-
- ctx.fillStyle = badge.bg;
- this._roundRect(ctx, badgeX, badgeY, badgeW, badgeH, 3);
- ctx.fill();
- ctx.fillStyle = badge.fg;
- ctx.textAlign = 'center';
- ctx.fillText(badgeText, centerX, badgeY + badgeH - 3);
-
- // 3. 말풍선 (waiting 상태에서만)
- if (sprite.state === 'waiting') {
- this._drawBubble(ctx, sprite, centerX, topY - ts * 0.2, zoom);
- }
-
- // 4. 알림 배지
- if (sprite.notificationCount > 0) {
- this._drawNotificationBadge(ctx, centerX + ts * 0.5, topY + ts * 0.2, sprite.notificationCount, zoom);
- }
- }
-
- _drawBubble(ctx, sprite, x, y, zoom) {
- const text = '승인 대기!';
- const fontSize = Math.max(10, 11 * zoom / 2);
- ctx.font = `bold ${fontSize}px 'Courier New', monospace`;
- const tw = ctx.measureText(text).width;
- const pw = tw + 16;
- const ph = fontSize + 12;
- const px = x - pw / 2;
- const py = y - ph;
-
- // 말풍선 배경
- ctx.fillStyle = '#fbbf24';
- this._roundRect(ctx, px, py, pw, ph, 6);
- ctx.fill();
-
- // 꼬리 삼각형
- ctx.beginPath();
- ctx.moveTo(x - 5, py + ph);
- ctx.lineTo(x + 5, py + ph);
- ctx.lineTo(x, py + ph + 6);
- ctx.closePath();
- ctx.fill();
-
- // 텍스트
- ctx.fillStyle = '#000000';
- ctx.textAlign = 'center';
- ctx.fillText(text, x, py + ph - 5);
- }
-
- _drawNotificationBadge(ctx, x, y, count, zoom) {
- const r = Math.max(7, 8 * zoom / 2);
- ctx.fillStyle = '#ef4444';
- ctx.beginPath();
- ctx.arc(x, y, r, 0, Math.PI * 2);
- ctx.fill();
-
- ctx.fillStyle = '#ffffff';
- ctx.font = `bold ${r}px sans-serif`;
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- ctx.fillText(count > 9 ? '9+' : String(count), x, y);
- ctx.textBaseline = 'alphabetic';
- }
-
- _roundRect(ctx, x, y, w, h, r) {
- ctx.beginPath();
- ctx.moveTo(x + r, y);
- ctx.lineTo(x + w - r, y);
- ctx.quadraticCurveTo(x + w, y, x + w, y + r);
- ctx.lineTo(x + w, y + h - r);
- ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
- ctx.lineTo(x + r, y + h);
- ctx.quadraticCurveTo(x, y + h, x, y + h - r);
- ctx.lineTo(x, y + r);
- ctx.quadraticCurveTo(x, y, x + r, y);
- ctx.closePath();
- }
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/canvas/OverlayRenderer.js
-git commit -m "feat(agent-office): add overlay renderer with labels, badges, and speech bubbles"
-```
-
----
-
-## Phase 4: 사이드 패널 (4탭)
-
-### Task 11: TopBar 컴포넌트
-
-**Files:**
-- Create: `src/pages/agent-office/components/TopBar.jsx`
-
-- [ ] **Step 1: TopBar 작성**
-
-```jsx
-// src/pages/agent-office/components/TopBar.jsx
-import { getThemeNames } from '../canvas/themes.js';
-
-export default function TopBar({ connected, theme, onThemeChange, zoom, onZoomChange }) {
- const themes = getThemeNames();
-
- return (
-
-
- Agent Office
-
- ● {connected ? 'Connected' : 'Disconnected'}
-
-
-
-
onThemeChange(e.target.value)}
- >
- {themes.map(t => (
- {t.name}
- ))}
-
-
- onZoomChange(Math.max(1, zoom - 0.5))} disabled={zoom <= 1}>-
- {zoom}x
- onZoomChange(Math.min(4, zoom + 0.5))} disabled={zoom >= 4}>+
-
-
-
- );
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/components/TopBar.jsx
-git commit -m "feat(agent-office): add TopBar component with theme and zoom controls"
-```
-
----
-
-### Task 12: CommandTab 컴포넌트
-
-**Files:**
-- Create: `src/pages/agent-office/components/CommandTab.jsx`
-
-- [ ] **Step 1: CommandTab 작성 (기존 AgentColumn 명령 기능 추출)**
-
-```jsx
-// src/pages/agent-office/components/CommandTab.jsx
-import { useState } from 'react';
-import { sendAgentCommand, approveAgentTask } from '../../../api';
-
-const QUICK_ACTIONS = {
- stock: [{ action: 'fetch_news', label: 'Fetch News' }, { action: 'test_telegram', label: 'Test Telegram' }],
- music: [{ action: 'credits', label: 'Check Credits' }],
- blog: [{ action: 'list_trend_keywords', label: 'List Keywords' }],
- realestate: [{ action: 'dashboard', label: 'Dashboard' }],
- lotto: [{ action: 'status', label: 'Status' }, { action: 'curate_now', label: 'Curate Now' }]
-};
-
-const PARAM_ACTIONS = {
- stock: { action: 'add_alert', label: 'Add Alert', placeholder: '{"symbol":"005930","target_price":70000,"direction":"above"}' },
- music: { action: 'compose', label: 'Compose', placeholder: 'jazzy lo-fi piano beat' },
- blog: { action: 'research', label: 'Research', placeholder: 'keyword to research' },
- realestate: { action: 'fetch_matches', label: 'Fetch Matches', placeholder: '' },
- lotto: null
-};
-
-export default function CommandTab({ agentId, agentState, pendingTask, onCommandResult }) {
- const [customAction, setCustomAction] = useState('');
- const [customParams, setCustomParams] = useState('');
- const [paramInput, setParamInput] = useState('');
- const [loading, setLoading] = useState(false);
-
- const quickActions = QUICK_ACTIONS[agentId] || [];
- const paramAction = PARAM_ACTIONS[agentId];
-
- const handleQuickAction = async (action) => {
- setLoading(true);
- try {
- const result = await sendAgentCommand(agentId, action, {});
- onCommandResult?.(result);
- } finally {
- setLoading(false);
- }
- };
-
- const handleParamAction = async () => {
- if (!paramAction || !paramInput.trim()) return;
- setLoading(true);
- try {
- let params = {};
- if (paramAction.action === 'compose') {
- params = { prompt: paramInput };
- } else if (paramAction.action === 'research') {
- params = { keyword: paramInput };
- } else {
- try { params = JSON.parse(paramInput); } catch { params = { value: paramInput }; }
- }
- const result = await sendAgentCommand(agentId, paramAction.action, params);
- onCommandResult?.(result);
- setParamInput('');
- } finally {
- setLoading(false);
- }
- };
-
- const handleCustomCommand = async () => {
- if (!customAction.trim()) return;
- setLoading(true);
- try {
- let params = {};
- if (customParams.trim()) {
- try { params = JSON.parse(customParams); } catch { params = { value: customParams }; }
- }
- const result = await sendAgentCommand(agentId, customAction, params);
- onCommandResult?.(result);
- setCustomAction('');
- setCustomParams('');
- } finally {
- setLoading(false);
- }
- };
-
- const handleApproval = async (approved) => {
- if (!pendingTask) return;
- setLoading(true);
- try {
- await approveAgentTask(agentId, pendingTask.id, approved);
- } finally {
- setLoading(false);
- }
- };
-
- return (
-
- {/* 승인 대기 UI */}
- {agentState === 'waiting' && pendingTask && (
-
-
Awaiting Approval
-
{pendingTask.task_type}: {pendingTask.detail || JSON.stringify(pendingTask.input_data)}
-
- handleApproval(true)} disabled={loading}>Approve
- handleApproval(false)} disabled={loading}>Reject
-
-
- )}
-
- {/* Quick Actions */}
-
-
Quick Actions
-
- {quickActions.map(qa => (
- handleQuickAction(qa.action)}
- disabled={loading}
- >
- {qa.label}
-
- ))}
-
-
-
- {/* Parameterized Action */}
- {paramAction && (
-
-
{paramAction.label}
-
- setParamInput(e.target.value)}
- placeholder={paramAction.placeholder}
- onKeyDown={e => e.key === 'Enter' && handleParamAction()}
- />
-
- Send
-
-
-
- )}
-
- {/* Custom Command */}
-
-
Custom Command
-
setCustomAction(e.target.value)}
- placeholder="Action name"
- />
-
setCustomParams(e.target.value)}
- placeholder='Parameters (JSON)'
- style={{ marginTop: 4 }}
- />
-
- Send Command
-
-
-
- );
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/components/CommandTab.jsx
-git commit -m "feat(agent-office): add CommandTab with quick actions, params, and approval UI"
-```
-
----
-
-### Task 13: TaskTab 컴포넌트
-
-**Files:**
-- Create: `src/pages/agent-office/components/TaskTab.jsx`
-
-- [ ] **Step 1: TaskTab 작성**
-
-```jsx
-// src/pages/agent-office/components/TaskTab.jsx
-import { useState, useEffect } from 'react';
-import { getAgentTasks } from '../../../api';
-
-const STATUS_STYLE = {
- succeeded: { bg: '#065f46', fg: '#34d399' },
- failed: { bg: '#7f1d1d', fg: '#fca5a5' },
- working: { bg: '#1e3a5f', fg: '#60a5fa' },
- pending: { bg: '#92400e', fg: '#fbbf24' },
- approved: { bg: '#065f46', fg: '#34d399' },
- rejected: { bg: '#7f1d1d', fg: '#fca5a5' }
-};
-
-function formatTime(ts) {
- if (!ts) return '';
- const d = new Date(ts);
- const now = new Date();
- const isToday = d.toDateString() === now.toDateString();
- const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
- return isToday ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`;
-}
-
-export default function TaskTab({ agentId, refreshTrigger }) {
- const [tasks, setTasks] = useState([]);
- const [expanded, setExpanded] = useState(null);
-
- useEffect(() => {
- let cancelled = false;
- getAgentTasks(agentId, 20).then(data => {
- if (!cancelled) setTasks(data || []);
- });
- return () => { cancelled = true; };
- }, [agentId, refreshTrigger]);
-
- return (
-
- {tasks.length === 0 &&
No tasks yet
}
- {tasks.map(task => {
- const style = STATUS_STYLE[task.status] || STATUS_STYLE.pending;
- return (
-
setExpanded(expanded === task.id ? null : task.id)}>
-
- {task.task_type}
- {task.status}
- {formatTime(task.created_at)}
-
- {expanded === task.id && task.result_data && (
-
{JSON.stringify(JSON.parse(task.result_data), null, 2)}
- )}
-
- );
- })}
-
- );
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/components/TaskTab.jsx
-git commit -m "feat(agent-office): add TaskTab component with expandable task history"
-```
-
----
-
-### Task 14: TokenTab 컴포넌트
-
-**Files:**
-- Create: `src/pages/agent-office/components/TokenTab.jsx`
-
-- [ ] **Step 1: TokenTab 작성**
-
-```jsx
-// src/pages/agent-office/components/TokenTab.jsx
-import { useState, useEffect } from 'react';
-import { getAgentTokenUsage } from '../../../api';
-
-export default function TokenTab({ agentId }) {
- const [usage, setUsage] = useState(null);
- const [days, setDays] = useState(1);
-
- useEffect(() => {
- let cancelled = false;
- getAgentTokenUsage(agentId, days).then(data => {
- if (!cancelled) setUsage(data);
- });
- return () => { cancelled = true; };
- }, [agentId, days]);
-
- if (!usage) return Loading...
;
-
- const inputTokens = usage.input_tokens || 0;
- const outputTokens = usage.output_tokens || 0;
- const cacheRead = usage.cache_read || 0;
- const cacheWrite = usage.cache_write || 0;
- const total = inputTokens + outputTokens;
- const cacheHitRate = inputTokens > 0 ? Math.round((cacheRead / inputTokens) * 100) : 0;
-
- return (
-
-
- {[1, 7, 30].map(d => (
- setDays(d)}
- >
- {d === 1 ? 'Today' : d === 7 ? '7 Days' : '30 Days'}
-
- ))}
-
-
-
-
-
Input Tokens
-
{inputTokens.toLocaleString()}
-
-
-
Output Tokens
-
{outputTokens.toLocaleString()}
-
-
-
Total
-
{total.toLocaleString()}
-
-
-
Cache Hit Rate
-
{cacheHitRate}%
-
-
-
- {/* Simple bar chart */}
-
-
Input vs Output
-
-
0 ? `${(inputTokens / total) * 100}%` : '0%' }}
- />
-
0 ? `${(outputTokens / total) * 100}%` : '0%' }}
- />
-
-
- Input
- Output
-
-
-
- {cacheRead > 0 && (
-
- Cache Read: {cacheRead.toLocaleString()}
- Cache Write: {cacheWrite.toLocaleString()}
-
- )}
-
- );
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/components/TokenTab.jsx
-git commit -m "feat(agent-office): add TokenTab with usage stats and cache hit rate"
-```
-
----
-
-### Task 15: LogTab 컴포넌트
-
-**Files:**
-- Create: `src/pages/agent-office/components/LogTab.jsx`
-
-- [ ] **Step 1: LogTab 작성**
-
-```jsx
-// src/pages/agent-office/components/LogTab.jsx
-import { useState, useEffect, useRef } from 'react';
-import { getAgentLogs } from '../../../api';
-
-const LEVEL_STYLE = {
- info: { color: '#60a5fa' },
- warning: { color: '#fbbf24' },
- error: { color: '#ef4444' }
-};
-
-export default function LogTab({ agentId, refreshTrigger }) {
- const [logs, setLogs] = useState([]);
- const scrollRef = useRef(null);
-
- useEffect(() => {
- let cancelled = false;
- getAgentLogs(agentId, 50).then(data => {
- if (!cancelled) setLogs(data || []);
- });
- return () => { cancelled = true; };
- }, [agentId, refreshTrigger]);
-
- useEffect(() => {
- if (scrollRef.current) {
- scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
- }
- }, [logs]);
-
- return (
-
- {logs.length === 0 &&
No logs yet
}
- {logs.map((log, i) => {
- const style = LEVEL_STYLE[log.level] || LEVEL_STYLE.info;
- const time = new Date(log.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
- return (
-
- {time}
- [{log.level}]
- {log.message}
-
- );
- })}
-
- );
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/components/LogTab.jsx
-git commit -m "feat(agent-office): add LogTab with auto-scroll and level coloring"
-```
-
----
-
-### Task 16: SidePanel 컨테이너 (4탭 통합)
-
-**Files:**
-- Create: `src/pages/agent-office/components/SidePanel.jsx`
-
-- [ ] **Step 1: SidePanel 작성**
-
-```jsx
-// src/pages/agent-office/components/SidePanel.jsx
-import { useState } from 'react';
-import CommandTab from './CommandTab.jsx';
-import TaskTab from './TaskTab.jsx';
-import TokenTab from './TokenTab.jsx';
-import LogTab from './LogTab.jsx';
-
-const AGENT_META = {
- stock: { displayName: '주식 트레이더', emoji: '📈', color: '#4488cc' },
- music: { displayName: '음악 프로듀서', emoji: '🎵', color: '#44aa88' },
- blog: { displayName: '블로그 마케터', emoji: '✍️', color: '#d97706' },
- realestate: { displayName: '청약 애널리스트', emoji: '🏢', color: '#c026d3' },
- lotto: { displayName: '로또 큐레이터', emoji: '🎱', color: '#ef4444' }
-};
-
-const TABS = ['Commands', 'Tasks', 'Tokens', 'Logs'];
-
-export default function SidePanel({ agentId, agentState, pendingTask, onClose, refreshTrigger }) {
- const [activeTab, setActiveTab] = useState('Commands');
- const meta = AGENT_META[agentId];
- if (!meta) return null;
-
- const stateText = agentState?.detail
- ? `${agentState.state} - ${agentState.detail}`
- : agentState?.state || 'unknown';
-
- return (
-
- {/* Header */}
-
-
-
- {meta.emoji}
-
-
-
{meta.displayName}
-
● {stateText}
-
-
-
×
-
-
- {/* Tabs */}
-
- {TABS.map(tab => (
- setActiveTab(tab)}
- >
- {tab}
-
- ))}
-
-
- {/* Tab Content */}
-
- {activeTab === 'Commands' && (
-
- )}
- {activeTab === 'Tasks' && (
-
- )}
- {activeTab === 'Tokens' && (
-
- )}
- {activeTab === 'Logs' && (
-
- )}
-
-
- );
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/components/SidePanel.jsx
-git commit -m "feat(agent-office): add SidePanel container with 4-tab layout"
-```
-
----
-
-## Phase 5: 페이지 통합
-
-### Task 17: useAgentManager 확장
-
-**Files:**
-- Modify: `src/pages/agent-office/hooks/useAgentManager.js`
-
-- [ ] **Step 1: lotto 에이전트 추가 + 상태 구조 개선**
-
-기존 `useAgentManager.js` 전체를 다음으로 교체:
-
-```javascript
-// src/pages/agent-office/hooks/useAgentManager.js
-import { useState, useEffect, useRef, useCallback } from 'react';
-
-const WS_RECONNECT_DELAY = 3000;
-
-export function useAgentManager() {
- const [agents, setAgents] = useState({}); // { agentId: { state, detail, task_id } }
- const [pendingTasks, setPendingTasks] = useState([]); // [{id, agent_id, task_type, input_data}]
- const [notifications, setNotifications] = useState({}); // { agentId: count }
- const [connected, setConnected] = useState(false);
- const [refreshTrigger, setRefreshTrigger] = useState(0); // 탭 데이터 리프레시용
-
- const wsRef = useRef(null);
- const reconnectRef = useRef(null);
-
- const connect = useCallback(() => {
- const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
- const ws = new WebSocket(`${protocol}://${window.location.host}/api/agent-office/ws`);
- wsRef.current = ws;
-
- ws.onopen = () => setConnected(true);
-
- ws.onmessage = (e) => {
- const msg = JSON.parse(e.data);
-
- switch (msg.type) {
- case 'init':
- // 에이전트 초기 상태 세팅
- const agentMap = {};
- for (const a of msg.agents) {
- agentMap[a.agent_id] = { state: a.state, detail: a.detail || '', task_id: a.task_id };
- }
- setAgents(agentMap);
- setPendingTasks(msg.pending || []);
- break;
-
- case 'agent_state':
- setAgents(prev => ({
- ...prev,
- [msg.agent]: { state: msg.state, detail: msg.detail || '', task_id: msg.task_id }
- }));
- // idle 전환 시 데이터 리프레시
- if (msg.state === 'idle') {
- setRefreshTrigger(n => n + 1);
- }
- break;
-
- case 'task_complete':
- setRefreshTrigger(n => n + 1);
- break;
-
- case 'notification':
- setNotifications(prev => ({
- ...prev,
- [msg.agent]: (prev[msg.agent] || 0) + 1
- }));
- break;
-
- case 'command_result':
- // 사이드 패널에서 처리
- break;
- }
- };
-
- ws.onclose = () => {
- setConnected(false);
- reconnectRef.current = setTimeout(connect, WS_RECONNECT_DELAY);
- };
-
- ws.onerror = () => ws.close();
- }, []);
-
- useEffect(() => {
- connect();
- return () => {
- if (wsRef.current) wsRef.current.close();
- if (reconnectRef.current) clearTimeout(reconnectRef.current);
- };
- }, [connect]);
-
- const sendCommand = useCallback((agent, action, params = {}) => {
- if (wsRef.current?.readyState === WebSocket.OPEN) {
- wsRef.current.send(JSON.stringify({ type: 'command', agent, action, params }));
- }
- }, []);
-
- const sendApproval = useCallback((agent, taskId, approved) => {
- if (wsRef.current?.readyState === WebSocket.OPEN) {
- wsRef.current.send(JSON.stringify({ type: 'approval', agent, task_id: taskId, approved }));
- }
- }, []);
-
- const clearNotifications = useCallback((agentId) => {
- setNotifications(prev => ({ ...prev, [agentId]: 0 }));
- }, []);
-
- return {
- agents,
- pendingTasks,
- notifications,
- connected,
- refreshTrigger,
- sendCommand,
- sendApproval,
- clearNotifications
- };
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/hooks/useAgentManager.js
-git commit -m "refactor(agent-office): extend useAgentManager with lotto agent and refresh triggers"
-```
-
----
-
-### Task 18: useOfficeCanvas 재작성
-
-**Files:**
-- Rewrite: `src/pages/agent-office/hooks/useOfficeCanvas.js`
-
-- [ ] **Step 1: useOfficeCanvas 재작성**
-
-```javascript
-// src/pages/agent-office/hooks/useOfficeCanvas.js
-import { useRef, useEffect, useCallback } from 'react';
-import { OfficeRenderer } from '../canvas/OfficeRenderer.js';
-
-export function useOfficeCanvas() {
- const canvasRef = useRef(null);
- const rendererRef = useRef(null);
-
- useEffect(() => {
- if (!canvasRef.current) return;
-
- const renderer = new OfficeRenderer(canvasRef.current);
- rendererRef.current = renderer;
- renderer.start();
-
- const handleResize = () => renderer.resize();
- window.addEventListener('resize', handleResize);
-
- return () => {
- window.removeEventListener('resize', handleResize);
- renderer.destroy();
- rendererRef.current = null;
- };
- }, []);
-
- const updateAgentState = useCallback((agentId, state, detail) => {
- rendererRef.current?.updateAgentState(agentId, state, detail);
- }, []);
-
- const setAgentNotification = useCallback((agentId, count) => {
- rendererRef.current?.setAgentNotification(agentId, count);
- }, []);
-
- const setTheme = useCallback((themeName) => {
- rendererRef.current?.setTheme(themeName);
- }, []);
-
- const setZoom = useCallback((level) => {
- rendererRef.current?.setZoom(level);
- }, []);
-
- const hitTest = useCallback((clientX, clientY) => {
- return rendererRef.current?.hitTest(clientX, clientY) || { type: 'empty' };
- }, []);
-
- const getZoom = useCallback(() => {
- return rendererRef.current?.zoom || 2;
- }, []);
-
- return {
- canvasRef,
- updateAgentState,
- setAgentNotification,
- setTheme,
- setZoom,
- hitTest,
- getZoom
- };
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/hooks/useOfficeCanvas.js
-git commit -m "refactor(agent-office): rewrite useOfficeCanvas hook for new renderer API"
-```
-
----
-
-### Task 19: AgentOffice.jsx 재작성 (전체 화면 캔버스 + 사이드 패널)
-
-**Files:**
-- Rewrite: `src/pages/agent-office/AgentOffice.jsx`
-
-- [ ] **Step 1: AgentOffice 전체 재작성**
-
-```jsx
-// src/pages/agent-office/AgentOffice.jsx
-import { useState, useEffect, useCallback } from 'react';
-import { useAgentManager } from './hooks/useAgentManager.js';
-import { useOfficeCanvas } from './hooks/useOfficeCanvas.js';
-import TopBar from './components/TopBar.jsx';
-import SidePanel from './components/SidePanel.jsx';
-import './AgentOffice.css';
-
-export default function AgentOffice() {
- const {
- agents, pendingTasks, notifications, connected,
- refreshTrigger, clearNotifications
- } = useAgentManager();
-
- const {
- canvasRef, updateAgentState, setAgentNotification,
- setTheme, setZoom, hitTest, getZoom
- } = useOfficeCanvas();
-
- const [selectedAgent, setSelectedAgent] = useState(null);
- const [theme, setThemeState] = useState(localStorage.getItem('agent-office-theme') || 'modern');
- const [zoom, setZoomState] = useState(2);
-
- // WebSocket 상태 → 캔버스 동기화
- useEffect(() => {
- for (const [id, agentState] of Object.entries(agents)) {
- updateAgentState(id, agentState.state, agentState.detail);
- }
- }, [agents, updateAgentState]);
-
- // 알림 → 캔버스 동기화
- useEffect(() => {
- for (const [id, count] of Object.entries(notifications)) {
- setAgentNotification(id, count);
- }
- }, [notifications, setAgentNotification]);
-
- // 캔버스 클릭 핸들러
- const handleCanvasClick = useCallback((e) => {
- const result = hitTest(e.clientX, e.clientY);
- if (result.type === 'agent') {
- setSelectedAgent(result.id);
- clearNotifications(result.id);
- setAgentNotification(result.id, 0);
- } else {
- setSelectedAgent(null);
- }
- }, [hitTest, clearNotifications, setAgentNotification]);
-
- // 테마 변경
- const handleThemeChange = useCallback((name) => {
- setThemeState(name);
- setTheme(name);
- }, [setTheme]);
-
- // 줌 변경
- const handleZoomChange = useCallback((level) => {
- setZoomState(level);
- setZoom(level);
- }, [setZoom]);
-
- // 선택된 에이전트의 pending task
- const pendingTask = selectedAgent
- ? pendingTasks.find(t => t.agent_id === selectedAgent)
- : null;
-
- return (
-
-
-
-
-
-
- {selectedAgent && (
- setSelectedAgent(null)}
- refreshTrigger={refreshTrigger}
- />
- )}
-
-
- );
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/AgentOffice.jsx
-git commit -m "refactor(agent-office): rewrite AgentOffice with full-screen canvas and side panel"
-```
-
----
-
-### Task 20: CSS 전체 재작성
-
-**Files:**
-- Rewrite: `src/pages/agent-office/AgentOffice.css`
-
-- [ ] **Step 1: CSS 재작성**
-
-```css
-/* src/pages/agent-office/AgentOffice.css */
-
-/* ===== Root Layout ===== */
-.ao-root {
- display: flex;
- flex-direction: column;
- height: 100vh;
- background: #0d0d1a;
- color: #ffffff;
- font-family: 'Courier New', monospace;
- overflow: hidden;
-}
-
-/* ===== Top Bar ===== */
-.ao-topbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- height: 44px;
- padding: 0 16px;
- background: #1a1a2e;
- border-bottom: 1px solid #333;
- flex-shrink: 0;
-}
-.ao-topbar-left {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-.ao-topbar-title {
- font-weight: bold;
- font-size: 15px;
- color: #8b5cf6;
-}
-.ao-topbar-status {
- font-size: 11px;
-}
-.ao-topbar-status.connected { color: #22c55e; }
-.ao-topbar-status.disconnected { color: #ef4444; }
-.ao-topbar-right {
- display: flex;
- align-items: center;
- gap: 10px;
-}
-.ao-topbar-select {
- background: #2a2a3e;
- color: #aaa;
- border: 1px solid #444;
- padding: 3px 8px;
- border-radius: 4px;
- font-size: 12px;
- font-family: inherit;
-}
-.ao-topbar-zoom {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-.ao-topbar-zoom button {
- background: #2a2a3e;
- color: #aaa;
- border: 1px solid #444;
- width: 24px;
- height: 24px;
- border-radius: 4px;
- cursor: pointer;
- font-size: 14px;
-}
-.ao-topbar-zoom button:disabled {
- opacity: 0.3;
- cursor: default;
-}
-.ao-topbar-zoom span {
- color: #888;
- font-size: 12px;
- min-width: 28px;
- text-align: center;
-}
-
-/* ===== Main Area ===== */
-.ao-main {
- flex: 1;
- display: flex;
- position: relative;
- overflow: hidden;
-}
-.ao-canvas {
- flex: 1;
- cursor: grab;
- display: block;
-}
-.ao-canvas:active {
- cursor: grabbing;
-}
-
-/* ===== Side Panel ===== */
-.ao-sidepanel {
- width: 320px;
- background: #111;
- border-left: 1px solid #333;
- display: flex;
- flex-direction: column;
- flex-shrink: 0;
- animation: slideIn 0.2s ease-out;
-}
-@keyframes slideIn {
- from { transform: translateX(100%); }
- to { transform: translateX(0); }
-}
-.ao-sidepanel-header {
- padding: 12px;
- border-bottom: 1px solid #333;
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-.ao-sidepanel-agent {
- display: flex;
- align-items: center;
- gap: 10px;
-}
-.ao-sidepanel-icon {
- width: 36px;
- height: 36px;
- border-radius: 8px;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 18px;
-}
-.ao-sidepanel-name {
- font-weight: bold;
- font-size: 14px;
-}
-.ao-sidepanel-state {
- font-size: 11px;
- color: #22c55e;
-}
-.ao-sidepanel-close {
- background: none;
- border: none;
- color: #666;
- font-size: 24px;
- cursor: pointer;
- padding: 0 4px;
-}
-.ao-sidepanel-close:hover {
- color: #fff;
-}
-
-/* Tabs */
-.ao-sidepanel-tabs {
- display: flex;
- border-bottom: 1px solid #333;
-}
-.ao-sidepanel-tab {
- flex: 1;
- padding: 8px 4px;
- text-align: center;
- font-size: 12px;
- font-family: inherit;
- background: none;
- border: none;
- border-bottom: 2px solid transparent;
- color: #666;
- cursor: pointer;
-}
-.ao-sidepanel-tab.active {
- color: #8b5cf6;
- border-bottom-color: #8b5cf6;
- font-weight: bold;
-}
-.ao-sidepanel-tab:hover {
- color: #aaa;
-}
-.ao-sidepanel-content {
- flex: 1;
- overflow-y: auto;
- padding: 12px;
-}
-
-/* ===== Command Tab ===== */
-.ao-command-tab { display: flex; flex-direction: column; gap: 12px; }
-.ao-section { margin-bottom: 4px; }
-.ao-section-label {
- color: #888;
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- margin-bottom: 6px;
-}
-.ao-quick-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-}
-.ao-btn-quick {
- background: #2a2a4e;
- color: #8b5cf6;
- border: 1px solid #4c1d95;
- padding: 5px 12px;
- border-radius: 4px;
- font-size: 11px;
- font-family: inherit;
- cursor: pointer;
-}
-.ao-btn-quick:hover { background: #3a3a5e; }
-.ao-btn-quick:disabled { opacity: 0.4; }
-
-.ao-param-row {
- display: flex;
- gap: 6px;
-}
-.ao-input {
- flex: 1;
- background: #1a1a2e;
- border: 1px solid #333;
- color: #fff;
- padding: 7px 10px;
- border-radius: 4px;
- font-size: 12px;
- font-family: inherit;
-}
-.ao-input::placeholder { color: #555; }
-.ao-btn-send {
- background: #4c1d95;
- color: #fff;
- border: none;
- padding: 7px 14px;
- border-radius: 4px;
- font-size: 12px;
- font-family: inherit;
- cursor: pointer;
- white-space: nowrap;
-}
-.ao-btn-send:hover { background: #5b21b6; }
-.ao-btn-send:disabled { opacity: 0.4; }
-
-/* Approval */
-.ao-approval-card {
- background: rgba(146,64,14,0.15);
- border: 1px solid #92400e;
- border-radius: 6px;
- padding: 10px;
-}
-.ao-approval-title {
- color: #fbbf24;
- font-size: 12px;
- font-weight: bold;
- margin-bottom: 4px;
-}
-.ao-approval-desc {
- color: #ddd;
- font-size: 11px;
- margin-bottom: 8px;
- word-break: break-all;
-}
-.ao-approval-actions {
- display: flex;
- gap: 6px;
-}
-.ao-btn-approve {
- flex: 1;
- background: #065f46;
- color: #fff;
- border: none;
- padding: 7px;
- border-radius: 4px;
- font-size: 12px;
- cursor: pointer;
-}
-.ao-btn-reject {
- flex: 1;
- background: #7f1d1d;
- color: #fff;
- border: none;
- padding: 7px;
- border-radius: 4px;
- font-size: 12px;
- cursor: pointer;
-}
-
-/* ===== Task Tab ===== */
-.ao-task-tab { display: flex; flex-direction: column; gap: 4px; }
-.ao-task-item {
- background: #1a1a2e;
- border-radius: 4px;
- padding: 8px;
- cursor: pointer;
-}
-.ao-task-item:hover { background: #222240; }
-.ao-task-header {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 12px;
-}
-.ao-task-type { color: #ccc; font-weight: bold; flex: 1; }
-.ao-task-badge {
- padding: 1px 6px;
- border-radius: 3px;
- font-size: 10px;
-}
-.ao-task-time { color: #666; font-size: 10px; }
-.ao-task-result {
- margin-top: 6px;
- background: #0d0d1a;
- padding: 6px;
- border-radius: 3px;
- font-size: 10px;
- color: #aaa;
- max-height: 200px;
- overflow-y: auto;
- white-space: pre-wrap;
- word-break: break-all;
-}
-
-/* ===== Token Tab ===== */
-.ao-token-tab { display: flex; flex-direction: column; gap: 12px; }
-.ao-token-period {
- display: flex;
- gap: 4px;
-}
-.ao-btn-period {
- flex: 1;
- background: #1a1a2e;
- color: #888;
- border: 1px solid #333;
- padding: 5px;
- border-radius: 4px;
- font-size: 11px;
- font-family: inherit;
- cursor: pointer;
-}
-.ao-btn-period.active {
- background: #4c1d95;
- color: #fff;
- border-color: #4c1d95;
-}
-.ao-token-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 8px;
-}
-.ao-token-card {
- background: #1a1a2e;
- border-radius: 6px;
- padding: 10px;
- text-align: center;
-}
-.ao-token-label {
- font-size: 10px;
- color: #888;
- text-transform: uppercase;
- margin-bottom: 4px;
-}
-.ao-token-value {
- font-size: 18px;
- font-weight: bold;
- color: #fff;
-}
-.ao-token-bar { margin-top: 4px; }
-.ao-token-bar-label { font-size: 10px; color: #888; margin-bottom: 4px; }
-.ao-token-bar-track {
- display: flex;
- height: 8px;
- border-radius: 4px;
- overflow: hidden;
- background: #1a1a2e;
-}
-.ao-token-bar-fill.input { background: #3b82f6; }
-.ao-token-bar-fill.output { background: #8b5cf6; }
-.ao-token-bar-legend {
- display: flex;
- gap: 12px;
- font-size: 10px;
- color: #888;
- margin-top: 4px;
-}
-.ao-token-bar-legend .dot {
- display: inline-block;
- width: 8px;
- height: 8px;
- border-radius: 50%;
- margin-right: 4px;
-}
-.ao-token-bar-legend .dot.input { background: #3b82f6; }
-.ao-token-bar-legend .dot.output { background: #8b5cf6; }
-.ao-token-detail {
- display: flex;
- justify-content: space-between;
- font-size: 10px;
- color: #666;
-}
-
-/* ===== Log Tab ===== */
-.ao-log-tab {
- max-height: 100%;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-.ao-log-item {
- display: flex;
- gap: 6px;
- font-size: 11px;
- padding: 3px 0;
- border-bottom: 1px solid #1a1a2e;
-}
-.ao-log-time { color: #555; min-width: 60px; }
-.ao-log-level { min-width: 48px; font-weight: bold; }
-.ao-log-msg { color: #ccc; word-break: break-all; }
-
-/* ===== Common ===== */
-.ao-empty {
- color: #555;
- text-align: center;
- padding: 24px;
- font-size: 13px;
-}
-
-/* ===== Mobile (< 768px) ===== */
-@media (max-width: 768px) {
- .ao-topbar-right { gap: 6px; }
- .ao-topbar-select { font-size: 11px; padding: 2px 6px; }
-
- .ao-main {
- flex-direction: column;
- }
-
- .ao-canvas {
- flex: 1;
- }
-
- /* Side panel → bottom sheet */
- .ao-sidepanel {
- position: fixed;
- bottom: 0;
- left: 0;
- right: 0;
- width: 100%;
- max-height: 55vh;
- border-left: none;
- border-top: 1px solid #333;
- border-radius: 16px 16px 0 0;
- animation: slideUp 0.25s ease-out;
- z-index: 100;
- }
- @keyframes slideUp {
- from { transform: translateY(100%); }
- to { transform: translateY(0); }
- }
-
- .ao-sidepanel-header {
- padding: 8px 12px;
- }
- .ao-sidepanel-header::before {
- content: '';
- display: block;
- width: 32px;
- height: 4px;
- background: #555;
- border-radius: 2px;
- margin: 0 auto 8px;
- }
-
- .ao-sidepanel-tab {
- font-size: 11px;
- padding: 6px 2px;
- }
-
- .ao-sidepanel-content {
- padding: 8px 12px;
- padding-bottom: env(safe-area-inset-bottom, 16px);
- }
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add src/pages/agent-office/AgentOffice.css
-git commit -m "refactor(agent-office): rewrite CSS for full-screen canvas layout with mobile bottom sheet"
-```
-
----
-
-### Task 21: 레거시 파일 정리
-
-**Files:**
-- Delete: `src/pages/agent-office/components/AgentColumn.jsx`
-- Delete: `src/pages/agent-office/components/CommandColumn.jsx`
-- Delete: `src/pages/agent-office/components/ChatPanel.jsx`
-- Delete: `src/pages/agent-office/components/DocumentPanel.jsx`
-- Delete: `src/pages/agent-office/canvas/SpriteSheet.js`
-
-- [ ] **Step 1: 레거시 파일 삭제**
-
-```bash
-rm src/pages/agent-office/components/AgentColumn.jsx
-rm src/pages/agent-office/components/CommandColumn.jsx
-rm src/pages/agent-office/components/ChatPanel.jsx
-rm src/pages/agent-office/components/DocumentPanel.jsx
-rm src/pages/agent-office/canvas/SpriteSheet.js
-```
-
-- [ ] **Step 2: 빌드 확인**
-
-```bash
-npm run build
-```
-
-Expected: 빌드 성공 (삭제된 파일을 import하는 곳이 없어야 함)
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add -A
-git commit -m "chore(agent-office): remove legacy dashboard components replaced by v2 UI"
-```
-
----
-
-### Task 22: 통합 테스트 (브라우저 수동)
-
-- [ ] **Step 1: 개발 서버 시작**
-
-```bash
-npm run dev
-```
-
-- [ ] **Step 2: 브라우저에서 http://localhost:3007/agent-office 접속 후 확인 항목**
-
-1. 전체 화면에 픽셀 오피스 캔버스가 표시되는가
-2. 5명의 에이전트(stock, music, blog, realestate, lotto)가 맵에 있는가
-3. 마우스 휠로 줌 인/아웃이 되는가 (1x~4x)
-4. 드래그로 패닝이 되는가
-5. 에이전트 클릭 시 사이드 패널이 열리는가
-6. 사이드 패널 4탭 (Commands, Tasks, Tokens, Logs)이 전환되는가
-7. Quick Action 버튼이 에이전트별로 다른가
-8. 빈 공간 클릭 시 사이드 패널이 닫히는가
-9. 테마 드롭다운으로 Modern/Retro/Minimal 전환이 되는가
-10. 상단 바에 연결 상태가 표시되는가
-
-- [ ] **Step 3: 모바일 확인 (DevTools → 모바일 뷰)**
-
-1. 캔버스가 전체 화면을 차지하는가
-2. 에이전트 탭 시 바텀 시트가 올라오는가
-3. 바텀 시트 닫기가 동작하는가
-
-- [ ] **Step 4: 문제 수정 후 커밋**
-
-```bash
-git add -A
-git commit -m "fix(agent-office): address integration issues from manual testing"
-```
-
----
-
-## Phase 6: 최종 검증
-
-### Task 23: 백엔드 agent_move 메시지 확인
-
-**Files:**
-- Check: `web-backend/agent-office/app/agents/base.py`
-- Check: `web-backend/agent-office/app/websocket_manager.py`
-
-- [ ] **Step 1: base.py의 transition 메서드에서 break 상태 시 agent_move 전송 확인**
-
-`base.py`의 `transition()` 메서드를 읽고, `break` 상태 전환 시 `agent_move` WebSocket 메시지가 broadcast되는지 확인.
-만약 누락되어 있다면 `transition()` 내부에 다음을 추가:
-
-```python
-# break 전환 시 프론트엔드에 이동 알림
-if new_state == "break":
- await self._ws_manager.broadcast_move(self.agent_id, "break_room")
-elif new_state in ("working", "reporting", "waiting"):
- await self._ws_manager.broadcast_move(self.agent_id, "desk")
-```
-
-단, 현재 프론트엔드는 `agent_state` 메시지만으로 이동을 처리하도록 설계했으므로 (`AgentSprite.onStateChange`가 상태에 따라 자동 이동), `agent_move`는 선택적. 프론트엔드가 `agent_state`만 사용하여 정상 동작하면 백엔드 수정 불필요.
-
-- [ ] **Step 2: 확인 결과에 따라 커밋 (변경 있을 때만)**
-
----
-
-### Task 24: CLAUDE.md 업데이트
-
-**Files:**
-- Modify: `web-ui` 저장소의 CLAUDE.md (해당사항 있으면)
-
-- [ ] **Step 1: 프론트엔드 파일 구조 변경 반영**
-
-Agent Office 섹션이 있다면, v2 파일 구조 (canvas/, components/, hooks/) 반영.
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add CLAUDE.md
-git commit -m "docs: update CLAUDE.md with Agent Office v2 file structure"
-```
-
----
-
-## Summary
-
-| Phase | Tasks | 설명 |
-|-------|-------|------|
-| 1. 캔버스 엔진 | 1-6 | 테마, 맵, BFS, 타일맵, 가구, 게임루프 |
-| 2. 에이전트 시스템 | 7-9 | 프로시저럴 스프라이트, AgentSprite, SpriteLoader |
-| 3. 오버레이 | 10 | 이름, 배지, 말풍선, 알림 |
-| 4. 사이드 패널 | 11-16 | TopBar, CommandTab, TaskTab, TokenTab, LogTab, SidePanel |
-| 5. 페이지 통합 | 17-22 | Hook 재작성, AgentOffice 재작성, CSS, 레거시 정리, 테스트 |
-| 6. 최종 검증 | 23-24 | 백엔드 확인, 문서 업데이트 |
diff --git a/docs/superpowers/plans/2026-04-27-portfolio.md b/docs/superpowers/plans/2026-04-27-portfolio.md
deleted file mode 100644
index effd678..0000000
--- a/docs/superpowers/plans/2026-04-27-portfolio.md
+++ /dev/null
@@ -1,2129 +0,0 @@
-# Portfolio Service 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:** 개인 포트폴리오 서비스 — 프로필/경력/프로젝트/기술스택/자기소개 CRUD + 비밀번호 인증 + PDF 내보내기 + 홈 연동
-
-**Architecture:** 새 백엔드 서비스 `portfolio/` (FastAPI + SQLite, 포트 18850) + 프론트 `/portfolio` 페이지 (3탭: 프로필&이력, 프로젝트, 자기소개). 기존 `/api/portfolio`가 stock-lab 주식 포트폴리오로 사용 중이므로 새 서비스 API 경로는 `/api/profile/`로 설정.
-
-**Tech Stack:** Python 3.12, FastAPI, SQLite, React, CSS
-
----
-
-## File Structure
-
-### Backend (`web-backend/portfolio/`)
-
-| 파일 | 역할 |
-|------|------|
-| `portfolio/Dockerfile` | Python 3.12-alpine 기반 컨테이너 |
-| `portfolio/requirements.txt` | fastapi, uvicorn, pydantic |
-| `portfolio/app/__init__.py` | 빈 패키지 파일 |
-| `portfolio/app/main.py` | FastAPI 앱, 라우트, CORS, 인증 미들웨어 |
-| `portfolio/app/db.py` | SQLite 연결, 테이블 초기화, CRUD 함수 |
-| `portfolio/app/models.py` | Pydantic 요청/응답 모델 |
-| `portfolio/app/auth.py` | 비밀번호 검증, 토큰 관리 |
-
-### Infra (기존 파일 수정)
-
-| 파일 | 변경 |
-|------|------|
-| `docker-compose.yml` | portfolio 서비스 블록 추가 |
-| `nginx/default.conf` | `/api/profile/` 프록시 추가 |
-| `scripts/deploy-nas.sh` | SERVICES에 portfolio 추가 |
-| `scripts/deploy.sh` | BUILD_TARGETS, CONTAINER_NAMES, HEALTH_ENDPOINTS, DATA_DIRS에 추가 |
-
-### Frontend (`web-ui/src/`)
-
-| 파일 | 역할 |
-|------|------|
-| `pages/portfolio/Portfolio.jsx` | 메인 페이지 (3탭 컨테이너 + 편집 모드) |
-| `pages/portfolio/Portfolio.css` | 전체 스타일 |
-| `pages/portfolio/ProfileTab.jsx` | 탭 1: 프로필 + 경력 타임라인 + 기술스택 |
-| `pages/portfolio/ProjectTab.jsx` | 탭 2: 프로젝트 카드 그리드 + 카테고리 필터 |
-| `pages/portfolio/IntroTab.jsx` | 탭 3: 자기소개 다중 버전 관리 |
-| `pages/portfolio/PasswordModal.jsx` | 비밀번호 입력 모달 |
-| `pages/portfolio/ResumeView.jsx` | PDF 출력 전용 이력서 레이아웃 |
-| `pages/portfolio/usePortfolioApi.js` | API 호출 + 인증 상태 관리 훅 |
-| `routes.jsx` | navLink + appRoute 추가 |
-| `components/Icons.jsx` | IconPortfolio 추가 |
-| `pages/home/Home.jsx` | Profile 섹션을 API 연동 요약 카드로 교체 |
-
----
-
-### Task 1: Backend — DB 스키마 + 초기화
-
-**Files:**
-- Create: `portfolio/app/__init__.py`
-- Create: `portfolio/app/db.py`
-
-- [ ] **Step 1: 빈 패키지 파일 생성**
-
-```python
-# portfolio/app/__init__.py
-# (빈 파일)
-```
-
-- [ ] **Step 2: db.py 작성 — 연결 헬퍼 + 5개 테이블 초기화**
-
-```python
-# portfolio/app/db.py
-import sqlite3
-import json
-import logging
-from typing import Dict, Any, List, Optional
-
-logger = logging.getLogger("portfolio")
-
-DB_PATH = "/app/data/portfolio.db"
-
-
-def _conn():
- c = sqlite3.connect(DB_PATH, timeout=10)
- c.row_factory = sqlite3.Row
- c.execute("PRAGMA journal_mode=WAL;")
- c.execute("PRAGMA foreign_keys=ON;")
- return c
-
-
-def _row_to_dict(r) -> Dict[str, Any]:
- if r is None:
- return None
- d = {c: r[c] for c in r.keys()}
- if "tech_stack" in d and isinstance(d["tech_stack"], str):
- try:
- d["tech_stack"] = json.loads(d["tech_stack"])
- except (json.JSONDecodeError, TypeError):
- d["tech_stack"] = []
- return d
-
-
-def init_db():
- with _conn() as conn:
- conn.execute("""
- CREATE TABLE IF NOT EXISTS profile (
- id INTEGER PRIMARY KEY CHECK (id = 1),
- name TEXT NOT NULL DEFAULT '',
- name_en TEXT NOT NULL DEFAULT '',
- role TEXT NOT NULL DEFAULT '',
- role_en TEXT NOT NULL DEFAULT '',
- email TEXT NOT NULL DEFAULT '',
- phone TEXT NOT NULL DEFAULT '',
- github_url TEXT NOT NULL DEFAULT '',
- blog_url TEXT NOT NULL DEFAULT '',
- photo_url TEXT NOT NULL DEFAULT '',
- bio TEXT NOT NULL DEFAULT '',
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
- )
- """)
- conn.execute("""
- INSERT OR IGNORE INTO profile (id) VALUES (1)
- """)
-
- conn.execute("""
- CREATE TABLE IF NOT EXISTS careers (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- category TEXT NOT NULL DEFAULT 'company',
- organization TEXT NOT NULL DEFAULT '',
- role TEXT NOT NULL DEFAULT '',
- description TEXT NOT NULL DEFAULT '',
- start_date TEXT NOT NULL DEFAULT '',
- end_date TEXT NOT NULL DEFAULT '',
- sort_order INTEGER NOT NULL DEFAULT 0,
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
- )
- """)
-
- conn.execute("""
- CREATE TABLE IF NOT EXISTS projects (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- category TEXT NOT NULL DEFAULT 'personal',
- title TEXT NOT NULL DEFAULT '',
- description TEXT NOT NULL DEFAULT '',
- tech_stack TEXT NOT NULL DEFAULT '[]',
- role TEXT NOT NULL DEFAULT '',
- start_date TEXT NOT NULL DEFAULT '',
- end_date TEXT NOT NULL DEFAULT '',
- url TEXT NOT NULL DEFAULT '',
- image_url TEXT NOT NULL DEFAULT '',
- sort_order INTEGER NOT NULL DEFAULT 0,
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
- )
- """)
-
- conn.execute("""
- CREATE TABLE IF NOT EXISTS skills (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- category TEXT NOT NULL DEFAULT 'language',
- name TEXT NOT NULL DEFAULT '',
- level INTEGER NOT NULL DEFAULT 3,
- sort_order INTEGER NOT NULL DEFAULT 0
- )
- """)
-
- conn.execute("""
- CREATE TABLE IF NOT EXISTS introductions (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- title TEXT NOT NULL DEFAULT '',
- content TEXT NOT NULL DEFAULT '',
- is_main INTEGER NOT NULL DEFAULT 0,
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
- )
- """)
- logger.info("portfolio DB initialized")
-```
-
-- [ ] **Step 3: db.py — CRUD 함수 추가 (profile)**
-
-아래 함수들을 `db.py` 끝에 추가:
-
-```python
-# ── Profile ──
-
-def get_profile() -> Dict[str, Any]:
- with _conn() as conn:
- row = conn.execute("SELECT * FROM profile WHERE id = 1").fetchone()
- return _row_to_dict(row)
-
-
-def update_profile(data: Dict[str, Any]) -> Dict[str, Any]:
- fields = {k: v for k, v in data.items() if k != "id" and v is not None}
- if not fields:
- return get_profile()
- set_clauses = ", ".join(f"{k} = ?" for k in fields)
- set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
- with _conn() as conn:
- conn.execute(
- f"UPDATE profile SET {set_clauses} WHERE id = 1",
- list(fields.values()),
- )
- return get_profile()
-```
-
-- [ ] **Step 4: db.py — CRUD 함수 추가 (careers, projects, skills)**
-
-```python
-# ── Careers ──
-
-def get_careers() -> List[Dict[str, Any]]:
- with _conn() as conn:
- rows = conn.execute("SELECT * FROM careers ORDER BY sort_order, start_date DESC").fetchall()
- return [_row_to_dict(r) for r in rows]
-
-
-def create_career(data: Dict[str, Any]) -> Dict[str, Any]:
- with _conn() as conn:
- conn.execute(
- """INSERT INTO careers (category, organization, role, description, start_date, end_date, sort_order)
- VALUES (?, ?, ?, ?, ?, ?, ?)""",
- (data.get("category", "company"), data.get("organization", ""),
- data.get("role", ""), data.get("description", ""),
- data.get("start_date", ""), data.get("end_date", ""),
- data.get("sort_order", 0)),
- )
- row = conn.execute("SELECT * FROM careers ORDER BY id DESC LIMIT 1").fetchone()
- return _row_to_dict(row)
-
-
-def update_career(career_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
- fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None}
- if not fields:
- return get_career(career_id)
- set_clauses = ", ".join(f"{k} = ?" for k in fields)
- set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
- with _conn() as conn:
- existing = conn.execute("SELECT id FROM careers WHERE id = ?", (career_id,)).fetchone()
- if not existing:
- return None
- conn.execute(f"UPDATE careers SET {set_clauses} WHERE id = ?", list(fields.values()) + [career_id])
- row = conn.execute("SELECT * FROM careers WHERE id = ?", (career_id,)).fetchone()
- return _row_to_dict(row)
-
-
-def delete_career(career_id: int) -> bool:
- with _conn() as conn:
- cur = conn.execute("DELETE FROM careers WHERE id = ?", (career_id,))
- return cur.rowcount > 0
-
-
-def get_career(career_id: int) -> Optional[Dict[str, Any]]:
- with _conn() as conn:
- row = conn.execute("SELECT * FROM careers WHERE id = ?", (career_id,)).fetchone()
- return _row_to_dict(row)
-
-
-# ── Projects ──
-
-def get_projects() -> List[Dict[str, Any]]:
- with _conn() as conn:
- rows = conn.execute("SELECT * FROM projects ORDER BY sort_order, start_date DESC").fetchall()
- return [_row_to_dict(r) for r in rows]
-
-
-def create_project(data: Dict[str, Any]) -> Dict[str, Any]:
- tech = json.dumps(data.get("tech_stack", []), ensure_ascii=False)
- with _conn() as conn:
- conn.execute(
- """INSERT INTO projects (category, title, description, tech_stack, role, start_date, end_date, url, image_url, sort_order)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
- (data.get("category", "personal"), data.get("title", ""),
- data.get("description", ""), tech,
- data.get("role", ""), data.get("start_date", ""),
- data.get("end_date", ""), data.get("url", ""),
- data.get("image_url", ""), data.get("sort_order", 0)),
- )
- row = conn.execute("SELECT * FROM projects ORDER BY id DESC LIMIT 1").fetchone()
- return _row_to_dict(row)
-
-
-def update_project(project_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
- fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None}
- if "tech_stack" in fields and isinstance(fields["tech_stack"], list):
- fields["tech_stack"] = json.dumps(fields["tech_stack"], ensure_ascii=False)
- if not fields:
- return get_project(project_id)
- set_clauses = ", ".join(f"{k} = ?" for k in fields)
- set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
- with _conn() as conn:
- existing = conn.execute("SELECT id FROM projects WHERE id = ?", (project_id,)).fetchone()
- if not existing:
- return None
- conn.execute(f"UPDATE projects SET {set_clauses} WHERE id = ?", list(fields.values()) + [project_id])
- row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
- return _row_to_dict(row)
-
-
-def delete_project(project_id: int) -> bool:
- with _conn() as conn:
- cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
- return cur.rowcount > 0
-
-
-def get_project(project_id: int) -> Optional[Dict[str, Any]]:
- with _conn() as conn:
- row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
- return _row_to_dict(row)
-
-
-# ── Skills ──
-
-def get_skills() -> List[Dict[str, Any]]:
- with _conn() as conn:
- rows = conn.execute("SELECT * FROM skills ORDER BY sort_order, category, name").fetchall()
- return [_row_to_dict(r) for r in rows]
-
-
-def create_skill(data: Dict[str, Any]) -> Dict[str, Any]:
- with _conn() as conn:
- conn.execute(
- "INSERT INTO skills (category, name, level, sort_order) VALUES (?, ?, ?, ?)",
- (data.get("category", "language"), data.get("name", ""),
- data.get("level", 3), data.get("sort_order", 0)),
- )
- row = conn.execute("SELECT * FROM skills ORDER BY id DESC LIMIT 1").fetchone()
- return _row_to_dict(row)
-
-
-def update_skill(skill_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
- fields = {k: v for k, v in data.items() if k != "id" and v is not None}
- if not fields:
- return get_skill(skill_id)
- set_clauses = ", ".join(f"{k} = ?" for k in fields)
- with _conn() as conn:
- existing = conn.execute("SELECT id FROM skills WHERE id = ?", (skill_id,)).fetchone()
- if not existing:
- return None
- conn.execute(f"UPDATE skills SET {set_clauses} WHERE id = ?", list(fields.values()) + [skill_id])
- row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
- return _row_to_dict(row)
-
-
-def delete_skill(skill_id: int) -> bool:
- with _conn() as conn:
- cur = conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,))
- return cur.rowcount > 0
-
-
-def get_skill(skill_id: int) -> Optional[Dict[str, Any]]:
- with _conn() as conn:
- row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
- return _row_to_dict(row)
-```
-
-- [ ] **Step 5: db.py — CRUD 함수 추가 (introductions + public)**
-
-```python
-# ── Introductions ──
-
-def get_introductions() -> List[Dict[str, Any]]:
- with _conn() as conn:
- rows = conn.execute("SELECT * FROM introductions ORDER BY is_main DESC, updated_at DESC").fetchall()
- return [_row_to_dict(r) for r in rows]
-
-
-def create_introduction(data: Dict[str, Any]) -> Dict[str, Any]:
- with _conn() as conn:
- conn.execute(
- "INSERT INTO introductions (title, content, is_main) VALUES (?, ?, ?)",
- (data.get("title", ""), data.get("content", ""), data.get("is_main", 0)),
- )
- row = conn.execute("SELECT * FROM introductions ORDER BY id DESC LIMIT 1").fetchone()
- return _row_to_dict(row)
-
-
-def update_introduction(intro_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
- fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None}
- if not fields:
- return get_introduction(intro_id)
- set_clauses = ", ".join(f"{k} = ?" for k in fields)
- set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
- with _conn() as conn:
- existing = conn.execute("SELECT id FROM introductions WHERE id = ?", (intro_id,)).fetchone()
- if not existing:
- return None
- conn.execute(f"UPDATE introductions SET {set_clauses} WHERE id = ?", list(fields.values()) + [intro_id])
- row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone()
- return _row_to_dict(row)
-
-
-def delete_introduction(intro_id: int) -> bool:
- with _conn() as conn:
- cur = conn.execute("DELETE FROM introductions WHERE id = ?", (intro_id,))
- return cur.rowcount > 0
-
-
-def get_introduction(intro_id: int) -> Optional[Dict[str, Any]]:
- with _conn() as conn:
- row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone()
- return _row_to_dict(row)
-
-
-def set_main_introduction(intro_id: int) -> Optional[Dict[str, Any]]:
- with _conn() as conn:
- existing = conn.execute("SELECT id FROM introductions WHERE id = ?", (intro_id,)).fetchone()
- if not existing:
- return None
- conn.execute("UPDATE introductions SET is_main = 0 WHERE is_main = 1")
- conn.execute("UPDATE introductions SET is_main = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", (intro_id,))
- row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone()
- return _row_to_dict(row)
-
-
-# ── Public (일괄 조회) ──
-
-def get_public_data() -> Dict[str, Any]:
- with _conn() as conn:
- profile = _row_to_dict(conn.execute("SELECT * FROM profile WHERE id = 1").fetchone())
- careers = [_row_to_dict(r) for r in conn.execute("SELECT * FROM careers ORDER BY sort_order, start_date DESC").fetchall()]
- projects = [_row_to_dict(r) for r in conn.execute("SELECT * FROM projects ORDER BY sort_order, start_date DESC").fetchall()]
- skills = [_row_to_dict(r) for r in conn.execute("SELECT * FROM skills ORDER BY sort_order, category, name").fetchall()]
- main_intro_row = conn.execute("SELECT * FROM introductions WHERE is_main = 1 LIMIT 1").fetchone()
- main_introduction = _row_to_dict(main_intro_row) if main_intro_row else None
- return {
- "profile": profile,
- "careers": careers,
- "projects": projects,
- "skills": skills,
- "main_introduction": main_introduction,
- }
-```
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add portfolio/app/__init__.py portfolio/app/db.py
-git commit -m "feat(portfolio): DB 스키마 + CRUD 함수 (5 테이블)"
-```
-
----
-
-### Task 2: Backend — Pydantic 모델 + 인증
-
-**Files:**
-- Create: `portfolio/app/models.py`
-- Create: `portfolio/app/auth.py`
-
-- [ ] **Step 1: models.py 작성**
-
-```python
-# portfolio/app/models.py
-from typing import Optional, List
-from pydantic import BaseModel
-
-
-class ProfileUpdate(BaseModel):
- name: Optional[str] = None
- name_en: Optional[str] = None
- role: Optional[str] = None
- role_en: Optional[str] = None
- email: Optional[str] = None
- phone: Optional[str] = None
- github_url: Optional[str] = None
- blog_url: Optional[str] = None
- photo_url: Optional[str] = None
- bio: Optional[str] = None
-
-
-class CareerCreate(BaseModel):
- category: str = "company"
- organization: str = ""
- role: str = ""
- description: str = ""
- start_date: str = ""
- end_date: str = ""
- sort_order: int = 0
-
-
-class CareerUpdate(BaseModel):
- category: Optional[str] = None
- organization: Optional[str] = None
- role: Optional[str] = None
- description: Optional[str] = None
- start_date: Optional[str] = None
- end_date: Optional[str] = None
- sort_order: Optional[int] = None
-
-
-class ProjectCreate(BaseModel):
- category: str = "personal"
- title: str = ""
- description: str = ""
- tech_stack: List[str] = []
- role: str = ""
- start_date: str = ""
- end_date: str = ""
- url: str = ""
- image_url: str = ""
- sort_order: int = 0
-
-
-class ProjectUpdate(BaseModel):
- category: Optional[str] = None
- title: Optional[str] = None
- description: Optional[str] = None
- tech_stack: Optional[List[str]] = None
- role: Optional[str] = None
- start_date: Optional[str] = None
- end_date: Optional[str] = None
- url: Optional[str] = None
- image_url: Optional[str] = None
- sort_order: Optional[int] = None
-
-
-class SkillCreate(BaseModel):
- category: str = "language"
- name: str = ""
- level: int = 3
- sort_order: int = 0
-
-
-class SkillUpdate(BaseModel):
- category: Optional[str] = None
- name: Optional[str] = None
- level: Optional[int] = None
- sort_order: Optional[int] = None
-
-
-class IntroCreate(BaseModel):
- title: str = ""
- content: str = ""
- is_main: int = 0
-
-
-class IntroUpdate(BaseModel):
- title: Optional[str] = None
- content: Optional[str] = None
-
-
-class AuthRequest(BaseModel):
- password: str
-```
-
-- [ ] **Step 2: auth.py 작성 — 토큰 관리**
-
-```python
-# portfolio/app/auth.py
-import os
-import uuid
-import time
-import logging
-from fastapi import Header, HTTPException
-
-logger = logging.getLogger("portfolio")
-
-EDIT_PASSWORD = os.getenv("PORTFOLIO_EDIT_PASSWORD", "")
-TOKEN_TTL = 86400 # 24시간
-
-_tokens: dict[str, float] = {} # token -> expiry timestamp
-
-
-def authenticate(password: str) -> dict:
- if not EDIT_PASSWORD:
- raise HTTPException(status_code=503, detail="Edit password not configured")
- if password != EDIT_PASSWORD:
- raise HTTPException(status_code=401, detail="Invalid password")
- token = uuid.uuid4().hex
- _tokens[token] = time.time() + TOKEN_TTL
- _cleanup()
- return {"token": token, "expires_in": TOKEN_TTL}
-
-
-def require_auth(authorization: str = Header("")):
- token = authorization.replace("Bearer ", "").strip()
- if not token or token not in _tokens:
- raise HTTPException(status_code=401, detail="Authentication required")
- if time.time() > _tokens[token]:
- del _tokens[token]
- raise HTTPException(status_code=401, detail="Token expired")
-
-
-def _cleanup():
- now = time.time()
- expired = [t for t, exp in _tokens.items() if now > exp]
- for t in expired:
- del _tokens[t]
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add portfolio/app/models.py portfolio/app/auth.py
-git commit -m "feat(portfolio): Pydantic 모델 + 토큰 인증"
-```
-
----
-
-### Task 3: Backend — FastAPI 앱 + 전체 라우트
-
-**Files:**
-- Create: `portfolio/app/main.py`
-
-- [ ] **Step 1: main.py 작성**
-
-```python
-# portfolio/app/main.py
-import os
-import logging
-from contextlib import asynccontextmanager
-from fastapi import FastAPI, Depends, HTTPException
-from fastapi.middleware.cors import CORSMiddleware
-
-from .db import (
- init_db, get_public_data,
- get_profile, update_profile,
- get_careers, create_career, update_career, delete_career,
- get_projects, create_project, update_project, delete_project,
- get_skills, create_skill, update_skill, delete_skill,
- get_introductions, create_introduction, update_introduction,
- delete_introduction, set_main_introduction,
-)
-from .models import (
- ProfileUpdate, CareerCreate, CareerUpdate,
- ProjectCreate, ProjectUpdate, SkillCreate, SkillUpdate,
- IntroCreate, IntroUpdate, AuthRequest,
-)
-from .auth import authenticate, require_auth
-
-logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
-logger = logging.getLogger("portfolio")
-
-
-@asynccontextmanager
-async def lifespan(app: FastAPI):
- init_db()
- logger.info("portfolio service 시작")
- yield
-
-
-app = FastAPI(lifespan=lifespan)
-
-_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
-app.add_middleware(
- CORSMiddleware,
- allow_origins=[o.strip() for o in _cors_origins],
- allow_credentials=False,
- allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
- allow_headers=["Content-Type", "Authorization"],
-)
-
-
-@app.get("/health")
-def health():
- return {"status": "ok"}
-
-
-# ── Public ──
-
-@app.get("/api/profile/public")
-def api_public():
- return get_public_data()
-
-
-# ── Auth ──
-
-@app.post("/api/profile/auth")
-def api_auth(body: AuthRequest):
- return authenticate(body.password)
-
-
-# ── Profile (편집) ──
-
-@app.get("/api/profile/profile", dependencies=[Depends(require_auth)])
-def api_profile_get():
- return get_profile()
-
-
-@app.put("/api/profile/profile", dependencies=[Depends(require_auth)])
-def api_profile_update(body: ProfileUpdate):
- return update_profile(body.model_dump(exclude_none=True))
-
-
-# ── Careers (편집) ──
-
-@app.get("/api/profile/careers", dependencies=[Depends(require_auth)])
-def api_careers_list():
- return get_careers()
-
-
-@app.post("/api/profile/careers", status_code=201, dependencies=[Depends(require_auth)])
-def api_career_create(body: CareerCreate):
- return create_career(body.model_dump())
-
-
-@app.put("/api/profile/careers/{career_id}", dependencies=[Depends(require_auth)])
-def api_career_update(career_id: int, body: CareerUpdate):
- result = update_career(career_id, body.model_dump(exclude_none=True))
- if not result:
- raise HTTPException(status_code=404, detail="Career not found")
- return result
-
-
-@app.delete("/api/profile/careers/{career_id}", dependencies=[Depends(require_auth)])
-def api_career_delete(career_id: int):
- if not delete_career(career_id):
- raise HTTPException(status_code=404, detail="Career not found")
- return {"ok": True}
-
-
-# ── Projects (편집) ──
-
-@app.get("/api/profile/projects", dependencies=[Depends(require_auth)])
-def api_projects_list():
- return get_projects()
-
-
-@app.post("/api/profile/projects", status_code=201, dependencies=[Depends(require_auth)])
-def api_project_create(body: ProjectCreate):
- return create_project(body.model_dump())
-
-
-@app.put("/api/profile/projects/{project_id}", dependencies=[Depends(require_auth)])
-def api_project_update(project_id: int, body: ProjectUpdate):
- result = update_project(project_id, body.model_dump(exclude_none=True))
- if not result:
- raise HTTPException(status_code=404, detail="Project not found")
- return result
-
-
-@app.delete("/api/profile/projects/{project_id}", dependencies=[Depends(require_auth)])
-def api_project_delete(project_id: int):
- if not delete_project(project_id):
- raise HTTPException(status_code=404, detail="Project not found")
- return {"ok": True}
-
-
-# ── Skills (편집) ──
-
-@app.get("/api/profile/skills", dependencies=[Depends(require_auth)])
-def api_skills_list():
- return get_skills()
-
-
-@app.post("/api/profile/skills", status_code=201, dependencies=[Depends(require_auth)])
-def api_skill_create(body: SkillCreate):
- return create_skill(body.model_dump())
-
-
-@app.put("/api/profile/skills/{skill_id}", dependencies=[Depends(require_auth)])
-def api_skill_update(skill_id: int, body: SkillUpdate):
- result = update_skill(skill_id, body.model_dump(exclude_none=True))
- if not result:
- raise HTTPException(status_code=404, detail="Skill not found")
- return result
-
-
-@app.delete("/api/profile/skills/{skill_id}", dependencies=[Depends(require_auth)])
-def api_skill_delete(skill_id: int):
- if not delete_skill(skill_id):
- raise HTTPException(status_code=404, detail="Skill not found")
- return {"ok": True}
-
-
-# ── Introductions (편집) ──
-
-@app.get("/api/profile/introductions", dependencies=[Depends(require_auth)])
-def api_intros_list():
- return get_introductions()
-
-
-@app.post("/api/profile/introductions", status_code=201, dependencies=[Depends(require_auth)])
-def api_intro_create(body: IntroCreate):
- return create_introduction(body.model_dump())
-
-
-@app.put("/api/profile/introductions/{intro_id}", dependencies=[Depends(require_auth)])
-def api_intro_update(intro_id: int, body: IntroUpdate):
- result = update_introduction(intro_id, body.model_dump(exclude_none=True))
- if not result:
- raise HTTPException(status_code=404, detail="Introduction not found")
- return result
-
-
-@app.delete("/api/profile/introductions/{intro_id}", dependencies=[Depends(require_auth)])
-def api_intro_delete(intro_id: int):
- if not delete_introduction(intro_id):
- raise HTTPException(status_code=404, detail="Introduction not found")
- return {"ok": True}
-
-
-@app.patch("/api/profile/introductions/{intro_id}/main", dependencies=[Depends(require_auth)])
-def api_intro_set_main(intro_id: int):
- result = set_main_introduction(intro_id)
- if not result:
- raise HTTPException(status_code=404, detail="Introduction not found")
- return result
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add portfolio/app/main.py
-git commit -m "feat(portfolio): FastAPI 앱 + 전체 API 라우트"
-```
-
----
-
-### Task 4: Backend — Dockerfile + requirements.txt
-
-**Files:**
-- Create: `portfolio/Dockerfile`
-- Create: `portfolio/requirements.txt`
-
-- [ ] **Step 1: Dockerfile 작성**
-
-```dockerfile
-FROM python:3.12-alpine
-ENV PYTHONUNBUFFERED=1
-
-WORKDIR /app
-COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
-
-COPY . .
-
-CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
-```
-
-- [ ] **Step 2: requirements.txt 작성**
-
-```
-fastapi==0.115.6
-uvicorn[standard]==0.30.6
-pydantic>=2.0
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add portfolio/Dockerfile portfolio/requirements.txt
-git commit -m "feat(portfolio): Dockerfile + requirements"
-```
-
----
-
-### Task 5: Infra — Docker Compose + Nginx + 배포 스크립트
-
-**Files:**
-- Modify: `docker-compose.yml` (agent-office 블록 뒤에 삽입)
-- Modify: `nginx/default.conf` (portfolio 프록시를 `/api/profile/`로 추가)
-- Modify: `scripts/deploy-nas.sh:5`
-- Modify: `scripts/deploy.sh:10,12,14,16`
-
-- [ ] **Step 1: docker-compose.yml에 portfolio 서비스 추가**
-
-agent-office 서비스 블록(`healthcheck` 3줄 포함) 뒤, `travel-proxy:` 블록 앞에 삽입:
-
-```yaml
- portfolio:
- build:
- context: ./portfolio
- container_name: portfolio
- restart: unless-stopped
- ports:
- - "18850:8000"
- environment:
- - TZ=${TZ:-Asia/Seoul}
- - PORTFOLIO_EDIT_PASSWORD=${PORTFOLIO_EDIT_PASSWORD:-}
- - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
- volumes:
- - ${RUNTIME_PATH:-.}/data/portfolio:/app/data
- healthcheck:
- test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
- interval: 30s
- timeout: 5s
- retries: 3
-```
-
-- [ ] **Step 2: nginx/default.conf에 `/api/profile/` 프록시 추가**
-
-기존 `/api/portfolio` (stock-lab) 블록 뒤, `# agent-office` 블록 앞에 삽입:
-
-```nginx
- # profile API (Portfolio Service)
- location /api/profile/ {
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_pass http://portfolio:8000/api/profile/;
- }
-```
-
-- [ ] **Step 3: deploy-nas.sh SERVICES에 portfolio 추가**
-
-Line 5를 수정:
-
-```bash
-SERVICES="backend travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office portfolio nginx scripts"
-```
-
-- [ ] **Step 4: deploy.sh에 portfolio 추가 (4곳)**
-
-Line 10:
-```bash
-BUILD_TARGETS="backend travel-proxy stock-lab music-lab blog-lab realestate-lab agent-office portfolio frontend"
-```
-
-Line 12:
-```bash
-CONTAINER_NAMES="lotto-backend stock-lab music-lab blog-lab realestate-lab agent-office travel-proxy portfolio lotto-frontend"
-```
-
-Line 14:
-```bash
-HEALTH_ENDPOINTS="backend stock-lab travel-proxy music-lab blog-lab realestate-lab agent-office portfolio"
-```
-
-Line 16:
-```bash
-DATA_DIRS="music stock blog realestate agent-office portfolio"
-```
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add docker-compose.yml nginx/default.conf scripts/deploy-nas.sh scripts/deploy.sh
-git commit -m "infra(portfolio): Docker Compose + Nginx + 배포 스크립트"
-```
-
----
-
-### Task 6: Frontend — 라우팅 + 아이콘 + API 훅
-
-**Files:**
-- Modify: `web-ui/src/routes.jsx`
-- Modify: `web-ui/src/components/Icons.jsx`
-- Create: `web-ui/src/pages/portfolio/usePortfolioApi.js`
-
-- [ ] **Step 1: Icons.jsx에 IconPortfolio 추가**
-
-파일 끝 `export const IconBuilding` 뒤에 추가:
-
-```jsx
-export const IconPortfolio = () =>
-
-
-
-
-
- ;
-```
-
-- [ ] **Step 2: routes.jsx에 Portfolio navLink + route 추가**
-
-import 섹션에 추가:
-```jsx
-import { IconPortfolio } from './components/Icons';
-```
-
-lazy import 추가 (기존 lazy import 블록 끝에):
-```jsx
-const Portfolio = lazy(() => import('./pages/portfolio/Portfolio'));
-```
-
-navLinks 배열에서 `agent-office` 항목 앞에 추가:
-```jsx
- {
- id: 'portfolio',
- label: 'Portfolio',
- path: '/portfolio',
- subtitle: 'RESUME',
- description: '개인 포트폴리오 — 프로필, 이력, 프로젝트 쇼케이스',
- icon:
,
- accent: '#06b6d4',
- },
-```
-
-appRoutes 배열에서 `agent-office` 항목 앞에 추가:
-```jsx
- {
- path: 'portfolio',
- element:
,
- },
-```
-
-- [ ] **Step 3: usePortfolioApi.js 작성**
-
-```jsx
-// web-ui/src/pages/portfolio/usePortfolioApi.js
-import { useState, useCallback } from 'react';
-
-const BASE = '/api/profile';
-
-async function apiFetch(path, options = {}) {
- const res = await fetch(`${BASE}${path}`, {
- headers: { 'Content-Type': 'application/json', ...options.headers },
- ...options,
- });
- if (!res.ok) {
- const err = await res.json().catch(() => ({ detail: res.statusText }));
- throw new Error(err.detail || res.statusText);
- }
- return res.json();
-}
-
-export default function usePortfolioApi() {
- const [token, setToken] = useState(null);
- const [authError, setAuthError] = useState('');
-
- const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
-
- const login = useCallback(async (password) => {
- setAuthError('');
- try {
- const data = await apiFetch('/auth', {
- method: 'POST',
- body: JSON.stringify({ password }),
- });
- setToken(data.token);
- return true;
- } catch (err) {
- setAuthError(err.message);
- return false;
- }
- }, []);
-
- const logout = useCallback(() => setToken(null), []);
-
- // ── Public ──
- const fetchPublic = useCallback(() => apiFetch('/public'), []);
-
- // ── Profile ──
- const fetchProfile = useCallback(() =>
- apiFetch('/profile', { headers: authHeaders }), [token]);
- const saveProfile = useCallback((data) =>
- apiFetch('/profile', { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
-
- // ── Careers ──
- const fetchCareers = useCallback(() =>
- apiFetch('/careers', { headers: authHeaders }), [token]);
- const addCareer = useCallback((data) =>
- apiFetch('/careers', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
- const editCareer = useCallback((id, data) =>
- apiFetch(`/careers/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
- const removeCareer = useCallback((id) =>
- apiFetch(`/careers/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
-
- // ── Projects ──
- const fetchProjects = useCallback(() =>
- apiFetch('/projects', { headers: authHeaders }), [token]);
- const addProject = useCallback((data) =>
- apiFetch('/projects', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
- const editProject = useCallback((id, data) =>
- apiFetch(`/projects/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
- const removeProject = useCallback((id) =>
- apiFetch(`/projects/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
-
- // ── Skills ──
- const fetchSkills = useCallback(() =>
- apiFetch('/skills', { headers: authHeaders }), [token]);
- const addSkill = useCallback((data) =>
- apiFetch('/skills', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
- const editSkill = useCallback((id, data) =>
- apiFetch(`/skills/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
- const removeSkill = useCallback((id) =>
- apiFetch(`/skills/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
-
- // ── Introductions ──
- const fetchIntros = useCallback(() =>
- apiFetch('/introductions', { headers: authHeaders }), [token]);
- const addIntro = useCallback((data) =>
- apiFetch('/introductions', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
- const editIntro = useCallback((id, data) =>
- apiFetch(`/introductions/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
- const removeIntro = useCallback((id) =>
- apiFetch(`/introductions/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
- const setMainIntro = useCallback((id) =>
- apiFetch(`/introductions/${id}/main`, { method: 'PATCH', headers: authHeaders }), [token]);
-
- return {
- token, authError, login, logout,
- fetchPublic,
- fetchProfile, saveProfile,
- fetchCareers, addCareer, editCareer, removeCareer,
- fetchProjects, addProject, editProject, removeProject,
- fetchSkills, addSkill, editSkill, removeSkill,
- fetchIntros, addIntro, editIntro, removeIntro, setMainIntro,
- };
-}
-```
-
-- [ ] **Step 4: Commit**
-
-```bash
-cd web-ui
-git add src/routes.jsx src/components/Icons.jsx src/pages/portfolio/usePortfolioApi.js
-git commit -m "feat(portfolio): 라우팅 + 아이콘 + API 훅"
-```
-
----
-
-### Task 7: Frontend — PasswordModal 컴포넌트
-
-**Files:**
-- Create: `web-ui/src/pages/portfolio/PasswordModal.jsx`
-
-- [ ] **Step 1: PasswordModal.jsx 작성**
-
-```jsx
-// web-ui/src/pages/portfolio/PasswordModal.jsx
-import { useState } from 'react';
-
-export default function PasswordModal({ open, onAuth, onClose, error }) {
- const [pw, setPw] = useState('');
- const [loading, setLoading] = useState(false);
-
- if (!open) return null;
-
- const handleSubmit = async (e) => {
- e.preventDefault();
- if (!pw.trim()) return;
- setLoading(true);
- await onAuth(pw);
- setLoading(false);
- setPw('');
- };
-
- return (
-
-
e.stopPropagation()}>
-
편집 모드
-
편집하려면 비밀번호를 입력하세요.
-
-
-
- );
-}
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-cd web-ui
-git add src/pages/portfolio/PasswordModal.jsx
-git commit -m "feat(portfolio): 비밀번호 모달 컴포넌트"
-```
-
----
-
-### Task 8: Frontend — ProfileTab (탭 1: 프로필 + 경력 + 기술)
-
-**Files:**
-- Create: `web-ui/src/pages/portfolio/ProfileTab.jsx`
-
-- [ ] **Step 1: ProfileTab.jsx 작성**
-
-```jsx
-// web-ui/src/pages/portfolio/ProfileTab.jsx
-import { useState } from 'react';
-
-const CAREER_CATEGORIES = { company: '회사', education: '교육', etc: '기타' };
-const SKILL_CATEGORIES = { language: '언어', framework: '프레임워크', infra: '인프라', tool: '도구' };
-
-const emptyCareer = { category: 'company', organization: '', role: '', description: '', start_date: '', end_date: '', sort_order: 0 };
-const emptySkill = { category: 'language', name: '', level: 3, sort_order: 0 };
-
-export default function ProfileTab({ data, editing, api, onRefresh }) {
- const { profile, careers, skills } = data;
- const [editingProfile, setEditingProfile] = useState(null);
- const [careerForm, setCareerForm] = useState(null);
- const [skillForm, setSkillForm] = useState(null);
-
- // ── Profile 편집 ──
- const startEditProfile = () => setEditingProfile({ ...profile });
- const saveProfileEdit = async () => {
- await api.saveProfile(editingProfile);
- setEditingProfile(null);
- onRefresh();
- };
-
- // ── Career CRUD ──
- const saveCareer = async () => {
- if (careerForm.id) {
- await api.editCareer(careerForm.id, careerForm);
- } else {
- await api.addCareer(careerForm);
- }
- setCareerForm(null);
- onRefresh();
- };
- const deleteCareer = async (id) => {
- await api.removeCareer(id);
- onRefresh();
- };
-
- // ── Skill CRUD ──
- const saveSkill = async () => {
- if (skillForm.id) {
- await api.editSkill(skillForm.id, skillForm);
- } else {
- await api.addSkill(skillForm);
- }
- setSkillForm(null);
- onRefresh();
- };
- const deleteSkill = async (id) => {
- await api.removeSkill(id);
- onRefresh();
- };
-
- const grouped = (items, catMap) => {
- const groups = {};
- for (const key of Object.keys(catMap)) groups[key] = [];
- for (const item of items) {
- const cat = item.category || Object.keys(catMap)[0];
- if (!groups[cat]) groups[cat] = [];
- groups[cat].push(item);
- }
- return groups;
- };
-
- return (
-
- {/* ── 프로필 카드 ── */}
-
- {editingProfile ? (
-
- ) : (
- <>
-
- {profile.photo_url &&
}
-
-
{profile.name || '이름 미설정'}
- {profile.name_en &&
{profile.name_en}
}
-
{profile.role || profile.role_en}
-
-
- {profile.bio &&
{profile.bio}
}
-
- {editing &&
프로필 수정 }
- >
- )}
-
-
- {/* ── 경력 타임라인 ── */}
-
-
-
경력
- {editing && setCareerForm({...emptyCareer})}>+ 추가 }
-
- {careerForm && (
-
- )}
- {Object.entries(grouped(careers, CAREER_CATEGORIES)).map(([cat, items]) =>
- items.length > 0 && (
-
-
{CAREER_CATEGORIES[cat]}
- {items.map((c) => (
-
-
{c.start_date} — {c.end_date || '현재'}
-
{c.role}
-
{c.organization}
- {c.description &&
{c.description}
}
- {editing && (
-
- setCareerForm({...c})}>수정
- deleteCareer(c.id)}>삭제
-
- )}
-
- ))}
-
- )
- )}
-
-
- {/* ── 기술 스택 ── */}
-
-
-
기술 스택
- {editing && setSkillForm({...emptySkill})}>+ 추가 }
-
- {skillForm && (
-
-
구분
- setSkillForm(f => ({...f, category: e.target.value}))}>
- {Object.entries(SKILL_CATEGORIES).map(([k, v]) => {v} )}
-
-
-
기술명 setSkillForm(f => ({...f, name: e.target.value}))} />
-
숙련도 (1~5)
- setSkillForm(f => ({...f, level: +e.target.value}))} />
- {skillForm.level}
-
-
- setSkillForm(null)}>취소
- 저장
-
-
- )}
- {Object.entries(grouped(skills, SKILL_CATEGORIES)).map(([cat, items]) =>
- items.length > 0 && (
-
-
{SKILL_CATEGORIES[cat]}
-
- {items.map((s) => (
-
- {s.name}
- {editing && (
-
- setSkillForm({...s})}>✎
- deleteSkill(s.id)}>×
-
- )}
-
- ))}
-
-
- )
- )}
-
-
- );
-}
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-cd web-ui
-git add src/pages/portfolio/ProfileTab.jsx
-git commit -m "feat(portfolio): ProfileTab — 프로필 + 경력 + 기술 탭"
-```
-
----
-
-### Task 9: Frontend — ProjectTab (탭 2: 프로젝트)
-
-**Files:**
-- Create: `web-ui/src/pages/portfolio/ProjectTab.jsx`
-
-- [ ] **Step 1: ProjectTab.jsx 작성**
-
-```jsx
-// web-ui/src/pages/portfolio/ProjectTab.jsx
-import { useState } from 'react';
-
-const CATEGORIES = [
- { key: 'all', label: '전체' },
- { key: 'company', label: '회사' },
- { key: 'personal', label: '개인' },
- { key: 'academy', label: '아카데미' },
-];
-
-const emptyProject = {
- category: 'personal', title: '', description: '', tech_stack: [],
- role: '', start_date: '', end_date: '', url: '', image_url: '', sort_order: 0,
-};
-
-export default function ProjectTab({ projects, editing, api, onRefresh }) {
- const [filter, setFilter] = useState('all');
- const [form, setForm] = useState(null);
- const [techInput, setTechInput] = useState('');
-
- const filtered = filter === 'all' ? projects : projects.filter(p => p.category === filter);
-
- const addTech = () => {
- const tag = techInput.trim();
- if (tag && !form.tech_stack.includes(tag)) {
- setForm(f => ({ ...f, tech_stack: [...f.tech_stack, tag] }));
- }
- setTechInput('');
- };
-
- const removeTech = (tag) => {
- setForm(f => ({ ...f, tech_stack: f.tech_stack.filter(t => t !== tag) }));
- };
-
- const save = async () => {
- if (form.id) {
- await api.editProject(form.id, form);
- } else {
- await api.addProject(form);
- }
- setForm(null);
- setTechInput('');
- onRefresh();
- };
-
- const remove = async (id) => {
- await api.removeProject(id);
- onRefresh();
- };
-
- return (
-
- {/* 카테고리 필터 */}
-
- {CATEGORIES.map(c => (
- setFilter(c.key)}
- >
- {c.label}
-
- ))}
- {editing && { setForm({...emptyProject}); setTechInput(''); }}>+ 추가 }
-
-
- {/* 추가/수정 폼 */}
- {form && (
-
- )}
-
- {/* 프로젝트 카드 그리드 */}
-
- {filtered.length === 0 &&
프로젝트가 없습니다.
}
- {filtered.map(p => (
-
-
- {CATEGORIES.find(c => c.key === p.category)?.label}
- {p.start_date} — {p.end_date || '현재'}
-
-
{p.title}
- {p.role &&
{p.role}
}
- {p.description &&
{p.description}
}
- {p.tech_stack?.length > 0 && (
-
- {p.tech_stack.map(t => {t} )}
-
- )}
- {p.url &&
링크 → }
- {editing && (
-
- { setForm({...p}); setTechInput(''); }}>수정
- remove(p.id)}>삭제
-
- )}
-
- ))}
-
-
- );
-}
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-cd web-ui
-git add src/pages/portfolio/ProjectTab.jsx
-git commit -m "feat(portfolio): ProjectTab — 프로젝트 카드 그리드 + 필터"
-```
-
----
-
-### Task 10: Frontend — IntroTab (탭 3: 자기소개 관리)
-
-**Files:**
-- Create: `web-ui/src/pages/portfolio/IntroTab.jsx`
-
-- [ ] **Step 1: IntroTab.jsx 작성**
-
-```jsx
-// web-ui/src/pages/portfolio/IntroTab.jsx
-import { useState } from 'react';
-
-const emptyIntro = { title: '', content: '', is_main: 0 };
-
-export default function IntroTab({ introductions, editing, api, onRefresh }) {
- const [form, setForm] = useState(null);
- const [copiedId, setCopiedId] = useState(null);
-
- const save = async () => {
- if (form.id) {
- await api.editIntro(form.id, { title: form.title, content: form.content });
- } else {
- await api.addIntro(form);
- }
- setForm(null);
- onRefresh();
- };
-
- const remove = async (id) => {
- await api.removeIntro(id);
- onRefresh();
- };
-
- const setMain = async (id) => {
- await api.setMainIntro(id);
- onRefresh();
- };
-
- const copyToClipboard = async (intro) => {
- try {
- await navigator.clipboard.writeText(intro.content);
- setCopiedId(intro.id);
- setTimeout(() => setCopiedId(null), 1500);
- } catch {
- /* 무시 */
- }
- };
-
- return (
-
- {editing && (
-
- setForm({...emptyIntro})}>+ 새 글 작성
-
- )}
-
- {/* 작성/수정 폼 */}
- {form && (
-
-
버전명 setForm(f => ({...f, title: e.target.value}))} />
-
본문
-
- setForm(null)}>취소
- 저장
-
-
- )}
-
- {/* 자기소개 목록 */}
-
- {introductions.length === 0 &&
자기소개 글이 없습니다.
}
- {introductions.map(intro => (
-
-
-
- {intro.is_main && MAIN }
- {intro.title || '제목 없음'}
-
-
- {intro.updated_at ? new Date(intro.updated_at).toLocaleDateString('ko-KR') : ''}
-
-
-
{intro.content}
-
- copyToClipboard(intro)}
- >
- {copiedId === intro.id ? '복사됨!' : '복사'}
-
- {editing && (
- <>
- setForm({...intro})}>수정
- {!intro.is_main && setMain(intro.id)}>메인 지정 }
- remove(intro.id)}>삭제
- >
- )}
-
-
- ))}
-
-
- );
-}
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-cd web-ui
-git add src/pages/portfolio/IntroTab.jsx
-git commit -m "feat(portfolio): IntroTab — 자기소개 다중 버전 + 클립보드 복사"
-```
-
----
-
-### Task 11: Frontend — ResumeView (PDF 인쇄 전용)
-
-**Files:**
-- Create: `web-ui/src/pages/portfolio/ResumeView.jsx`
-
-- [ ] **Step 1: ResumeView.jsx 작성**
-
-```jsx
-// web-ui/src/pages/portfolio/ResumeView.jsx
-
-export default function ResumeView({ data, onClose }) {
- const { profile, careers, projects, skills, main_introduction } = data;
-
- const handlePrint = () => {
- window.print();
- };
-
- return (
-
-
- PDF 저장 / 인쇄
- 닫기
-
-
- {/* 헤더 */}
-
-
- {/* About */}
- {(main_introduction?.content || profile.bio) && (
-
- About
- {main_introduction?.content || profile.bio}
-
- )}
-
- {/* Experience */}
- {careers.length > 0 && (
-
- Experience
- {careers.map(c => (
-
-
- {c.role}
- {c.organization}
- {c.start_date} — {c.end_date || '현재'}
-
- {c.description &&
{c.description}
}
-
- ))}
-
- )}
-
- {/* Projects */}
- {projects.length > 0 && (
-
- Projects
- {projects.map(p => (
-
-
- {p.title}
- {p.start_date} — {p.end_date || '현재'}
-
- {p.description &&
{p.description}
}
- {p.tech_stack?.length > 0 && (
-
{p.tech_stack.join(' · ')}
- )}
-
- ))}
-
- )}
-
- {/* Skills */}
- {skills.length > 0 && (
-
- Skills
- {skills.map(s => s.name).join(' · ')}
-
- )}
-
-
- );
-}
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-cd web-ui
-git add src/pages/portfolio/ResumeView.jsx
-git commit -m "feat(portfolio): ResumeView — PDF 인쇄 전용 이력서 레이아웃"
-```
-
----
-
-### Task 12: Frontend — Portfolio 메인 페이지 + CSS
-
-**Files:**
-- Create: `web-ui/src/pages/portfolio/Portfolio.jsx`
-- Create: `web-ui/src/pages/portfolio/Portfolio.css`
-
-- [ ] **Step 1: Portfolio.jsx 작성**
-
-```jsx
-// web-ui/src/pages/portfolio/Portfolio.jsx
-import { useCallback, useEffect, useState } from 'react';
-import { useIsMobile } from '../../hooks/useIsMobile';
-import SwipeableView from '../../components/SwipeableView';
-import usePortfolioApi from './usePortfolioApi';
-import PasswordModal from './PasswordModal';
-import ProfileTab from './ProfileTab';
-import ProjectTab from './ProjectTab';
-import IntroTab from './IntroTab';
-import ResumeView from './ResumeView';
-import './Portfolio.css';
-
-export default function Portfolio() {
- const isMobile = useIsMobile();
- const api = usePortfolioApi();
-
- const [data, setData] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState('');
- const [editing, setEditing] = useState(false);
- const [showPwModal, setShowPwModal] = useState(false);
- const [showResume, setShowResume] = useState(false);
-
- const load = useCallback(async () => {
- setLoading(true);
- setError('');
- try {
- const d = await api.fetchPublic();
- setData(d);
- } catch (err) {
- setError(err.message);
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => { load(); }, [load]);
-
- const handleEditToggle = () => {
- if (editing) {
- setEditing(false);
- return;
- }
- if (api.token) {
- setEditing(true);
- } else {
- setShowPwModal(true);
- }
- };
-
- const handleAuth = async (pw) => {
- const ok = await api.login(pw);
- if (ok) {
- setShowPwModal(false);
- setEditing(true);
- }
- };
-
- // 편집 후 데이터 리프레시
- const refresh = useCallback(async () => {
- try {
- const d = await api.fetchPublic();
- setData(d);
- } catch { /* 무시 */ }
- }, []);
-
- if (loading && !data) return
;
- if (error && !data) return
;
- if (!data) return null;
-
- if (showResume) {
- return
setShowResume(false)} />;
- }
-
- const tabs = [
- {
- key: 'profile',
- label: '프로필',
- content: ,
- },
- {
- key: 'projects',
- label: '프로젝트',
- content: ,
- },
- {
- key: 'intro',
- label: '자기소개',
- content: ,
- },
- ];
-
- return (
-
-
-
- {editing ? '편집 완료' : '편집'}
-
- setShowResume(true)}>
- PDF 내보내기
-
-
-
- {isMobile ? (
-
- ) : (
-
- )}
-
-
setShowPwModal(false)}
- error={api.authError}
- />
-
- );
-}
-```
-
-- [ ] **Step 2: Portfolio.css 작성**
-
-이 파일은 전체 포트폴리오 페이지 + 모든 컴포넌트의 스타일을 포함합니다.
-CSS 파일이 길지만, 프로젝트 패턴(단일 CSS per page)을 따릅니다.
-실제 구현 시 기존 사이버펑크 테마 변수(`var(--surface-card)`, `var(--line)`, `var(--text-bright)` 등)를 활용하여 작성합니다.
-
-주요 클래스 목록:
-- `.pf-page` — 페이지 루트 (grid, gap)
-- `.pf-toolbar` — 편집/PDF 버튼 바
-- `.pf-modal-backdrop`, `.pf-modal` — 비밀번호 모달
-- `.pf-profile-card` — 프로필 카드
-- `.pf-section` — 경력/기술 섹션 래퍼
-- `.pf-career-group`, `.pf-career-item` — 경력 타임라인
-- `.pf-skill-group`, `.pf-skill-tag` — 기술 태그
-- `.pf-edit-form` — 인라인 편집 폼 (공통)
-- `.pf-filter-bar`, `.pf-filter-btn` — 프로젝트 필터
-- `.pf-project-grid`, `.pf-project-card` — 프로젝트 카드
-- `.pf-tech-input`, `.pf-tech-tag` — 기술 태그 입력
-- `.pf-intro-tab`, `.pf-intro-card` — 자기소개 카드
-- `.pf-resume-overlay`, `.pf-resume` — PDF 이력서 뷰
-- `@media print` — 인쇄 전용 스타일
-- `@media (max-width: 768px)` — 모바일 반응형
-
-(CSS 전체 코드는 구현 subagent가 위 클래스 구조에 맞춰 사이버펑크 테마로 작성)
-
-- [ ] **Step 3: Commit**
-
-```bash
-cd web-ui
-git add src/pages/portfolio/Portfolio.jsx src/pages/portfolio/Portfolio.css
-git commit -m "feat(portfolio): 메인 페이지 + 3탭 구조 + CSS"
-```
-
----
-
-### Task 13: Frontend — IntroTab에 introductions 데이터 전달 수정
-
-**Files:**
-- Modify: `web-ui/src/pages/portfolio/Portfolio.jsx`
-
-- [ ] **Step 1: public API에서 introductions도 가져오도록 수정**
-
-현재 `get_public_data()`가 `main_introduction`만 반환하므로, IntroTab에서 전체 목록이 필요합니다. 두 가지 선택:
-
-편집 모드 진입 시 `fetchIntros()`를 별도 호출하여 전체 목록을 로드합니다.
-
-Portfolio.jsx의 state에 `intros` 추가:
-
-```jsx
-const [intros, setIntros] = useState([]);
-```
-
-`handleAuth` 성공 후 intros 로드:
-```jsx
-const handleAuth = async (pw) => {
- const ok = await api.login(pw);
- if (ok) {
- setShowPwModal(false);
- setEditing(true);
- try {
- const list = await api.fetchIntros();
- setIntros(list);
- } catch { /* 무시 */ }
- }
-};
-```
-
-refresh에서도:
-```jsx
-const refresh = useCallback(async () => {
- try {
- const d = await api.fetchPublic();
- setData(d);
- if (api.token) {
- const list = await api.fetchIntros();
- setIntros(list);
- }
- } catch { /* 무시 */ }
-}, [api.token]);
-```
-
-IntroTab에 전달:
-```jsx
-content: ,
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-cd web-ui
-git add src/pages/portfolio/Portfolio.jsx
-git commit -m "fix(portfolio): IntroTab에 편집 모드 시 전체 자기소개 목록 로드"
-```
-
----
-
-### Task 14: Frontend — 홈 페이지 Profile 섹션 연동
-
-**Files:**
-- Modify: `web-ui/src/pages/home/Home.jsx`
-- Modify: `web-ui/src/pages/home/Home.css`
-
-- [ ] **Step 1: Home.jsx Profile 섹션을 API 연동 요약 카드로 교체**
-
-기존 하드코딩 Profile 섹션(line 215~271)을 교체합니다.
-
-Home 컴포넌트 상단에 state 추가:
-```jsx
-const [portfolio, setPortfolio] = useState(null);
-
-useEffect(() => {
- fetch('/api/profile/public')
- .then(r => r.ok ? r.json() : null)
- .catch(() => null)
- .then(d => setPortfolio(d));
-}, []);
-```
-
-기존 Profile 섹션을 교체:
-```jsx
-
-
-
Profile
-
페이지 주인 소개 영역입니다.
-
-
-
-
-
-
-
{portfolio?.profile?.role || 'Server Developer'}
-
{portfolio?.profile?.name || '박 재 오'}
-
-
-
- {portfolio?.profile?.bio || '주변 동료와 함께 소통하며 성장하는걸 좋아합니다.'}
-
-
- {(portfolio?.skills || []).slice(0, 8).map((s) => (
- {s.name}
- ))}
- {!portfolio && ['C++', 'Git', 'AWS', 'Jira', 'MySQL', 'Docker', 'Kubernetes', 'Linux'].map((tag) => (
- {tag}
- ))}
-
-
-
-
-
-```
-
-기존 연혁(timeline) 섹션과 "프로필 수정" 버튼은 제거합니다 — 포트폴리오 페이지에서 관리.
-
-- [ ] **Step 2: Commit**
-
-```bash
-cd web-ui
-git add src/pages/home/Home.jsx src/pages/home/Home.css
-git commit -m "refactor(home): Profile 섹션 portfolio API 연동 + 요약 카드"
-```
-
----
-
-### Task 15: CLAUDE.md 문서 업데이트
-
-**Files:**
-- Modify: `web-backend/CLAUDE.md`
-
-- [ ] **Step 1: portfolio 서비스 섹션 추가**
-
-Docker 서비스 & 포트 테이블에 추가:
-```
-| `portfolio` | 18850 | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개 관리) |
-```
-
-Nginx 라우팅 규칙 테이블에 추가:
-```
-| `/api/profile/` | portfolio | 포트폴리오 API |
-```
-
-서비스별 핵심 정보에 portfolio 섹션 추가:
-
-```markdown
-### portfolio (portfolio/)
-- 개인 포트폴리오 서비스 (프로필, 경력, 프로젝트, 기술스택, 자기소개 관리)
-- DB: `/app/data/portfolio.db` (profile, careers, projects, skills, introductions 테이블)
-- 편집 인증: `PORTFOLIO_EDIT_PASSWORD` 환경변수, Bearer 토큰 (24시간 TTL)
-- 파일 구조: `main.py`, `db.py`, `models.py`, `auth.py`
-
-**환경변수**
-- `PORTFOLIO_EDIT_PASSWORD`: 편집 모드 비밀번호 (미설정 시 편집 불가)
-
-**portfolio API 목록**
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/profile/public` | 공개 데이터 일괄 조회 |
-| POST | `/api/profile/auth` | 비밀번호 인증 → 토큰 |
-| GET | `/api/profile/profile` | 프로필 조회 (인증) |
-| PUT | `/api/profile/profile` | 프로필 수정 (인증) |
-| GET | `/api/profile/careers` | 경력 목록 (인증) |
-| POST | `/api/profile/careers` | 경력 추가 (인증) |
-| PUT | `/api/profile/careers/{id}` | 경력 수정 (인증) |
-| DELETE | `/api/profile/careers/{id}` | 경력 삭제 (인증) |
-| GET | `/api/profile/projects` | 프로젝트 목록 (인증) |
-| POST | `/api/profile/projects` | 프로젝트 추가 (인증) |
-| PUT | `/api/profile/projects/{id}` | 프로젝트 수정 (인증) |
-| DELETE | `/api/profile/projects/{id}` | 프로젝트 삭제 (인증) |
-| GET | `/api/profile/skills` | 기술 목록 (인증) |
-| POST | `/api/profile/skills` | 기술 추가 (인증) |
-| PUT | `/api/profile/skills/{id}` | 기술 수정 (인증) |
-| DELETE | `/api/profile/skills/{id}` | 기술 삭제 (인증) |
-| GET | `/api/profile/introductions` | 자기소개 목록 (인증) |
-| POST | `/api/profile/introductions` | 자기소개 추가 (인증) |
-| PUT | `/api/profile/introductions/{id}` | 자기소개 수정 (인증) |
-| DELETE | `/api/profile/introductions/{id}` | 자기소개 삭제 (인증) |
-| PATCH | `/api/profile/introductions/{id}/main` | 메인 자기소개 지정 (인증) |
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add CLAUDE.md
-git commit -m "docs: CLAUDE.md에 portfolio 서비스 추가"
-```
diff --git a/docs/superpowers/plans/2026-04-28-realestate-frontend-targeting.md b/docs/superpowers/plans/2026-04-28-realestate-frontend-targeting.md
deleted file mode 100644
index 1b0bb7f..0000000
--- a/docs/superpowers/plans/2026-04-28-realestate-frontend-targeting.md
+++ /dev/null
@@ -1,971 +0,0 @@
-# 청약 타겟팅 프론트엔드 구현 계획
-
-> **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:** 백엔드에서 추가된 자치구 5티어 매칭 기능을 `web-ui`의 청약(Subscription) 페이지에 노출 — 프로필 편집 UI(드래그&드롭 + 슬라이더 + 토글) + 카드/상세에 district·5티어·reasons 표시.
-
-**Architecture:** `Subscription.jsx`(1354줄, 단일 파일)의 ProfileTab에 신규 컴포넌트 2개(`DistrictTierEditor`, `NotificationSettings`)를 추가하고, `AnnouncementCard`/`AnnouncementDetail`/`MatchesTab` 3 곳에 district + 5티어 뱃지 + reasons 표시를 추가한다. 백엔드 응답은 이미 모든 필요 데이터를 포함하므로 API 변경 없음.
-
-**Tech Stack:** React 18 + Vite + JavaScript / Native HTML5 drag-and-drop / `window.matchMedia` 분기 / ESLint / 단위 테스트 인프라 없음(빌드 + lint + 수동 시각 검증)
-
-**스펙 참조:** `web-backend/docs/superpowers/specs/2026-04-28-realestate-frontend-targeting-design.md`
-
-**작업 디렉토리:** `C:\Users\jaeoh\Desktop\workspace\web-ui` (web-backend와 별도 git repo. commit/push도 web-ui repo에서 처리.)
-
-**검증 방식:**
-- 단위 테스트 인프라 없음 → 각 task는 `npm run build` 통과 + `npm run lint` 통과로 1차 검증
-- 마지막 task에서 `npm run dev` + 브라우저로 수동 시각 검증 시나리오 일괄 실행
-- 백엔드는 NAS에 이미 배포됨(2a8635e..a508a56) → 실제 응답으로 동작 확인 가능
-
----
-
-## Task 1: 모듈 상단 변경 — DEFAULT_PROFILE 확장 + extractTier 헬퍼
-
-`Subscription.jsx` 모듈 상단에 신규 3 필드 default와 reasons → tier 추출 헬퍼를 추가. 이후 task들이 이 두 가지를 의존.
-
-**Files:**
-- Modify: `web-ui/src/pages/subscription/Subscription.jsx` (모듈 상단 — `DEFAULT_PROFILE` 상수 + 새 헬퍼)
-
-- [ ] **Step 1: DEFAULT_PROFILE에 신규 3 필드 default 추가**
-
-`Subscription.jsx`에서 `DEFAULT_PROFILE` 상수 정의를 찾는다 (grep `DEFAULT_PROFILE =`). 끝부분에 3 필드 추가:
-
-```javascript
-const DEFAULT_PROFILE = {
- // ... 기존 필드 그대로 유지
- preferred_regions: '',
- preferred_types: '',
- min_area: '',
- max_area: '',
- max_price: '',
- // 신규 (자치구 5티어 + 알림 설정)
- preferred_districts: {},
- min_match_score: 70,
- notify_enabled: true,
-};
-```
-
-(주의: 기존 마지막 필드 뒤에 콤마가 있는지 확인 후 일관성 유지)
-
-- [ ] **Step 2: `extractTier` 헬퍼 함수 추가**
-
-`DEFAULT_PROFILE` 정의 위(또는 fmt 헬퍼들 근처, 모듈 최상단 영역) 어딘가에 추가:
-
-```javascript
-// 매칭 reasons에서 자치구 티어를 추출 ("자치구 S티어: 강남구 (+25)" → "S")
-function extractTier(reasons) {
- for (const r of reasons || []) {
- const m = r.match(/자치구 ([SABCD])티어/);
- if (m) return m[1];
- }
- return null;
-}
-```
-
-- [ ] **Step 3: 빌드 + 린트 검증**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-npm run build
-npm run lint
-```
-
-Expected: build 성공, lint 0 errors / 0 warnings.
-
-- [ ] **Step 4: 커밋**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-git add src/pages/subscription/Subscription.jsx
-git commit -m "feat(subscription): DEFAULT_PROFILE 신규 3필드 + extractTier 헬퍼"
-```
-
----
-
-## Task 2: DistrictTierEditor 컴포넌트 신규
-
-자치구 5티어 분류 UI. 데스크톱 드래그&드롭 + 모바일 read-only.
-
-**Files:**
-- Create: `web-ui/src/pages/subscription/components/DistrictTierEditor.jsx`
-
-- [ ] **Step 1: 컴포넌트 파일 생성**
-
-```jsx
-import { useEffect, useState } from "react";
-
-const SEOUL_DISTRICTS = [
- "강남구","강동구","강북구","강서구","관악구",
- "광진구","구로구","금천구","노원구","도봉구",
- "동대문구","동작구","마포구","서대문구","서초구",
- "성동구","성북구","송파구","양천구","영등포구",
- "용산구","은평구","종로구","중구","중랑구",
-];
-
-const TIERS = [
- { key: "S", label: "S", weight: "100%" },
- { key: "A", label: "A", weight: "80%" },
- { key: "B", label: "B", weight: "60%" },
- { key: "C", label: "C", weight: "40%" },
- { key: "D", label: "D", weight: "20%" },
-];
-
-const EMPTY_TIERS = { S: [], A: [], B: [], C: [], D: [] };
-
-function useIsDesktop() {
- const [isDesktop, setIsDesktop] = useState(
- typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches
- );
- useEffect(() => {
- if (typeof window === "undefined") return;
- const mq = window.matchMedia("(min-width: 768px)");
- const handler = (e) => setIsDesktop(e.matches);
- mq.addEventListener("change", handler);
- return () => mq.removeEventListener("change", handler);
- }, []);
- return isDesktop;
-}
-
-export default function DistrictTierEditor({ value, onChange }) {
- const isDesktop = useIsDesktop();
- const [dragOver, setDragOver] = useState(null); // 현재 hover 중인 zone key
-
- const current = value && Object.keys(value).length > 0 ? value : EMPTY_TIERS;
-
- const unassigned = SEOUL_DISTRICTS.filter(
- d => !TIERS.some(t => (current[t.key] || []).includes(d))
- );
-
- const moveDistrict = (district, targetTier /* null = 미할당 */) => {
- const next = { S: [], A: [], B: [], C: [], D: [] };
- for (const t of Object.keys(next)) {
- next[t] = (current[t] || []).filter(d => d !== district);
- }
- if (targetTier) {
- next[targetTier] = [...next[targetTier], district];
- }
- onChange(next);
- };
-
- const onDragStart = (e, district) => {
- e.dataTransfer.setData("text/district", district);
- e.dataTransfer.effectAllowed = "move";
- };
- const onDragOver = (e, key) => {
- e.preventDefault();
- e.dataTransfer.dropEffect = "move";
- if (dragOver !== key) setDragOver(key);
- };
- const onDragLeave = () => setDragOver(null);
- const onDrop = (e, targetTier /* null = 미할당 */) => {
- e.preventDefault();
- const district = e.dataTransfer.getData("text/district");
- setDragOver(null);
- if (district) moveDistrict(district, targetTier);
- };
-
- if (!isDesktop) {
- return (
-
-
-
- {TIERS.map(t => (
-
-
- {t.label} {t.weight}
-
-
- {(current[t.key] || []).length === 0
- ? (없음)
- : (current[t.key] || []).join(", ")}
-
-
- ))}
-
✏️ 자치구 분류는 PC에서 편집할 수 있어요
-
-
- );
- }
-
- return (
-
-
-
자치구 우선순위
-
지역 5티어 (드래그해서 분류)
-
-
- {/* 미할당 풀 */}
-
onDragOver(e, "_unassigned")}
- onDragLeave={onDragLeave}
- onDrop={(e) => onDrop(e, null)}
- >
-
미할당 ({unassigned.length})
-
- {unassigned.map(d => (
- onDragStart(e, d)}
- className="sub-chip sub-chip--district dte-chip"
- >
- {d}
-
- ))}
-
-
-
- {/* 5티어 그리드 */}
-
- {TIERS.map(t => (
-
onDragOver(e, t.key)}
- onDragLeave={onDragLeave}
- onDrop={(e) => onDrop(e, t.key)}
- >
-
- {t.label} {t.weight}
-
-
- {(current[t.key] || []).map(d => (
- onDragStart(e, d)}
- className="sub-chip sub-chip--district dte-chip"
- >
- {d}
- moveDistrict(d, null)}
- aria-label={`${d} 미할당으로`}
- >
- ×
-
-
- ))}
-
-
- ))}
-
-
-
- );
-}
-```
-
-- [ ] **Step 2: 빌드 + 린트 검증**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-npm run build
-npm run lint
-```
-
-Expected: build 성공, lint 0 errors. 컴포넌트가 아직 사용되지 않으므로 dead code warning은 무시.
-
-- [ ] **Step 3: 커밋**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-git add src/pages/subscription/components/DistrictTierEditor.jsx
-git commit -m "feat(subscription): DistrictTierEditor — 자치구 5티어 드래그앤드롭"
-```
-
----
-
-## Task 3: NotificationSettings 컴포넌트 신규
-
-임계값 슬라이더 + 알림 토글 + 미리보기.
-
-**Files:**
-- Create: `web-ui/src/pages/subscription/components/NotificationSettings.jsx`
-
-- [ ] **Step 1: 컴포넌트 파일 생성**
-
-```jsx
-export default function NotificationSettings({ minScore, notifyEnabled, onChange }) {
- const score = minScore ?? 70;
- const enabled = notifyEnabled ?? true;
-
- return (
-
-
-
-
- 텔레그램 알림
-
- onChange({ notify_enabled: e.target.checked })}
- />
- {enabled ? "ON" : "OFF"}
-
-
-
-
- 매칭 임계값 — {score}점
- onChange({ min_match_score: Number(e.target.value) })}
- className="ns-slider"
- disabled={!enabled}
- />
-
- 0
- 50
- 100
-
-
-
-
- {enabled
- ? `💡 ${score}점 이상 매치 시 텔레그램에 자동 알림합니다.`
- : "⚠️ 알림 OFF — 임계값을 통과한 매칭이 있어도 메시지가 발송되지 않습니다."}
-
-
-
- );
-}
-```
-
-- [ ] **Step 2: 빌드 + 린트 검증**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-npm run build
-npm run lint
-```
-
-Expected: build 성공, lint 0 errors.
-
-- [ ] **Step 3: 커밋**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-git add src/pages/subscription/components/NotificationSettings.jsx
-git commit -m "feat(subscription): NotificationSettings — 임계값 슬라이더 + 알림 토글"
-```
-
----
-
-## Task 4: ProfileTab에 두 컴포넌트 통합 + handleSave 변경
-
-신규 컴포넌트 2개를 ProfileTab에 import·렌더하고, handleSave가 신규 3 필드를 PUT body에 포함하도록 변경.
-
-**Files:**
-- Modify: `web-ui/src/pages/subscription/Subscription.jsx` ProfileTab 함수 (956~1299줄 부근)
-
-- [ ] **Step 1: import 추가 (파일 상단의 다른 import들 근처)**
-
-```javascript
-import DistrictTierEditor from "./components/DistrictTierEditor";
-import NotificationSettings from "./components/NotificationSettings";
-```
-
-- [ ] **Step 2: handleSave 안 신규 3 필드 처리 추가**
-
-`handleSave` 함수 안에서 payload 변환 부분을 찾아 다음을 추가. 기존 `preferred_regions` / `preferred_types` 변환 직후에:
-
-```javascript
-// 신규: preferred_districts (객체), min_match_score, notify_enabled
-payload.preferred_districts = profile.preferred_districts && typeof profile.preferred_districts === "object"
- ? profile.preferred_districts
- : {};
-payload.min_match_score = profile.min_match_score ?? null;
-payload.notify_enabled = profile.notify_enabled ?? null;
-```
-
-- [ ] **Step 3: ProfileTab의 GET 응답 처리에 신규 3 필드 매핑 보강**
-
-`useEffect` 안의 `apiGet('/api/realestate/profile')` 응답 처리에서 `display = { ...DEFAULT_PROFILE, ...data }` 라인이 이미 있어 자동으로 spread 됨. 별도 수정 불필요. (백엔드가 항상 응답에 포함시키므로 fallback도 자연스러움.)
-
-확인 차원에서 `min_match_score`/`notify_enabled`/`preferred_districts`가 응답에 없을 경우 DEFAULT 값이 사용되는지 검증.
-
-- [ ] **Step 4: ProfileTab 렌더 — DistrictTierEditor / NotificationSettings 추가**
-
-`return ()` 안에서 기존 "선호 조건" 패널과 저장 버튼 사이에 두 컴포넌트 삽입. 정확한 위치는 기존 코드의 마지막 `` (선호 조건 패널) 다음 + 저장 버튼 직전:
-
-```jsx
-{/* 자치구 5티어 */}
-
setProfile(prev => ({ ...prev, preferred_districts: next }))}
-/>
-
-{/* 알림 설정 */}
- setProfile(prev => ({ ...prev, ...patch }))}
-/>
-```
-
-- [ ] **Step 5: 빌드 + 린트 검증**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-npm run build
-npm run lint
-```
-
-Expected: build 성공, lint 0 errors.
-
-- [ ] **Step 6: 커밋**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-git add src/pages/subscription/Subscription.jsx
-git commit -m "feat(subscription): ProfileTab에 5티어/알림 설정 통합"
-```
-
----
-
-## Task 5: Subscription.css — 5티어 + 드래그영역 + 토글 + 슬라이더 + 매칭분석 스타일
-
-신규 컴포넌트와 카드 표시 변경에 필요한 모든 CSS를 한 번에 추가.
-
-**Files:**
-- Modify: `web-ui/src/pages/subscription/Subscription.css` (파일 끝에 신규 섹션 추가)
-
-- [ ] **Step 1: 5티어 + district 뱃지 색상**
-
-`Subscription.css` 파일 끝에 추가:
-
-```css
-/* === 신규: 자치구 5티어 + district 뱃지 ============================== */
-.sub-chip--district {
- background: #f3f4f6;
- color: #374151;
- border-color: #d1d5db;
-}
-.sub-chip--tier {
- font-weight: 700;
-}
-.sub-chip--tier-S { background: #fee2e2; color: #dc2626; border-color: #fca5a5; }
-.sub-chip--tier-A { background: #fef3c7; color: #d97706; border-color: #fcd34d; }
-.sub-chip--tier-B { background: #d1fae5; color: #059669; border-color: #6ee7b7; }
-.sub-chip--tier-C { background: #dbeafe; color: #2563eb; border-color: #93c5fd; }
-.sub-chip--tier-D { background: #ede9fe; color: #7c3aed; border-color: #c4b5fd; }
-```
-
-- [ ] **Step 2: DistrictTierEditor 드래그&드롭 영역**
-
-같은 파일에 이어서 추가:
-
-```css
-/* === 신규: DistrictTierEditor ====================================== */
-.dte-pool {
- border: 1px dashed var(--border-soft, #e5e7eb);
- border-radius: 12px;
- padding: 12px;
- transition: background 0.15s, border-color 0.15s;
-}
-.dte-pool--over {
- background: #f0f9ff;
- border-color: #38bdf8;
-}
-.dte-pool__title {
- margin: 0 0 8px;
- font-size: 12px;
- color: var(--text-muted, #6b7280);
- text-transform: uppercase;
- letter-spacing: 0.05em;
-}
-.dte-chips {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-}
-.dte-chip {
- cursor: grab;
- user-select: none;
-}
-.dte-chip:active { cursor: grabbing; }
-.dte-chip__remove {
- background: transparent;
- border: 0;
- color: inherit;
- margin-left: 4px;
- padding: 0 2px;
- cursor: pointer;
- font-size: 14px;
- line-height: 1;
- opacity: 0.6;
-}
-.dte-chip__remove:hover { opacity: 1; }
-
-.dte-grid {
- display: grid;
- grid-template-columns: repeat(5, 1fr);
- gap: 8px;
-}
-.dte-zone {
- border: 1px solid var(--border-soft, #e5e7eb);
- border-radius: 12px;
- padding: 8px;
- min-height: 120px;
- transition: background 0.15s, border-color 0.15s;
-}
-.dte-zone--over {
- background: #f0f9ff;
- border-color: #38bdf8;
-}
-.dte-zone__head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 4px 8px;
- border-radius: 6px;
- font-weight: 700;
- margin-bottom: 8px;
-}
-.dte-zone__weight {
- font-size: 11px;
- font-weight: 500;
- opacity: 0.8;
-}
-.dte-zone__chips {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-/* 모바일 read-only 뷰 */
-.dte-row {
- display: grid;
- grid-template-columns: 80px 1fr;
- align-items: center;
- gap: 12px;
- padding: 8px 0;
- border-bottom: 1px solid var(--border-soft, #e5e7eb);
-}
-.dte-row:last-of-type { border-bottom: 0; }
-.dte-row__list {
- color: var(--text, #1f2937);
- font-size: 14px;
-}
-.dte-empty {
- color: var(--text-muted, #6b7280);
- font-style: italic;
-}
-.dte-mobile-hint {
- margin: 4px 0 0;
- color: var(--text-muted, #6b7280);
- font-size: 13px;
- text-align: center;
-}
-```
-
-- [ ] **Step 3: NotificationSettings — 토글 + 슬라이더**
-
-같은 파일에 이어서 추가:
-
-```css
-/* === 신규: NotificationSettings ==================================== */
-.ns-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
-}
-.ns-row--column {
- flex-direction: column;
- align-items: stretch;
-}
-.ns-row__label {
- font-weight: 600;
- color: var(--text, #1f2937);
-}
-.ns-toggle {
- display: inline-flex;
- align-items: center;
- gap: 8px;
-}
-.sub-toggle {
- appearance: none;
- width: 40px;
- height: 22px;
- background: #d1d5db;
- border-radius: 11px;
- position: relative;
- cursor: pointer;
- transition: background 0.2s;
- margin: 0;
-}
-.sub-toggle::before {
- content: "";
- position: absolute;
- top: 2px;
- left: 2px;
- width: 18px;
- height: 18px;
- background: #fff;
- border-radius: 50%;
- transition: transform 0.2s;
-}
-.sub-toggle:checked {
- background: #10b981;
-}
-.sub-toggle:checked::before {
- transform: translateX(18px);
-}
-.sub-toggle__label {
- font-size: 12px;
- font-weight: 600;
- color: var(--text-muted, #6b7280);
-}
-.ns-slider {
- width: 100%;
- margin: 8px 0;
-}
-.ns-slider:disabled {
- opacity: 0.5;
-}
-.ns-scale {
- display: flex;
- justify-content: space-between;
- font-size: 11px;
- color: var(--text-muted, #6b7280);
-}
-.ns-hint {
- margin: 0;
- font-size: 13px;
- color: var(--text-muted, #6b7280);
- line-height: 1.5;
-}
-```
-
-- [ ] **Step 4: 매칭 분석 섹션 + 모바일 그리드 fallback**
-
-같은 파일에 이어서 추가:
-
-```css
-/* === 신규: 매칭 분석 섹션 ========================================== */
-.sub-match-analysis {
- display: grid;
- gap: 12px;
- padding: 16px;
- background: var(--surface-soft, #f9fafb);
- border-radius: 12px;
- margin-top: 16px;
-}
-.sub-match-analysis__score {
- font-family: var(--font-display, system-ui);
- font-size: 28px;
- font-weight: 700;
- color: var(--accent, #3b82f6);
-}
-.sub-match-analysis__reasons {
- margin: 0;
- padding-left: 18px;
- color: var(--text, #1f2937);
- font-size: 14px;
- line-height: 1.7;
-}
-.sub-match-analysis__reasons li {
- margin: 2px 0;
-}
-.sub-match-analysis__elig {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-}
-
-/* 모바일 dte-grid → 1칼럼 */
-@media (max-width: 767px) {
- .dte-grid {
- grid-template-columns: 1fr;
- }
-}
-```
-
-- [ ] **Step 5: 빌드 검증**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-npm run build
-```
-
-Expected: build 성공 (CSS 추가는 lint 영향 없음).
-
-- [ ] **Step 6: 커밋**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-git add src/pages/subscription/Subscription.css
-git commit -m "feat(subscription): 5티어 뱃지 + 드래그영역 + 토글 + 슬라이더 스타일"
-```
-
----
-
-## Task 6: AnnouncementCard에 district + 5티어 뱃지
-
-매칭 결과 데이터가 있는 경우만 뱃지 표시.
-
-**Files:**
-- Modify: `web-ui/src/pages/subscription/Subscription.jsx` AnnouncementCard 함수 (315~389줄 부근)
-
-- [ ] **Step 1: AnnouncementCard 안 메타 라인에 뱃지 추가**
-
-`AnnouncementCard` 컴포넌트의 JSX에서 단지명·지역 라인 직후 또는 메타 라인에 다음 뱃지를 추가:
-
-```jsx
-{item.district && (
- {item.district}
-)}
-{(() => {
- const tier = extractTier(item.match_reasons);
- return tier ? (
-
- {tier}티어
-
- ) : null;
-})()}
-```
-
-(`extractTier`는 Task 1에서 모듈 상단에 정의됨. JSX 안에서 직접 호출 가능.)
-
-정확한 삽입 위치: 카드 헤더의 region/area 라인 옆, 또는 status 뱃지 옆에 자연스럽게 배치. 기존 카드 구조에 맞춰 조정.
-
-- [ ] **Step 2: 빌드 + 린트 검증**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-npm run build
-npm run lint
-```
-
-Expected: build 성공, lint 0 errors.
-
-- [ ] **Step 3: 커밋**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-git add src/pages/subscription/Subscription.jsx
-git commit -m "feat(subscription): AnnouncementCard에 district + 5티어 뱃지"
-```
-
----
-
-## Task 7: AnnouncementDetail에 매칭 분석 섹션
-
-매칭 결과가 있는 공고만 매칭 분석 섹션을 노출.
-
-**Files:**
-- Modify: `web-ui/src/pages/subscription/Subscription.jsx` AnnouncementDetail 함수 (390~595줄 부근)
-
-- [ ] **Step 1: AnnouncementDetail 안 매칭 분석 섹션 추가**
-
-`AnnouncementDetail` 컴포넌트의 JSX 마지막 부분(다른 모든 섹션 다음)에 추가:
-
-```jsx
-{item.match_score !== undefined && item.match_score !== null && (
-
-
-
매칭 분석
-
- ⭐ {item.match_score} / 100
-
-
-
- {item.match_reasons && item.match_reasons.length > 0 && (
-
-
💡 매칭 사유
-
- {item.match_reasons.map((r, idx) => (
- {r}
- ))}
-
-
- )}
-
- {item.eligible_types && item.eligible_types.length > 0 && (
-
-
✓ 신청 자격
-
- {item.eligible_types.map(t => (
- {t}
- ))}
-
-
- )}
-
-)}
-```
-
-- [ ] **Step 2: 빌드 + 린트 검증**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-npm run build
-npm run lint
-```
-
-Expected: build 성공, lint 0 errors.
-
-- [ ] **Step 3: 커밋**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-git add src/pages/subscription/Subscription.jsx
-git commit -m "feat(subscription): AnnouncementDetail에 매칭 분석 섹션"
-```
-
----
-
-## Task 8: MatchesTab 매치 카드에 district + 5티어 뱃지
-
-`MatchesTab`은 별도의 매치 카드 마크업을 가지고 있을 가능성이 높다. `AnnouncementCard`와 동일한 helper(`extractTier`) + 뱃지 패턴을 적용.
-
-**Files:**
-- Modify: `web-ui/src/pages/subscription/Subscription.jsx` MatchesTab 함수 (763~955줄 부근)
-
-- [ ] **Step 1: MatchesTab의 매치 카드 마크업을 찾아 district + 5티어 뱃지 삽입**
-
-`MatchesTab` 함수 안에서 매치 한 건당 렌더하는 영역(보통 `match.house_nm` / `match.region_name` 등을 표시하는 곳)을 찾는다. 거기에 다음 뱃지를 추가:
-
-```jsx
-{match.district && (
- {match.district}
-)}
-{(() => {
- const tier = extractTier(match.match_reasons);
- return tier ? (
-
- {tier}티어
-
- ) : null;
-})()}
-```
-
-(`match` 변수명은 실제 코드의 변수명에 맞춰 조정. 보통 `match`, `m`, 또는 `item`)
-
-- [ ] **Step 2: 빌드 + 린트 검증**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-npm run build
-npm run lint
-```
-
-Expected: build 성공, lint 0 errors.
-
-- [ ] **Step 3: 커밋**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-git add src/pages/subscription/Subscription.jsx
-git commit -m "feat(subscription): MatchesTab 카드에 district + 5티어 뱃지"
-```
-
----
-
-## Task 9: CLAUDE.md 업데이트 + 수동 시각 검증
-
-**Files:**
-- Modify: `web-ui/CLAUDE.md` (페이지/엔드포인트 표 업데이트)
-
-- [ ] **Step 1: CLAUDE.md 업데이트**
-
-`web-ui/CLAUDE.md`를 열고:
-1. Subscription 페이지 설명 섹션에 신규 기능 한 줄 추가:
- ```
- - 프로필: 자치구 5티어 분류(드래그&드롭), 알림 임계값/토글 (백엔드 2026-04-28-realestate-targeting-enhancement-design 참조)
- - 카드/상세: district + 5티어 뱃지 + 매칭 사유 텍스트
- ```
-2. API 엔드포인트 매핑 표에 `/api/realestate/profile` PUT body가 `preferred_districts` (object), `min_match_score` (int), `notify_enabled` (bool)을 받는다는 한 줄 추가.
-
-- [ ] **Step 2: 수동 시각 검증 (dev server)**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-npm run dev
-```
-
-브라우저에서 `http://localhost:3007` 접속 후 청약 페이지(Subscription) 진입.
-
-검증 시나리오 (모두 통과해야 함):
-
-| # | 시나리오 | 기대 결과 |
-|---|---------|----------|
-| 1 | 데스크톱 뷰포트(>=768px) → 프로필 탭 → 자치구 영역 표시 | 미할당 풀 + 5티어 그리드(S/A/B/C/D) 노출, 25개 자치구가 미할당 풀에 보임 |
-| 2 | "강남구"를 S 슬롯으로 드래그 | S 슬롯에 들어가고 미할당에서 사라짐 |
-| 3 | "송파구"를 A 슬롯으로, "강남구"를 다시 S에서 A로 드래그 | A 슬롯에 둘 다, S는 비워짐 |
-| 4 | A 슬롯의 "송파구" 칩의 × 버튼 클릭 | 미할당 풀로 복귀 |
-| 5 | 알림 토글 OFF → 슬라이더 disabled, 안내 텍스트가 "알림 OFF" 톤 |
-| 6 | 슬라이더 80으로 변경 → "80점 이상 매치 시…" 텍스트 즉시 갱신 |
-| 7 | "저장" 버튼 클릭 → 새로고침 → 자치구/임계값/토글 값 유지 |
-| 8 | 모바일 뷰포트(<768px) | 자치구 영역이 read-only 리스트로 변경, 편집 영역 숨김, "PC에서 편집해주세요" 안내 표시 |
-| 9 | 공고 탭 → 매칭 결과 있는 공고 카드 | district 뱃지 + 5티어 뱃지 표시 (매칭 데이터 있는 경우만) |
-| 10 | 공고 카드 클릭 → 상세 모달 | 매칭 분석 섹션에 점수 + reasons + 자격 표시 |
-| 11 | 매칭 탭 → 카드들 | district + 5티어 뱃지 표시 |
-| 12 | 회귀 — 기존 프로필 필드(나이/청약통장/특공) 입력·저장 | 정상 동작 |
-
-문제 발견 시 해당 task로 돌아가 수정.
-
-- [ ] **Step 3: 빌드 최종 검증**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-npm run build
-npm run lint
-```
-
-Expected: 에러·경고 없음.
-
-- [ ] **Step 4: 커밋**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-git add CLAUDE.md
-git commit -m "docs(web-ui): 청약 5티어 + 알림 설정 문서 업데이트"
-```
-
----
-
-## 완료 기준
-
-- 9개 task 모두 commit 완료
-- `npm run build` warning/error 없이 통과
-- `npm run lint` 0 errors / 0 warnings
-- 12개 수동 시각 검증 시나리오 모두 통과
-- 매칭 점수 70점 이상 + notify_enabled=true + 자치구 S 티어에 강남구 설정 시, 백엔드(이미 NAS에 배포됨)가 신규 매칭 발견 시 텔레그램 알림 송신 (end-to-end)
-
----
-
-## 배포
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-ui
-npm run release:nas
-```
-
-NAS에 robocopy로 빌드 산출물 업로드. NAS Z 드라이브 연결이 전제(`\\gahusb.synology.me\docker`).
-
----
-
-## 참고 — 후속 별도 plan
-
-- Subscription.jsx 자체 분할 (1354줄 → 별도 리팩토링)
-- 5축 점수 분해 progress bar (백엔드 응답에 5축 점수 추가 필요)
-- 임계값 통과 매치 카운트 미리보기 (`dashboard.pass_count` 백엔드 신설 필요)
-- 자치구 5티어 자동 추천 (사용자 가점·예산 기반)
-- 알림 채널 추가 (이메일/Slack)
-- 모바일 자치구 편집 지원 (touch backend 도입 시)
diff --git a/docs/superpowers/plans/2026-04-28-realestate-targeting-enhancement.md b/docs/superpowers/plans/2026-04-28-realestate-targeting-enhancement.md
deleted file mode 100644
index b4ea1b9..0000000
--- a/docs/superpowers/plans/2026-04-28-realestate-targeting-enhancement.md
+++ /dev/null
@@ -1,2198 +0,0 @@
-# 청약 서비스 타겟팅 고도화 구현 계획
-
-> **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:** realestate-lab의 수집·매칭을 자치구 5티어 가중치 기반으로 정교화하고, agent-office를 통해 신규 매칭 발견 즉시 텔레그램 푸시 한다.
-
-**Architecture:** realestate-lab은 09:00 cron에서 `collect → 정리 → 매칭 → notifier 푸시` 순으로 진행. notifier는 임계값 통과한 미알림 매칭을 모아 agent-office의 `/api/agent-office/realestate/notify` 엔드포인트로 HTTP push. agent-office의 `RealestateAgent.on_new_matches()`가 텔레그램 메시지 fmt + 인라인 키보드 빌드 + 송신 후 `notified_at`를 마킹. 데일리 리포트 cron은 폐기.
-
-**Tech Stack:** Python 3.12 / FastAPI / SQLite (WAL) / APScheduler / requests / pytest / unittest.mock / aiogram-style raw Telegram Bot API.
-
-**스펙 참조:** `docs/superpowers/specs/2026-04-28-realestate-targeting-enhancement-design.md`
-
----
-
-## Task 1: realestate-lab 테스트 환경 셋업 (DB 경로 환경변수 + conftest)
-
-테스트가 운영 DB(`/app/data/realestate.db`)를 건드리지 않도록 환경변수로 DB 경로 오버라이드 가능하게 만든다.
-
-**Files:**
-- Modify: `realestate-lab/app/db.py:10` (DB_PATH 환경변수화)
-- Create: `realestate-lab/tests/__init__.py`
-- Create: `realestate-lab/tests/conftest.py`
-- Create: `realestate-lab/tests/test_db_basic.py` (smoke test)
-
-- [ ] **Step 1: DB_PATH를 환경변수로 변경**
-
-`realestate-lab/app/db.py:10` 수정.
-
-기존:
-```python
-DB_PATH = "/app/data/realestate.db"
-```
-
-변경:
-```python
-import os
-DB_PATH = os.getenv("REALESTATE_DB_PATH", "/app/data/realestate.db")
-```
-
-`os` import는 파일 상단의 import 블록에 추가.
-
-- [ ] **Step 2: tests 디렉토리 패키지 표시**
-
-`realestate-lab/tests/__init__.py` 생성. 비어 있는 파일.
-
-- [ ] **Step 3: conftest.py 생성 — 임시 DB 픽스처**
-
-`realestate-lab/tests/conftest.py`:
-
-```python
-import os
-import sys
-import tempfile
-import pytest
-
-# 테스트 임시 DB 경로를 import 전에 주입
-_TMP_DB = tempfile.mktemp(suffix=".db")
-os.environ["REALESTATE_DB_PATH"] = _TMP_DB
-
-# app 패키지 import 가능하게 PYTHONPATH 보정
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-
-@pytest.fixture(autouse=True)
-def _clean_db():
- """각 테스트마다 DB 초기화."""
- if os.path.exists(_TMP_DB):
- os.remove(_TMP_DB)
- from app.db import init_db
- init_db()
- yield
- if os.path.exists(_TMP_DB):
- os.remove(_TMP_DB)
-```
-
-- [ ] **Step 4: smoke test 작성**
-
-`realestate-lab/tests/test_db_basic.py`:
-
-```python
-def test_init_db_creates_tables():
- from app.db import _conn
- with _conn() as conn:
- tables = {row[0] for row in conn.execute(
- "SELECT name FROM sqlite_master WHERE type='table'"
- )}
- assert "announcements" in tables
- assert "announcement_models" in tables
- assert "user_profile" in tables
- assert "match_results" in tables
- assert "collect_log" in tables
-```
-
-- [ ] **Step 5: 테스트 실행 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/ -v`
-Expected: 1 passed
-
-- [ ] **Step 6: 커밋**
-
-```bash
-git add realestate-lab/app/db.py realestate-lab/tests/__init__.py realestate-lab/tests/conftest.py realestate-lab/tests/test_db_basic.py
-git commit -m "test(realestate): add pytest harness with isolated SQLite fixture"
-```
-
----
-
-## Task 2: realestate-lab DB 스키마 마이그레이션
-
-`user_profile`에 3 컬럼, `announcements`에 `district`, `match_results`에 `notified_at` 추가. `init_db()` 안에서 try/except 패턴으로 운영 DB 무중단 마이그레이션.
-
-**Files:**
-- Modify: `realestate-lab/app/db.py` (init_db 내부 ALTER 추가, _profile_row_to_dict 확장, PROFILE_COLUMNS 확장)
-- Test: `realestate-lab/tests/test_db_migration.py`
-
-- [ ] **Step 1: 마이그레이션 단위 테스트 작성 (실패 예상)**
-
-`realestate-lab/tests/test_db_migration.py`:
-
-```python
-def test_user_profile_has_new_columns():
- from app.db import _conn
- with _conn() as conn:
- cols = {row["name"] for row in conn.execute("PRAGMA table_info(user_profile)")}
- assert "preferred_districts" in cols
- assert "min_match_score" in cols
- assert "notify_enabled" in cols
-
-
-def test_announcements_has_district():
- from app.db import _conn
- with _conn() as conn:
- cols = {row["name"] for row in conn.execute("PRAGMA table_info(announcements)")}
- assert "district" in cols
-
-
-def test_match_results_has_notified_at():
- from app.db import _conn
- with _conn() as conn:
- cols = {row["name"] for row in conn.execute("PRAGMA table_info(match_results)")}
- assert "notified_at" in cols
-
-
-def test_district_index_exists():
- from app.db import _conn
- with _conn() as conn:
- idx = {row["name"] for row in conn.execute(
- "SELECT name FROM sqlite_master WHERE type='index'"
- )}
- assert "idx_ann_district" in idx
-
-
-def test_profile_defaults():
- from app.db import upsert_profile, get_profile
- upsert_profile({"name": "테스트"})
- profile = get_profile()
- assert profile["preferred_districts"] == {}
- assert profile["min_match_score"] == 70
- assert profile["notify_enabled"] is True
-```
-
-- [ ] **Step 2: 테스트 실행 — 실패 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_db_migration.py -v`
-Expected: 5 failed (컬럼 없음, default 미적용)
-
-- [ ] **Step 3: announcements 테이블에 district 컬럼 + 인덱스 마이그레이션**
-
-`realestate-lab/app/db.py` 의 `init_db()` 안, `idx_ann_region` 인덱스 생성 다음 줄에 추가.
-
-찾을 위치 (현재 코드):
-```python
- conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_region ON announcements(region_name);")
-
- # ── 마이그레이션: is_bookmarked 컬럼 추가 ──
-```
-
-다음과 같이 수정:
-```python
- conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_region ON announcements(region_name);")
-
- # ── 마이그레이션: district 컬럼 + 인덱스 추가 ──
- try:
- conn.execute("SELECT district FROM announcements LIMIT 1")
- except Exception:
- conn.execute("ALTER TABLE announcements ADD COLUMN district TEXT")
- conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_district ON announcements(district);")
-
- # ── 마이그레이션: is_bookmarked 컬럼 추가 ──
-```
-
-- [ ] **Step 4: user_profile에 3 컬럼 마이그레이션**
-
-`init_db()`의 user_profile CREATE 문 뒤에 추가. 현재 `match_results` CREATE 문 시작 부분 직전에 끼워 넣음.
-
-찾을 위치:
-```python
- );
- """)
-
- # ── match_results ────────────────────────────────────────────────
-```
-
-(이 직전이 user_profile CREATE 종료점)
-
-다음과 같이 추가:
-```python
- );
- """)
-
- # ── 마이그레이션: user_profile 신규 3컬럼 ──
- for col, ddl in (
- ("preferred_districts", "ALTER TABLE user_profile ADD COLUMN preferred_districts TEXT NOT NULL DEFAULT '{}'"),
- ("min_match_score", "ALTER TABLE user_profile ADD COLUMN min_match_score INTEGER NOT NULL DEFAULT 70"),
- ("notify_enabled", "ALTER TABLE user_profile ADD COLUMN notify_enabled INTEGER NOT NULL DEFAULT 1"),
- ):
- try:
- conn.execute(f"SELECT {col} FROM user_profile LIMIT 1")
- except Exception:
- conn.execute(ddl)
-
- # ── match_results ────────────────────────────────────────────────
-```
-
-- [ ] **Step 5: match_results에 notified_at 컬럼 마이그레이션**
-
-`init_db()`의 match_results CREATE 직후에 추가.
-
-찾을 위치:
-```python
- UNIQUE(announcement_id, model_id)
- );
- """)
-
- # ── collect_log ──────────────────────────────────────────────────
-```
-
-다음과 같이 변경:
-```python
- UNIQUE(announcement_id, model_id)
- );
- """)
-
- # ── 마이그레이션: notified_at 컬럼 추가 ──
- try:
- conn.execute("SELECT notified_at FROM match_results LIMIT 1")
- except Exception:
- conn.execute("ALTER TABLE match_results ADD COLUMN notified_at TEXT")
-
- # ── collect_log ──────────────────────────────────────────────────
-```
-
-- [ ] **Step 6: PROFILE_COLUMNS와 _profile_row_to_dict, upsert_profile에 신규 필드 처리**
-
-`realestate-lab/app/db.py`의 `_profile_row_to_dict` 함수 (현재 파일 라인 ~536)를 다음과 같이 수정:
-
-```python
-def _profile_row_to_dict(r) -> Dict[str, Any]:
- d = {}
- for c in r.keys():
- val = r[c]
- if c in ("is_homeless", "is_householder", "has_dependents", "is_newlywed",
- "has_newborn", "is_first_home", "notify_enabled"):
- d[c] = bool(val) if val is not None else None
- elif c in ("preferred_regions", "preferred_types"):
- d[c] = json.loads(val) if val else []
- elif c == "preferred_districts":
- d[c] = json.loads(val) if val else {}
- else:
- d[c] = val
- return d
-```
-
-`PROFILE_COLUMNS` 상수 (현재 라인 ~560)를 다음과 같이 확장:
-
-```python
-PROFILE_COLUMNS = {
- "name", "age", "is_homeless", "is_householder",
- "subscription_months", "subscription_amount", "family_members",
- "has_dependents", "children_count", "is_newlywed", "marriage_months",
- "has_newborn", "is_first_home", "income_level",
- "preferred_regions", "preferred_types", "preferred_districts",
- "min_area", "max_area", "max_price",
- "min_match_score", "notify_enabled",
-}
-```
-
-`upsert_profile` 함수 안에서 list 처리 분기는 이미 있으므로, dict 분기를 추가해야 한다. 현재 코드:
-
-```python
- if isinstance(v, bool):
- updates[k] = 1 if v else 0
- elif isinstance(v, list):
- updates[k] = json.dumps(v)
- else:
- updates[k] = v
-```
-
-다음과 같이 수정:
-
-```python
- if isinstance(v, bool):
- updates[k] = 1 if v else 0
- elif isinstance(v, (list, dict)):
- updates[k] = json.dumps(v)
- else:
- updates[k] = v
-```
-
-- [ ] **Step 7: 테스트 실행 — 통과 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_db_migration.py tests/test_db_basic.py -v`
-Expected: 6 passed
-
-- [ ] **Step 8: 커밋**
-
-```bash
-git add realestate-lab/app/db.py realestate-lab/tests/test_db_migration.py
-git commit -m "feat(realestate-db): add district / notify / 5tier columns with migration"
-```
-
----
-
-## Task 3: realestate-lab DB 신규 함수 3종
-
-`delete_old_completed_announcements`, `get_unnotified_matches`, `mark_matches_notified`.
-
-**Files:**
-- Modify: `realestate-lab/app/db.py` (함수 3종 추가)
-- Test: `realestate-lab/tests/test_db_functions.py`
-
-- [ ] **Step 1: 테스트 작성 — delete_old_completed_announcements**
-
-`realestate-lab/tests/test_db_functions.py`:
-
-```python
-import json
-from datetime import date, timedelta
-from app.db import _conn
-
-
-def _seed_announcement(house_nm, status, winner_date=None, hmno="HM1", pno="P1"):
- with _conn() as conn:
- conn.execute("""
- INSERT INTO announcements (house_manage_no, pblanc_no, house_nm, status, winner_date, source)
- VALUES (?, ?, ?, ?, ?, 'manual')
- """, (hmno, pno, house_nm, status, winner_date))
- return conn.execute("SELECT id FROM announcements WHERE house_manage_no=?", (hmno,)).fetchone()["id"]
-
-
-def test_delete_old_completed_removes_expired():
- from app.db import delete_old_completed_announcements
- old = (date.today() - timedelta(days=100)).isoformat()
- _seed_announcement("OldA", "완료", old, hmno="OLD", pno="1")
- deleted = delete_old_completed_announcements(grace_days=90)
- assert deleted == 1
-
-
-def test_delete_old_completed_keeps_recent():
- from app.db import delete_old_completed_announcements
- recent = (date.today() - timedelta(days=30)).isoformat()
- _seed_announcement("RecentA", "완료", recent, hmno="REC", pno="1")
- deleted = delete_old_completed_announcements(grace_days=90)
- assert deleted == 0
-
-
-def test_delete_old_completed_keeps_active():
- from app.db import delete_old_completed_announcements
- old = (date.today() - timedelta(days=200)).isoformat()
- _seed_announcement("ActiveA", "청약중", old, hmno="ACT", pno="1")
- deleted = delete_old_completed_announcements(grace_days=90)
- assert deleted == 0
-
-
-def test_delete_old_completed_keeps_null_winner_date():
- from app.db import delete_old_completed_announcements
- _seed_announcement("NullA", "완료", None, hmno="NULL", pno="1")
- deleted = delete_old_completed_announcements(grace_days=90)
- assert deleted == 0 # winner_date NULL은 안전 보존
-
-
-def test_get_unnotified_matches_filters_by_score_and_null():
- from app.db import get_unnotified_matches
- aid = _seed_announcement("MatchA", "청약중", hmno="MA", pno="1")
- with _conn() as conn:
- # 임계값 미만
- conn.execute("""
- INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new)
- VALUES (?, NULL, 50, '[]', '[]', 1)
- """, (aid,))
- # 임계값 통과 — 미알림
- conn.execute("""
- INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new)
- VALUES (?, 1, 80, '[]', '[]', 1)
- """, (aid,))
- # 임계값 통과 — 이미 알림됨
- conn.execute("""
- INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new, notified_at)
- VALUES (?, 2, 90, '[]', '[]', 1, '2026-04-01T00:00:00.000Z')
- """, (aid,))
-
- matches = get_unnotified_matches(min_score=70)
- assert len(matches) == 1
- assert matches[0]["match_score"] == 80
- assert matches[0]["house_nm"] == "MatchA"
-
-
-def test_mark_matches_notified_sets_timestamp():
- from app.db import mark_matches_notified
- aid = _seed_announcement("NotifyA", "청약중", hmno="NT", pno="1")
- with _conn() as conn:
- cur = conn.execute("""
- INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new)
- VALUES (?, NULL, 80, '[]', '[]', 1)
- """, (aid,))
- match_id = cur.lastrowid
-
- mark_matches_notified([match_id])
-
- with _conn() as conn:
- row = conn.execute("SELECT notified_at FROM match_results WHERE id = ?", (match_id,)).fetchone()
- assert row["notified_at"] is not None
-```
-
-- [ ] **Step 2: 테스트 실행 — 실패 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_db_functions.py -v`
-Expected: 6 failed (함수 미정의)
-
-- [ ] **Step 3: db.py에 함수 3종 추가**
-
-`realestate-lab/app/db.py` 파일의 `delete_closed_announcements` 함수 다음에 추가 (현재 라인 ~408 인근).
-
-찾을 위치:
-```python
-def delete_closed_announcements() -> int:
- """status='완료' 공고 일괄 삭제. 삭제된 건수 반환."""
- with _conn() as conn:
- cur = conn.execute("DELETE FROM announcements WHERE status = '완료'")
- return cur.rowcount
-```
-
-직후에 추가:
-
-```python
-def delete_old_completed_announcements(grace_days: int = 90) -> int:
- """winner_date + grace_days 경과한 status='완료' 공고를 삭제.
- winner_date가 NULL인 행은 안전하게 보존(수동 검토 대상).
- match_results는 FK CASCADE로 자동 삭제. 삭제된 건수 반환.
- """
- with _conn() as conn:
- cur = conn.execute(
- """
- DELETE FROM announcements
- WHERE status = '완료'
- AND winner_date IS NOT NULL
- AND date(winner_date) < date('now', ?)
- """,
- (f"-{grace_days} days",),
- )
- return cur.rowcount
-```
-
-같은 파일의 `mark_match_read` 함수 다음에 추가 (현재 라인 ~660 인근). 찾을 위치:
-
-```python
-def mark_match_read(match_id: int) -> bool:
- with _conn() as conn:
- cur = conn.execute("UPDATE match_results SET is_new = 0 WHERE id = ?", (match_id,))
- return cur.rowcount > 0
-```
-
-직후에 추가:
-
-```python
-def get_unnotified_matches(min_score: int) -> List[Dict[str, Any]]:
- """notified_at IS NULL AND match_score >= min_score 인 매칭과 공고 정보 조인 반환."""
- with _conn() as conn:
- rows = conn.execute("""
- SELECT m.id, m.announcement_id, m.match_score, m.match_reasons, m.eligible_types,
- a.house_nm, a.region_name, a.district, a.address,
- a.receipt_start, a.receipt_end, a.winner_date,
- a.house_secd, a.is_speculative_area, a.is_price_cap, a.pblanc_url
- FROM match_results m
- JOIN announcements a ON a.id = m.announcement_id
- WHERE m.notified_at IS NULL
- AND m.match_score >= ?
- ORDER BY m.match_score DESC
- """, (min_score,)).fetchall()
- items = []
- for r in rows:
- d = {c: r[c] for c in r.keys()}
- d["match_reasons"] = json.loads(d["match_reasons"]) if d["match_reasons"] else []
- d["eligible_types"] = json.loads(d["eligible_types"]) if d["eligible_types"] else []
- items.append(d)
- return items
-
-
-def mark_matches_notified(match_ids: List[int]) -> None:
- """주어진 match_results IDs의 notified_at을 현재 시각으로 일괄 업데이트."""
- if not match_ids:
- return
- placeholders = ",".join("?" for _ in match_ids)
- with _conn() as conn:
- conn.execute(
- f"UPDATE match_results SET notified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') "
- f"WHERE id IN ({placeholders})",
- match_ids,
- )
-```
-
-- [ ] **Step 4: 테스트 실행 — 통과 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_db_functions.py -v`
-Expected: 6 passed
-
-- [ ] **Step 5: 커밋**
-
-```bash
-git add realestate-lab/app/db.py realestate-lab/tests/test_db_functions.py
-git commit -m "feat(realestate-db): add notify queue + 90-day grace cleanup"
-```
-
----
-
-## Task 4: realestate-lab collector 변경
-
-`_extract_district` 추가, 모집공고일 윈도우 사전 좁힘, `완료` 상태 skip.
-
-**Files:**
-- Modify: `realestate-lab/app/collector.py`
-- Test: `realestate-lab/tests/test_collector.py`
-
-- [ ] **Step 1: _extract_district 단위 테스트 작성**
-
-`realestate-lab/tests/test_collector.py`:
-
-```python
-def test_extract_district_seoul_full_address():
- from app.collector import _extract_district
- parsed = {"address": "서울특별시 강남구 도곡동 123-45", "region_name": None}
- assert _extract_district(parsed) == "강남구"
-
-
-def test_extract_district_seoul_short():
- from app.collector import _extract_district
- parsed = {"address": None, "region_name": "서울 송파구"}
- assert _extract_district(parsed) == "송파구"
-
-
-def test_extract_district_busan_returns_none():
- from app.collector import _extract_district
- parsed = {"address": "부산광역시 해운대구 우동", "region_name": None}
- assert _extract_district(parsed) is None
-
-
-def test_extract_district_empty_returns_none():
- from app.collector import _extract_district
- parsed = {"address": "", "region_name": ""}
- assert _extract_district(parsed) is None
-
-
-def test_extract_district_seoul_county():
- from app.collector import _extract_district
- parsed = {"address": "서울 강서구", "region_name": None}
- assert _extract_district(parsed) == "강서구"
-
-
-def test_extract_district_prefers_address_over_region():
- from app.collector import _extract_district
- parsed = {"address": "서울특별시 마포구 합정동", "region_name": "서울 강남구"}
- assert _extract_district(parsed) == "마포구"
-```
-
-- [ ] **Step 2: 테스트 실행 — 실패 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_collector.py -v`
-Expected: 6 failed (`_extract_district` 미정의)
-
-- [ ] **Step 3: collector.py에 _extract_district 추가**
-
-`realestate-lab/app/collector.py`의 import 블록 변경:
-
-```python
-import os
-import re
-import logging
-from datetime import date, timedelta
-import requests
-from typing import List, Dict, Any
-```
-
-(`re`, `date`, `timedelta` 추가)
-
-기존 `_parse_apt_detail` 함수 위에 정규식 + 헬퍼 함수 추가:
-
-```python
-DISTRICT_PATTERN = re.compile(r"(?:서울특별시|서울시|서울)\s+(\S+?(?:구|군))")
-
-
-def _extract_district(parsed: Dict[str, Any]) -> str | None:
- """파싱된 공고에서 자치구를 추출. 서울 외 지역·실패 시 None."""
- for src in (parsed.get("address"), parsed.get("region_name")):
- if not src:
- continue
- m = DISTRICT_PATTERN.search(src)
- if m:
- return m.group(1)
- return None
-```
-
-- [ ] **Step 4: 테스트 실행 — 통과 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_collector.py -v`
-Expected: 6 passed
-
-- [ ] **Step 5: collect_all 통합 테스트 추가 — 완료 skip + 윈도우**
-
-`realestate-lab/tests/test_collector.py` 파일 끝에 추가:
-
-```python
-from datetime import date, timedelta
-from unittest.mock import patch
-
-
-def test_collect_skips_completed_status(monkeypatch):
- """winner_date가 과거인 응답은 status='완료'로 판정되어 upsert되지 않는다."""
- from app import collector
- from app.db import _conn
-
- monkeypatch.setenv("DATA_GO_KR_API_KEY", "TEST")
- # 모듈 상수도 갱신
- monkeypatch.setattr(collector, "API_KEY", "TEST")
-
- past_winner = (date.today() - timedelta(days=10)).strftime("%Y-%m-%d")
-
- fake_detail_rows = [{
- "HOUSE_MANAGE_NO": "DONE-1",
- "PBLANC_NO": "01",
- "HOUSE_NM": "완료된단지",
- "HSSPLY_ADRES": "서울특별시 강남구",
- "RCEPT_BGNDE": "2026-01-01",
- "RCEPT_ENDDE": "2026-01-05",
- "PRZWNER_PRESNATN_DE": past_winner,
- }]
-
- def fake_call(endpoint, params=None):
- if "Detail" in endpoint:
- return fake_detail_rows
- return []
-
- monkeypatch.setattr(collector, "_api_call", fake_call)
- collector.collect_all()
-
- with _conn() as conn:
- rows = conn.execute("SELECT * FROM announcements WHERE house_manage_no='DONE-1'").fetchall()
- assert len(rows) == 0
-
-
-def test_collect_stores_district_for_seoul_announcement(monkeypatch):
- from app import collector
- from app.db import _conn
-
- monkeypatch.setenv("DATA_GO_KR_API_KEY", "TEST")
- monkeypatch.setattr(collector, "API_KEY", "TEST")
-
- future_start = (date.today() + timedelta(days=10)).strftime("%Y-%m-%d")
- future_end = (date.today() + timedelta(days=15)).strftime("%Y-%m-%d")
- future_winner = (date.today() + timedelta(days=30)).strftime("%Y-%m-%d")
-
- fake_detail = [{
- "HOUSE_MANAGE_NO": "SEOUL-1",
- "PBLANC_NO": "01",
- "HOUSE_NM": "강남단지",
- "HSSPLY_ADRES": "서울특별시 강남구 도곡동 1",
- "RCEPT_BGNDE": future_start,
- "RCEPT_ENDDE": future_end,
- "PRZWNER_PRESNATN_DE": future_winner,
- }]
-
- def fake_call(endpoint, params=None):
- if "Detail" in endpoint:
- return fake_detail
- return []
-
- monkeypatch.setattr(collector, "_api_call", fake_call)
- collector.collect_all()
-
- with _conn() as conn:
- row = conn.execute("SELECT district, status FROM announcements WHERE house_manage_no='SEOUL-1'").fetchone()
- assert row["district"] == "강남구"
- assert row["status"] in ("청약예정", "청약중")
-
-
-def test_collect_passes_date_window_param(monkeypatch):
- from app import collector
-
- monkeypatch.setenv("DATA_GO_KR_API_KEY", "TEST")
- monkeypatch.setattr(collector, "API_KEY", "TEST")
-
- captured_params = []
-
- def fake_call(endpoint, params=None):
- captured_params.append(params or {})
- return []
-
- monkeypatch.setattr(collector, "_api_call", fake_call)
- collector.collect_all()
-
- expected_from = (date.today() - timedelta(days=30)).strftime("%Y%m%d")
- detail_calls = [p for p in captured_params if "RCRIT_PBLANC_DE_FROM" in p]
- assert detail_calls, "detail 엔드포인트 호출에 윈도우 파라미터가 없음"
- assert detail_calls[0]["RCRIT_PBLANC_DE_FROM"] == expected_from
-```
-
-- [ ] **Step 6: 통합 테스트 실행 — 실패 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_collector.py -v -k "test_collect"`
-Expected: 3 failed (윈도우/완료/district 미적용)
-
-- [ ] **Step 7: collect_all 본문 변경 — 윈도우 + skip + district**
-
-`realestate-lab/app/collector.py`의 `collect_all` 함수를 다음과 같이 수정:
-
-기존:
-```python
-def collect_all() -> Dict[str, Any]:
- """모든 엔드포인트를 순회하며 공고 + 모델 데이터를 수집·저장한다."""
- if not API_KEY:
- logger.warning("API 키 미설정 — 수집 중단")
- save_collect_log(0, 0, "API 키 미설정")
- return {"new_count": 0, "total_count": 0}
-
- total_count = 0
- new_count = 0
-
- for detail_ep, model_ep in DETAIL_ENDPOINTS:
- # 공고 상세 수집
- detail_rows = _api_call(detail_ep)
- for raw in detail_rows:
- try:
- parsed = _parse_apt_detail(raw)
- # 일정 정보가 하나도 없는 공고는 건너뜀
- has_dates = any(parsed.get(f) for f in (
- "receipt_start", "receipt_end", "spsply_start",
- "gnrl_rank1_start", "winner_date", "contract_start",
- ))
- if not has_dates:
- continue
- _, is_new = upsert_announcement(parsed)
- total_count += 1
- if is_new:
- new_count += 1
- except Exception as e:
- logger.error("공고 upsert 실패 [%s]: %s", detail_ep, e)
-```
-
-변경:
-```python
-def collect_all() -> Dict[str, Any]:
- """모든 엔드포인트를 순회하며 공고 + 모델 데이터를 수집·저장한다.
- 모집공고일 30일 이전 데이터는 API 파라미터로 사전 좁힘.
- status='완료'로 판정되는 응답은 저장하지 않음.
- """
- if not API_KEY:
- logger.warning("API 키 미설정 — 수집 중단")
- save_collect_log(0, 0, "API 키 미설정")
- return {"new_count": 0, "total_count": 0}
-
- today = date.today()
- date_from = (today - timedelta(days=30)).strftime("%Y%m%d")
-
- total_count = 0
- new_count = 0
- skipped_completed = 0
-
- for detail_ep, model_ep in DETAIL_ENDPOINTS:
- # 공고 상세 수집 — API에 모집공고일 윈도우 파라미터 전달
- # 일부 엔드포인트는 파라미터 미지원일 수 있어 무시되지만 응답에 영향 없음
- detail_rows = _api_call(detail_ep, params={"RCRIT_PBLANC_DE_FROM": date_from})
- for raw in detail_rows:
- try:
- parsed = _parse_apt_detail(raw)
- parsed["district"] = _extract_district(parsed)
-
- # 일정 정보가 하나도 없는 공고는 건너뜀 (기존)
- has_dates = any(parsed.get(f) for f in (
- "receipt_start", "receipt_end", "spsply_start",
- "gnrl_rank1_start", "winner_date", "contract_start",
- ))
- if not has_dates:
- continue
-
- # status='완료'면 저장하지 않음 (자원 절감)
- from .db import compute_status
- status = compute_status(
- parsed.get("receipt_start", "") or "",
- parsed.get("receipt_end", "") or "",
- parsed.get("winner_date", "") or "",
- )
- if status == "완료":
- skipped_completed += 1
- continue
-
- _, is_new = upsert_announcement(parsed)
- total_count += 1
- if is_new:
- new_count += 1
- except Exception as e:
- logger.error("공고 upsert 실패 [%s]: %s", detail_ep, e)
-```
-
-- [ ] **Step 8: upsert_announcement에 district 컬럼 처리 — db.py 변경**
-
-`realestate-lab/app/db.py`의 `upsert_announcement` 함수의 INSERT 컬럼 목록과 ON CONFLICT 절에 `district`를 추가.
-
-찾을 위치 (`INSERT INTO announcements` 부분):
-```python
- conn.execute("""
- INSERT INTO announcements (
- house_manage_no, pblanc_no, house_nm, house_secd, house_dtl_secd,
- rent_secd, region_code, region_name, address, total_units,
-```
-
-다음과 같이 변경:
-```python
- conn.execute("""
- INSERT INTO announcements (
- house_manage_no, pblanc_no, house_nm, house_secd, house_dtl_secd,
- rent_secd, region_code, region_name, district, address, total_units,
-```
-
-VALUES 블록도 변경:
-기존:
-```python
- ) VALUES (
- :house_manage_no, :pblanc_no, :house_nm, :house_secd, :house_dtl_secd,
- :rent_secd, :region_code, :region_name, :address, :total_units,
-```
-
-변경:
-```python
- ) VALUES (
- :house_manage_no, :pblanc_no, :house_nm, :house_secd, :house_dtl_secd,
- :rent_secd, :region_code, :region_name, :district, :address, :total_units,
-```
-
-ON CONFLICT 절에도 추가. 찾을 위치:
-```python
- region_code=excluded.region_code,
- region_name=excluded.region_name,
- address=excluded.address,
-```
-
-다음과 같이 변경:
-```python
- region_code=excluded.region_code,
- region_name=excluded.region_name,
- district=excluded.district,
- address=excluded.address,
-```
-
-마지막으로, `upsert_announcement` 함수 시작부에 district 기본값 보정 추가. 함수 첫 줄 직후:
-
-```python
-def upsert_announcement(data: Dict[str, Any]) -> tuple:
- """공고 upsert — house_manage_no + pblanc_no 기준. Returns (dict, is_new: bool)."""
- data.setdefault("district", None) # 수동 등록 등에서 누락 시 안전 처리
- status = compute_status(
- ...
-```
-
-`ANNOUNCEMENT_COLUMNS` 상수에도 추가:
-
-```python
-ANNOUNCEMENT_COLUMNS = {
- "house_nm", "house_secd", "house_dtl_secd", "rent_secd",
- "region_code", "region_name", "district", "address", "total_units",
- ...
-}
-```
-
-- [ ] **Step 9: 테스트 실행 — 통과 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_collector.py -v`
-Expected: 9 passed
-
-- [ ] **Step 10: 커밋**
-
-```bash
-git add realestate-lab/app/collector.py realestate-lab/app/db.py realestate-lab/tests/test_collector.py
-git commit -m "feat(realestate-collector): 30-day window + district extraction + completed skip"
-```
-
----
-
-## Task 5: realestate-lab matcher 5티어 + 자격 점수 재배분
-
-지역 35점(광역 10 + 자치구 가중 25), 자격 25점(첫 자격 15 + 추가 5씩 최대 +10).
-
-**Files:**
-- Modify: `realestate-lab/app/matcher.py`
-- Test: `realestate-lab/tests/test_matcher.py`
-
-- [ ] **Step 1: 지역·자격 점수 단위 테스트 작성**
-
-`realestate-lab/tests/test_matcher.py`:
-
-```python
-def test_region_score_no_districts_full_when_region_match():
- """자치구 미설정: 광역 일치 시 35점."""
- from app.matcher import _region_score
- profile = {"preferred_regions": ["서울"], "preferred_districts": {}}
- ann = {"region_name": "서울특별시", "district": None}
- score, _ = _region_score(profile, ann)
- assert score == 35
-
-
-def test_region_score_no_districts_zero_when_region_mismatch():
- from app.matcher import _region_score
- profile = {"preferred_regions": ["서울"], "preferred_districts": {}}
- ann = {"region_name": "부산광역시", "district": None}
- score, _ = _region_score(profile, ann)
- assert score == 0
-
-
-def test_region_score_s_tier_district():
- """광역 매칭 + S티어 자치구: 10 + 25 = 35."""
- from app.matcher import _region_score
- profile = {
- "preferred_regions": ["서울"],
- "preferred_districts": {"S": ["강남구"], "A": [], "B": [], "C": [], "D": []},
- }
- ann = {"region_name": "서울특별시", "district": "강남구"}
- score, _ = _region_score(profile, ann)
- assert score == 35
-
-
-def test_region_score_a_tier_district():
- """광역 매칭 + A티어 자치구: 10 + 20 = 30."""
- from app.matcher import _region_score
- profile = {
- "preferred_regions": ["서울"],
- "preferred_districts": {"S": [], "A": ["송파구"], "B": [], "C": [], "D": []},
- }
- ann = {"region_name": "서울특별시", "district": "송파구"}
- score, _ = _region_score(profile, ann)
- assert score == 30
-
-
-def test_region_score_d_tier_district():
- """광역 매칭 + D티어 자치구: 10 + 5 = 15."""
- from app.matcher import _region_score
- profile = {
- "preferred_regions": ["서울"],
- "preferred_districts": {"S": [], "A": [], "B": [], "C": [], "D": ["도봉구"]},
- }
- ann = {"region_name": "서울특별시", "district": "도봉구"}
- score, _ = _region_score(profile, ann)
- assert score == 15
-
-
-def test_region_score_district_set_but_not_listed():
- """광역 매칭 + 자치구 5티어 어디에도 없음: 10점만."""
- from app.matcher import _region_score
- profile = {
- "preferred_regions": ["서울"],
- "preferred_districts": {"S": ["강남구"], "A": [], "B": [], "C": [], "D": []},
- }
- ann = {"region_name": "서울특별시", "district": "강서구"}
- score, _ = _region_score(profile, ann)
- assert score == 10
-
-
-def test_eligibility_score_zero_when_empty():
- from app.matcher import _eligibility_score
- assert _eligibility_score([]) == 0
-
-
-def test_eligibility_score_one_type_returns_15():
- from app.matcher import _eligibility_score
- assert _eligibility_score(["일반1순위"]) == 15
-
-
-def test_eligibility_score_two_types_returns_20():
- from app.matcher import _eligibility_score
- assert _eligibility_score(["일반1순위", "특별-신혼부부"]) == 20
-
-
-def test_eligibility_score_caps_at_25():
- from app.matcher import _eligibility_score
- assert _eligibility_score(["a", "b", "c", "d", "e"]) == 25
-```
-
-- [ ] **Step 2: 테스트 실행 — 실패 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_matcher.py -v`
-Expected: 10 failed
-
-- [ ] **Step 3: matcher.py에 신규 함수 추가**
-
-`realestate-lab/app/matcher.py` 파일 상단 (logger 정의 후, _HOUSE_TYPE_MAP 위)에 추가:
-
-```python
-TIER_WEIGHTS = {"S": 1.00, "A": 0.80, "B": 0.60, "C": 0.40, "D": 0.20}
-
-
-def _region_score(profile: Dict[str, Any], ann: Dict[str, Any]) -> tuple[int, list[str]]:
- """지역 점수 계산. 광역 10점 + 자치구 5티어 가중치 0~25점.
- 자치구 기준 미설정 시 광역 매칭만으로 35점 풀 점수(기존 호환).
- """
- region_name = ann.get("region_name") or ""
- district = ann.get("district") or ""
- preferred_regions = profile.get("preferred_regions") or []
- preferred_districts = profile.get("preferred_districts") or {}
-
- region_match = bool(region_name and any(r in region_name for r in preferred_regions))
- if not region_match:
- return 0, []
-
- has_districts = any(preferred_districts.get(t) for t in TIER_WEIGHTS)
- if not has_districts:
- return 35, [f"선호 지역 일치: {region_name}"]
-
- score = 10
- reasons = [f"광역 일치: {region_name}"]
- for tier, weight in TIER_WEIGHTS.items():
- if district and district in (preferred_districts.get(tier) or []):
- tier_score = round(25 * weight)
- score += tier_score
- reasons.append(f"자치구 {tier}티어: {district} (+{tier_score})")
- break
- return score, reasons
-
-
-def _eligibility_score(eligible_types: List[str]) -> int:
- """자격 점수 0~25. 첫 자격 15점 + 추가 자격당 5점, 최대 +10."""
- if not eligible_types:
- return 0
- return 15 + min((len(eligible_types) - 1) * 5, 10)
-```
-
-- [ ] **Step 4: _compute_score 본문 교체 — 새 함수 호출 통합**
-
-`realestate-lab/app/matcher.py`의 `_compute_score` 함수 본문을 다음과 같이 교체:
-
-```python
-def _compute_score(
- profile: Dict[str, Any],
- ann: Dict[str, Any],
- models: List[Dict[str, Any]],
-) -> Dict[str, Any]:
- """매칭 점수(0-100)와 사유를 계산한다.
- 배분: 지역 35 / 유형 10 / 면적 15 / 가격 15 / 자격 25.
- """
- score = 0
- reasons: List[str] = []
-
- # 1. 지역 (35점) — 광역 + 자치구 5티어
- region_score, region_reasons = _region_score(profile, ann)
- score += region_score
- reasons.extend(region_reasons)
-
- # 2. 주택유형 (10점) — binary
- preferred_types = profile.get("preferred_types") or []
- house_secd = ann.get("house_secd") or ""
- type_name = _HOUSE_TYPE_MAP.get(house_secd, house_secd)
- if type_name and type_name in preferred_types:
- score += 10
- reasons.append(f"선호 유형 일치: {type_name}")
-
- # 3. 면적 (15점) — binary, 범위 안 모델 1개라도 있으면 통과
- min_area = profile.get("min_area")
- max_area = profile.get("max_area")
- if min_area is not None and max_area is not None and models:
- for m in models:
- supply_area = m.get("supply_area")
- if supply_area is not None and min_area <= supply_area <= max_area:
- score += 15
- reasons.append(f"희망 면적 범위 내 모델 존재 ({supply_area}㎡)")
- break
-
- # 4. 가격 (15점) — binary, 예산 이하 모델 1개라도 있으면 통과
- max_price = profile.get("max_price")
- if max_price is not None and models:
- for m in models:
- top_amount = m.get("top_amount")
- if top_amount is not None and top_amount <= max_price:
- score += 15
- reasons.append(f"예산 범위 내 모델 존재 (최고가 {top_amount:,}만원)")
- break
-
- # 5. 자격 (25점) — 첫 자격 15 + 추가당 5
- eligible_types = _check_eligible_types(profile, ann)
- elig_score = _eligibility_score(eligible_types)
- if elig_score > 0:
- score += elig_score
- reasons.append(f"자격 유형 {len(eligible_types)}개: {', '.join(eligible_types)}")
-
- return {
- "match_score": score,
- "match_reasons": reasons,
- "eligible_types": eligible_types,
- }
-```
-
-- [ ] **Step 5: 테스트 실행 — 통과 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_matcher.py -v`
-Expected: 10 passed
-
-- [ ] **Step 6: 커밋**
-
-```bash
-git add realestate-lab/app/matcher.py realestate-lab/tests/test_matcher.py
-git commit -m "feat(realestate-matcher): 5-tier district weighting + eligibility curve"
-```
-
----
-
-## Task 6: realestate-lab Profile API 확장
-
-`ProfileUpdate` Pydantic 모델에 3 필드 추가. `models.py`만 수정하면 main.py 흐름은 자동 반영(이미 PROFILE_COLUMNS 기반).
-
-**Files:**
-- Modify: `realestate-lab/app/models.py`
-- Test: `realestate-lab/tests/test_profile_api.py`
-
-- [ ] **Step 1: API 통합 테스트 작성**
-
-`realestate-lab/tests/test_profile_api.py`:
-
-```python
-from fastapi.testclient import TestClient
-
-
-def test_profile_update_accepts_new_fields():
- from app.main import app
- client = TestClient(app)
- body = {
- "name": "테스트",
- "preferred_districts": {
- "S": ["강남구", "서초구"],
- "A": ["송파구"],
- "B": [],
- "C": [],
- "D": [],
- },
- "min_match_score": 75,
- "notify_enabled": True,
- }
- resp = client.put("/api/realestate/profile", json=body)
- assert resp.status_code == 200
- data = resp.json()
- assert data["preferred_districts"]["S"] == ["강남구", "서초구"]
- assert data["min_match_score"] == 75
- assert data["notify_enabled"] is True
-
-
-def test_profile_get_returns_defaults_for_new_fields():
- from app.main import app
- from app.db import upsert_profile
- upsert_profile({"name": "기본"})
-
- client = TestClient(app)
- resp = client.get("/api/realestate/profile")
- assert resp.status_code == 200
- data = resp.json()
- assert data["preferred_districts"] == {}
- assert data["min_match_score"] == 70
- assert data["notify_enabled"] is True
-```
-
-- [ ] **Step 2: 테스트 실행 — 실패 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_profile_api.py -v`
-Expected: 2 failed (모델에 필드 없음 → 422 또는 dict 직렬화 실패)
-
-- [ ] **Step 3: ProfileUpdate 모델에 필드 추가**
-
-`realestate-lab/app/models.py` 파일의 import 변경:
-
-```python
-from typing import Optional, List, Dict
-from pydantic import BaseModel, Field
-```
-
-`ProfileUpdate` 클래스 끝에 필드 추가:
-
-```python
-class ProfileUpdate(BaseModel):
- # ... 기존 필드 그대로
- name: Optional[str] = None
- age: Optional[int] = None
- is_homeless: Optional[bool] = None
- is_householder: Optional[bool] = None
- subscription_months: Optional[int] = None
- subscription_amount: Optional[int] = None
- family_members: Optional[int] = None
- has_dependents: Optional[bool] = None
- children_count: Optional[int] = None
- is_newlywed: Optional[bool] = None
- marriage_months: Optional[int] = None
- has_newborn: Optional[bool] = None
- is_first_home: Optional[bool] = None
- income_level: Optional[str] = None
- preferred_regions: Optional[List[str]] = None
- preferred_types: Optional[List[str]] = None
- min_area: Optional[float] = None
- max_area: Optional[float] = None
- max_price: Optional[int] = None
- # 신규
- preferred_districts: Optional[Dict[str, List[str]]] = None
- min_match_score: Optional[int] = Field(default=None, ge=0, le=100)
- notify_enabled: Optional[bool] = None
-```
-
-- [ ] **Step 4: 테스트 실행 — 통과 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_profile_api.py -v`
-Expected: 2 passed
-
-- [ ] **Step 5: 커밋**
-
-```bash
-git add realestate-lab/app/models.py realestate-lab/tests/test_profile_api.py
-git commit -m "feat(realestate-profile): expose 5tier districts + min_match_score + notify_enabled"
-```
-
----
-
-## Task 7: realestate-lab notifier.py 신규
-
-agent-office로 push 트리거. 임계값 + notify_enabled 필터 + 멱등 마킹.
-
-**Files:**
-- Create: `realestate-lab/app/notifier.py`
-- Create: `realestate-lab/tests/test_notifier.py`
-
-- [ ] **Step 1: notifier 단위 테스트 작성**
-
-`realestate-lab/tests/test_notifier.py`:
-
-```python
-from unittest.mock import patch, MagicMock
-
-
-def _seed_profile_and_match(score, notify_enabled=True, threshold=70):
- from app.db import _conn, upsert_profile
- upsert_profile({
- "name": "u",
- "notify_enabled": notify_enabled,
- "min_match_score": threshold,
- })
- with _conn() as conn:
- conn.execute("""
- INSERT INTO announcements (house_manage_no, pblanc_no, house_nm, status, source)
- VALUES ('NF1', '01', '단지', '청약중', 'manual')
- """)
- ann_id = conn.execute("SELECT id FROM announcements WHERE house_manage_no='NF1'").fetchone()["id"]
- conn.execute("""
- INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new)
- VALUES (?, NULL, ?, '[]', '[]', 1)
- """, (ann_id, score))
- match_id = conn.execute("SELECT id FROM match_results WHERE announcement_id=?", (ann_id,)).fetchone()["id"]
- return match_id
-
-
-def test_notify_skips_when_disabled():
- from app import notifier
- _seed_profile_and_match(score=80, notify_enabled=False)
- with patch.object(notifier, "requests") as r:
- result = notifier.notify_new_matches()
- assert r.post.call_count == 0
- assert result["sent"] == 0
- assert result.get("skipped") == "notify_disabled"
-
-
-def test_notify_filters_below_threshold():
- from app import notifier
- _seed_profile_and_match(score=60, threshold=70)
- with patch.object(notifier, "requests") as r:
- result = notifier.notify_new_matches()
- assert r.post.call_count == 0
- assert result["sent"] == 0
-
-
-def test_notify_pushes_and_marks_notified():
- from app import notifier
- from app.db import _conn
-
- match_id = _seed_profile_and_match(score=80, threshold=70)
-
- fake_resp = MagicMock()
- fake_resp.json.return_value = {"sent": 1, "sent_ids": [match_id]}
- fake_resp.raise_for_status.return_value = None
-
- with patch.object(notifier.requests, "post", return_value=fake_resp) as post:
- result = notifier.notify_new_matches()
-
- assert post.call_count == 1
- args, kwargs = post.call_args
- assert "/api/agent-office/realestate/notify" in args[0]
- assert kwargs["json"]["matches"][0]["id"] == match_id
-
- with _conn() as conn:
- row = conn.execute("SELECT notified_at FROM match_results WHERE id=?", (match_id,)).fetchone()
- assert row["notified_at"] is not None
- assert result["sent"] == 1
-
-
-def test_notify_does_not_mark_on_failure():
- from app import notifier
- from app.db import _conn
- import requests as real_requests
-
- match_id = _seed_profile_and_match(score=80, threshold=70)
-
- def boom(*a, **k):
- raise real_requests.RequestException("agent-office down")
-
- with patch.object(notifier.requests, "post", side_effect=boom):
- result = notifier.notify_new_matches()
-
- with _conn() as conn:
- row = conn.execute("SELECT notified_at FROM match_results WHERE id=?", (match_id,)).fetchone()
- assert row["notified_at"] is None
- assert result["sent"] == 0
- assert "error" in result
-```
-
-- [ ] **Step 2: 테스트 실행 — 실패 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_notifier.py -v`
-Expected: 4 failed (모듈 없음)
-
-- [ ] **Step 3: notifier.py 작성**
-
-`realestate-lab/app/notifier.py`:
-
-```python
-"""신규 매칭을 agent-office로 push하여 텔레그램 알림을 트리거한다."""
-import os
-import logging
-import requests
-
-from .db import get_profile, get_unnotified_matches, mark_matches_notified
-
-logger = logging.getLogger("realestate-lab")
-
-AGENT_OFFICE_URL = os.getenv("AGENT_OFFICE_URL", "http://agent-office:8000")
-NOTIFY_TIMEOUT_SECONDS = int(os.getenv("REALESTATE_NOTIFY_TIMEOUT", "15"))
-
-
-def notify_new_matches() -> dict:
- """프로필의 임계값을 통과한 미알림 매칭을 agent-office로 push한다.
-
- 응답이 200이고 sent_ids가 비어있지 않으면 해당 IDs의 notified_at을 마킹.
- 실패 시 마킹하지 않아 다음 사이클에서 재시도된다.
- """
- profile = get_profile()
- if not profile:
- return {"sent": 0, "skipped": "no_profile"}
-
- if not profile.get("notify_enabled"):
- return {"sent": 0, "skipped": "notify_disabled"}
-
- threshold = profile.get("min_match_score") or 70
- matches = get_unnotified_matches(threshold)
- if not matches:
- return {"sent": 0}
-
- url = f"{AGENT_OFFICE_URL}/api/agent-office/realestate/notify"
- try:
- resp = requests.post(url, json={"matches": matches}, timeout=NOTIFY_TIMEOUT_SECONDS)
- resp.raise_for_status()
- body = resp.json()
- except requests.RequestException as e:
- logger.error("agent-office push 실패: %s", e)
- return {"sent": 0, "error": str(e)}
-
- sent_ids = body.get("sent_ids") or []
- if sent_ids:
- mark_matches_notified(sent_ids)
- logger.info("알림 송신: %d건", len(sent_ids))
- return body
-```
-
-- [ ] **Step 4: 테스트 실행 — 통과 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_notifier.py -v`
-Expected: 4 passed
-
-- [ ] **Step 5: 커밋**
-
-```bash
-git add realestate-lab/app/notifier.py realestate-lab/tests/test_notifier.py
-git commit -m "feat(realestate-notifier): push unnotified matches to agent-office"
-```
-
----
-
-## Task 8: realestate-lab scheduled_collect 흐름 통합
-
-`scheduled_collect`에 정리 + notifier 호출 추가.
-
-**Files:**
-- Modify: `realestate-lab/app/main.py`
-- Test: `realestate-lab/tests/test_scheduled_flow.py`
-
-- [ ] **Step 1: 흐름 통합 테스트 작성**
-
-`realestate-lab/tests/test_scheduled_flow.py`:
-
-```python
-from unittest.mock import patch
-
-
-def test_scheduled_collect_calls_cleanup_and_notifier():
- from app import main as app_main
-
- calls = []
-
- def fake_collect():
- calls.append("collect")
- return {"new_count": 0, "total_count": 0}
-
- def fake_cleanup(grace_days=90):
- calls.append(("cleanup", grace_days))
- return 0
-
- def fake_match():
- calls.append("match")
-
- def fake_notify():
- calls.append("notify")
- return {"sent": 0}
-
- with patch.object(app_main, "collect_all", side_effect=fake_collect), \
- patch.object(app_main, "delete_old_completed_announcements", side_effect=fake_cleanup), \
- patch.object(app_main, "run_matching", side_effect=fake_match), \
- patch.object(app_main, "notify_new_matches", side_effect=fake_notify):
- app_main.scheduled_collect()
-
- assert calls == ["collect", ("cleanup", 90), "match", "notify"]
-```
-
-- [ ] **Step 2: 테스트 실행 — 실패 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_scheduled_flow.py -v`
-Expected: 1 failed (notify_new_matches/delete_old_completed import 안 됨)
-
-- [ ] **Step 3: main.py 수정**
-
-`realestate-lab/app/main.py`의 import 블록에 추가:
-
-```python
-from .db import (
- init_db, get_announcements, get_announcement, create_announcement,
- update_announcement, delete_announcement, delete_closed_announcements, toggle_bookmark,
- update_all_statuses,
- get_profile, upsert_profile, get_matches, mark_match_read,
- get_last_collect_log, get_dashboard,
- delete_old_completed_announcements, # NEW
-)
-from .collector import collect_all
-from .matcher import run_matching
-from .notifier import notify_new_matches # NEW
-from .models import AnnouncementCreate, AnnouncementUpdate, ProfileUpdate
-```
-
-`scheduled_collect` 함수 수정:
-
-기존:
-```python
-def scheduled_collect():
- """매일 09:00 — 수집 + 매칭"""
- logger.info("스케줄 수집 시작")
- collect_all()
- run_matching()
- logger.info("스케줄 수집 + 매칭 완료")
-```
-
-변경:
-```python
-def scheduled_collect():
- """매일 09:00 — 수집 + 정리 + 매칭 + 알림 push"""
- logger.info("스케줄 수집 시작")
- collect_all()
- deleted = delete_old_completed_announcements(grace_days=90)
- if deleted:
- logger.info("정리: %d건 삭제", deleted)
- run_matching()
- notify_new_matches()
- logger.info("스케줄 수집 + 매칭 + 알림 완료")
-```
-
-`_run_collect_and_match`도 통일성을 위해 같은 흐름 적용:
-
-기존:
-```python
-def _run_collect_and_match():
- if not _collect_lock.acquire(blocking=False):
- logger.info("수집 이미 진행 중 — 건너뜀")
- return
- try:
- collect_all()
- run_matching()
- finally:
- _collect_lock.release()
-```
-
-변경:
-```python
-def _run_collect_and_match():
- if not _collect_lock.acquire(blocking=False):
- logger.info("수집 이미 진행 중 — 건너뜀")
- return
- try:
- collect_all()
- delete_old_completed_announcements(grace_days=90)
- run_matching()
- notify_new_matches()
- finally:
- _collect_lock.release()
-```
-
-- [ ] **Step 4: 테스트 실행 — 통과 확인**
-
-Run: `cd realestate-lab && python -m pytest tests/test_scheduled_flow.py -v`
-Expected: 1 passed
-
-- [ ] **Step 5: 전체 회귀 검증**
-
-Run: `cd realestate-lab && python -m pytest tests/ -v`
-Expected: 모든 테스트 통과
-
-- [ ] **Step 6: 커밋**
-
-```bash
-git add realestate-lab/app/main.py realestate-lab/tests/test_scheduled_flow.py
-git commit -m "feat(realestate): wire cleanup + notifier into scheduled flow"
-```
-
----
-
-## Task 9: agent-office 텔레그램 fmt + messaging 헬퍼
-
-청약 매칭 메시지 포맷터(묶음/풀 카드) + 인라인 키보드 빌더 + 송신 헬퍼.
-
-**Files:**
-- Create: `agent-office/app/telegram/realestate_message.py`
-- Create: `agent-office/tests/test_realestate_message.py`
-
-- [ ] **Step 1: 테스트 작성**
-
-`agent-office/tests/test_realestate_message.py`:
-
-```python
-def test_format_realestate_match_full_card_single():
- from app.telegram.realestate_message import format_realestate_matches
- matches = [{
- "id": 1,
- "match_score": 90,
- "house_nm": "디에이치 강남",
- "region_name": "서울특별시",
- "district": "강남구",
- "is_speculative_area": "Y",
- "is_price_cap": "Y",
- "receipt_start": "2026-05-15",
- "receipt_end": "2026-05-19",
- "match_reasons": ["광역 일치", "자치구 S티어: 강남구 (+25)", "예산 범위"],
- "eligible_types": ["일반1순위", "특별-신혼부부"],
- "pblanc_url": "https://example.com/p/1",
- }]
- text = format_realestate_matches(matches)
- assert "디에이치 강남" in text
- assert "90점" in text
- assert "강남구" in text
- assert "2026-05-15" in text
-
-
-def test_format_realestate_match_compact_when_three_or_more():
- from app.telegram.realestate_message import format_realestate_matches
- matches = [
- {"id": i, "match_score": 90 - i, "house_nm": f"단지{i}", "district": "강남구",
- "region_name": "서울특별시", "receipt_start": "2026-05-15", "receipt_end": "2026-05-19",
- "match_reasons": [], "eligible_types": [], "pblanc_url": ""}
- for i in range(3)
- ]
- text = format_realestate_matches(matches)
- assert "3건" in text or "3" in text
- for i in range(3):
- assert f"단지{i}" in text
-
-
-def test_build_keyboard_single_match_has_bookmark_and_url():
- from app.telegram.realestate_message import build_match_keyboard
- matches = [{"id": 42, "pblanc_url": "https://example.com/p/42"}]
- kb = build_match_keyboard(matches)
- rows = kb["inline_keyboard"]
- flat = [b for row in rows for b in row]
- assert any(b.get("callback_data", "").startswith("realestate_bookmark_42") for b in flat)
- assert any(b.get("url") == "https://example.com/p/42" for b in flat)
-
-
-def test_build_keyboard_multi_matches_uses_dashboard_link():
- from app.telegram.realestate_message import build_match_keyboard
- matches = [{"id": i, "pblanc_url": ""} for i in range(3)]
- kb = build_match_keyboard(matches)
- flat = [b for row in kb["inline_keyboard"] for b in row]
- # 3건 이상이면 [전체 보기] 단일 URL 버튼
- assert any("전체" in b.get("text", "") for b in flat)
-
-
-def test_build_keyboard_empty_returns_none():
- from app.telegram.realestate_message import build_match_keyboard
- assert build_match_keyboard([]) is None
-```
-
-- [ ] **Step 2: 테스트 실행 — 실패 확인**
-
-Run: `cd agent-office && python -m pytest tests/test_realestate_message.py -v`
-Expected: 5 failed (모듈 없음)
-
-- [ ] **Step 3: realestate_message.py 작성**
-
-`agent-office/app/telegram/realestate_message.py`:
-
-```python
-"""청약 매칭 알림 — 텔레그램 메시지 포맷터 + 인라인 키보드 빌더."""
-import os
-from html import escape as _h
-from typing import Optional
-
-DASHBOARD_URL = os.getenv("REALESTATE_DASHBOARD_URL", "https://example.com/realestate")
-
-_TIER_BADGE = {"S": "S", "A": "A", "B": "B", "C": "C", "D": "D"}
-
-
-def _format_one_compact(m: dict) -> str:
- score = m.get("match_score", 0)
- name = _h(m.get("house_nm") or "(제목 없음)")
- district = m.get("district") or ""
- region = m.get("region_name") or ""
- where = f"{region.split()[0] if region else ''} {district}".strip() or "위치 미상"
- rstart = m.get("receipt_start") or ""
- rend = m.get("receipt_end") or ""
- return (
- f"⭐ {score}점 — {name} \n"
- f"📍 {_h(where)} 📅 {_h(rstart)} ~ {_h(rend)}"
- )
-
-
-def _format_one_full(m: dict) -> str:
- score = m.get("match_score", 0)
- name = _h(m.get("house_nm") or "(제목 없음)")
- district = m.get("district") or ""
- region = m.get("region_name") or ""
- flags = []
- if m.get("is_speculative_area") == "Y":
- flags.append("투기과열")
- if m.get("is_price_cap") == "Y":
- flags.append("분양가상한제")
- flag_str = f" ({', '.join(flags)})" if flags else ""
-
- rstart = m.get("receipt_start") or ""
- rend = m.get("receipt_end") or ""
- elig = m.get("eligible_types") or []
- reasons = m.get("match_reasons") or []
-
- where = f"{region.split()[0] if region else ''} {district}".strip() or "위치 미상"
-
- lines = [
- f"⭐ {score}점 — {name} ",
- f"📍 {_h(where)}{_h(flag_str)}",
- f"📅 청약 {_h(rstart)} ~ {_h(rend)}",
- ]
- if elig:
- lines.append(f"✓ 자격: {_h(', '.join(elig))}")
- if reasons:
- lines.append(f"💡 {_h(' / '.join(reasons[:4]))}")
- return "\n".join(lines)
-
-
-def format_realestate_matches(matches: list[dict]) -> str:
- """매칭 목록을 텔레그램 HTML 메시지로 변환.
- 1~2건은 풀 카드, 3건 이상은 묶음 카드(상위 5건).
- """
- if not matches:
- return "🏢 새 청약 매칭이 없습니다."
-
- if len(matches) <= 2:
- body = "\n\n".join(_format_one_full(m) for m in matches)
- return f"🏢 새 청약 매칭 {len(matches)}건 \n━━━━━━━━━━\n\n{body}"
-
- top = matches[:5]
- body = "\n\n".join(_format_one_compact(m) for m in top)
- suffix = f"\n\n…외 {len(matches) - 5}건" if len(matches) > 5 else ""
- return f"🏢 새 청약 매칭 {len(matches)}건 \n━━━━━━━━━━\n\n{body}{suffix}"
-
-
-def build_match_keyboard(matches: list[dict]) -> Optional[dict]:
- """1~2건: 매치별 [북마크][공고 보기] 행. 3건 이상: [전체 보기] 단일 행."""
- if not matches:
- return None
-
- if len(matches) <= 2:
- rows = []
- for m in matches:
- buttons = [{
- "text": "🔖 북마크",
- "callback_data": f"realestate_bookmark_{m['id']}",
- }]
- url = m.get("pblanc_url")
- if url:
- buttons.append({"text": "📄 공고 보기", "url": url})
- rows.append(buttons)
- return {"inline_keyboard": rows}
-
- return {
- "inline_keyboard": [[
- {"text": "📋 전체 보기", "url": DASHBOARD_URL},
- ]],
- }
-```
-
-- [ ] **Step 4: 테스트 실행 — 통과 확인**
-
-Run: `cd agent-office && python -m pytest tests/test_realestate_message.py -v`
-Expected: 5 passed
-
-- [ ] **Step 5: 커밋**
-
-```bash
-git add agent-office/app/telegram/realestate_message.py agent-office/tests/test_realestate_message.py
-git commit -m "feat(agent-office-telegram): realestate match formatter + keyboard"
-```
-
----
-
-## Task 10: agent-office RealestateAgent.on_new_matches + endpoint
-
-신규 메소드 추가, endpoint 추가, on_schedule는 폐기 표시(cron 등록 빠지면 호출 안 됨, fetch_matches 명령은 on_new_matches 직접 호출로 단순화).
-
-**Files:**
-- Modify: `agent-office/app/agents/realestate.py`
-- Modify: `agent-office/app/main.py` (endpoint 추가)
-- Test: `agent-office/tests/test_realestate_agent.py`
-
-- [ ] **Step 1: 에이전트 + endpoint 테스트 작성**
-
-`agent-office/tests/test_realestate_agent.py`:
-
-```python
-import os
-import sys
-import tempfile
-
-_TMP = tempfile.mktemp(suffix=".db")
-os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-
-import asyncio
-from unittest.mock import AsyncMock, patch
-import pytest
-
-
-@pytest.fixture(autouse=True)
-def _init_db():
- if os.path.exists(_TMP):
- os.remove(_TMP)
- from app.db import init_db
- init_db()
- yield
-
-
-def test_on_new_matches_returns_empty_when_no_matches():
- from app.agents.realestate import RealestateAgent
-
- agent = RealestateAgent()
- result = asyncio.run(agent.on_new_matches([]))
- assert result == {"sent": 0, "sent_ids": []}
-
-
-def test_on_new_matches_sends_telegram_and_returns_ids():
- from app.agents.realestate import RealestateAgent
- from app.telegram import messaging
-
- matches = [{
- "id": 7, "match_score": 80, "house_nm": "단지A",
- "region_name": "서울특별시", "district": "강남구",
- "receipt_start": "2026-05-01", "receipt_end": "2026-05-05",
- "match_reasons": [], "eligible_types": [], "pblanc_url": "https://x.test/7",
- }]
-
- fake_send = AsyncMock(return_value={"ok": True, "message_id": 123})
- with patch.object(messaging, "send_raw", fake_send):
- agent = RealestateAgent()
- result = asyncio.run(agent.on_new_matches(matches))
-
- assert result["sent"] == 1
- assert result["sent_ids"] == [7]
- assert result["message_id"] == 123
- fake_send.assert_awaited_once()
- args, kwargs = fake_send.call_args
- text = args[0]
- assert "단지A" in text
-
-
-def test_on_new_matches_telegram_failure_returns_zero():
- from app.agents.realestate import RealestateAgent
- from app.telegram import messaging
-
- matches = [{
- "id": 8, "match_score": 80, "house_nm": "단지B",
- "region_name": "서울", "district": "송파구",
- "receipt_start": "", "receipt_end": "",
- "match_reasons": [], "eligible_types": [], "pblanc_url": "",
- }]
-
- fake_send = AsyncMock(return_value={"ok": False, "description": "401"})
- with patch.object(messaging, "send_raw", fake_send):
- agent = RealestateAgent()
- result = asyncio.run(agent.on_new_matches(matches))
-
- assert result["sent"] == 0
- assert result["sent_ids"] == []
- assert "error" in result
-
-
-def test_endpoint_calls_agent_on_new_matches():
- from fastapi.testclient import TestClient
- from app.main import app
- from app.agents.realestate import RealestateAgent
-
- fake = AsyncMock(return_value={"sent": 1, "sent_ids": [99], "message_id": 1})
- with patch.object(RealestateAgent, "on_new_matches", fake):
- client = TestClient(app)
- resp = client.post(
- "/api/agent-office/realestate/notify",
- json={"matches": [{"id": 99, "match_score": 80}]},
- )
- assert resp.status_code == 200
- body = resp.json()
- assert body["sent"] == 1
- assert body["sent_ids"] == [99]
-```
-
-- [ ] **Step 2: 테스트 실행 — 실패 확인**
-
-Run: `cd agent-office && python -m pytest tests/test_realestate_agent.py -v`
-Expected: 4 failed (메소드/endpoint 없음)
-
-- [ ] **Step 3: agents/realestate.py 수정 — on_new_matches 추가, on_schedule 단순화**
-
-`agent-office/app/agents/realestate.py` 전체 파일을 다음 내용으로 교체:
-
-```python
-from .base import BaseAgent
-from ..db import create_task, update_task_status, add_log
-from .. import service_proxy
-from ..telegram import messaging
-from ..telegram.realestate_message import format_realestate_matches, build_match_keyboard
-
-
-class RealestateAgent(BaseAgent):
- """부동산 청약 에이전트.
-
- realestate-lab이 신규 매칭 발견 시 /realestate/notify로 push해 트리거됨.
- on_new_matches가 메인 진입점. on_schedule은 사용하지 않음(cron 폐기).
- """
-
- agent_id = "realestate"
- display_name = "청약 애널리스트"
-
- async def on_new_matches(self, matches: list[dict]) -> dict:
- """신규 매칭 N건을 텔레그램 1통으로 푸시.
- 성공 시 sent_ids 반환 → realestate-lab이 notified_at 마킹.
- 실패 시 sent=0, sent_ids=[] 반환 → 다음 사이클 재시도.
- """
- if not matches:
- return {"sent": 0, "sent_ids": []}
-
- task_id = create_task(self.agent_id, "notify_matches", {"count": len(matches)})
-
- try:
- text = format_realestate_matches(matches)
- keyboard = build_match_keyboard(matches)
- await self.transition("reporting", f"매칭 {len(matches)}건 알림", task_id)
-
- tg = await messaging.send_raw(text, reply_markup=keyboard)
- if not tg.get("ok"):
- update_task_status(task_id, "failed", {"error": tg.get("description")})
- await self.transition("idle", "알림 실패")
- return {"sent": 0, "sent_ids": [], "error": tg.get("description")}
-
- sent_ids = [m["id"] for m in matches if "id" in m]
- update_task_status(task_id, "succeeded", {
- "sent": len(matches),
- "telegram_message_id": tg.get("message_id"),
- })
- await self.transition("idle", f"매칭 {len(matches)}건 알림 완료")
- return {
- "sent": len(matches),
- "sent_ids": sent_ids,
- "message_id": tg.get("message_id"),
- }
- except Exception as e:
- add_log(self.agent_id, f"on_new_matches failed: {e}", "error", task_id)
- update_task_status(task_id, "failed", {"error": str(e)})
- await self.transition("idle", f"오류: {e}")
- return {"sent": 0, "sent_ids": [], "error": str(e)}
-
- async def on_command(self, command: str, params: dict) -> dict:
- if command == "fetch_matches":
- try:
- matches = await service_proxy.realestate_matches(limit=20)
- if not matches:
- return {"ok": True, "message": "매칭 없음"}
- result = await self.on_new_matches(matches)
- return {"ok": True, "result": result}
- except Exception as e:
- return {"ok": False, "message": str(e)}
-
- if command == "dashboard":
- try:
- data = await service_proxy.realestate_dashboard()
- return {"ok": True, "dashboard": data}
- except Exception as e:
- return {"ok": False, "message": str(e)}
-
- return {"ok": False, "message": f"Unknown command: {command}"}
-
- async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
- pass
-```
-
-- [ ] **Step 4: main.py에 endpoint 추가**
-
-`agent-office/app/main.py`를 열고 다른 라우트들이 정의된 곳에 다음 endpoint를 추가한다. 정확한 위치는 다른 `/api/agent-office/...` 경로 근처. 만약 RealestateAgent가 registry로 관리된다면 그 패턴을 따르고, 아니면 직접 인스턴스화.
-
-먼저 agent dispatch 패턴을 확인해야 한다. `agent-office/app/main.py`에서 다른 에이전트가 어떻게 호출되는지 본다 (예: stock 에이전트의 명령 처리). 그 패턴에 맞춰 RealestateAgent 인스턴스를 얻고 `on_new_matches` 호출.
-
-다음 엔드포인트를 추가:
-
-```python
-from .agents.realestate import RealestateAgent
-from pydantic import BaseModel
-from typing import List, Dict, Any
-
-
-class RealestateNotifyBody(BaseModel):
- matches: List[Dict[str, Any]]
-
-
-_realestate_agent_singleton: RealestateAgent | None = None
-
-
-def _get_realestate_agent() -> RealestateAgent:
- global _realestate_agent_singleton
- if _realestate_agent_singleton is None:
- _realestate_agent_singleton = RealestateAgent()
- return _realestate_agent_singleton
-
-
-@app.post("/api/agent-office/realestate/notify")
-async def realestate_notify(body: RealestateNotifyBody):
- agent = _get_realestate_agent()
- return await agent.on_new_matches(body.matches)
-```
-
-> ⚠️ **구현 시 검증**: agent-office가 이미 에이전트 인스턴스 registry/dispatcher를 가지고 있다면 (`agents/__init__.py` 또는 `main.py` 상단), 그 패턴을 따르고 위의 singleton 패턴은 사용하지 않는다. 기존 패턴이 없을 때만 위 코드 그대로 사용. 다른 에이전트(stock/music/lotto)의 인스턴스 관리 방식을 먼저 grep해서 확인할 것: `cd agent-office && grep -rn "StockAgent\|MusicAgent\|LottoAgent" app/main.py app/agents/__init__.py`.
-
-- [ ] **Step 5: 테스트 실행 — 통과 확인**
-
-Run: `cd agent-office && python -m pytest tests/test_realestate_agent.py -v`
-Expected: 4 passed
-
-- [ ] **Step 6: 커밋**
-
-```bash
-git add agent-office/app/agents/realestate.py agent-office/app/main.py agent-office/tests/test_realestate_agent.py
-git commit -m "feat(agent-office): realestate on_new_matches + /notify endpoint"
-```
-
----
-
-## Task 11: agent-office scheduler에서 realestate cron 제거 + 콜백 라우팅
-
-데일리 09:15 cron 등록 제거. 인라인 키보드 콜백(`realestate_bookmark_*`)을 텔레그램 webhook에서 처리해 realestate-lab으로 프록시.
-
-**Files:**
-- Modify: `agent-office/app/scheduler.py`
-- Modify: `agent-office/app/telegram/webhook.py` (또는 callback 라우팅 위치)
-- Modify: `agent-office/app/service_proxy.py` (북마크 토글 헬퍼 추가)
-
-- [ ] **Step 1: scheduler에서 realestate cron 제거**
-
-`agent-office/app/scheduler.py`를 열어 realestate 관련 add_job 호출을 찾는다 (예: `realestate_agent.on_schedule` 등록부). 해당 라인을 제거하고 import도 정리.
-
-찾는 패턴 예시:
-```python
-scheduler.add_job(realestate_agent.on_schedule, "cron", hour=9, minute=15, id="realestate_daily")
-```
-
-이 라인과 관련 import를 삭제한다. 만약 RealestateAgent import가 다른 곳에서 안 쓰이면 import도 함께 제거.
-
-- [ ] **Step 2: 콜백 핸들러 위치 확인**
-
-Run: `cd agent-office && grep -rn "callback_query\|callback_data" app/telegram/`
-
-다음을 확인:
-- `webhook.py` 또는 `router.py`에서 `callback_query`를 받아 처리하는 함수
-- 현재 패턴(예: `approve_*`, `reject_*` 콜백 처리 방식)
-
-- [ ] **Step 3: service_proxy에 북마크 토글 추가**
-
-`agent-office/app/service_proxy.py` 끝에 추가:
-
-```python
-async def realestate_bookmark_toggle(announcement_id: int) -> dict:
- """realestate-lab의 PATCH /api/realestate/announcements/{id}/bookmark 호출."""
- url = f"{REALESTATE_LAB_URL}/api/realestate/announcements/{announcement_id}/bookmark"
- async with httpx.AsyncClient(timeout=10) as client:
- resp = await client.patch(url)
- resp.raise_for_status()
- return resp.json()
-```
-
-(상단의 `REALESTATE_LAB_URL`, `httpx` import는 이미 service_proxy에 있을 것이므로 신규 환경변수만 docker-compose의 agent-office 환경에 노출. 만약 없다면 다음 라인을 service_proxy 상단에 추가:)
-
-```python
-REALESTATE_LAB_URL = os.getenv("REALESTATE_LAB_URL", "http://realestate-lab:8000")
-```
-
-- [ ] **Step 4: 콜백 라우팅에 realestate_bookmark 처리 추가**
-
-webhook 또는 router의 `callback_query` 디스패처에 다음 분기를 추가 (정확한 함수명은 step 2에서 확인한 위치에 맞춤):
-
-```python
-if data.startswith("realestate_bookmark_"):
- try:
- ann_id = int(data.removeprefix("realestate_bookmark_"))
- except ValueError:
- return {"ok": False, "message": "잘못된 콜백 데이터"}
- try:
- await service_proxy.realestate_bookmark_toggle(ann_id)
- await messaging.send_raw(f"🔖 북마크 토글 완료 (#{ann_id})")
- return {"ok": True}
- except Exception as e:
- await messaging.send_raw(f"⚠️ 북마크 실패: {e}")
- return {"ok": False, "error": str(e)}
-```
-
-> ⚠️ **위치 적응 필요**: step 2에서 확인한 콜백 디스패처의 기존 분기 스타일에 맞춰 통합. 기존 분기가 `if data.startswith("approve_"): ... elif data.startswith("reject_"): ...` 라면 `elif`로 추가.
-
-- [ ] **Step 5: 콜백 라우팅 단위 테스트**
-
-`agent-office/tests/test_realestate_callback.py`:
-
-```python
-import os
-import sys
-import tempfile
-from unittest.mock import AsyncMock, patch
-
-_TMP = tempfile.mktemp(suffix=".db")
-os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-
-def test_callback_realestate_bookmark_calls_proxy():
- """callback_data 'realestate_bookmark_42' 가 service_proxy.realestate_bookmark_toggle(42) 를 호출."""
- import asyncio
- from app import service_proxy
- from app.telegram import webhook # 실제 모듈명에 맞춰 수정 필요
-
- fake = AsyncMock(return_value={"ok": True})
- with patch.object(service_proxy, "realestate_bookmark_toggle", fake):
- # webhook의 callback dispatcher를 직접 호출. 정확한 함수 시그니처는 step 2 확인 결과로 채움.
- # 예시: await webhook.handle_callback({"data": "realestate_bookmark_42", ...})
- result = asyncio.run(webhook.handle_callback({"data": "realestate_bookmark_42", "from": {"id": 1}, "id": "cb1"}))
-
- fake.assert_awaited_once_with(42)
-```
-
-> ⚠️ **테스트 시그니처 적응**: step 2에서 확인한 콜백 핸들러의 정확한 함수명·인자에 맞춰 수정. 이 테스트는 realistic stub이며 실제 webhook 모듈 구조에 맞게 업데이트해야 통과.
-
-- [ ] **Step 6: 회귀 검증**
-
-Run: `cd agent-office && python -m pytest tests/ -v`
-Expected: 모든 기존 테스트 통과 + 신규 추가분 통과
-
-- [ ] **Step 7: 커밋**
-
-```bash
-git add agent-office/app/scheduler.py agent-office/app/telegram/ agent-office/app/service_proxy.py agent-office/tests/test_realestate_callback.py
-git commit -m "feat(agent-office): drop daily realestate cron + bookmark callback routing"
-```
-
----
-
-## Task 12: docker-compose 환경변수 + 통합 검증
-
-`AGENT_OFFICE_URL`을 realestate-lab 환경에 추가, `REALESTATE_LAB_URL`이 agent-office에 없으면 추가.
-
-**Files:**
-- Modify: `docker-compose.yml`
-- Modify: `.env.example`
-
-- [ ] **Step 1: docker-compose.yml의 realestate-lab 서비스에 AGENT_OFFICE_URL 추가**
-
-`docker-compose.yml`에서 `realestate-lab` 서비스 정의를 찾고, `environment:` 블록에 추가:
-
-```yaml
- realestate-lab:
- # ... 기존
- environment:
- # ... 기존
- AGENT_OFFICE_URL: http://agent-office:8000
-```
-
-- [ ] **Step 2: docker-compose.yml의 agent-office 서비스에 REALESTATE_LAB_URL 추가 (없는 경우)**
-
-agent-office 서비스 environment에:
-
-```yaml
- agent-office:
- # ... 기존
- environment:
- # ... 기존
- REALESTATE_LAB_URL: http://realestate-lab:8000
- REALESTATE_DASHBOARD_URL: ${REALESTATE_DASHBOARD_URL:-http://localhost:8080/realestate}
-```
-
-- [ ] **Step 3: .env.example에 신규 변수 명시**
-
-`.env.example` 파일 끝에 추가 (이미 있는 변수와 중복 없는지 확인):
-
-```bash
-# 청약 알림 — agent-office push
-AGENT_OFFICE_URL=http://agent-office:8000
-REALESTATE_LAB_URL=http://realestate-lab:8000
-REALESTATE_DASHBOARD_URL=http://localhost:8080/realestate
-REALESTATE_NOTIFY_TIMEOUT=15
-```
-
-- [ ] **Step 4: 두 서비스 전체 회귀 검증**
-
-Run:
-```bash
-cd realestate-lab && python -m pytest tests/ -v
-cd ../agent-office && python -m pytest tests/ -v
-```
-
-Expected: 모든 테스트 통과
-
-- [ ] **Step 5: 운영 배포 sanity check (수동, 선택)**
-
-NAS 배포 후 (`git push`로 자동), 다음 시나리오 수동 검증:
-
-1. `PUT /api/realestate/profile` body에 `preferred_districts`/`min_match_score`/`notify_enabled` 포함하여 저장 → 200, 응답에 새 필드 반영
-2. `POST /api/realestate/collect` 트리거 → 수집 후 `GET /api/realestate/announcements?region=서울` 시 `district` 필드 포함, `완료` 공고 없음
-3. 매칭 점수 70점 이상이고 미알림인 매치 1건 이상 존재 시 → 텔레그램에 메시지 도착, 인라인 키보드 표시
-4. 텔레그램 [🔖 북마크] 클릭 → realestate-lab의 `is_bookmarked` 토글 확인
-5. `notify_enabled=false`로 변경 후 `POST /collect` → 텔레그램 푸시 미발생
-
-- [ ] **Step 6: 커밋**
-
-```bash
-git add docker-compose.yml .env.example
-git commit -m "chore(deploy): wire realestate↔agent-office URLs for push notify"
-```
-
----
-
-## 완료 기준
-
-- 모든 task의 테스트 통과 (`realestate-lab/tests/`, `agent-office/tests/`)
-- 회귀 없이 기존 endpoint 동작 유지
-- 매칭 점수 모델: 35 + 10 + 15 + 15 + 25 = 100점 일관
-- realestate-lab 09:00 cron이 `collect → cleanup → match → notify` 순으로 동작
-- agent-office 09:15 데일리 cron 제거됨
-- 텔레그램에 신규 매칭 알림 + 인라인 키보드 동작
-- `match_results.notified_at` 멱등 마킹
-
----
-
-## 참고 — 후속 별도 plan
-
-- `web-ui` 프론트 자치구 5티어 입력 UI (별도 frontend plan)
-- 청약 가점 vs 공고별 예상 커트라인 비교 (외부 데이터 의존성 연구)
-- 서울 외 광역(부산 해운대구 등) 자치구 파싱 확장
-- `POST /notifications/resend` (임계값 변경 후 재발송)
-- 자치구별 매칭 분포 대시보드 위젯
diff --git a/docs/superpowers/plans/2026-05-01-music-youtube-tab-frontend.md b/docs/superpowers/plans/2026-05-01-music-youtube-tab-frontend.md
deleted file mode 100644
index 1e1a2c5..0000000
--- a/docs/superpowers/plans/2026-05-01-music-youtube-tab-frontend.md
+++ /dev/null
@@ -1,1761 +0,0 @@
-# Music YouTube Tab Frontend 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:** MusicStudio 페이지에 🎯 YouTube 탭을 추가하고, 영상 제작 / 수익 추적 / 시장 트렌드 3개 서브탭을 구현한다.
-
-**Architecture:** MusicStudio.jsx의 기존 탭 state에 `'youtube'`를 추가하고 YoutubeTab 컴포넌트를 조건부 렌더링한다. YoutubeTab은 서브탭 state를 갖고 VideoProjectsTab / RevenueTab / TrendsTab을 렌더링한다. Library 탭의 LibraryCard에 "YouTube 프로젝트" 버튼을 추가해 트랙을 pre-select한 채 YouTube 탭으로 이동할 수 있다.
-
-**Tech Stack:** React 18, Vite, plain fetch API helper (apiGet/apiPost/apiPut/apiDelete), CSS (MusicStudio.css 확장)
-
----
-
-## 파일 구조
-
-| 파일 | 변경 |
-|------|------|
-| `src/api.js` | 비디오/수익/트렌드 API 함수 추가 (파일 끝에 append) |
-| `src/pages/music/MusicStudio.jsx` | YouTube 탭 버튼, YoutubeTab 렌더링, LibraryCard 버튼, initialTrackId state |
-| `src/pages/music/MusicStudio.css` | `.yt-*` CSS 클래스 추가 |
-| `src/pages/music/components/YoutubeTab.jsx` | 신규 — 서브탭 shell |
-| `src/pages/music/components/VideoProjectsTab.jsx` | 신규 — 영상 제작 서브탭 |
-| `src/pages/music/components/RevenueTab.jsx` | 신규 — 수익 추적 서브탭 |
-| `src/pages/music/components/TrendsTab.jsx` | 신규 — 시장 트렌드 서브탭 |
-
----
-
-## Task 1: Feature 브랜치 생성 + API 함수 추가
-
-**작업 위치:** `/Users/jaeohpark/development/web-page/`
-
-**Files:**
-- Modify: `src/api.js` (파일 끝에 append)
-
-- [ ] **Step 1: Feature 브랜치 생성**
-
-```bash
-cd /Users/jaeohpark/development/web-page
-git checkout -b feat/music-youtube-tab
-```
-
-- [ ] **Step 2: `src/api.js` 파일 끝에 YouTube/Revenue/Trends API 함수 추가**
-
-현재 파일은 628행. 파일 끝(`triggerLottoCurate` 함수 닫는 브레이스 다음)에 아래를 추가한다.
-
-```js
-// ── Music Lab — Video Projects ────────────────────
-export const createVideoProject = (data) => apiPost('/api/music/video-project', data);
-export const getVideoProjects = () => apiGet('/api/music/video-projects');
-export const renderVideoProject = (id) => apiPost(`/api/music/video-project/${id}/render`);
-export const exportVideoProject = (id) => apiGet(`/api/music/video-project/${id}/export`);
-export const deleteVideoProject = (id) => apiDelete(`/api/music/video-project/${id}`);
-
-// ── Music Lab — Revenue ───────────────────────────
-export const getRevenueDashboard = () => apiGet('/api/music/revenue/dashboard');
-export const getRevenueRecords = () => apiGet('/api/music/revenue');
-export const addRevenueRecord = (data) => apiPost('/api/music/revenue', data);
-export const updateRevenueRecord = (id, data) => apiPut(`/api/music/revenue/${id}`, data);
-export const deleteRevenueRecord = (id) => apiDelete(`/api/music/revenue/${id}`);
-
-// ── Music Lab — Market Trends ─────────────────────
-export const getLatestTrendReport = () => apiGet('/api/music/market/report/latest');
-export const getTrendReports = () => apiGet('/api/music/market/report');
-export const getMarketSuggestions = () => apiGet('/api/music/market/suggest');
-export const triggerYoutubeResearch = () => apiPost('/api/agent-office/youtube/research', {});
-```
-
-- [ ] **Step 3: 브라우저 확인 불필요 (함수만 추가, 타입 오류 없음). dev 서버 시작 확인.**
-
-```bash
-cd /Users/jaeohpark/development/web-page
-npm run dev
-```
-
-Expected: 콘솔에 에러 없이 `http://localhost:5173` (또는 포트 출력) 기동.
-
-- [ ] **Step 4: 커밋**
-
-```bash
-cd /Users/jaeohpark/development/web-page
-git add src/api.js
-git commit -m "feat(api): video-project / revenue / market-trends API 함수 추가"
-```
-
----
-
-## Task 2: YoutubeTab.jsx — 서브탭 shell
-
-**작업 위치:** `/Users/jaeohpark/development/web-page/`
-
-**Files:**
-- Create: `src/pages/music/components/YoutubeTab.jsx`
-
-- [ ] **Step 1: `YoutubeTab.jsx` 생성**
-
-```jsx
-// src/pages/music/components/YoutubeTab.jsx
-import { useState, useEffect } from 'react';
-import VideoProjectsTab from './VideoProjectsTab';
-import RevenueTab from './RevenueTab';
-import TrendsTab from './TrendsTab';
-
-export default function YoutubeTab({ library, initialTrackId, onClearInitialTrack }) {
- const [subtab, setSubtab] = useState('video');
-
- // initialTrackId가 들어오면 video 서브탭으로 전환
- useEffect(() => {
- if (initialTrackId) setSubtab('video');
- }, [initialTrackId]);
-
- return (
-
-
- setSubtab('video')}
- >
- 🎬 영상 제작
-
- setSubtab('revenue')}
- >
- 💰 수익 추적
-
- setSubtab('trends')}
- >
- 📊 시장 트렌드
-
-
-
- {subtab === 'video' && (
-
- )}
- {subtab === 'revenue' && }
- {subtab === 'trends' && }
-
- );
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-cd /Users/jaeohpark/development/web-page
-git add src/pages/music/components/YoutubeTab.jsx
-git commit -m "feat(youtube-tab): YoutubeTab 서브탭 shell 컴포넌트"
-```
-
----
-
-## Task 3: VideoProjectsTab.jsx — 영상 제작 서브탭
-
-**작업 위치:** `/Users/jaeohpark/development/web-page/`
-
-**Files:**
-- Create: `src/pages/music/components/VideoProjectsTab.jsx`
-
-> **참고:** `video-projects` API 응답 형식은 `{ projects: [...] }` 또는 배열 직접 반환일 수 있다. 양쪽 모두 처리한다.
-
-- [ ] **Step 1: `VideoProjectsTab.jsx` 생성**
-
-```jsx
-// src/pages/music/components/VideoProjectsTab.jsx
-import { useState, useEffect, useRef } from 'react';
-import {
- createVideoProject, getVideoProjects,
- renderVideoProject, exportVideoProject, deleteVideoProject,
-} from '../../../api';
-
-const COUNTRY_OPTIONS = ['BR', 'US', 'ID', 'MX', 'KR'];
-const COUNTRY_FLAGS = { BR: '🇧🇷', US: '🇺🇸', ID: '🇮🇩', MX: '🇲🇽', KR: '🇰🇷' };
-
-export default function VideoProjectsTab({ library, initialTrackId, onClearInitialTrack }) {
- const [projects, setProjects] = useState([]);
- const [selectedTrackId, setSelectedTrackId] = useState(initialTrackId ?? '');
- const [format, setFormat] = useState('visualizer');
- const [countries, setCountries] = useState(['BR']);
- const [creating, setCreating] = useState(false);
- const [exportData, setExportData] = useState(null);
- const [exportingId, setExportingId] = useState(null);
- const pollRef = useRef(null);
-
- // initialTrackId prop 반영
- useEffect(() => {
- if (initialTrackId) {
- setSelectedTrackId(String(initialTrackId));
- onClearInitialTrack?.();
- }
- }, [initialTrackId]);
-
- const loadProjects = async () => {
- try {
- const data = await getVideoProjects();
- setProjects(Array.isArray(data) ? data : data.projects ?? []);
- } catch (e) {
- console.error('getVideoProjects:', e);
- }
- };
-
- useEffect(() => { loadProjects(); }, []);
-
- // 렌더링 중인 프로젝트가 있으면 5초마다 폴링
- useEffect(() => {
- const hasRendering = projects.some(p => p.status === 'rendering');
- if (hasRendering && !pollRef.current) {
- pollRef.current = setInterval(loadProjects, 5000);
- } else if (!hasRendering && pollRef.current) {
- clearInterval(pollRef.current);
- pollRef.current = null;
- }
- return () => {
- if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
- };
- }, [projects]);
-
- const toggleCountry = (c) => {
- setCountries(prev =>
- prev.includes(c) ? prev.filter(x => x !== c) : [...prev, c]
- );
- };
-
- const handleCreate = async () => {
- if (!selectedTrackId || countries.length === 0) return;
- setCreating(true);
- try {
- await createVideoProject({
- track_id: Number(selectedTrackId),
- format,
- target_countries: countries,
- });
- await loadProjects();
- } catch (e) {
- console.error('createVideoProject:', e);
- } finally {
- setCreating(false);
- }
- };
-
- const handleRender = async (id) => {
- try {
- await renderVideoProject(id);
- await loadProjects();
- } catch (e) {
- console.error('renderVideoProject:', e);
- }
- };
-
- const handleExport = async (id) => {
- setExportingId(id);
- try {
- const data = await exportVideoProject(id);
- setExportData({ id, ...data });
- } catch (e) {
- console.error('exportVideoProject:', e);
- } finally {
- setExportingId(null);
- }
- };
-
- const handleDelete = async (id) => {
- if (!window.confirm('이 프로젝트를 삭제할까요?')) return;
- try {
- await deleteVideoProject(id);
- setProjects(prev => prev.filter(p => p.id !== id));
- if (exportData?.id === id) setExportData(null);
- } catch (e) {
- console.error('deleteVideoProject:', e);
- }
- };
-
- return (
-
- {/* ① 새 영상 만들기 */}
-
-
① 새 영상 만들기
-
-
setSelectedTrackId(e.target.value)}
- >
- 📚 트랙 선택...
- {(library ?? []).map(t => (
- {t.title}
- ))}
-
-
- {['visualizer', 'slideshow'].map(f => (
- setFormat(f)}
- >
- {f === 'visualizer' ? '비주얼라이저' : '슬라이드쇼'}
-
- ))}
-
-
-
타겟 국가 (복수 선택)
-
- {COUNTRY_OPTIONS.map(c => (
- toggleCountry(c)}
- >
- {COUNTRY_FLAGS[c]} {c}
-
- ))}
-
-
- {creating ? '생성 중...' : '프로젝트 생성'}
-
-
-
- {/* ② 프로젝트 목록 */}
-
-
② 영상 프로젝트
- {projects.length === 0 ? (
-
트랙을 선택해 영상을 만들어보세요
- ) : (
-
- {projects.map(p => (
-
- ))}
-
- )}
-
-
- {/* ③ 내보내기 패키지 */}
- {exportData && (
-
-
③ 내보내기 패키지
-
- {exportData.metadata && (
-
-
metadata.json 미리보기
-
- {JSON.stringify(exportData.metadata, null, 2)}
-
-
- )}
-
- )}
-
- );
-}
-
-function ProjectCard({ project, onRender, onExport, onDelete, isExporting }) {
- const STATUS_MAP = {
- pending: { text: '대기', cls: 'yt-status--pending' },
- rendering: { text: '⚙ 처리중', cls: 'yt-status--rendering' },
- done: { text: '✓ 완료', cls: 'yt-status--done' },
- failed: { text: '실패', cls: 'yt-status--failed' },
- };
- const s = STATUS_MAP[project.status] ?? { text: project.status, cls: '' };
-
- return (
-
-
- {project.status === 'rendering' ? '⚙️' : project.status === 'done' ? '🎬' : '🎵'}
-
-
-
- {project.title ?? `프로젝트 #${project.id}`}
-
-
- {project.format} · {(project.target_countries ?? []).join(' ')}
-
- {project.status === 'rendering' && (
-
- )}
-
-
{s.text}
- {project.status === 'pending' && (
-
onRender(project.id)}
- >
- ▶ 렌더
-
- )}
- {project.status === 'done' && (
-
onExport(project.id)}
- disabled={isExporting}
- >
- {isExporting ? '...' : '↓ 내보내기'}
-
- )}
-
onDelete(project.id)}
- aria-label="삭제"
- >
- ✕
-
-
- );
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-cd /Users/jaeohpark/development/web-page
-git add src/pages/music/components/VideoProjectsTab.jsx
-git commit -m "feat(youtube-tab): VideoProjectsTab 영상 제작 서브탭"
-```
-
----
-
-## Task 4: RevenueTab.jsx — 수익 추적 서브탭
-
-**Files:**
-- Create: `src/pages/music/components/RevenueTab.jsx`
-
-- [ ] **Step 1: `RevenueTab.jsx` 생성**
-
-```jsx
-// src/pages/music/components/RevenueTab.jsx
-import { useState, useEffect } from 'react';
-import {
- getRevenueDashboard, getRevenueRecords,
- addRevenueRecord, updateRevenueRecord, deleteRevenueRecord,
-} from '../../../api';
-
-const COUNTRIES = ['BR', 'US', 'ID', 'MX', 'KR'];
-const currentMonth = () => new Date().toISOString().slice(0, 7);
-
-export default function RevenueTab() {
- const [dashboard, setDashboard] = useState(null);
- const [records, setRecords] = useState([]);
- const [form, setForm] = useState({
- yt_video_id: '', record_month: currentMonth(),
- revenue_usd: '', views: '', country: 'BR',
- });
- const [saving, setSaving] = useState(false);
- const [editingId, setEditingId] = useState(null);
- const [editForm, setEditForm] = useState({});
-
- const loadAll = async () => {
- const [dash, recs] = await Promise.all([
- getRevenueDashboard().catch(() => null),
- getRevenueRecords().catch(() => []),
- ]);
- setDashboard(dash);
- setRecords(Array.isArray(recs) ? recs : recs.records ?? []);
- };
-
- useEffect(() => { loadAll(); }, []);
-
- const handleAdd = async () => {
- if (!form.yt_video_id || !form.revenue_usd || !form.views) return;
- setSaving(true);
- try {
- await addRevenueRecord({
- yt_video_id: form.yt_video_id,
- record_month: form.record_month,
- revenue_usd: parseFloat(form.revenue_usd),
- views: parseInt(form.views, 10),
- country: form.country,
- });
- setForm({ yt_video_id: '', record_month: currentMonth(), revenue_usd: '', views: '', country: 'BR' });
- await loadAll();
- } catch (e) {
- console.error('addRevenueRecord:', e);
- } finally {
- setSaving(false);
- }
- };
-
- const handleEditSave = async () => {
- try {
- await updateRevenueRecord(editingId, {
- revenue_usd: parseFloat(editForm.revenue_usd),
- views: parseInt(editForm.views, 10),
- });
- setEditingId(null);
- await loadAll();
- } catch (e) {
- console.error('updateRevenueRecord:', e);
- }
- };
-
- const handleDelete = async (id) => {
- if (!window.confirm('이 기록을 삭제할까요?')) return;
- try {
- await deleteRevenueRecord(id);
- await loadAll();
- } catch (e) {
- console.error('deleteRevenueRecord:', e);
- }
- };
-
- // 영상별 RPM 상위 5개 (bar chart 용)
- const chartData = records
- .filter(r => r.views > 0)
- .map(r => ({
- label: r.yt_video_id,
- rpm: (r.revenue_usd / r.views) * 1000,
- }))
- .sort((a, b) => b.rpm - a.rpm)
- .slice(0, 5);
- const maxRpm = chartData.length > 0 ? Math.max(...chartData.map(d => d.rpm)) : 1;
-
- return (
-
- {/* 대시보드 카드 3개 */}
-
-
-
총 수익
-
- ${dashboard?.total_revenue_usd?.toFixed(2) ?? '—'}
-
-
누적
-
-
-
총 조회수
-
- {dashboard?.total_views != null
- ? (dashboard.total_views >= 1000
- ? `${(dashboard.total_views / 1000).toFixed(1)}K`
- : String(dashboard.total_views))
- : '—'}
-
-
누적
-
-
-
평균 RPM
-
- ${dashboard?.avg_rpm?.toFixed(2) ?? '—'}
-
-
가중평균
-
-
-
- {/* 영상별 RPM 바 차트 */}
- {chartData.length > 0 && (
-
-
영상별 RPM 비교
-
- {chartData.map((d, i) => (
-
-
- {d.label.slice(0, 11)}
-
-
-
${d.rpm.toFixed(2)}
-
- ))}
-
-
- )}
-
- {/* 수익 기록 추가 폼 */}
-
-
+ 수익 기록 추가
-
-
- setForm(f => ({ ...f, country: e.target.value }))}
- >
- {COUNTRIES.map(c => {c} )}
-
-
- {saving ? '저장 중...' : '저장'}
-
-
-
-
- {/* 수익 기록 테이블 */}
-
-
수익 기록
- {records.length === 0 ? (
-
수익 기록이 없습니다. 위 폼으로 추가해보세요.
- ) : (
-
-
- 영상 ID
- 월
- 수익
- 조회수
- RPM
-
-
- {records.map(rec => (
- editingId === rec.id ? (
-
-
{rec.yt_video_id.slice(0, 11)}
-
{rec.record_month}
-
setEditForm(f => ({ ...f, revenue_usd: e.target.value }))}
- />
-
setEditForm(f => ({ ...f, views: e.target.value }))}
- />
-
—
-
- 저장
- setEditingId(null)}>취소
-
-
- ) : (
-
{
- setEditingId(rec.id);
- setEditForm({ revenue_usd: rec.revenue_usd, views: rec.views });
- }}
- style={{ cursor: 'pointer' }}
- >
- {rec.yt_video_id.slice(0, 11)}
- {rec.record_month}
- ${rec.revenue_usd?.toFixed(2)}
- {rec.views?.toLocaleString()}
-
- {rec.views > 0
- ? `$${((rec.revenue_usd / rec.views) * 1000).toFixed(2)}`
- : '—'}
-
- { e.stopPropagation(); handleDelete(rec.id); }}
- aria-label="삭제"
- >✕
-
- )
- ))}
-
- )}
-
-
- );
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-cd /Users/jaeohpark/development/web-page
-git add src/pages/music/components/RevenueTab.jsx
-git commit -m "feat(youtube-tab): RevenueTab 수익 추적 서브탭"
-```
-
----
-
-## Task 5: TrendsTab.jsx — 시장 트렌드 서브탭
-
-**Files:**
-- Create: `src/pages/music/components/TrendsTab.jsx`
-
-- [ ] **Step 1: `TrendsTab.jsx` 생성**
-
-```jsx
-// src/pages/music/components/TrendsTab.jsx
-import { useState, useEffect } from 'react';
-import {
- getLatestTrendReport, getTrendReports,
- getMarketSuggestions, triggerYoutubeResearch,
-} from '../../../api';
-
-const FLAG = { BR: '🇧🇷', US: '🇺🇸', ID: '🇮🇩', MX: '🇲🇽', KR: '🇰🇷' };
-
-export default function TrendsTab() {
- const [latestReport, setLatestReport] = useState(null);
- const [reports, setReports] = useState([]);
- const [suggestions, setSuggestions] = useState([]);
- const [selectedReport, setSelectedReport] = useState(null);
- const [researching, setResearching] = useState(false);
- const [copiedIdx, setCopiedIdx] = useState(null);
-
- const loadAll = async () => {
- const [latest, rpts, sugg] = await Promise.all([
- getLatestTrendReport().catch(() => null),
- getTrendReports().catch(() => []),
- getMarketSuggestions().catch(() => []),
- ]);
- setLatestReport(latest);
- setReports(Array.isArray(rpts) ? rpts : rpts.reports ?? []);
- setSuggestions(Array.isArray(sugg) ? sugg : sugg.suggestions ?? []);
- };
-
- useEffect(() => { loadAll(); }, []);
-
- const handleResearch = async () => {
- setResearching(true);
- try {
- await triggerYoutubeResearch();
- } catch (e) {
- console.error('triggerYoutubeResearch:', e);
- } finally {
- setResearching(false);
- }
- };
-
- const handleCopy = (text, idx) => {
- navigator.clipboard.writeText(text).then(() => {
- setCopiedIdx(idx);
- setTimeout(() => setCopiedIdx(null), 2000);
- });
- };
-
- // 선택된 리포트가 있으면 그것, 없으면 최신 리포트의 장르 표시
- const displayReport = selectedReport ?? latestReport;
- const topGenres = displayReport?.top_genres?.slice(0, 5) ?? [];
- const maxScore = topGenres.length > 0 ? Math.max(...topGenres.map(g => g.score)) : 1;
-
- return (
-
- {/* 수집 상태 바 */}
-
-
-
-
- 마지막 수집: {latestReport?.report_date ?? '없음'}
- {latestReport && ` · ${latestReport.top_genres?.length ?? 0}개 장르`}
-
-
-
- {researching ? '수집 중...' : '↻ 수동 수집'}
-
-
-
- {/* 인기 장르 Top 5 */}
-
-
🔥 오늘의 인기 장르 Top 5
- {topGenres.length === 0 ? (
-
- 트렌드 데이터가 없습니다. 수동 수집을 실행하거나 agent-office가 내일 09:00에 자동 수집합니다.
-
- ) : (
-
- {topGenres.map((g, i) => (
-
-
#{i + 1}
-
-
- {g.genre}
-
- {(g.countries ?? []).map(c => FLAG[c] ?? c).join(' ')}
-
-
-
-
-
{g.score}
-
- ))}
-
- )}
-
-
- {/* Suno 프롬프트 추천 */}
- {suggestions.length > 0 && (
-
-
✨ AI 추천 Suno 프롬프트
-
- {suggestions.map((s, i) => (
-
-
- {s.genre}
-
- {(s.target_countries ?? []).map(c => FLAG[c] ?? c).join(' ')}
-
-
-
handleCopy(s.suno_prompt, i)}
- title="클릭해서 복사"
- >
- {s.suno_prompt}
-
- {copiedIdx === i && (
-
✓ 복사됨
- )}
- {s.reason && (
-
{s.reason}
- )}
-
- ))}
-
-
- )}
-
- {/* 트렌드 리포트 이력 */}
-
-
📋 트렌드 리포트 이력
- {reports.length === 0 ? (
-
리포트 이력이 없습니다
- ) : (
-
- {reports.map(r => (
-
setSelectedReport(
- selectedReport?.report_date === r.report_date ? null : r
- )}
- >
-
- {r.report_date}
- {r.report_date === latestReport?.report_date && (
- ● 오늘
- )}
-
-
- {r.top_genres?.length ?? 0}개 장르 · {r.recommended_styles?.length ?? 0}개 추천
-
- 보기 →
-
- ))}
-
- )}
-
-
- );
-}
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-cd /Users/jaeohpark/development/web-page
-git add src/pages/music/components/TrendsTab.jsx
-git commit -m "feat(youtube-tab): TrendsTab 시장 트렌드 서브탭"
-```
-
----
-
-## Task 6: MusicStudio.jsx 연결 + CSS + Library 버튼
-
-**Files:**
-- Modify: `src/pages/music/MusicStudio.jsx`
-- Modify: `src/pages/music/MusicStudio.css`
-
-### 6-A: MusicStudio.jsx import 추가
-
-- [ ] **Step 1: 파일 상단 import 블록에 YoutubeTab import 추가**
-
-`MusicStudio.jsx` 파일 상단에서 기존 import 블록을 찾는다 (RemixTab import 근처). 그 아래에 추가:
-
-```jsx
-import YoutubeTab from './components/YoutubeTab';
-```
-
-기존 import 블록 예시 (3~10행 근처):
-```jsx
-import LyricsTab from './components/LyricsTab';
-import RemixTab from './components/RemixTab';
-// 이 아래에 추가:
-import YoutubeTab from './components/YoutubeTab';
-```
-
-### 6-B: MusicStudio 함수 내 상태 추가
-
-- [ ] **Step 2: `tab` state 선언 아래에 `initialTrackId` state 추가**
-
-현재 517행:
-```jsx
-const [tab, setTab] = useState('create');
-```
-
-이 바로 아래에 추가:
-```jsx
-const [initialTrackId, setInitialTrackId] = useState(null);
-```
-
-### 6-C: LibraryCard에 YouTube 프로젝트 버튼 추가
-
-- [ ] **Step 3: `LibraryCard` 컴포넌트 props에 `onVideoProject` 추가**
-
-현재 340행:
-```jsx
-const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating }) => {
-```
-
-이를 아래로 교체:
-```jsx
-const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, isGenerating }) => {
-```
-
-- [ ] **Step 4: `hasSunoId` 조건 블록의 `•••` 드롭다운 안에 YouTube 프로젝트 버튼 추가**
-
-현재 426~427행 (`🎬 Music Video` 버튼 다음):
-```jsx
- { onVideoGenerate(track); setMenuOpen(false); }}
- disabled={isGenerating}>🎬 Music Video
-```
-
-이 버튼 **아래**에 추가:
-```jsx
- { onVideoProject(track); setMenuOpen(false); }}>
- 🎯 YouTube 프로젝트
-
-```
-
-### 6-D: Library 컴포넌트에 onVideoProject prop 전달
-
-- [ ] **Step 5: `Library` 컴포넌트 props에 `onVideoProject` 추가**
-
-현재 450행:
-```jsx
-const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating, loading }) => {
-```
-
-이를 아래로 교체:
-```jsx
-const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, isGenerating, loading }) => {
-```
-
-- [ ] **Step 6: `Library` 내부의 `LibraryCard` 렌더링에 `onVideoProject` prop 추가**
-
-현재 491~515행의 `` 렌더링 블록에서, 기존 `onVideoGenerate={onVideoGenerate}` 아래에:
-```jsx
-onVideoProject={onVideoProject}
-```
-
-추가한다.
-
-### 6-E: MusicStudio 함수 내 핸들러 추가
-
-- [ ] **Step 7: `handleVideoGenerate` 핸들러 근처에 `handleVideoProject` 핸들러 추가**
-
-`handleVideoGenerate` 함수를 찾아 (파일 내 `onVideoGenerate` 콜백 정의 위치) 그 바로 아래에 추가:
-
-```jsx
-const handleVideoProject = (track) => {
- setInitialTrackId(track.id);
- setTab('youtube');
-};
-```
-
-### 6-F: Library 렌더링 블록에 prop 연결
-
-- [ ] **Step 8: `tab === 'library'` 블록의 `` 컴포넌트에 `onVideoProject` prop 추가**
-
-현재 1129~1143행의 `` 컴포넌트에서, 기존 `onVideoGenerate={handleVideoGenerate}` 아래에:
-```jsx
-onVideoProject={handleVideoProject}
-```
-
-추가한다.
-
-### 6-G: YouTube 탭 버튼 추가
-
-- [ ] **Step 9: 탭 nav에 YouTube 탭 버튼 추가**
-
-현재 1117~1123행 (Remix 탭 버튼):
-```jsx
- setTab('remix')}
->
- 🔄 Remix
-
-```
-
-이 버튼 **다음**에 추가:
-```jsx
- setTab('youtube')}
->
- 🎯 YouTube
-
-```
-
-### 6-H: YouTube 탭 콘텐츠 렌더링 추가
-
-- [ ] **Step 10: Remix 탭 렌더 블록 다음에 YouTube 탭 렌더 블록 추가**
-
-현재 1151~1167행 (Remix 탭 렌더):
-```jsx
-{/* ═══ REMIX TAB ═══ */}
-{tab === 'remix' && (
-
-)}
-```
-
-이 블록 **다음**에 추가:
-```jsx
-{/* ═══ YOUTUBE TAB ═══ */}
-{tab === 'youtube' && (
- setInitialTrackId(null)}
- />
-)}
-```
-
-### 6-I: CSS 추가
-
-- [ ] **Step 11: `MusicStudio.css` 파일 끝에 YouTube 탭 스타일 추가**
-
-```css
-/* ══════════════════════════════════════════
- YouTube Tab — yt-* classes
-══════════════════════════════════════════ */
-
-/* YouTube 탭 버튼 강조 (amber) */
-.ms-tab--youtube.is-active {
- color: #f59e0b;
- border-bottom-color: #f59e0b;
-}
-
-.yt-container {
- display: flex;
- flex-direction: column;
- gap: 0;
-}
-
-/* ── 서브탭 네비게이션 ── */
-.yt-subtabs {
- display: flex;
- border-bottom: 1px solid #1f2937;
- background: #0d1117;
- padding: 0 16px;
-}
-
-.yt-subtab {
- padding: 10px 18px;
- font-size: 12px;
- color: #6b7280;
- background: none;
- border: none;
- border-bottom: 2px solid transparent;
- cursor: pointer;
- transition: color 0.15s, border-color 0.15s;
- white-space: nowrap;
-}
-
-.yt-subtab:hover { color: #9ca3af; }
-
-.yt-subtab.is-active {
- color: #22c55e;
- border-bottom-color: #22c55e;
- font-weight: 600;
-}
-
-/* ── 공통 콘텐츠 래퍼 ── */
-.yt-content {
- display: flex;
- flex-direction: column;
- gap: 14px;
- padding: 16px;
-}
-
-/* ── 카드 ── */
-.yt-card {
- background: #0d1117;
- border: 1px solid #1f2937;
- border-radius: 10px;
- padding: 14px;
-}
-
-.yt-card--create {
- border-color: #22c55e33;
-}
-
-.yt-card--export {
- border-color: #3b82f633;
- border-style: dashed;
-}
-
-.yt-card__title {
- font-size: 12px;
- font-weight: 700;
- color: #ccc;
- margin: 0 0 12px;
-}
-
-.yt-card--create .yt-card__title { color: #86efac; }
-.yt-card--export .yt-card__title { color: #93c5fd; }
-
-/* ── 행 레이아웃 ── */
-.yt-row {
- display: flex;
- gap: 8px;
- margin-bottom: 10px;
- align-items: center;
-}
-
-.yt-row--bottom {
- margin-bottom: 0;
- margin-top: 8px;
-}
-
-/* ── 폼 그리드 ── */
-.yt-form-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 8px;
- margin-bottom: 0;
-}
-
-.yt-field { display: flex; flex-direction: column; gap: 4px; }
-
-.yt-field__label {
- font-size: 10px;
- color: #6b7280;
-}
-
-.yt-input {
- background: #1f2937;
- border: 1px solid #374151;
- border-radius: 6px;
- padding: 7px 10px;
- color: #ccc;
- font-size: 12px;
- width: 100%;
- box-sizing: border-box;
-}
-
-.yt-input:focus {
- outline: none;
- border-color: #22c55e;
-}
-
-.yt-input--sm {
- padding: 4px 8px;
- font-size: 11px;
-}
-
-/* ── 셀렉트 ── */
-.yt-select {
- flex: 1;
- background: #1f2937;
- border: 1px solid #374151;
- border-radius: 6px;
- padding: 8px 10px;
- color: #9ca3af;
- font-size: 12px;
-}
-
-/* ── 형식 토글 ── */
-.yt-format-toggle {
- display: flex;
- gap: 4px;
-}
-
-.yt-format-btn {
- background: #1f2937;
- border: 1px solid #374151;
- border-radius: 6px;
- padding: 8px 10px;
- color: #9ca3af;
- font-size: 11px;
- cursor: pointer;
- white-space: nowrap;
-}
-
-.yt-format-btn.is-active {
- background: #1a2e1a;
- border-color: #22c55e;
- color: #86efac;
-}
-
-/* ── 국가 칩 ── */
-.yt-country-label {
- font-size: 11px;
- color: #6b7280;
- margin-bottom: 6px;
-}
-
-.yt-country-chips {
- display: flex;
- gap: 6px;
- flex-wrap: wrap;
- margin-bottom: 10px;
-}
-
-.yt-chip {
- background: #1f2937;
- border: 1px solid #374151;
- border-radius: 4px;
- padding: 3px 10px;
- color: #6b7280;
- font-size: 11px;
- cursor: pointer;
- transition: all 0.15s;
-}
-
-.yt-chip.is-active {
- background: #1e3a2a;
- border-color: #22c55e;
- color: #86efac;
-}
-
-/* ── 생성 버튼 ── */
-.yt-create-btn {
- width: 100%;
- margin-top: 2px;
-}
-
-/* ── 프로젝트 목록 ── */
-.yt-project-list {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.yt-project-card {
- background: #1f2937;
- border-radius: 8px;
- padding: 10px 12px;
- display: flex;
- align-items: center;
- gap: 10px;
-}
-
-.yt-project-card__icon {
- width: 40px;
- height: 40px;
- background: #111827;
- border-radius: 6px;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 18px;
- flex-shrink: 0;
-}
-
-.yt-project-card__info { flex: 1; min-width: 0; }
-
-.yt-project-card__title {
- font-size: 12px;
- font-weight: 600;
- color: #ccc;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.yt-project-card__meta {
- font-size: 10px;
- color: #6b7280;
- margin-top: 2px;
-}
-
-/* ── 상태 배지 ── */
-.yt-status {
- font-size: 10px;
- padding: 2px 8px;
- border-radius: 4px;
- white-space: nowrap;
- flex-shrink: 0;
-}
-
-.yt-status--pending { background: #1f2937; color: #9ca3af; }
-.yt-status--rendering { background: #1a1500; color: #f59e0b; }
-.yt-status--done { background: #0a3d1a; color: #22c55e; }
-.yt-status--failed { background: #2d0a0a; color: #f87171; }
-
-/* ── 진행 바 ── */
-.yt-progress-bar {
- height: 3px;
- background: #374151;
- border-radius: 2px;
- margin-top: 6px;
- overflow: hidden;
-}
-
-.yt-progress-bar__fill {
- height: 100%;
- width: 65%;
- background: linear-gradient(90deg, #f59e0b, #fbbf24);
- border-radius: 2px;
- animation: yt-progress-pulse 2s ease-in-out infinite;
-}
-
-@keyframes yt-progress-pulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.6; }
-}
-
-/* ── 내보내기 ── */
-.yt-export-links {
- display: flex;
- gap: 8px;
- flex-wrap: wrap;
- margin-bottom: 10px;
-}
-
-.yt-meta-preview {
- background: #111827;
- border-radius: 6px;
- padding: 8px;
-}
-
-.yt-meta-preview__label {
- font-size: 10px;
- color: #6b7280;
- margin-bottom: 4px;
-}
-
-.yt-meta-preview__content {
- font-size: 11px;
- color: #9ca3af;
- font-family: monospace;
- margin: 0;
- white-space: pre-wrap;
- word-break: break-all;
-}
-
-/* ── 빈 상태 ── */
-.yt-empty {
- text-align: center;
- color: #6b7280;
- font-size: 11px;
- padding: 8px 0;
- margin: 0;
-}
-
-/* ── 대시보드 카드 ── */
-.yt-dash-cards {
- display: grid;
- grid-template-columns: 1fr 1fr 1fr;
- gap: 10px;
-}
-
-.yt-dash-card {
- background: #0d1117;
- border: 1px solid #1f2937;
- border-radius: 8px;
- padding: 12px;
- text-align: center;
-}
-
-.yt-dash-card__label { font-size: 10px; color: #6b7280; margin-bottom: 4px; }
-.yt-dash-card__sub { font-size: 9px; color: #6b7280; margin-top: 2px; }
-
-.yt-dash-card__value { font-size: 18px; font-weight: 700; }
-.yt-dash-card__value--green { color: #22c55e; }
-.yt-dash-card__value--blue { color: #60a5fa; }
-.yt-dash-card__value--amber { color: #f59e0b; }
-
-/* ── 바 차트 ── */
-.yt-bar-chart {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.yt-bar-row {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.yt-bar-row__label {
- width: 80px;
- font-size: 11px;
- color: #9ca3af;
- text-align: right;
- flex-shrink: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.yt-bar-row__rank {
- width: 24px;
- font-size: 11px;
- font-weight: 700;
- color: #f59e0b;
- text-align: center;
- flex-shrink: 0;
-}
-
-.yt-bar-row__info { flex: 1; }
-
-.yt-bar-row__genre-header {
- display: flex;
- justify-content: space-between;
- margin-bottom: 3px;
-}
-
-.yt-bar-row__genre-name { font-size: 12px; color: #ccc; }
-.yt-bar-row__flags { font-size: 10px; color: #9ca3af; }
-
-.yt-bar-row__track {
- flex: 1;
- height: 6px;
- background: #1f2937;
- border-radius: 3px;
- overflow: hidden;
-}
-
-.yt-bar-row__fill {
- height: 100%;
- background: linear-gradient(90deg, #22c55e, #4ade80);
- border-radius: 3px;
- transition: width 0.4s ease;
-}
-
-.yt-bar-row__fill--genre {
- background: linear-gradient(90deg, #f59e0b, #fbbf24);
-}
-
-.yt-bar-row__value {
- width: 44px;
- font-size: 11px;
- color: #22c55e;
- text-align: right;
- flex-shrink: 0;
-}
-
-/* ── 테이블 ── */
-.yt-table { display: flex; flex-direction: column; gap: 2px; }
-
-.yt-table__header {
- display: grid;
- grid-template-columns: 2fr 1fr 1fr 1fr 1fr 28px;
- gap: 4px;
- padding: 0 4px 6px;
- border-bottom: 1px solid #1f2937;
- font-size: 10px;
- color: #6b7280;
-}
-
-.yt-table__row {
- display: grid;
- grid-template-columns: 2fr 1fr 1fr 1fr 1fr 28px;
- gap: 4px;
- padding: 7px 4px;
- border-bottom: 1px solid #111827;
- align-items: center;
-}
-
-.yt-table__row--editing {
- background: #111827;
- border-radius: 6px;
- padding: 8px;
-}
-
-.yt-table__row:last-child { border-bottom: none; }
-
-.yt-table__cell { font-size: 11px; color: #9ca3af; }
-.yt-table__cell--mono { font-family: monospace; }
-.yt-table__cell--green { color: #22c55e; }
-.yt-table__cell--amber { color: #f59e0b; }
-
-.yt-table__actions {
- display: flex;
- gap: 4px;
- grid-column: span 2;
-}
-
-/* ── 상태 바 (트렌드) ── */
-.yt-status-bar {
- background: #0d1117;
- border: 1px solid #1f2937;
- border-radius: 8px;
- padding: 10px 14px;
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-
-.yt-status-bar__left {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.yt-status-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: #22c55e;
- box-shadow: 0 0 6px #22c55e;
- flex-shrink: 0;
-}
-
-.yt-status-bar__text {
- font-size: 11px;
- color: #9ca3af;
-}
-
-/* ── 프롬프트 카드 ── */
-.yt-prompt-list {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.yt-prompt-card {
- background: #1a0d2e;
- border-radius: 8px;
- padding: 10px 12px;
-}
-
-.yt-prompt-card__header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 5px;
-}
-
-.yt-prompt-card__genre { font-size: 11px; font-weight: 700; color: #c084fc; }
-.yt-prompt-card__countries { font-size: 10px; color: #6b7280; }
-
-.yt-prompt-card__text {
- display: block;
- width: 100%;
- text-align: left;
- background: #110820;
- border: none;
- border-radius: 4px;
- padding: 6px 8px;
- font-size: 11px;
- font-family: monospace;
- color: #e9d5ff;
- line-height: 1.6;
- cursor: pointer;
- transition: background 0.15s;
-}
-
-.yt-prompt-card__text:hover { background: #1a0d30; }
-
-.yt-prompt-card__copied {
- font-size: 10px;
- color: #22c55e;
- margin-top: 4px;
- display: block;
-}
-
-.yt-prompt-card__reason {
- font-size: 10px;
- color: #6b7280;
- margin-top: 5px;
-}
-
-/* ── 리포트 이력 ── */
-.yt-report-list {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.yt-report-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 6px 8px;
- border-radius: 6px;
- cursor: pointer;
- transition: background 0.15s;
-}
-
-.yt-report-row:hover { background: #1f2937; }
-.yt-report-row.is-selected { background: #1f2937; }
-
-.yt-report-row__date {
- font-size: 11px;
- color: #ccc;
-}
-
-.yt-report-row__today {
- font-size: 10px;
- color: #22c55e;
- margin-left: 4px;
-}
-
-.yt-report-row__meta { font-size: 10px; color: #9ca3af; }
-.yt-report-row__action { font-size: 11px; color: #60a5fa; }
-
-/* ── 모바일 반응형 ── */
-@media (max-width: 600px) {
- .yt-dash-cards { grid-template-columns: 1fr 1fr; }
- .yt-form-grid { grid-template-columns: 1fr; }
- .yt-table__header,
- .yt-table__row { grid-template-columns: 2fr 1fr 1fr 28px; }
- .yt-table__header span:nth-child(4),
- .yt-table__header span:nth-child(5),
- .yt-table__row span:nth-child(4),
- .yt-table__row span:nth-child(5) { display: none; }
-}
-```
-
-- [ ] **Step 12: 커밋**
-
-```bash
-cd /Users/jaeohpark/development/web-page
-git add src/pages/music/MusicStudio.jsx src/pages/music/MusicStudio.css
-git commit -m "feat(youtube-tab): MusicStudio YouTube 탭 연결 + CSS + Library 버튼"
-```
-
----
-
-## Task 7: 브라우저 통합 검증
-
-**작업 위치:** `/Users/jaeohpark/development/web-page/`
-
-- [ ] **Step 1: dev 서버 시작 (이미 실행 중이면 스킵)**
-
-```bash
-cd /Users/jaeohpark/development/web-page
-npm run dev
-```
-
-- [ ] **Step 2: 브라우저에서 `http://localhost:5173` (또는 출력된 포트) 열기**
-
-- [ ] **Step 3: Music 페이지 → YouTube 탭 버튼 클릭 확인**
-
-Expected:
-- 탭 바에 `🎯 YouTube` 버튼이 보임
-- 클릭 시 3개 서브탭(`🎬 영상 제작 / 💰 수익 추적 / 📊 시장 트렌드`) 표시
-- 콘솔 에러 없음
-
-- [ ] **Step 4: 영상 제작 서브탭 확인**
-
-- 트랙 드롭다운에 Library 트랙 목록 표시 (Library에 트랙이 있는 경우)
-- 국가 칩 BR/US/ID/MX/KR 클릭 토글 동작
-- 비주얼라이저/슬라이드쇼 토글 동작
-- "프로젝트 생성" 버튼 클릭 → 트랙 미선택 시 비활성화 확인
-
-- [ ] **Step 5: 수익 추적 서브탭 확인**
-
-- 대시보드 카드 3개 표시 (데이터 없으면 `—` 표시)
-- 수익 추가 폼에 YouTube ID / 월 / 수익 / 조회수 / 국가 입력 후 저장
-- 저장 후 테이블에 레코드 표시, 대시보드 수치 갱신
-
-- [ ] **Step 6: 시장 트렌드 서브탭 확인**
-
-- 수집 상태 바 표시 (마지막 수집 일시)
-- 장르 Top 5 바 차트 표시 (데이터 있는 경우)
-- Suno 프롬프트 클릭 → 클립보드 복사 + "✓ 복사됨" 메시지
-- 리포트 이력 클릭 → 해당 날짜 데이터로 Top 5 갱신
-
-- [ ] **Step 7: Library 탭 → 트랙 카드 `•••` → `🎯 YouTube 프로젝트` 버튼 확인**
-
-Expected: 클릭 시 YouTube 탭으로 이동, 해당 트랙이 드롭다운에 pre-select됨
-
-- [ ] **Step 8: 최종 커밋 및 PR 준비**
-
-```bash
-cd /Users/jaeohpark/development/web-page
-git log --oneline feat/music-youtube-tab ^main
-```
-
-Expected: Task 1~6에서 만든 커밋 6개 표시.
-
-```bash
-git push -u origin feat/music-youtube-tab
-```
diff --git a/docs/superpowers/plans/2026-05-05-packs-lab-infra-integration.md b/docs/superpowers/plans/2026-05-05-packs-lab-infra-integration.md
deleted file mode 100644
index 85a3889..0000000
--- a/docs/superpowers/plans/2026-05-05-packs-lab-infra-integration.md
+++ /dev/null
@@ -1,976 +0,0 @@
-# packs-lab 인프라 통합 + admin mint-token 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:** packs-lab을 운영 가능 상태로 만든다 — admin upload 토큰 발급 endpoint + Supabase 스키마 + docker-compose/nginx/env 통합 + 통합 테스트 + 문서 갱신.
-
-**Architecture:** 기존 코드(HMAC + DSM client + 4 라우트)는 그대로 유지하고, 신규 라우트 1개(`POST /api/packs/admin/mint-token`)를 routes.py에 추가한다. Supabase `pack_files` DDL 파일과 인프라(docker-compose 18950, nginx 5GB streaming, .env.example 6+1 환경변수)를 신설하고, 통합 테스트(routes + dsm_client mock)와 CLAUDE.md 5+1곳을 갱신한다.
-
-**Tech Stack:** Python 3.12 / FastAPI / pytest + unittest.mock / Supabase(PostgreSQL) / Synology DSM 7.x API / nginx / Docker Compose
-
-**스펙 참조:** `docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.md`
-
-**작업 디렉토리:** `C:\Users\jaeoh\Desktop\workspace\web-backend` (기존 web-backend repo)
-
----
-
-## Task 1: 테스트 인프라 — `tests/conftest.py`
-
-기존 `tests/test_auth.py`는 `BACKEND_HMAC_SECRET=secret` 같은 fixture가 없어 환경변수 의존. 모든 테스트가 동일한 secret으로 동작하도록 autouse fixture를 conftest에 정리.
-
-**Files:**
-- Create: `packs-lab/tests/conftest.py`
-
-- [ ] **Step 1: conftest.py 생성**
-
-`packs-lab/tests/conftest.py`:
-
-```python
-"""packs-lab 테스트 공통 fixture."""
-import pytest
-
-
-@pytest.fixture(autouse=True)
-def _hmac_secret(monkeypatch):
- """모든 테스트에서 동일한 HMAC secret 사용. auth._SECRET 모듈 캐시까지 갱신."""
- monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
- # auth.py 모듈은 import 시점에 _SECRET을 캐시하므로 monkeypatch로 함께 갱신
- from app import auth
- monkeypatch.setattr(auth, "_SECRET", "test-secret-do-not-use-in-prod")
-```
-
-- [ ] **Step 2: 기존 test_auth.py 회귀 검증**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab
-python -m pytest tests/test_auth.py -v
-```
-
-Expected: 기존 테스트 모두 PASS (conftest 영향 없거나 PASS 그대로 유지). 만약 secret 인코딩 차이로 실패 시 해당 테스트의 secret 사용 부분을 conftest 값과 일치시킨다.
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add packs-lab/tests/conftest.py
-git commit -m "test(packs-lab): conftest로 HMAC secret 통일"
-```
-
----
-
-## Task 2: admin mint-token 라우트 (스키마 + 구현 + 테스트)
-
-`POST /api/packs/admin/mint-token` 신규. Pydantic 스키마 추가 + 라우트 구현 + 통합 테스트.
-
-**Files:**
-- Modify: `packs-lab/app/models.py` (스키마 2개 추가)
-- Modify: `packs-lab/app/routes.py` (import 보강 + 라우트 추가)
-- Create: `packs-lab/tests/test_routes.py` (mint-token 관련 테스트만 우선)
-
-- [ ] **Step 1: failing 테스트 작성**
-
-`packs-lab/tests/test_routes.py`:
-
-```python
-"""packs-lab 라우트 통합 테스트.
-
-DSM·Supabase는 mock. HMAC 검증·토큰 발급·검증은 실제 코드 사용.
-"""
-import hashlib
-import hmac
-import json
-import time
-from unittest.mock import patch, MagicMock
-
-from fastapi.testclient import TestClient
-
-from app.main import app
-
-SECRET = "test-secret-do-not-use-in-prod"
-
-
-def _hmac_headers(body_bytes: bytes) -> dict:
- """body에 대한 X-Timestamp + X-Signature 헤더 생성."""
- ts = str(int(time.time()))
- sig = hmac.new(SECRET.encode(), ts.encode() + b"." + body_bytes, hashlib.sha256).hexdigest()
- return {"X-Timestamp": ts, "X-Signature": sig}
-
-
-def test_mint_token_hmac_required():
- """HMAC 헤더 누락 → 401."""
- client = TestClient(app)
- body = {"tier": "pro", "label": "샘플", "filename": "x.zip", "size_bytes": 1024}
- resp = client.post("/api/packs/admin/mint-token", json=body)
- assert resp.status_code == 401
-
-
-def test_mint_token_returns_valid_token():
- """발급된 token이 verify_upload_token으로 통과해야 한다."""
- from app.auth import verify_upload_token
-
- body = {"tier": "pro", "label": "샘플", "filename": "test.zip", "size_bytes": 2048}
- body_bytes = json.dumps(body).encode()
- headers = _hmac_headers(body_bytes)
- headers["Content-Type"] = "application/json"
-
- client = TestClient(app)
- resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
- assert resp.status_code == 200
- data = resp.json()
- assert "token" in data and "expires_at" in data and "jti" in data
-
- payload = verify_upload_token(data["token"])
- assert payload["tier"] == "pro"
- assert payload["label"] == "샘플"
- assert payload["filename"] == "test.zip"
- assert payload["size_bytes"] == 2048
- assert payload["jti"] == data["jti"]
-
-
-def test_mint_token_invalid_filename():
- """허용 외 확장자 → 400."""
- body = {"tier": "pro", "label": "샘플", "filename": "x.exe", "size_bytes": 1024}
- body_bytes = json.dumps(body).encode()
- headers = _hmac_headers(body_bytes)
- headers["Content-Type"] = "application/json"
-
- client = TestClient(app)
- resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
- assert resp.status_code == 400
-```
-
-- [ ] **Step 2: 실패 확인**
-
-```bash
-cd packs-lab
-python -m pytest tests/test_routes.py -v
-```
-
-Expected: 모든 테스트 FAIL — `/api/packs/admin/mint-token` 라우트 없음 (404 또는 405).
-
-- [ ] **Step 3: models.py에 스키마 추가**
-
-`packs-lab/app/models.py` 끝부분에 추가:
-
-```python
-class MintTokenRequest(BaseModel):
- """Vercel → backend: admin upload 토큰 발급 요청."""
- tier: PackTier
- label: str = Field(..., max_length=200)
- filename: str = Field(..., max_length=255)
- size_bytes: int = Field(..., gt=0, le=5 * 1024 * 1024 * 1024)
-
-
-class MintTokenResponse(BaseModel):
- token: str
- expires_at: datetime
- jti: str
-```
-
-- [ ] **Step 4: routes.py에 mint-token 라우트 추가**
-
-`packs-lab/app/routes.py` 상단 import 블록에 다음을 추가:
-
-```python
-import time
-from datetime import timezone
-```
-
-(이미 `import uuid`, `from datetime import datetime`은 있음)
-
-`from .auth import` 라인을 다음과 같이 확장:
-
-```python
-from .auth import mint_upload_token, verify_request_hmac, verify_upload_token
-```
-
-`from .models import` 라인을 다음과 같이 확장:
-
-```python
-from .models import (
- MintTokenRequest,
- MintTokenResponse,
- PackFileItem,
- SignLinkRequest,
- SignLinkResponse,
- UploadResponse,
-)
-```
-
-상수 추가 (`MAX_BYTES` 다음 줄에):
-
-```python
-UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
-```
-
-라우트 추가 (`sign_link` 함수 다음, `upload` 함수 앞):
-
-```python
-@router.post("/admin/mint-token", response_model=MintTokenResponse)
-async def mint_token(
- request: Request,
- x_timestamp: str = Header(""),
- x_signature: str = Header(""),
-):
- body = await request.body()
- verify_request_hmac(body, x_timestamp, x_signature)
- payload = MintTokenRequest.model_validate_json(body)
- _check_filename(payload.filename)
-
- jti = str(uuid.uuid4())
- expires_ts = int(time.time()) + UPLOAD_TOKEN_TTL_SEC
- token = mint_upload_token({
- "tier": payload.tier,
- "label": payload.label,
- "filename": payload.filename,
- "size_bytes": payload.size_bytes,
- "jti": jti,
- "expires_at": expires_ts,
- })
- return MintTokenResponse(
- token=token,
- expires_at=datetime.fromtimestamp(expires_ts, tz=timezone.utc),
- jti=jti,
- )
-```
-
-- [ ] **Step 5: 테스트 통과 확인**
-
-```bash
-cd packs-lab
-python -m pytest tests/test_routes.py -v
-```
-
-Expected: 3 passed.
-
-- [ ] **Step 6: 커밋**
-
-```bash
-git add packs-lab/app/models.py packs-lab/app/routes.py packs-lab/tests/test_routes.py
-git commit -m "feat(packs-lab): POST /api/packs/admin/mint-token 라우트 + 통합 테스트"
-```
-
----
-
-## Task 3: 기존 4 라우트 통합 테스트 (sign-link / upload / list / delete)
-
-기존 라우트는 변경 없음. 테스트만 추가해 회귀 안전망 확보.
-
-**Files:**
-- Modify: `packs-lab/tests/test_routes.py` (테스트 8개 추가)
-
-- [ ] **Step 1: sign-link 테스트 추가**
-
-`tests/test_routes.py` 끝에 추가:
-
-```python
-def test_sign_link_hmac_required():
- """HMAC 헤더 없으면 401."""
- client = TestClient(app)
- body = {"file_path": "/volume1/docker/webpage/media/packs/pro/x.zip"}
- resp = client.post("/api/packs/sign-link", json=body)
- assert resp.status_code == 401
-
-
-def test_sign_link_outside_base_dir():
- """PACK_BASE_DIR 외부 경로 → 400."""
- body = {"file_path": "/etc/passwd"}
- body_bytes = json.dumps(body).encode()
- headers = _hmac_headers(body_bytes)
- headers["Content-Type"] = "application/json"
-
- client = TestClient(app)
- resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)
- assert resp.status_code == 400
-
-
-def test_sign_link_calls_dsm():
- """DSM client 호출되고 응답 URL 반환."""
- from datetime import datetime, timezone
- from unittest.mock import AsyncMock
-
- body = {"file_path": "/volume1/docker/webpage/media/packs/pro/sample.zip"}
- body_bytes = json.dumps(body).encode()
- headers = _hmac_headers(body_bytes)
- headers["Content-Type"] = "application/json"
-
- fake_url = "https://gahusb.synology.me:5001/sharing/abc123"
- fake_expires = datetime(2026, 5, 5, 13, 0, tzinfo=timezone.utc)
-
- with patch("app.routes.create_share_link", new=AsyncMock(return_value=(fake_url, fake_expires))) as mock:
- client = TestClient(app)
- resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)
-
- assert resp.status_code == 200
- data = resp.json()
- assert data["url"] == fake_url
- mock.assert_awaited_once()
-```
-
-- [ ] **Step 2: upload 테스트 추가**
-
-```python
-def _make_upload_token(tier="pro", label="샘플", filename="test.zip", size_bytes=1024, jti=None, ttl=1800):
- """테스트용 upload token 생성. mint_token endpoint 거치지 않고 직접."""
- import uuid
- from app.auth import mint_upload_token
- return mint_upload_token({
- "tier": tier,
- "label": label,
- "filename": filename,
- "size_bytes": size_bytes,
- "jti": jti or str(uuid.uuid4()),
- "expires_at": int(time.time()) + ttl,
- })
-
-
-def test_upload_token_required():
- """Authorization Bearer 누락 → 401."""
- client = TestClient(app)
- resp = client.post("/api/packs/upload", files={"file": ("x.zip", b"hello")})
- assert resp.status_code == 401
-
-
-def test_upload_size_mismatch(tmp_path, monkeypatch):
- """토큰 size_bytes ≠ 실제 → 400 + 파일 정리됨."""
- monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
- token = _make_upload_token(size_bytes=999) # 실제 5바이트지만 토큰엔 999
-
- client = TestClient(app)
- resp = client.post(
- "/api/packs/upload",
- files={"file": ("test.zip", b"hello")},
- headers={"Authorization": f"Bearer {token}"},
- )
- assert resp.status_code == 400
- assert "크기" in resp.json()["detail"]
-
-
-def test_upload_jti_replay(tmp_path, monkeypatch):
- """같은 jti 토큰 두 번 → 두 번째 409."""
- monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
-
- fake_supabase = MagicMock()
- fake_supabase.table.return_value.insert.return_value.execute.return_value = MagicMock(
- data=[{"uploaded_at": "2026-05-05T12:00:00+00:00"}]
- )
-
- token = _make_upload_token(filename="replay.zip", size_bytes=5, jti="replay-jti-1")
-
- with patch("app.routes._supabase", return_value=fake_supabase):
- client = TestClient(app)
-
- # 1차: 성공
- resp1 = client.post(
- "/api/packs/upload",
- files={"file": ("replay.zip", b"hello")},
- headers={"Authorization": f"Bearer {token}"},
- )
- assert resp1.status_code == 200
-
- # 2차: 동일 토큰 재사용 — 두 번째 파일은 다른 이름으로 보내 파일명 충돌 회피
- resp2 = client.post(
- "/api/packs/upload",
- files={"file": ("replay.zip", b"world")},
- headers={"Authorization": f"Bearer {token}"},
- )
- assert resp2.status_code == 409
-```
-
-- [ ] **Step 3: list / delete 테스트 추가**
-
-```python
-def test_list_returns_active_only():
- """mock supabase가 deleted_at IS NULL 행만 반환하는지 (쿼리 빌더 호출 검증)."""
- fake_rows = [
- {
- "id": "11111111-1111-1111-1111-111111111111",
- "min_tier": "pro",
- "label": "샘플",
- "file_path": "/volume1/docker/webpage/media/packs/pro/a.zip",
- "filename": "a.zip",
- "size_bytes": 1024,
- "sort_order": 0,
- "uploaded_at": "2026-05-05T12:00:00+00:00",
- }
- ]
-
- fake_supabase = MagicMock()
- chain = fake_supabase.table.return_value.select.return_value
- chain.is_.return_value.order.return_value.order.return_value.execute.return_value = MagicMock(data=fake_rows)
-
- body_bytes = b""
- headers = _hmac_headers(body_bytes)
-
- with patch("app.routes._supabase", return_value=fake_supabase):
- client = TestClient(app)
- resp = client.get("/api/packs/list", headers=headers)
-
- assert resp.status_code == 200
- items = resp.json()
- assert len(items) == 1
- assert items[0]["filename"] == "a.zip"
- fake_supabase.table.return_value.select.return_value.is_.assert_called_with("deleted_at", "null")
-
-
-def test_delete_soft_deletes():
- """DELETE 시 supabase update에 deleted_at ISO timestamp가 들어가야 한다."""
- fake_supabase = MagicMock()
- fake_supabase.table.return_value.update.return_value.eq.return_value.execute.return_value = MagicMock(
- data=[{"id": "abc"}]
- )
-
- body_bytes = b""
- headers = _hmac_headers(body_bytes)
-
- with patch("app.routes._supabase", return_value=fake_supabase):
- client = TestClient(app)
- resp = client.delete("/api/packs/abc", headers=headers)
-
- assert resp.status_code == 200
- update_call = fake_supabase.table.return_value.update.call_args
- update_kwargs = update_call.args[0]
- assert "deleted_at" in update_kwargs
- # ISO 8601 timestamp 형식 검증 (예: 2026-05-05T12:00:00+00:00)
- assert "T" in update_kwargs["deleted_at"]
-```
-
-- [ ] **Step 4: 테스트 실행**
-
-```bash
-cd packs-lab
-python -m pytest tests/test_routes.py -v
-```
-
-Expected: 11 passed (3 from Task 2 + 3 sign-link + 3 upload + 2 list/delete).
-
-- [ ] **Step 5: 커밋**
-
-```bash
-git add packs-lab/tests/test_routes.py
-git commit -m "test(packs-lab): 기존 4 라우트 통합 테스트 (sign-link, upload, list, delete)"
-```
-
----
-
-## Task 4: `tests/test_dsm_client.py` — DSM client mock 테스트
-
-**Files:**
-- Create: `packs-lab/tests/test_dsm_client.py`
-
-- [ ] **Step 1: DSM client 테스트 작성**
-
-`packs-lab/tests/test_dsm_client.py`:
-
-```python
-"""DSM 7.x API client 테스트 — httpx mock으로 외부 호출 차단."""
-import asyncio
-from unittest.mock import patch, MagicMock
-
-import pytest
-import httpx
-
-from app.dsm_client import create_share_link, DSMError, _login, _logout
-
-
-@pytest.fixture(autouse=True)
-def _dsm_env(monkeypatch):
- monkeypatch.setenv("DSM_HOST", "https://test-nas:5001")
- monkeypatch.setenv("DSM_USER", "test-user")
- monkeypatch.setenv("DSM_PASS", "test-pass")
- # 모듈 캐시도 갱신
- from app import dsm_client
- monkeypatch.setattr(dsm_client, "DSM_HOST", "https://test-nas:5001")
- monkeypatch.setattr(dsm_client, "DSM_USER", "test-user")
- monkeypatch.setattr(dsm_client, "DSM_PASS", "test-pass")
-
-
-def _make_response(json_data, status_code=200):
- """httpx.Response mock."""
- mock = MagicMock(spec=httpx.Response)
- mock.json.return_value = json_data
- mock.status_code = status_code
- mock.raise_for_status = MagicMock()
- return mock
-
-
-def test_create_share_link_login_logout():
- """login → Sharing.create → logout 순서가 보장되어야 한다."""
- call_order = []
-
- async def fake_get(self, url, *, params=None, **kw):
- api = (params or {}).get("api", "")
- method = (params or {}).get("method", "")
- call_order.append(f"{api}.{method}")
- if api == "SYNO.API.Auth" and method == "login":
- return _make_response({"success": True, "data": {"sid": "fake-sid"}})
- if api == "SYNO.API.Auth" and method == "logout":
- return _make_response({"success": True})
- if api == "SYNO.FileStation.Sharing" and method == "create":
- return _make_response({
- "success": True,
- "data": {"links": [{"url": "https://test-nas:5001/sharing/abc"}]},
- })
- return _make_response({"success": False, "error": "unexpected"})
-
- with patch.object(httpx.AsyncClient, "get", new=fake_get):
- url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=3600))
-
- assert url == "https://test-nas:5001/sharing/abc"
- assert call_order == [
- "SYNO.API.Auth.login",
- "SYNO.FileStation.Sharing.create",
- "SYNO.API.Auth.logout",
- ]
-
-
-def test_create_share_link_returns_url_and_expiry():
- """응답 파싱 — links[0].url 사용."""
- async def fake_get(self, url, *, params=None, **kw):
- method = (params or {}).get("method", "")
- if method == "login":
- return _make_response({"success": True, "data": {"sid": "sid"}})
- if method == "create":
- return _make_response({
- "success": True,
- "data": {"links": [{"url": "https://nas/sharing/xyz"}]},
- })
- return _make_response({"success": True})
-
- with patch.object(httpx.AsyncClient, "get", new=fake_get):
- url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=7200))
-
- assert url == "https://nas/sharing/xyz"
- assert expires_at is not None
-
-
-def test_dsm_login_failure_raises():
- """login API success=False → DSMError."""
- async def fake_get(self, url, *, params=None, **kw):
- return _make_response({"success": False, "error": {"code": 400}})
-
- with patch.object(httpx.AsyncClient, "get", new=fake_get):
- with pytest.raises(DSMError, match="login 실패"):
- asyncio.run(create_share_link("/volume1/test/file.zip"))
-
-
-def test_dsm_share_failure_logs_out():
- """Sharing.create 실패해도 logout 호출 (try/finally)."""
- call_order = []
-
- async def fake_get(self, url, *, params=None, **kw):
- method = (params or {}).get("method", "")
- call_order.append(method)
- if method == "login":
- return _make_response({"success": True, "data": {"sid": "sid"}})
- if method == "create":
- return _make_response({"success": False, "error": {"code": 401}})
- if method == "logout":
- return _make_response({"success": True})
- return _make_response({"success": False})
-
- with patch.object(httpx.AsyncClient, "get", new=fake_get):
- with pytest.raises(DSMError, match="Sharing.create 실패"):
- asyncio.run(create_share_link("/volume1/test/file.zip"))
-
- assert "login" in call_order
- assert "logout" in call_order, "logout이 호출되지 않음 (finally 누락 의심)"
-```
-
-- [ ] **Step 2: 테스트 실행**
-
-```bash
-cd packs-lab
-python -m pytest tests/test_dsm_client.py -v
-```
-
-Expected: 4 passed.
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add packs-lab/tests/test_dsm_client.py
-git commit -m "test(packs-lab): DSM client mock 테스트 (login/share/logout 순서)"
-```
-
----
-
-## Task 5: DELETE 라우트 docstring 수정
-
-`routes.py` 모듈 docstring의 한 줄 변경.
-
-**Files:**
-- Modify: `packs-lab/app/routes.py:1-7` (모듈 docstring)
-
-- [ ] **Step 1: docstring 수정**
-
-`packs-lab/app/routes.py` 첫 docstring을 다음으로 변경:
-
-```python
-"""packs-lab API 엔드포인트.
-
-- POST /api/packs/sign-link — Vercel HMAC 인증 → DSM 공유 링크
-- POST /api/packs/admin/mint-token — Vercel HMAC 인증 → 일회성 upload 토큰
-- POST /api/packs/upload — 일회성 토큰 인증 → multipart 저장 + supabase INSERT
-- GET /api/packs/list — Vercel HMAC 인증 → pack_files 전체 조회
-- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
-"""
-```
-
-(변경: `정리` → `자동 만료`, mint-token 줄 추가)
-
-- [ ] **Step 2: 회귀 검증**
-
-```bash
-cd packs-lab
-python -m pytest tests/ -v
-```
-
-Expected: 모든 테스트 그대로 통과 (15 passed).
-
-- [ ] **Step 3: 커밋**
-
-```bash
-git add packs-lab/app/routes.py
-git commit -m "docs(packs-lab): routes 모듈 docstring 정리 (mint-token 추가, DSM 자동 만료 명시)"
-```
-
----
-
-## Task 6: Supabase `pack_files` DDL
-
-운영 적용 시 Supabase SQL editor에서 실행할 SQL 파일.
-
-**Files:**
-- Create: `packs-lab/supabase/pack_files.sql`
-
-- [ ] **Step 1: SQL 파일 생성**
-
-`packs-lab/supabase/pack_files.sql`:
-
-```sql
--- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
--- 운영 적용: Supabase Dashboard → SQL editor에서 실행
-create table if not exists public.pack_files (
- id uuid primary key default gen_random_uuid(),
- min_tier text not null check (min_tier in ('starter','pro','master')),
- label text not null,
- file_path text not null unique,
- filename text not null,
- size_bytes bigint not null check (size_bytes > 0),
- sort_order integer not null default 0,
- uploaded_at timestamptz not null default now(),
- deleted_at timestamptz
-);
-
--- list 라우트 hot path: deleted_at IS NULL + tier/order 정렬
-create index if not exists pack_files_active_idx
- on public.pack_files (min_tier, sort_order)
- where deleted_at is null;
-
--- soft-deleted 통계 / cleanup 잡 대비
-create index if not exists pack_files_deleted_at_idx
- on public.pack_files (deleted_at)
- where deleted_at is not null;
-```
-
-- [ ] **Step 2: 커밋**
-
-```bash
-git add packs-lab/supabase/pack_files.sql
-git commit -m "feat(packs-lab): Supabase pack_files DDL + 활성/삭제 인덱스"
-```
-
----
-
-## Task 7: 인프라 통합 — docker-compose / nginx / .env.example
-
-**Files:**
-- Modify: `docker-compose.yml` (packs-lab 서비스 추가)
-- Modify: `nginx/default.conf` (`/api/packs/` 라우팅)
-- Modify: `.env.example` (6+1 환경변수)
-
-- [ ] **Step 1: docker-compose.yml — packs-lab 서비스 추가**
-
-`docker-compose.yml`에서 다른 lab 서비스(예: `realestate-lab`) 정의 다음에 추가:
-
-```yaml
- packs-lab:
- build:
- context: ./packs-lab
- dockerfile: Dockerfile
- container_name: packs-lab
- restart: unless-stopped
- ports:
- - "18950:8000"
- environment:
- TZ: Asia/Seoul
- DSM_HOST: ${DSM_HOST}
- DSM_USER: ${DSM_USER}
- DSM_PASS: ${DSM_PASS}
- BACKEND_HMAC_SECRET: ${BACKEND_HMAC_SECRET}
- SUPABASE_URL: ${SUPABASE_URL}
- SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_KEY}
- UPLOAD_TOKEN_TTL_SEC: ${UPLOAD_TOKEN_TTL_SEC:-1800}
- volumes:
- - ${PACK_DATA_PATH:-./data/packs}:/volume1/docker/webpage/media/packs
-```
-
-- [ ] **Step 2: nginx/default.conf — /api/packs/ 라우팅**
-
-기존 `location /api/agent-office/ { ... }` 다음(또는 다른 `/api/...` 라우트들 근처)에 추가:
-
-```nginx
- location /api/packs/ {
- proxy_pass http://packs-lab:8000;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
-
- # 5GB 멀티파트 업로드 대응
- client_max_body_size 5G;
- proxy_request_buffering off;
- proxy_read_timeout 1800s;
- proxy_send_timeout 1800s;
- }
-```
-
-- [ ] **Step 3: .env.example — 6+1 환경변수 추가**
-
-`.env.example` 끝에 추가:
-
-```bash
-
-# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
-# Synology DSM 7.x 인증 (공유 링크 발급용)
-DSM_HOST=https://gahusb.synology.me:5001
-DSM_USER=
-DSM_PASS=
-
-# Vercel SaaS ↔ backend HMAC 시크릿 (양쪽 동일 값)
-BACKEND_HMAC_SECRET=
-
-# Supabase pack_files 테이블 접근 (service_role 키, RLS 우회)
-SUPABASE_URL=https://.supabase.co
-SUPABASE_SERVICE_KEY=
-
-# admin upload 토큰 TTL (초). default 1800 = 30분
-UPLOAD_TOKEN_TTL_SEC=1800
-
-# 로컬 개발: ./data/packs / NAS 운영: /volume1/docker/webpage/media/packs
-PACK_DATA_PATH=./data/packs
-```
-
-- [ ] **Step 4: docker compose config 검증**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-backend
-docker compose config 2>&1 | grep -A 10 "packs-lab:"
-```
-
-Expected: packs-lab 서비스 정의가 정상 출력 (port mapping, environment 변수, volumes 모두 보임). 환경변수가 비어있어도 docker compose config는 통과.
-
-> ⚠️ Docker가 로컬에 설치되어 있어야 검증 가능. 실제 실행은 NAS에서. 로컬 docker가 없으면 step skip하고 nginx config 문법만 별도 검증.
-
-- [ ] **Step 5: 커밋**
-
-```bash
-git add docker-compose.yml nginx/default.conf .env.example
-git commit -m "chore(infra): packs-lab 서비스 통합 (compose 18950 + nginx 5GB streaming + env 7개)"
-```
-
----
-
-## Task 8: NAS 디렉토리 준비 가이드 + 문서 갱신
-
-**Files:**
-- Modify: `web-backend/CLAUDE.md` (5곳 갱신)
-- Modify: `workspace/CLAUDE.md` (1줄 추가)
-
-- [ ] **Step 1: web-backend/CLAUDE.md — 1.프로젝트 개요**
-
-찾을 위치 (1.프로젝트 개요 섹션):
-
-```
-- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
-```
-
-다음으로 수정:
-
-```
-- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
-```
-
-같은 섹션의 인프라 줄도:
-
-```
-- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
-```
-
-- [ ] **Step 2: web-backend/CLAUDE.md — 4.Docker 서비스 표**
-
-표 마지막에 신규 행 추가 (deployer 행 직전 또는 personal 행 다음 — 알파벳 순):
-
-```
-| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
-```
-
-- [ ] **Step 3: web-backend/CLAUDE.md — 5.Nginx 라우팅 표**
-
-표 적절한 위치에 신규 행 추가:
-
-```
-| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 대응 (`client_max_body_size 5G`, `proxy_request_buffering off`, 1800s timeout) |
-```
-
-- [ ] **Step 4: web-backend/CLAUDE.md — 8.로컬 개발 표**
-
-표 끝에 신규 행 추가:
-
-```
-| Packs Lab | http://localhost:18950 |
-```
-
-- [ ] **Step 5: web-backend/CLAUDE.md — 9.서비스별 packs-lab 신규 섹션**
-
-`### deployer (deployer/)` 섹션 직전에 추가 (또는 personal 다음):
-
-```
-### packs-lab (packs-lab/)
-- NAS 자료 다운로드 자동화 — Synology DSM 공유링크 발급 + 5GB 멀티파트 업로드 수신
-- Vercel SaaS와 HMAC 인증으로 통신, 사용자 인증은 Vercel이 Supabase로 처리 (본 서비스는 외부 인증 없음)
-- DB: 외부 Supabase `pack_files` 테이블 (DDL: `packs-lab/supabase/pack_files.sql`)
-- 파일 구조: `app/main.py`, `app/auth.py`, `app/dsm_client.py`, `app/routes.py`, `app/models.py`
-- 운영 디렉토리: `/volume1/docker/webpage/media/packs/{starter,pro,master}/` (NAS PUID:PGID 권한 필요)
-
-**환경변수**
-- `DSM_HOST` / `DSM_USER` / `DSM_PASS`: Synology DSM 7.x 인증 (공유 링크 발급용)
-- `BACKEND_HMAC_SECRET`: Vercel SaaS와 양쪽 공유 시크릿 (HMAC SHA256)
-- `SUPABASE_URL` / `SUPABASE_SERVICE_KEY`: Supabase pack_files 테이블 접근 (service_role, RLS 우회)
-- `UPLOAD_TOKEN_TTL_SEC`: admin upload 토큰 TTL (기본 1800초 = 30분)
-- `PACK_DATA_PATH`: 호스트 마운트 경로 (로컬 `./data/packs`, NAS `/volume1/docker/webpage/media/packs`)
-
-**HMAC 인증 패턴**
-- Vercel → backend 요청: `X-Timestamp` (UNIX 초) + `X-Signature` (HMAC_SHA256(timestamp + "." + body, secret))
-- Replay 방어: 타임스탬프 ±5분 윈도우
-- admin browser → backend upload: `Authorization: Bearer ` (jti 단발성)
-
-**packs-lab API 목록**
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| POST | `/api/packs/sign-link` | Vercel HMAC → DSM Sharing.create로 4시간 유효 다운로드 URL 발급 |
-| POST | `/api/packs/admin/mint-token` | Vercel HMAC → 일회성 upload 토큰 발급 (기본 30분 TTL) |
-| POST | `/api/packs/upload` | Bearer token → multipart 5GB 저장 + Supabase INSERT |
-| GET | `/api/packs/list` | Vercel HMAC → 활성 pack_files 목록 (deleted_at IS NULL) |
-| DELETE | `/api/packs/{file_id}` | Vercel HMAC → soft delete (DSM 공유는 자동 만료) |
-```
-
-- [ ] **Step 6: workspace/CLAUDE.md — 컨테이너 표 한 줄 추가**
-
-`workspace/CLAUDE.md`의 "Docker 서비스 & 포트" 표에 추가:
-
-```
-| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (Vercel SaaS와 HMAC 통신) |
-```
-
-(personal 행 다음 또는 적절한 위치)
-
-- [ ] **Step 7: 커밋 (web-backend repo의 CLAUDE.md만)**
-
-작업 디렉토리는 `C:\Users\jaeoh\Desktop\workspace\web-backend`. 그 안의 `CLAUDE.md`만 git 추적 대상.
-
-```bash
-git add CLAUDE.md
-git commit -m "docs(claude): packs-lab 10번째 서비스로 등록 (포트/라우팅/API 표 + 신규 섹션)"
-```
-
-> ℹ️ `workspace/CLAUDE.md`(상위 디렉토리의 워크스페이스 메모)는 git repo가 아님. 텍스트 편집만 하고 commit 대상에서 제외.
-
----
-
-## Task 9: 회귀 검증 + NAS 디렉토리 가이드
-
-전체 테스트 + docker compose config + NAS 배포 전 가이드.
-
-**Files:**
-- (검증만)
-
-- [ ] **Step 1: 전체 pytest**
-
-```bash
-cd packs-lab
-python -m pytest tests/ -v
-```
-
-Expected: 모든 테스트 통과 (test_auth + test_routes + test_dsm_client = 약 15+ tests).
-
-- [ ] **Step 2: docker compose config 검증**
-
-```bash
-cd C:\Users\jaeoh\Desktop\workspace\web-backend
-docker compose config 2>&1 | tail -30
-```
-
-Expected: error 없이 packs-lab 포함된 전체 config 출력.
-
-> ⚠️ Docker 미설치 시 skip. NAS에서 git push 후 webhook 배포 시점에 검증됨.
-
-- [ ] **Step 3: NAS 배포 전 가이드 출력**
-
-배포 전 NAS에서 SSH로 1회 실행할 명령들을 README 또는 NAS 배포 노트로 정리. 본 task에서는 명령만 제시 (실행은 사용자):
-
-```bash
-# NAS SSH로 접속 후
-mkdir -p /volume1/docker/webpage/media/packs/{starter,pro,master}
-chown -R PUID:PGID /volume1/docker/webpage/media/packs # PUID/PGID는 .env 값 사용
-
-# .env에 신규 환경변수 추가 (DSM_*, BACKEND_HMAC_SECRET, SUPABASE_*, UPLOAD_TOKEN_TTL_SEC, PACK_DATA_PATH=/volume1/docker/webpage/media/packs)
-
-# Supabase에서 packs-lab/supabase/pack_files.sql 실행
-
-# git push 후 webhook이 자동 배포
-```
-
-- [ ] **Step 4: 최종 commit (검증 결과 빈 commit으로 마일스톤 표시 — 선택)**
-
-```bash
-# 만약 위 step에서 어떤 자동 수정이 있었으면 commit. 없으면 skip.
-git status
-```
-
-회귀 검증으로 변경 사항 없으면 별도 commit 없이 종료.
-
----
-
-## 완료 기준
-
-- 모든 task의 step 통과 (체크박스 모두 체크)
-- `cd packs-lab && python -m pytest tests/ -v` — 통과 (test_auth + test_routes + test_dsm_client)
-- `docker compose config` — packs-lab 포함된 전체 config 정상
-- web-backend/CLAUDE.md 5곳 갱신 + workspace/CLAUDE.md 1줄
-- Supabase DDL 파일 존재 (운영 적용은 사용자가 NAS에서 SQL editor로)
-- NAS 디렉토리 준비 명령은 사용자가 SSH로 실행 (배포 전 1회)
-
----
-
-## 배포
-
-git push → Gitea webhook → deployer rsync → docker compose up -d --build (자동).
-
-**배포 전 사용자 액션 (1회)**:
-1. Supabase에서 `pack_files` 테이블 생성 (DDL 실행)
-2. NAS SSH로 `/volume1/docker/webpage/media/packs/{starter,pro,master}` 디렉토리 생성 + 권한
-3. NAS `.env`에 신규 7개 환경변수 입력 (DSM 인증, HMAC secret, Supabase 키 등)
-
----
-
-## 참고 — 후속 별도 plan (스코프 외)
-
-- Vercel SaaS-side admin UI / 사용자 다운로드 UI / Supabase user 테이블
-- DSM 공유 추적 (즉시 차단 필요 시)
-- deleted_at + N일 후 실제 파일 삭제 cron
-- multi-admin 토큰 발급 권한 분리
-- resumable multipart 업로드 (5GB tus 등)
-- pack_files sort_order 편집 endpoint
-- 모니터링 (업로드 실패율, DSM API latency)
diff --git a/docs/superpowers/specs/2026-04-05-lotto-purchase-strategy-evolution-design.md b/docs/superpowers/specs/2026-04-05-lotto-purchase-strategy-evolution-design.md
deleted file mode 100644
index 4888c48..0000000
--- a/docs/superpowers/specs/2026-04-05-lotto-purchase-strategy-evolution-design.md
+++ /dev/null
@@ -1,402 +0,0 @@
-# Lotto 구매 연동 + 전략 진화 시스템 설계
-
-> 작성일: 2026-04-05
-> 상태: 승인 대기
-
----
-
-## 1. 목표
-
-로또 번호 추천 기능을 고도화하여:
-1. **동행복권 실 구매 연동** — 추천 번호를 클립보드 복사 + 동행복권 바로가기로 실제 구매 지원
-2. **가상 구매 모드** — 돈을 쓰지 않고 "이 번호로 구매한다"를 등록, 결과 발표 후 자동 가상 수익률 계산
-3. **전략 진화 시스템** — 구매 이력 기반으로 각 추천 전략(combined, simulation, heatmap, manual, custom)의 성과를 추적하고, EMA + Softmax로 가중치를 자동 조정하는 메타 전략
-4. **통합 구매 이력** — 실제/가상 구매를 하나의 리스트에서 관리하되, 실 구매는 시각적으로 강조
-
----
-
-## 2. 접근 방식
-
-**방식 1 (단일 확장) 채택**: 기존 `lotto-backend`(backend/) 서비스 내부에 모듈 추가.
-- NAS Celeron J4025 환경에서 새 컨테이너 추가는 리소스 부담
-- 기존 checker/recommender/DB와 자연스러운 연동 가능
-- 파일 수준 모듈 분리로 유지보수성 확보
-
----
-
-## 3. 데이터 모델
-
-### 3.1 기존 `purchase_history` 테이블 마이그레이션
-
-현재 스키마:
-```sql
-CREATE TABLE purchase_history (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- draw_no INTEGER NOT NULL,
- amount INTEGER NOT NULL,
- sets INTEGER NOT NULL DEFAULT 1,
- prize INTEGER NOT NULL DEFAULT 0,
- note TEXT NOT NULL DEFAULT '',
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
-);
-```
-
-마이그레이션 전략: **ALTER TABLE로 컬럼 추가** (기존 데이터 보존)
-
-```sql
-ALTER TABLE purchase_history ADD COLUMN numbers TEXT NOT NULL DEFAULT '[]';
-ALTER TABLE purchase_history ADD COLUMN is_real INTEGER NOT NULL DEFAULT 1;
-ALTER TABLE purchase_history ADD COLUMN source_strategy TEXT NOT NULL DEFAULT 'manual';
-ALTER TABLE purchase_history ADD COLUMN source_detail TEXT NOT NULL DEFAULT '{}';
-ALTER TABLE purchase_history ADD COLUMN checked INTEGER NOT NULL DEFAULT 0;
-ALTER TABLE purchase_history ADD COLUMN results TEXT NOT NULL DEFAULT '[]';
-ALTER TABLE purchase_history ADD COLUMN total_prize INTEGER NOT NULL DEFAULT 0;
-```
-
-- 기존 레코드: `is_real=1`, `source_strategy='manual'`, `checked=0` (기본값)
-- 기존 `prize` 컬럼은 하위호환용으로 유지. 신규 로직은 `total_prize` + `results` 사용
-- 기존 `sets` 컬럼은 하위호환용으로 유지. 신규 로직은 `numbers` JSON 배열 길이로 세트 수 산출
-
-### 3.2 신규 `strategy_performance` 테이블
-
-```sql
-CREATE TABLE IF NOT EXISTS strategy_performance (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- strategy TEXT NOT NULL,
- draw_no INTEGER NOT NULL,
- sets_count INTEGER NOT NULL DEFAULT 0,
- total_correct INTEGER NOT NULL DEFAULT 0,
- max_correct INTEGER NOT NULL DEFAULT 0,
- prize_total INTEGER NOT NULL DEFAULT 0,
- avg_score REAL NOT NULL DEFAULT 0.0,
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- UNIQUE(strategy, draw_no)
-);
-```
-
-### 3.3 신규 `strategy_weights` 테이블
-
-```sql
-CREATE TABLE IF NOT EXISTS strategy_weights (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- strategy TEXT NOT NULL UNIQUE,
- weight REAL NOT NULL DEFAULT 0.2,
- ema_score REAL NOT NULL DEFAULT 0.15,
- total_sets INTEGER NOT NULL DEFAULT 0,
- total_hits_3plus INTEGER NOT NULL DEFAULT 0,
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
-);
-```
-
-초기 가중치 (첫 실행 시 seed):
-
-| strategy | weight | ema_score |
-|-----------|--------|-----------|
-| combined | 0.30 | 0.15 |
-| simulation | 0.25 | 0.15 |
-| heatmap | 0.20 | 0.15 |
-| manual | 0.15 | 0.15 |
-| custom | 0.10 | 0.15 |
-
----
-
-## 4. API 설계
-
-### 4.1 구매 API (기존 경로 확장)
-
-| 메서드 | 경로 | 변경 사항 |
-|--------|------|----------|
-| `POST` | `/api/lotto/purchase` | 요청 바디 확장 (numbers, is_real, source_strategy, source_detail 추가) |
-| `GET` | `/api/lotto/purchase` | 필터 추가: `is_real`, `strategy`, `checked` |
-| `GET` | `/api/lotto/purchase/stats` | 응답 확장: total/real/virtual + by_strategy 섹션 |
-| `PUT` | `/api/lotto/purchase/{id}` | 기존 그대로 (allowed 필드 확장) |
-| `DELETE` | `/api/lotto/purchase/{id}` | 기존 그대로 |
-
-**POST 요청 바디:**
-```json
-{
- "draw_no": 1125,
- "numbers": [[3, 12, 23, 34, 38, 45], [7, 14, 21, 29, 36, 42]],
- "is_real": true,
- "amount": 2000,
- "source_strategy": "combined",
- "source_detail": {"recommendation_ids": [451, 452]},
- "note": ""
-}
-```
-
-하위호환: `numbers`가 빈 배열이면 기존 방식(sets + amount만)으로 동작. `is_real` 미지정 시 기본값 `true`.
-
-**GET /purchase/stats 응답:**
-```json
-{
- "total": {"sets": 48, "invested": 48000, "prize": 15000, "roi": -68.75, "win_rate": 12.5},
- "real": {"sets": 20, "invested": 20000, "prize": 10000, "roi": -50.0, "win_rate": 15.0},
- "virtual": {"sets": 28, "invested": 28000, "prize": 5000, "roi": -82.14, "win_rate": 10.7},
- "by_strategy": {
- "combined": {"sets": 15, "avg_correct": 1.8, "hits_3plus": 3, "roi": -45.0},
- "simulation": {"sets": 12, "avg_correct": 2.1, "hits_3plus": 4, "roi": -30.0}
- }
-}
-```
-
-### 4.2 전략 진화 API (신규)
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| `GET` | `/api/lotto/strategy/weights` | 현재 전략별 가중치 + 성과 요약 + trend |
-| `GET` | `/api/lotto/strategy/performance` | 전략별 회차 성과 이력 (차트용, `days` 파라미터) |
-| `POST` | `/api/lotto/strategy/evolve` | 수동 가중치 재계산 트리거 |
-
-**GET /strategy/weights 응답:**
-```json
-{
- "weights": [
- {"strategy": "combined", "weight": 0.32, "ema_score": 0.285, "total_sets": 15, "hits_3plus": 3, "trend": "up"},
- {"strategy": "simulation", "weight": 0.28, "ema_score": 0.312, "total_sets": 12, "hits_3plus": 4, "trend": "up"},
- {"strategy": "heatmap", "weight": 0.18, "ema_score": 0.195, "total_sets": 10, "hits_3plus": 1, "trend": "down"},
- {"strategy": "manual", "weight": 0.14, "ema_score": 0.160, "total_sets": 8, "hits_3plus": 1, "trend": "stable"},
- {"strategy": "custom", "weight": 0.08, "ema_score": 0.105, "total_sets": 3, "hits_3plus": 0, "trend": "stable"}
- ],
- "last_evolved": "2026-04-05T09:10:00",
- "min_data_draws": 10,
- "current_data_draws": 32,
- "status": "active"
-}
-```
-
-### 4.3 스마트 추천 API (신규)
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| `GET` | `/api/lotto/recommend/smart` | 전략 가중치 기반 메타 전략 추천. `sets` 파라미터 (기본 5) |
-
-**응답:**
-```json
-{
- "sets": [
- {
- "numbers": [3, 12, 23, 34, 38, 45],
- "meta_score": 0.847,
- "source_strategy": "simulation",
- "contribution": {"simulation": 0.42, "combined": 0.31, "heatmap": 0.27},
- "individual_scores": {"frequency": 0.82, "fingerprint": 0.91, "gap": 0.78, "cooccur": 0.85, "diversity": 0.73}
- }
- ],
- "strategy_weights_used": {"combined": 0.32, "simulation": 0.28, "heatmap": 0.18, "manual": 0.14, "custom": 0.08},
- "learning_status": {"draws_learned": 32, "status": "active", "message": ""}
-}
-```
-
----
-
-## 5. 전략 진화 알고리즘
-
-### 5.1 성과 점수 산출 (회차별, 세트별)
-
-```python
-set_score = correct_count / 6.0
-
-# 당첨 등수별 보너스
-RANK_BONUS = {5: 0.1, 4: 0.3, 3: 0.6, 2: 0.8, 1: 1.0}
-set_score += RANK_BONUS.get(rank, 0)
-
-# 한 구매 건의 draw_score = avg(set_scores)
-```
-
-### 5.2 EMA 갱신
-
-```python
-ALPHA = 0.3 # 최근 3~4회차가 EMA의 ~65% 차지
-new_ema = ALPHA * draw_score + (1 - ALPHA) * old_ema
-```
-
-### 5.3 가중치 변환 (Softmax)
-
-```python
-TEMPERATURE = 2.0
-MIN_WEIGHT = 0.05
-
-raw = {s: exp(ema / TEMPERATURE) for s, ema in ema_scores.items()}
-total = sum(raw.values())
-weights = {s: max(v / total, MIN_WEIGHT) for s, v in raw.items()}
-# 재정규화하여 합 = 1.0
-remainder = 1.0 - sum(weights.values())
-# ... 비례 배분으로 조정
-```
-
-### 5.4 재계산 타이밍
-
-- **자동**: `check_results_for_draw()` → purchases 체크 → strategy_performance 갱신 → weights 재계산
-- **수동**: `POST /api/lotto/strategy/evolve`
-
-### 5.5 스마트 추천 흐름
-
-1. `strategy_weights` 로드
-2. 각 전략에서 후보 10세트 생성:
- - `combined`: `generate_combined_recommendation()` x 10
- - `simulation`: `get_best_picks()` 상위 10개
- - `heatmap`: `recommend_with_heatmap()` x 10
- - `manual`: `recommend_numbers()` x 10
- - `custom`: 데이터 없으면 skip
-3. `meta_score = original_score x strategy_weight`
-4. 전체 풀에서 중복 제거 후 상위 N세트 선출
-5. 각 세트에 출처 전략 + 기여도 breakdown 첨부
-
-### 5.6 콜드 스타트
-
-- 구매 이력 0건: 초기 가중치 그대로 사용
-- 특정 전략 구매 0건: 해당 전략 EMA 초기값(0.15) 유지
-- 10회차 미만: 스마트 추천 응답에 `status: "learning"` + 기존 combined 추천 병행
-
-### 5.7 Trend 판정
-
-```python
-recent_delta = current_ema - ema_5_draws_ago
-if recent_delta > 0.02: trend = "up"
-elif recent_delta < -0.02: trend = "down"
-else: trend = "stable"
-```
-
----
-
-## 6. 체커 연동 (자동 파이프라인)
-
-기존 흐름에 purchase 체크를 연결:
-
-```
-Scheduler (09:10 / 21:10)
- → sync_latest()
- → 새 회차 감지 시:
- → check_results_for_draw() # 기존: recommendations 체크
- → check_purchases_for_draw() # 신규: purchases 체크
- → 각 세트별 rank/correct/bonus 계산 (checker._calc_rank 재사용)
- → purchases.results, total_prize, checked=1 갱신
- → strategy_performance upsert
- → strategy_evolver.recalculate_weights()
-```
-
----
-
-## 7. 백엔드 모듈 구조
-
-### 7.1 신규 파일
-
-| 파일 | 역할 |
-|------|------|
-| `purchase_manager.py` | 구매 이력 관리 + 결과 체크 |
-| `strategy_evolver.py` | EMA 계산 + 가중치 진화 + 스마트 추천 |
-
-### 7.2 수정 파일
-
-| 파일 | 변경 내용 |
-|------|----------|
-| `db.py` | purchase_history ALTER + 신규 테이블 2개 + CRUD 함수 추가 |
-| `main.py` | 신규 엔드포인트 9개 + Pydantic 모델 + import |
-| `checker.py` | `check_results_for_draw()` 끝에 purchase 체크 호출 추가 |
-
-### 7.3 기존 유지 파일 (변경 없음)
-
-`recommender.py`, `generator.py`, `analyzer.py`, `collector.py`, `utils.py`
-
----
-
-## 8. 프론트엔드 변경
-
-### 8.1 신규 컴포넌트
-
-| 컴포넌트 | 역할 |
-|----------|------|
-| `SmartRecommendPanel.jsx` | 전략 진화 기반 메타 추천 + 구매 버튼 |
-| `PurchaseHub.jsx` | 통합 구매 이력 (기존 PurchasePanel 대체) |
-| `StrategyDashboard.jsx` | 전략 가중치 시각화 + 성과 추이 차트 |
-| `PurchaseButton.jsx` | 공통 구매 버튼 (실구매/가상구매) |
-
-### 8.2 수정 컴포넌트
-
-| 컴포넌트 | 변경 내용 |
-|----------|----------|
-| `CombinedRecommendPanel.jsx` | 구매 버튼(PurchaseButton) 추가 |
-| `Functions.jsx` | 신규 패널 3개 추가 + import |
-
-### 8.3 신규 훅
-
-| 훅 | 역할 |
-|----|------|
-| `useStrategyWeights.js` | 전략 가중치/성과 데이터 fetch |
-
-### 8.4 수정 훅
-
-| 훅 | 변경 내용 |
-|----|----------|
-| `usePurchases.js` | 새 API 스키마 연동 (numbers, is_real, source_strategy 등) |
-
-### 8.5 API 헬퍼 추가 (`api.js`)
-
-```javascript
-// 전략
-getStrategyWeights() // GET /api/lotto/strategy/weights
-getStrategyPerformance(days) // GET /api/lotto/strategy/performance
-triggerStrategyEvolve() // POST /api/lotto/strategy/evolve
-
-// 스마트 추천
-getSmartRecommend(sets) // GET /api/lotto/recommend/smart
-```
-
-### 8.6 동행복권 바로가기
-
-별도 API 없음. 프론트엔드 PurchaseButton에서:
-1. 번호를 클립보드에 복사
-2. `window.open('https://dhlottery.co.kr/gameResult.do?method=byWin')` — 새 탭
-3. 확인 다이얼로그 "구매 완료했나요?" → 예 → `POST /api/lotto/purchase (is_real=1)`
-
-### 8.7 UI 시각 구분
-
-- 실 구매: 금색/강조 배경 + 지갑 아이콘
-- 가상 구매: 기본 배경 + 게임패드 아이콘
-- 미확인: 시계 아이콘
-- 당첨: 초록 하이라이트 + 체크 아이콘
-
----
-
-## 9. 전체 데이터 흐름
-
-```
-추천(기존) ──[구매 버튼]──→ POST /purchase
- │
-스마트 추천(신규) ──[구매 버튼]──┘
- ↓
- purchase_history 테이블
- │
-매주 토요일 추첨 결과 ──→ sync_latest()
- ↓
- check_results_for_draw()
- ├── recommendations 체크 (기존)
- └── check_purchases_for_draw() (신규)
- ↓
- strategy_performance 갱신
- ↓
- recalculate_weights()
- ↓
- strategy_weights 갱신
- ↓
- 다음 스마트 추천에 반영 ──→ 순환
-```
-
----
-
-## 10. 비기능 요구사항
-
-- **하위호환**: 기존 purchase API 사용자(프론트 PurchasePanel)는 마이그레이션 중에도 동작해야 함
-- **성능**: 스마트 추천은 각 전략 10세트 생성 → 총 50세트 중 상위 N개 선출. 1-2초 내 응답 목표
-- **데이터 안전**: ALTER TABLE은 SQLite 트랜잭션으로 안전하게 실행. 기존 데이터 유실 없음
-- **콜드 스타트**: 구매 데이터 없어도 스마트 추천 동작 (초기 가중치 사용)
-
----
-
-## 11. 범위 외 (추후 고려)
-
-- 동행복권 자동 로그인/자동 구매 (CAPTCHA + 보안 정책으로 불가)
-- 번호 자동 입력 브라우저 확장 프로그램
-- 푸시 알림 (당첨 결과 통보)
-- 다중 사용자 지원
diff --git a/docs/superpowers/specs/2026-04-05-realestate-lab-design.md b/docs/superpowers/specs/2026-04-05-realestate-lab-design.md
deleted file mode 100644
index 4d49db9..0000000
--- a/docs/superpowers/specs/2026-04-05-realestate-lab-design.md
+++ /dev/null
@@ -1,342 +0,0 @@
-# realestate-lab 설계 스펙
-
-> 부동산 청약 공고 자동 수집 + 프로필 기반 자격 매칭 서비스
-
----
-
-## 1. 개요
-
-공공데이터포털(한국부동산원 청약홈 분양정보 API)에서 청약 공고를 자동 수집하고, 사용자 프로필 기반으로 지원 가능 여부를 자동 판별하는 독립 서비스.
-
-**핵심 목표:**
-- 수동 공고 등록 없이 자동 수집 → DB 저장
-- 프로필 기반 자격 매칭 → 지원 가능한 청약만 필터링
-- 프론트에서 "새 공고 N건" 확인 → 향후 텔레그램 알림 확장
-
----
-
-## 2. 서비스 아키텍처
-
-### 독립 서비스 구조
-
-```
-realestate-lab/ # 포트 18800
-├── app/
-│ ├── main.py # FastAPI 앱 + APScheduler
-│ ├── db.py # SQLite CRUD (realestate.db)
-│ ├── collector.py # 공공데이터포털 API 수집기
-│ ├── matcher.py # 프로필 기반 자격 매칭 엔진
-│ └── models.py # Pydantic 요청/응답 모델
-├── Dockerfile
-└── requirements.txt
-```
-
-### 수집 흐름
-
-```
-APScheduler (매일 09:00)
- → collector.py: 청약홈 API 5개 엔드포인트 호출
- → DB에 신규 공고 upsert (HOUSE_MANAGE_NO + PBLANC_NO 기준)
- → matcher.py: 프로필 매칭 → 적격 공고에 match_status 부여
- → 신규 매칭 공고 카운트 → (향후) 텔레그램 알림
-```
-
----
-
-## 3. 데이터 소스
-
-### 공공데이터포털 — 한국부동산원_청약홈 분양정보 조회 서비스
-
-- **Base URL**: `https://api.odcloud.kr/api`
-- **서비스 키**: `DATA_GO_KR_API_KEY` 환경변수
-- **일 호출 제한**: 40,000건
-- **데이터 포맷**: JSON
-
-### 수집 대상 API 엔드포인트
-
-| 엔드포인트 | 설명 |
-|-----------|------|
-| `/ApplyhomeInfoDetailSvc/v1/getAPTLttotPblancDetail` | APT 분양정보 상세 |
-| `/ApplyhomeInfoDetailSvc/v1/getUrbtyOfctlLttotPblancDetail` | 오피스텔/도시형/민간임대 상세 |
-| `/ApplyhomeInfoDetailSvc/v1/getRemndrLttotPblancDetail` | 잔여세대 상세 |
-| `/ApplyhomeInfoDetailSvc/v1/getPblPvtRentLttotPblancDetail` | 공공지원 민간임대 상세 |
-| `/ApplyhomeInfoDetailSvc/v1/getOPTLttotPblancDetail` | 임의공급 상세 |
-
-### 주택형별 상세 API (모델별 세대수·분양가)
-
-| 엔드포인트 | 설명 |
-|-----------|------|
-| `/ApplyhomeInfoDetailSvc/v1/getAPTLttotPblancMdl` | APT 주택형별 |
-| `/ApplyhomeInfoDetailSvc/v1/getUrbtyOfctlLttotPblancMdl` | 오피스텔 주택형별 |
-| `/ApplyhomeInfoDetailSvc/v1/getRemndrLttotPblancMdl` | 잔여세대 주택형별 |
-| `/ApplyhomeInfoDetailSvc/v1/getPblPvtRentLttotPblancMdl` | 공공지원 민간임대 주택형별 |
-| `/ApplyhomeInfoDetailSvc/v1/getOPTLttotPblancMdl` | 임의공급 주택형별 |
-
-### 공통 쿼리 파라미터
-
-- `page` (기본: 1), `perPage` (기본: 100)
-- `serviceKey` — 인코딩된 API 키
-- `cond[RCRIT_PBLANC_DE::GTE]` / `cond[RCRIT_PBLANC_DE::LTE]` — 모집공고일 범위 필터
-
----
-
-## 4. DB 스키마 (realestate.db)
-
-### announcements (청약 공고)
-
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | INTEGER PK | 자동 증가 |
-| house_manage_no | TEXT NOT NULL | 주택관리번호 |
-| pblanc_no | TEXT NOT NULL | 공고번호 |
-| house_nm | TEXT | 주택명 |
-| house_secd | TEXT | 주택구분코드 (01:APT, 02:오피스텔, 04:무순위 등) |
-| house_dtl_secd | TEXT | 주택상세구분코드 (01:민영, 03:국민 등) |
-| rent_secd | TEXT | 분양구분 (0:분양, 1:임대) |
-| region_code | TEXT | 공급지역코드 |
-| region_name | TEXT | 공급지역명 |
-| address | TEXT | 공급위치 |
-| total_units | INTEGER | 공급규모 |
-| rcrit_date | TEXT | 모집공고일 |
-| receipt_start | TEXT | 청약접수시작일 |
-| receipt_end | TEXT | 청약접수종료일 |
-| spsply_start | TEXT | 특별공급 접수시작일 |
-| spsply_end | TEXT | 특별공급 접수종료일 |
-| gnrl_rank1_start | TEXT | 1순위 접수시작일 |
-| gnrl_rank1_end | TEXT | 1순위 접수종료일 |
-| winner_date | TEXT | 당첨자발표일 |
-| contract_start | TEXT | 계약시작일 |
-| contract_end | TEXT | 계약종료일 |
-| homepage_url | TEXT | 홈페이지 |
-| pblanc_url | TEXT | 공고 URL |
-| constructor | TEXT | 시공사 |
-| developer | TEXT | 시행사 |
-| move_in_month | TEXT | 입주예정월 |
-| is_speculative_area | TEXT | 투기과열지구 |
-| is_price_cap | TEXT | 분양가상한제 |
-| contact | TEXT | 문의처 |
-| status | TEXT | 청약예정/청약중/결과발표/완료 (자동 계산) |
-| source | TEXT | auto/manual |
-| created_at | TEXT | |
-| updated_at | TEXT | |
-
-- UNIQUE 제약: `(house_manage_no, pblanc_no)`
-- INDEX: `idx_realestate_status` on `status`
-- INDEX: `idx_realestate_region` on `region_name`
-
-### announcement_models (주택형별 상세)
-
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | INTEGER PK | |
-| house_manage_no | TEXT | FK → announcements |
-| pblanc_no | TEXT | FK → announcements |
-| model_no | TEXT | 모델번호 |
-| house_ty | TEXT | 주택형 (84A 등) |
-| supply_area | REAL | 공급면적(㎡) |
-| general_units | INTEGER | 일반공급 세대수 |
-| special_units | INTEGER | 특별공급 세대수 |
-| multi_child_units | INTEGER | 다자녀 |
-| newlywed_units | INTEGER | 신혼부부 |
-| first_life_units | INTEGER | 생애최초 |
-| old_parent_units | INTEGER | 노부모부양 |
-| institution_units | INTEGER | 기관추천 |
-| youth_units | INTEGER | 청년 |
-| newborn_units | INTEGER | 신생아 |
-| top_amount | INTEGER | 분양최고금액(만원) |
-
-- UNIQUE: `(house_manage_no, pblanc_no, model_no)`
-
-### user_profile (사용자 청약 프로필)
-
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | INTEGER PK | 항상 1 (단일 사용자) |
-| name | TEXT | 이름 |
-| age | INTEGER | 나이 |
-| is_homeless | BOOLEAN | 무주택 여부 |
-| is_householder | BOOLEAN | 세대주 여부 |
-| subscription_months | INTEGER | 청약통장 가입개월수 |
-| subscription_amount | INTEGER | 청약통장 납입총액(만원) |
-| family_members | INTEGER | 세대원 수 |
-| has_dependents | BOOLEAN | 부양가족 유무 |
-| children_count | INTEGER | 미성년 자녀수 |
-| is_newlywed | BOOLEAN | 신혼부부 여부 |
-| marriage_months | INTEGER | 혼인기간(개월) |
-| has_newborn | BOOLEAN | 2세 이하 자녀 유무 |
-| is_first_home | BOOLEAN | 생애최초 해당 여부 |
-| income_level | TEXT | 소득수준 (100%이하/100~130%/130~160%) |
-| preferred_regions | TEXT | 관심지역 JSON 배열 |
-| preferred_types | TEXT | 관심주택유형 JSON 배열 |
-| min_area | REAL | 최소 희망면적(㎡) |
-| max_area | REAL | 최대 희망면적(㎡) |
-| max_price | INTEGER | 최대 분양가(만원) |
-| updated_at | TEXT | |
-
-### match_results (매칭 결과)
-
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | INTEGER PK | |
-| announcement_id | INTEGER | FK → announcements |
-| model_id | INTEGER | FK → announcement_models (nullable) |
-| match_score | INTEGER | 매칭 점수 (0~100) |
-| match_reasons | TEXT | 매칭 사유 JSON 배열 |
-| eligible_types | TEXT | 지원 가능 유형 JSON 배열 |
-| is_new | BOOLEAN | 신규 매칭 여부 (알림용) |
-| created_at | TEXT | |
-
-- UNIQUE: `(announcement_id, model_id)`
-
----
-
-## 5. API 엔드포인트
-
-### 청약 공고
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/realestate/announcements` | 공고 목록 (필터: region, status, house_type, matched_only, sort, page, size) |
-| GET | `/api/realestate/announcements/{id}` | 공고 상세 (주택형별 포함) |
-| POST | `/api/realestate/announcements` | 수동 공고 등록 |
-| PUT | `/api/realestate/announcements/{id}` | 공고 수정 |
-| DELETE | `/api/realestate/announcements/{id}` | 공고 삭제 |
-
-### 수집 관리
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| POST | `/api/realestate/collect` | 수동 수집 트리거 |
-| GET | `/api/realestate/collect/status` | 마지막 수집 결과 (수집일시, 신규건수, 에러) |
-
-### 프로필
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/realestate/profile` | 내 프로필 조회 |
-| PUT | `/api/realestate/profile` | 프로필 수정 (upsert) |
-
-### 매칭
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/realestate/matches` | 매칭 결과 목록 (점수순, 신규 우선) |
-| POST | `/api/realestate/matches/refresh` | 매칭 재계산 |
-| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 처리 |
-
-### 대시보드
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/realestate/dashboard` | 요약 (진행중 공고수, 신규 매칭수, 다가오는 일정) |
-
----
-
-## 6. 매칭 엔진
-
-### 점수 산출 (0~100)
-
-| 기준 | 가중치 | 로직 |
-|------|--------|------|
-| 지역 매칭 | 30 | preferred_regions에 포함 → 30점 |
-| 주택유형 매칭 | 10 | preferred_types에 포함 → 10점 |
-| 면적 매칭 | 15 | min_area~max_area 범위 내 주택형 존재 → 15점 |
-| 가격 매칭 | 15 | max_price 이하 주택형 존재 → 15점 |
-| 자격 매칭 | 30 | 지원 가능 공급유형 수에 비례 |
-
-### 자격 매칭 세부
-
-| 공급유형 | 판별 조건 |
-|----------|----------|
-| 일반 1순위 | 무주택 + 세대주 + 청약통장 가입기간 충족 (투기과열 24개월, 그 외 12개월) |
-| 일반 2순위 | 1순위 미충족 시 |
-| 특별-신혼부부 | is_newlywed + 무주택 + 소득기준 |
-| 특별-생애최초 | is_first_home + 무주택 + 소득기준 |
-| 특별-다자녀 | children_count >= 2 + 무주택 |
-| 특별-노부모부양 | has_dependents + 무주택 |
-| 특별-청년 | age 19~39 + 무주택 |
-| 특별-신생아 | has_newborn + 무주택 |
-
-- 1개 유형 → 10점, 2개 → 20점, 3개 이상 → 30점
-- `eligible_types`: 지원 가능 유형 목록 저장
-- `match_reasons`: 각 판별 사유 저장
-
-### 상태 자동 계산
-
-```
-오늘 < receipt_start → 청약예정
-receipt_start ≤ 오늘 ≤ receipt_end → 청약중
-receipt_end < 오늘 ≤ winner_date → 결과발표
-오늘 > winner_date → 완료
-```
-
-### 매칭 실행 시점
-
-- 신규 공고 수집 후 자동 실행
-- 프로필 변경 시 `POST /matches/refresh`로 재계산
-- 매일 00:00 상태 갱신 시 재매칭
-
----
-
-## 7. 인프라 통합
-
-### Docker Compose
-
-```yaml
-realestate-lab:
- build: ./realestate-lab
- container_name: realestate-lab
- ports:
- - "18800:8000"
- volumes:
- - ${RUNTIME_PATH:-.}/data:/app/data
- environment:
- - DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY}
- restart: unless-stopped
-```
-
-### Nginx
-
-```nginx
-location /api/realestate/ {
- proxy_pass http://realestate-lab:8000;
-}
-```
-
-### APScheduler
-
-| 시간 | Job | 설명 |
-|------|-----|------|
-| 매일 09:00 | `run_collection` | 5개 API 수집 → 매칭 |
-| 매일 00:00 | `update_statuses` | 날짜 기반 상태 갱신 |
-
-### 배포
-
-- `scripts/deploy-nas.sh`에 `realestate-lab/` rsync 대상 추가
-
----
-
-## 8. lotto-backend 제거 대상
-
-| 파일 | 제거 항목 |
-|------|----------|
-| `backend/app/db.py` | `realestate_complexes` 테이블 생성, CRUD 함수 5개 |
-| `backend/app/main.py` | `ComplexCreate`/`ComplexUpdate` 모델, `/api/realestate/complexes` 라우트 4개 |
-
-기존 `realestate_complexes` 테이블 데이터는 마이그레이션 불필요 (스키마 완전 상이).
-
----
-
-## 9. 환경변수
-
-| 변수 | 설명 | 필수 |
-|------|------|------|
-| `DATA_GO_KR_API_KEY` | 공공데이터포털 API 키 | 선택 (미설정 시 수동 등록만 가능) |
-
----
-
-## 10. 향후 확장
-
-- **텔레그램 알림**: 신규 매칭 공고 발생 시 텔레그램 봇으로 push (알림 모듈 분리 구조 대비)
-- **경쟁률 조회**: 청약 접수 기간 중 경쟁률 실시간 수집
-- **실거래가 비교**: 주변 시세와 분양가 비교 분석
diff --git a/docs/superpowers/specs/2026-04-08-music-lab-suno-enhancement-design.md b/docs/superpowers/specs/2026-04-08-music-lab-suno-enhancement-design.md
deleted file mode 100644
index 02cd712..0000000
--- a/docs/superpowers/specs/2026-04-08-music-lab-suno-enhancement-design.md
+++ /dev/null
@@ -1,398 +0,0 @@
-# Music Lab Suno API 전체 기능 확장 설계
-
-> 작성일: 2026-04-08
-> 범위: 백엔드 (web-backend/music-lab) + 프론트엔드 (web-ui/src/pages/music)
-
----
-
-## 1. 목표
-
-Suno API의 미사용 기능을 전부 활용하여 music-lab을 완전한 AI 음악 프로덕션 스튜디오로 업그레이드한다. 실용도 기준 3단계로 점진 확장.
-
-## 2. 단계별 기능 목록
-
-### Phase 1: 핵심 생성 강화
-
-| # | 기능 | 설명 |
-|---|------|------|
-| 1-1 | 크레딧 잔액 상시 표시 | 헤더에 실시간 크레딧 배지, 10 이하 경고 |
-| 1-2 | 보컬 성별 선택 | Male / Female / Auto 3버튼 |
-| 1-3 | negativeTags | 제외 스타일 텍스트 + 프리셋 칩 |
-| 1-4 | V5_5 모델 추가 | SUNO_MODELS 딕셔너리에 추가 |
-| 1-5 | styleWeight / audioWeight | 0~1.0 슬라이더 2개 |
-| 1-6 | 커버 이미지 생성 | 라이브러리 카드에서 앨범아트 2장 생성 |
-
-### Phase 2: 후처리 파워업
-
-| # | 기능 | 설명 |
-|---|------|------|
-| 2-1 | WAV 고음질 변환 | MP3→WAV 변환 다운로드 |
-| 2-2 | 12스템 분리 | 드럼/베이스/기타 등 12개 개별 추출 |
-| 2-3 | 타임스탬프 가사 | 가라오케 스타일 싱크 재생 |
-| 2-4 | 스타일 부스트 | AI로 최적 스타일 프롬프트 자동 생성 |
-
-### Phase 3: 고급 크리에이티브
-
-| # | 기능 | 설명 |
-|---|------|------|
-| 3-1 | 오디오 업로드 + 커버 | 외부 음원을 Suno 스타일로 리메이크 |
-| 3-2 | 오디오 업로드 + 확장 | 외부 음원 이어서 만들기 |
-| 3-3 | 보컬 추가 | 인스트루멘탈에 AI 보컬 입히기 |
-| 3-4 | 인스트루멘탈 추가 | 보컬에 AI 반주 입히기 |
-| 3-5 | 뮤직비디오 생성 | MP4 자동 생성 |
-
----
-
-## 3. 백엔드 API 설계
-
-### 3.1 기존 엔드포인트 수정
-
-#### GenerateRequest 스키마 확장 (main.py)
-
-```python
-class GenerateRequest(BaseModel):
- # 기존 필드 유지
- provider: str = "suno"
- model: str = "V4"
- title: str = ""
- genre: str = ""
- moods: list[str] = []
- instruments: list[str] = []
- duration_sec: int | None = None
- bpm: int | None = None
- key: str = ""
- scale: str = ""
- prompt: str = ""
- lyrics: str = ""
- instrumental: bool = False
-
- # Phase 1 추가
- vocal_gender: str | None = None # "m" | "f" | None(auto)
- negative_tags: str | None = None # 제외 스타일
- style_weight: float | None = None # 0.0~1.0
- audio_weight: float | None = None # 0.0~1.0
-```
-
-#### SUNO_MODELS 확장 (suno_provider.py)
-
-```python
-SUNO_MODELS = {
- "V4": {"name": "V4", "max_duration": 240},
- "V4_5": {"name": "V4.5", "max_duration": 480},
- "V4_5PLUS": {"name": "V4.5+", "max_duration": 480},
- "V4_5ALL": {"name": "V4.5 All","max_duration": 480},
- "V5": {"name": "V5", "max_duration": 480},
- "V5_5": {"name": "V5.5", "max_duration": 480}, # 추가
-}
-```
-
-#### _build_suno_payload 확장
-
-새 파라미터를 Suno API 페이로드에 매핑:
-- `vocal_gender` → `vocalGender`
-- `negative_tags` → `negativeTags`
-- `style_weight` → `styleWeight`
-- `audio_weight` → `audioWeight`
-
-None이 아닌 경우에만 페이로드에 포함.
-
-### 3.2 신규 엔드포인트
-
-#### Phase 1
-
-```
-POST /api/music/cover-image
-Request: { "task_id": str, "suno_id": str }
-Response: { "task_id": str } → 폴링 → { "images": [url1, url2] }
-```
-
-#### Phase 2
-
-```
-POST /api/music/wav
-Request: { "task_id": str, "suno_id": str }
-Response: { "task_id": str } → 폴링 → { "wav_url": str }
-
-POST /api/music/stem-split
-Request: { "task_id": str, "suno_id": str }
-Response: { "task_id": str } → 폴링 → { "stems": { vocal: url, drums: url, ... } }
-
-GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
-Response: { "aligned_words": [...], "waveform_data": [...] }
-
-POST /api/music/style-boost
-Request: { "content": str }
-Response: { "result": str, "credits_consumed": float }
-```
-
-#### Phase 3
-
-```
-POST /api/music/upload-cover
-Request: { "upload_url": str, "model": str, "custom_mode": bool,
- "instrumental": bool, "prompt"?: str, "style"?: str, "title"?: str,
- "vocal_gender"?: str, "negative_tags"?: str,
- "style_weight"?: float, "audio_weight"?: float }
-Response: { "task_id": str }
-
-POST /api/music/upload-extend
-Request: { "upload_url": str, "model": str, "continue_at"?: float,
- "default_param_flag": bool, "prompt"?: str, "style"?: str, "title"?: str,
- "vocal_gender"?: str, "negative_tags"?: str }
-Response: { "task_id": str }
-
-POST /api/music/add-vocals
-Request: { "upload_url": str, "prompt": str, "title": str, "style": str,
- "negative_tags": str, "vocal_gender"?: str, "model"?: str,
- "style_weight"?: float, "audio_weight"?: float }
-Response: { "task_id": str }
-
-POST /api/music/add-instrumental
-Request: { "upload_url": str, "title": str, "tags": str, "negative_tags": str,
- "vocal_gender"?: str, "model"?: str,
- "style_weight"?: float, "audio_weight"?: float }
-Response: { "task_id": str }
-
-POST /api/music/video
-Request: { "task_id": str, "suno_id": str, "author"?: str, "domain_name"?: str }
-Response: { "task_id": str } → 폴링 → { "video_url": str }
-```
-
-### 3.3 suno_provider.py 리팩토링
-
-**공통 폴링 헬퍼 추출:**
-
-```python
-def _poll_suno_task(
- record_info_url: str,
- task_id: str,
- max_attempts: int = 40,
- interval: int = 8,
- success_extractor: Callable[[dict], Any] = None
-) -> dict:
- """
- 범용 Suno 작업 폴링.
- record_info_url: 예) "/api/v1/generate/record-info"
- success_extractor: SUCCESS 상태일 때 결과 추출 함수
- """
-```
-
-기존 `run_suno_generation`, `run_suno_extend`, `run_vocal_removal`도 이 헬퍼를 사용하도록 리팩토링.
-
-**신규 함수 목록:**
-
-| 함수 | Phase | Suno 엔드포인트 | 비동기 |
-|------|-------|----------------|--------|
-| `run_cover_image` | 1 | `POST /api/v1/suno/cover/generate` | 폴링 |
-| `run_wav_convert` | 2 | `POST /api/v1/wav/generate` | 폴링 |
-| `run_stem_split` | 2 | `POST /api/v1/vocal-removal/generate` (type=split_stem) | 폴링 |
-| `get_timestamped_lyrics` | 2 | `POST /api/v1/generate/get-timestamped-lyrics` | 동기 |
-| `generate_style_boost` | 2 | `POST /api/v1/style/generate` | 동기 |
-| `run_upload_cover` | 3 | `POST /api/v1/generate/upload-cover` | 폴링 |
-| `run_upload_extend` | 3 | `POST /api/v1/generate/upload-extend` | 폴링 |
-| `run_add_vocals` | 3 | `POST /api/v1/generate/add-vocals` | 폴링 |
-| `run_add_instrumental` | 3 | `POST /api/v1/generate/add-instrumental` | 폴링 |
-| `run_video_generate` | 3 | `POST /api/v1/mp4/generate` | 폴링 |
-
-### 3.4 DB 스키마 변경
-
-**music_library 테이블 컬럼 추가 (ALTER TABLE 마이그레이션):**
-
-```sql
-ALTER TABLE music_library ADD COLUMN cover_images TEXT NOT NULL DEFAULT '[]';
-ALTER TABLE music_library ADD COLUMN wav_url TEXT NOT NULL DEFAULT '';
-ALTER TABLE music_library ADD COLUMN video_url TEXT NOT NULL DEFAULT '';
-ALTER TABLE music_library ADD COLUMN stem_urls TEXT NOT NULL DEFAULT '{}';
-```
-
-**db.py 함수 추가:**
-
-```python
-def update_track_cover_images(track_id: int, images: list[str])
-def update_track_wav_url(track_id: int, wav_url: str)
-def update_track_video_url(track_id: int, video_url: str)
-def update_track_stem_urls(track_id: int, stems: dict)
-```
-
----
-
-## 4. 프론트엔드 UI/UX 설계
-
-### 4.1 파일 구조 (컴포넌트 분할)
-
-```
-web-ui/src/pages/music/
-├── MusicStudio.jsx -- 메인 (탭 라우팅 + 공유 상태)
-├── MusicStudio.css -- 전체 스타일
-├── components/
-│ ├── CreateTab.jsx -- 생성 폼 (6단계 + Phase 1 확장)
-│ ├── LyricsTab.jsx -- 가사 관리
-│ ├── LibraryTab.jsx -- 라이브러리 + 카드
-│ ├── RemixTab.jsx -- Phase 3: 업로드/리믹스
-│ ├── AudioPlayer.jsx -- 오디오 플레이어
-│ ├── LibraryCard.jsx -- 트랙 카드 + 액션 메뉴
-│ ├── StemModal.jsx -- 12스템 결과 모달
-│ ├── CoverArtModal.jsx -- 커버 이미지 선택 모달
-│ ├── SyncedLyricsPlayer.jsx -- 타임스탬프 가사 오버레이
-│ └── CreditsBadge.jsx -- 크레딧 잔액 배지
-```
-
-### 4.2 Phase 1 UI 변경
-
-#### 크레딧 배지 (CreditsBadge)
-- 위치: 헤더 우측 상단, 탭 옆
-- 표시: `⚡ 127 credits`
-- 10 이하: 빨간색 + pulse 애니메이션
-- 갱신 시점: 페이지 로드, 생성 완료 후, 30초 자동 갱신
-
-#### Create 탭 Step 4 확장
-
-**Vocal Gender (Suno 전용):**
-- 3버튼 토글: `♂ Male` | `♀ Female` | `Auto`
-- 기본값: Auto
-- 스타일: `.ms-gender-toggle` (기존 duration-rail과 유사)
-
-**Negative Tags:**
-- 텍스트 입력 필드 + 프리셋 칩
-- 프리셋: screaming, autotune, distortion, whisper, falsetto, rap
-- 칩 클릭 시 텍스트에 추가/제거
-- 스타일: `.ms-negative-tags` (기존 mood-rack과 유사)
-
-**Style Weight / Audio Weight:**
-- range 슬라이더 2개 (기존 BPM 슬라이더와 동일 스타일)
-- 레이블: "Prompt ↔ Style Balance" / "Original ↔ AI Balance"
-- 0~100 표시, API 전송 시 0.0~1.0 변환
-- 기본값: 미설정 (슬라이더 중앙, 값 전송 안 함)
-
-#### Library 카드 액션 메뉴 확장
-
-기존 5개 버튼 → 6개 (Cover Art 추가)
-4개 초과 시 `•••` 더보기 드롭다운 메뉴로 분기:
-- 기본 노출: Play, Download, Delete
-- 더보기: Extend, Vocal Split, Cover Art (+ Phase 2/3 추가분)
-
-#### CoverArtModal
-- 2장 이미지 좌우 비교 표시
-- 각 이미지 아래 "이 이미지 사용" 버튼
-- 선택 시 라이브러리 카드 썸네일 업데이트
-
-### 4.3 Phase 2 UI 변경
-
-#### Library 카드 더보기 메뉴 추가
-- WAV 다운로드
-- Stem Split (12스템)
-- Synced Lyrics
-- Style Boost (Create 탭 프롬프트로 전달)
-
-#### StemModal
-- 3×4 그리드 카드 레이아웃
-- 각 스템: 이름 아이콘 + 미니 재생 버튼 + 다운로드 버튼
-- 12개 스템: vocal, backing_vocals, drums, bass, guitar, keyboard, strings, brass, woodwinds, percussion, synth, fx
-- 스타일: 기존 라이브러리 카드의 축소 버전
-
-#### SyncedLyricsPlayer
-- AudioPlayer 교체/오버레이 모드
-- 재생 중 현재 단어를 accent 컬러로 하이라이트
-- 하단에 waveformData 기반 파형 바
-- 닫기 버튼으로 일반 플레이어 복귀
-
-#### Style Boost 버튼
-- Create 탭 장르 선택 영역에 `✨ Style Boost` 버튼
-- 클릭 시: 현재 genre + moods 조합 → API 호출 → 결과를 프롬프트에 삽입
-- 로딩 중 버튼 스피너
-
-### 4.4 Phase 3 UI 변경
-
-#### Remix 탭 (신규 4번째 탭)
-- 탭 레이블: `REMIX`
-- 상단: 오디오 URL 입력 필드 (또는 라이브러리에서 선택)
-- 4개 액션 카드 그리드 (2×2):
- - **AI Cover**: 아이콘 + 설명 + 파라미터 폼 (펼침)
- - **Extend**: 아이콘 + 설명 + continue_at 입력
- - **Add Vocals**: 아이콘 + 설명 + prompt/style 입력
- - **Add Instrumental**: 아이콘 + 설명 + tags 입력
-- 선택한 카드만 펼쳐서 세부 옵션 표시
-- 하단: 뮤직비디오 생성 버튼 (라이브러리에서 선택한 곡 대상)
-
-### 4.5 디자인 토큰 추가
-
-```css
-/* Phase 1 추가 토큰 */
---ms-danger: #e74c3c; /* 크레딧 경고 빨간색 */
---ms-male: #4a9eff; /* 남성 보컬 파란색 */
---ms-female: #ff6b9d; /* 여성 보컬 분홍색 */
-```
-
----
-
-## 5. api.js 추가 함수
-
-```javascript
-// Phase 1
-export const generateCoverImage = (payload) => apiPost('/api/music/cover-image', payload);
-
-// Phase 2
-export const convertToWav = (payload) => apiPost('/api/music/wav', payload);
-export const splitStems = (payload) => apiPost('/api/music/stem-split', payload);
-export const getTimestampedLyrics = (taskId, sunoId) =>
- apiGet(`/api/music/timestamped-lyrics?task_id=${taskId}&suno_id=${sunoId}`);
-export const generateStyleBoost = (content) => apiPost('/api/music/style-boost', { content });
-
-// Phase 3
-export const uploadAndCover = (payload) => apiPost('/api/music/upload-cover', payload);
-export const uploadAndExtend = (payload) => apiPost('/api/music/upload-extend', payload);
-export const addVocals = (payload) => apiPost('/api/music/add-vocals', payload);
-export const addInstrumental = (payload) => apiPost('/api/music/add-instrumental', payload);
-export const generateVideo = (payload) => apiPost('/api/music/video', payload);
-```
-
----
-
-## 6. 폴링 패턴
-
-모든 비동기 작업은 기존 음악 생성과 동일한 폴링 패턴:
-
-1. POST 요청 → `{ task_id }` 반환
-2. 프론트: 3초 간격 `GET /api/music/status/{task_id}` 폴링
-3. 백엔드: BackgroundTask에서 Suno 폴링 → music_tasks 상태 업데이트
-4. `status: succeeded` → 결과 반환 + 라이브러리 자동 갱신
-
-동기 API (타임스탬프 가사, 스타일 부스트)는 즉시 응답.
-
----
-
-## 7. 구현 순서
-
-### Phase 1 (핵심 생성 강화)
-1. 백엔드: SUNO_MODELS에 V5_5 추가 + 공통 폴링 헬퍼 추출
-2. 백엔드: GenerateRequest 스키마 확장 + _build_suno_payload 매핑
-3. 백엔드: 커버 이미지 생성 엔드포인트 + suno_provider 함수
-4. 백엔드: DB 마이그레이션 (cover_images, wav_url, video_url, stem_urls)
-5. 프론트: 컴포넌트 분할 (MusicStudio → CreateTab, LyricsTab, LibraryTab, etc.)
-6. 프론트: CreditsBadge 구현
-7. 프론트: Create 탭 Step 4 확장 (vocal gender, negative tags, weight 슬라이더)
-8. 프론트: LibraryCard 더보기 메뉴 + CoverArtModal
-9. 프론트: api.js 함수 추가
-
-### Phase 2 (후처리 파워업)
-10. 백엔드: WAV/스템/타임스탬프/스타일부스트 엔드포인트
-11. 프론트: StemModal + SyncedLyricsPlayer + Style Boost 버튼
-12. 프론트: Library 카드 Phase 2 액션 추가
-
-### Phase 3 (고급 크리에이티브)
-13. 백엔드: upload-cover/upload-extend/add-vocals/add-instrumental/video 엔드포인트
-14. 프론트: RemixTab 구현
-15. 프론트: Library 카드 Phase 3 액션 (Video)
-
----
-
-## 8. 제약사항 및 주의점
-
-- **Suno 파일 보관**: 14~15일 후 자동 삭제 → 로컬 다운로드 필수 (기존 패턴 유지)
-- **동시 요청 제한**: 10초당 20건 → 배치 작업 시 rate limiting 고려
-- **12스템 분리 비용**: 50크레딧 → UI에 비용 경고 표시
-- **WAV 중복 변환**: 409 에러 → 이미 변환된 경우 기존 URL 반환
-- **뮤직비디오**: taskId + audioId 필요 → 라이브러리에 task_id 저장 필수
-- **V5_5 모델**: 커스텀 모델 → 문서상 제한사항 추가 확인 필요
-- **크레딧 조회 엔드포인트**: 기존 `/api/v1/get-credits` vs 문서 `/api/v1/generate/credit` → 두 엔드포인트 폴백 시도
-- **upload 계열 API**: upload_url은 외부 접근 가능한 URL이어야 함 → 로컬 파일은 NAS nginx URL로 변환
diff --git a/docs/superpowers/specs/2026-04-11-agent-office-design.md b/docs/superpowers/specs/2026-04-11-agent-office-design.md
deleted file mode 100644
index 8776224..0000000
--- a/docs/superpowers/specs/2026-04-11-agent-office-design.md
+++ /dev/null
@@ -1,444 +0,0 @@
-# Agent Office - AI 에이전트 사무실 시각화 설계
-
-## 개요
-
-Lab 하위에 2D 픽셀아트 스타일의 가상 사무실을 구현하여, AI 에이전트들이 실시간으로 작업하는 모습을 게임처럼 시각화하고 상호작용하는 페이지.
-
-### 핵심 컨셉
-- **게임 같은 사무실**: 2D 픽셀아트 오픈 오피스에 에이전트 캐릭터들이 배치
-- **실제 작업 수행**: 에이전트들이 기존 백엔드 서비스 API를 호출하여 실제 결과물 생성
-- **직접 지시**: 에이전트 클릭 → 채팅/명령 패널로 지시, 승인 요청 시 알림 표시
-- **텔레그램 양방향**: 알림 발송 + 인라인 버튼으로 승인/거절/수정
-- **아이들 행동**: 장시간 명령 없으면 휴게실에서 커피, 졸기, 동료 잡담 등
-
-### MVP 범위
-- **에이전트 2개**: StockAgent (주식 뉴스/주가 알람), MusicAgent (작곡 파이프라인)
-- **사무실**: 단일 오픈 오피스 (향후 방/층 확장 가능)
-- **텔레그램**: 양방향 (알림 + 인라인 버튼 승인)
-
----
-
-## 1. 아키텍처
-
-```
-┌─────────────────────────────────────────────────┐
-│ Frontend (React) │
-│ │
-│ ┌──────────────┐ ┌─────────────────────────┐ │
-│ │ OfficeCanvas │ │ React Overlay │ │
-│ │ (Canvas 2D) │ │ - ChatPanel │ │
-│ │ - 타일맵 렌더 │ │ - AgentStatus │ │
-│ │ - 스프라이트 │ │ - TaskHistory │ │
-│ │ - 클릭 히트맵 │ │ - ApprovalDialog │ │
-│ └──────────────┘ └─────────────────────────┘ │
-│ │
-│ ┌──────────────────────────────────────────┐ │
-│ │ useAgentManager (상태 + WebSocket) │ │
-│ └──────────────────────────────────────────┘ │
-└──────────────────┬──────────────────────────────┘
- │ WebSocket + REST
-┌──────────────────▼──────────────────────────────┐
-│ Backend: agent-office (새 서비스, 포트 18900) │
-│ │
-│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │
-│ │ Scheduler │ │ Agent FSM │ │ Telegram Bot │ │
-│ │(APScheduler)│ │ (상태머신) │ │ (양방향) │ │
-│ └────────────┘ └────────────┘ └──────────────┘ │
-│ │
-│ ┌──────────────────────────────────────────┐ │
-│ │ Service Proxy (기존 서비스 API 호출) │ │
-│ │ stock-lab / music-lab 등 │ │
-│ └──────────────────────────────────────────┘ │
-└─────────────────────────────────────────────────┘
-```
-
-### 핵심 결정
-- **agent-office**: 새 백엔드 서비스 (포트 18900). 기존 서비스는 수정하지 않음
-- **Service Proxy 패턴**: agent-office가 기존 서비스 API를 HTTP 호출
-- **WebSocket**: 에이전트 상태 변화를 실시간 전달
-- **Canvas + React 오버레이 하이브리드**: 게임 렌더링은 Canvas, UI 패널은 React DOM
-
----
-
-## 2. 에이전트 상태 머신 (FSM)
-
-### 상태 전이
-
-```
-┌──────┐ 스케줄/지시 ┌──────────┐ 완료 ┌──────────┐
-│ idle │ ──────────────→ │ working │ ───────→ │ reporting│
-└──┬───┘ └────┬─────┘ └────┬─────┘
- │ │ 승인 필요 │
- │ 장시간 idle ▼ │ 결과 전달 후
- │ ┌───────────┐ │
- ▼ │ waiting │ │
-┌──────┐ │ (승인대기) │ │
-│ break│ └───────────┘ │
-│ (휴식)│ │
-└──┬───┘◄───────────────────────────────────────────┘
- │ 새 작업 발생
- └──────────→ idle
-```
-
-### 상태별 시각화
-
-| 상태 | 캐릭터 행동 | 위치 | 오버레이 |
-|------|------------|------|---------|
-| `idle` | 모니터 보며 대기 애니메이션 | 자기 데스크 | 없음 |
-| `working` | 타이핑 애니메이션, 모니터에 진행 표시 | 자기 데스크 | 작업명 말풍선 |
-| `waiting` | 살짝 좌우 흔들림 | 자기 데스크 | `❗` 아이콘 (클릭 유도) |
-| `reporting` | 결과물 들고 걸어감 | 데스크 → 회의 테이블 | 결과 요약 말풍선 |
-| `break` | 커피 마시기/졸기/산책/잡담 | 휴게실/복도 | `☕`/`💤` 아이콘 |
-
-### 아이들 행동 규칙
-- idle 상태 5분 경과 → 50% 확률로 break 전환
-- break 지속: 1~3분 랜덤 → idle 복귀
-- break 중 에이전트끼리 근처에 있으면 잡담 애니메이션
-- 새 작업 발생 시 즉시 break 종료 → idle → working
-
-### 승인 흐름별 분류
-
-| 에이전트 | 자동 실행 | 승인 필요 |
-|---------|----------|----------|
-| Stock | 뉴스 요약, 주가 알람 | - |
-| Music | - | 작곡 (프롬프트 확인 후) |
-| Lotto (향후) | 통계 분석, 추천번호 | 구매 관련 |
-| Blog (향후) | - | 키워드 제시 후 글 생성 |
-| Realestate (향후) | 공고 수집, 매칭 | - |
-| Claude AI (향후) | - | 직접 지시 + 승인 |
-
----
-
-## 3. 사무실 맵 & 렌더링
-
-### 타일맵 구조 (MVP: 단일 오픈 오피스)
-
-```
-┌─────────────────────────────────────────┐
-│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
-│ │Stock│ │Music│ │Claude│ │ (빈) │ │
-│ │Desk │ │Desk │ │Desk │ │향후용│ │
-│ └─────┘ └─────┘ └─────┘ └─────┘ │
-│ │
-│ ┌───────────┐ │
-│ │ 회의 테이블 │ │
-│ │ (보고구역) │ │
-│ └───────────┘ │
-│ │
-│ ┌──────────┐ ┌─────────────────┐ │
-│ │ 휴게실 │ │ CEO 데스크 (나) │ │
-│ │ coffee │ │ │ │
-│ └──────────┘ └─────────────────┘ │
-└─────────────────────────────────────────┘
-```
-
-### 렌더링 계층 (아래→위)
-1. **바닥 타일**: 카펫, 나무 바닥
-2. **가구**: 데스크, 의자, 소파, 화분, 커피머신
-3. **캐릭터**: 에이전트 스프라이트 (상태별 애니메이션)
-4. **오버레이**: 말풍선, 상태 아이콘, 이름표
-
-### 스프라이트 에셋
-- 무료 픽셀아트 에셋팩 활용 (타일셋, 가구)
-- 에이전트 캐릭터: 기본 인물 스프라이트 + 액세서리로 구분
- - Stock: 넥타이 + 차트 모니터
- - Music: 헤드폰 + 음표 이펙트
- - Claude: 보라색 톤 + AI 아이콘
-- 스프라이트시트: 4방향 × 4프레임 (idle, walk, work, break)
-
-### Canvas 렌더링 엔진
-- **게임 루프**: `requestAnimationFrame` 기반, 60fps 타겟
-- **카메라**: 고정 뷰 (MVP), 향후 줌/팬 추가 가능
-- **클릭 히트맵**: 캐릭터 바운딩 박스 체크 → 클릭 시 React 이벤트 발생
-- **이동**: 웨이포인트 기반 lerp (데스크↔회의실↔휴게실)
-
----
-
-## 4. 백엔드: agent-office 서비스
-
-### 파일 구조
-
-```
-agent-office/
-├── app/
-│ ├── main.py # FastAPI + WebSocket + lifespan
-│ ├── db.py # SQLite (agent_tasks, agent_logs, agent_config)
-│ ├── config.py # 환경변수, 서비스 URL 설정
-│ ├── scheduler.py # APScheduler 스케줄 관리
-│ ├── telegram_bot.py # Telegram Bot API 양방향
-│ ├── websocket_manager.py # WebSocket 연결 관리 + 브로드캐스트
-│ ├── service_proxy.py # 기존 서비스 API 호출 래퍼
-│ ├── agents/
-│ │ ├── base.py # BaseAgent (FSM, 공통 로직)
-│ │ ├── stock.py # StockAgent
-│ │ └── music.py # MusicAgent
-│ └── models.py # Pydantic 모델
-├── Dockerfile
-└── requirements.txt
-```
-
-### DB 테이블 (agent_office.db)
-
-**agent_config**
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| agent_id | TEXT PK | 에이전트 식별자 (stock, music, ...) |
-| display_name | TEXT | 표시명 ("주식 트레이더") |
-| enabled | BOOLEAN | 활성 상태 |
-| schedule_config | TEXT (JSON) | 스케줄 설정 |
-| custom_config | TEXT (JSON) | 에이전트별 커스텀 설정 (감시 종목 등) |
-| created_at | TEXT | 생성 시각 |
-| updated_at | TEXT | 수정 시각 |
-
-**agent_tasks**
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | TEXT PK (UUID) | 작업 ID |
-| agent_id | TEXT FK | 에이전트 |
-| task_type | TEXT | 작업 유형 (news_summary, price_alert, compose, ...) |
-| status | TEXT | pending / approved / working / succeeded / failed |
-| input_data | TEXT (JSON) | 입력 파라미터 |
-| result_data | TEXT (JSON) | 결과 데이터 |
-| requires_approval | BOOLEAN | 승인 필요 여부 |
-| approved_at | TEXT | 승인 시각 |
-| approved_via | TEXT | 승인 경로 (web / telegram) |
-| created_at | TEXT | 생성 시각 |
-| completed_at | TEXT | 완료 시각 |
-
-**agent_logs**
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | INTEGER PK | 자동 증가 |
-| agent_id | TEXT FK | 에이전트 |
-| task_id | TEXT FK | 관련 작업 (nullable) |
-| level | TEXT | info / warn / error |
-| message | TEXT | 로그 메시지 |
-| created_at | TEXT | 시각 |
-
-**telegram_state**
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| callback_id | TEXT PK | 텔레그램 콜백 ID |
-| task_id | TEXT FK | 매핑된 작업 |
-| agent_id | TEXT FK | 매핑된 에이전트 |
-| action | TEXT | approve / reject / modify |
-| responded | BOOLEAN | 응답 완료 여부 |
-| created_at | TEXT | 생성 시각 |
-
-### BaseAgent 인터페이스
-
-```python
-class BaseAgent:
- agent_id: str
- state: str # idle, working, waiting, reporting, break
-
- async def on_schedule(self) -> None:
- """스케줄러에 의해 호출. 자동 작업 실행."""
-
- async def on_command(self, command: str, params: dict) -> dict:
- """사용자 직접 지시 처리."""
-
- async def on_approval(self, task_id: str, approved: bool, feedback: str) -> None:
- """승인/거절 콜백."""
-
- async def get_status(self) -> dict:
- """현재 상태 + 최근 작업 요약."""
-```
-
-### MVP 에이전트 상세
-
-**StockAgent:**
-- 스케줄: 매일 08:00 `on_schedule()` → `stock-lab GET /api/stock/news` 호출
-- AI 요약: 뉴스 데이터를 Ollama(192.168.45.59)로 요약 생성
-- 텔레그램 전송: 요약 결과를 포맷팅하여 발송 (자동, 승인 불필요)
-- 주가 알람: `agent_config.custom_config`에 감시 종목/조건 저장, 주기적 체크
-- 상태 전이: idle → working(뉴스 수집) → reporting(텔레그램 전송) → idle
-
-**MusicAgent:**
-- 트리거: 사용자 웹/텔레그램 지시 → `on_command()`
-- 프롬프트 확인: 사용자 입력 프롬프트를 텔레그램으로 전송 + 인라인 버튼
-- 승인 시: `music-lab POST /api/music/generate` 호출
-- 상태 폴링: `music-lab GET /api/music/status/{task_id}` → 완료까지 반복
-- 결과 알림: 생성된 음악 URL을 텔레그램 + 웹에 전달
-- 상태 전이: idle → waiting(프롬프트 승인 대기) → working(생성 중) → reporting(결과 전달) → idle
-
----
-
-## 5. 텔레그램 봇
-
-### 구성
-- **Telegram Bot API** + **Webhook 수신** (NAS에서)
-- agent-office 서비스 내부에 통합 (별도 프로세스 아님)
-- Nginx: `/api/agent-office/telegram/webhook` → `agent-office:8000`
-
-### 환경변수
-- `TELEGRAM_BOT_TOKEN`: Bot Father에서 발급
-- `TELEGRAM_CHAT_ID`: 사용자 채팅 ID (1:1 봇)
-- `TELEGRAM_WEBHOOK_URL`: Webhook 수신 URL (NAS 외부 접근 가능 URL)
-
-### 메시지 포맷
-
-**자동 알림 (뉴스 요약):**
-```
-📈 [주식 에이전트] 아침 뉴스 요약
-━━━━━━━━━━━━━━━━
-• 삼성전자: 반도체 수출 호조...
-• 코스피: 외인 순매수 전환...
-• 미국 CPI 발표 예정...
-
-📊 관심종목 현황
-삼성전자 82,500원 (+2.1%)
-AAPL $185.20 (+1.2%)
-```
-
-**승인 요청 (작곡):**
-```
-🎵 [음악 에이전트] 작곡 요청
-━━━━━━━━━━━━━━━━
-프롬프트: "Lo-fi hip hop, rainy day, piano"
-스타일: Chill, Ambient
-모델: V5.5
-
-[✅ 승인] [❌ 거절] [✏️ 수정]
-```
-
-**주가 알람:**
-```
-🚨 [주식 에이전트] 주가 알림
-━━━━━━━━━━━━━━━━
-삼성전자 82,500원
-조건: 82,000원 이상 → 도달!
-현재 등락: +2.1%
-```
-
-### 양방향 흐름
-1. 에이전트 → `telegram_bot.send_message()` → 텔레그램
-2. 사용자 → 인라인 버튼 클릭 or 텍스트 입력
-3. 텔레그램 → Webhook POST → `telegram_bot.handle_webhook()`
-4. `handle_webhook()` → `telegram_state` 조회 → 에이전트 `on_approval()` 호출
-5. 에이전트 FSM 상태 전이 → WebSocket 브로드캐스트 → 프론트엔드 반영
-
----
-
-## 6. 프론트엔드 구조
-
-### 파일 구조
-
-```
-src/pages/agent-office/
-├── AgentOffice.jsx # 메인 페이지 (Canvas + Overlay 컨테이너)
-├── AgentOffice.css # 스타일
-├── canvas/
-│ ├── OfficeRenderer.js # Canvas 렌더링 엔진 (게임루프)
-│ ├── SpriteSheet.js # 스프라이트시트 로더 + 프레임 애니메이션
-│ ├── TileMap.js # 타일맵 데이터 + 렌더링
-│ └── AgentSprite.js # 에이전트 캐릭터 (위치, 상태, 이동, 애니메이션)
-├── components/
-│ ├── ChatPanel.jsx # 에이전트 채팅/명령 패널
-│ ├── AgentBubble.jsx # 말풍선/상태 아이콘 오버레이
-│ ├── TaskHistory.jsx # 작업 이력 사이드패널
-│ └── ApprovalDialog.jsx # 승인 요청 다이얼로그
-├── hooks/
-│ ├── useAgentManager.js # WebSocket + 에이전트 상태 관리
-│ └── useOfficeCanvas.js # Canvas 초기화 + 이벤트 바인딩
-└── assets/
- ├── tileset.png # 사무실 타일셋 (16x16 or 32x32)
- ├── agents.png # 에이전트 스프라이트시트
- └── office-map.json # 타일맵 데이터
-```
-
-### WebSocket 프로토콜
-
-**서버 → 클라이언트:**
-```json
-{"type": "agent_state", "agent": "stock", "state": "working", "detail": "뉴스 수집 중..."}
-{"type": "agent_state", "agent": "music", "state": "waiting", "detail": "프롬프트 승인 대기", "task_id": "abc-123"}
-{"type": "task_complete", "agent": "stock", "task_id": "...", "result": {"summary": "..."}}
-{"type": "agent_move", "agent": "stock", "target": "break_room"}
-```
-
-**클라이언트 → 서버:**
-```json
-{"type": "command", "agent": "music", "action": "compose", "params": {"prompt": "...", "style": "..."}}
-{"type": "approval", "agent": "music", "task_id": "abc-123", "approved": true}
-{"type": "query", "agent": "stock", "action": "status"}
-```
-
-### ChatPanel 기능
-- 에이전트별 채팅 히스토리 표시
-- 텍스트 입력 + 빠른 액션 버튼
-- 승인 대기 중인 작업 강조 표시
-- 최근 작업 결과 인라인 표시
-
----
-
-## 7. 인프라 변경
-
-### Docker Compose 추가
-
-```yaml
-agent-office:
- build: ./agent-office
- container_name: agent-office
- ports:
- - "18900:8000"
- volumes:
- - ${RUNTIME_PATH}/data:/app/data
- environment:
- - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
- - TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL}
- - STOCK_LAB_URL=http://stock-lab:8000
- - MUSIC_LAB_URL=http://music-lab:8000
- depends_on:
- - stock-lab
- - music-lab
- restart: unless-stopped
-```
-
-### Nginx 라우팅 추가
-
-```nginx
-location /api/agent-office/ {
- proxy_pass http://agent-office:8000;
- proxy_http_version 1.1;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade"; # WebSocket 지원
-}
-```
-
-### 라우팅 (React Router)
-
-```javascript
-// routes.jsx
-{ path: 'agent-office', lazy: () => import('./pages/agent-office/AgentOffice') }
-```
-
-Lab 페이지(EffectLab.jsx)의 LAB_ITEMS에 Agent Office 항목 추가.
-
----
-
-## 8. 향후 확장 (Phase 2+)
-
-| 단계 | 내용 |
-|------|------|
-| Phase 2 | LottoAgent, BlogAgent, RealestateAgent 추가 |
-| Phase 3 | Claude AI Agent (자연어 복합 지시) |
-| Phase 4 | 방/층 확장 (부서별 공간 분리) |
-| Phase 5 | 에이전트 간 협업 시각화 (회의 테이블에서 토론) |
-| Phase 6 | 에이전트 커스텀 (이름, 외형, 성격 설정) |
-
----
-
-## 9. 기술 스택 요약
-
-| 레이어 | 기술 |
-|--------|------|
-| 사무실 렌더링 | HTML5 Canvas 2D (커스텀 엔진) |
-| 프론트엔드 | React 18 + Vite |
-| 실시간 통신 | WebSocket (FastAPI) |
-| 백엔드 | FastAPI (Python 3.12) |
-| DB | SQLite (agent_office.db) |
-| 스케줄러 | APScheduler |
-| 메시징 | Telegram Bot API (Webhook) |
-| 서비스 연동 | HTTP Proxy (기존 서비스 API 호출) |
diff --git a/docs/superpowers/specs/2026-04-15-lotto-ai-curator-design.md b/docs/superpowers/specs/2026-04-15-lotto-ai-curator-design.md
deleted file mode 100644
index c935ea0..0000000
--- a/docs/superpowers/specs/2026-04-15-lotto-ai-curator-design.md
+++ /dev/null
@@ -1,350 +0,0 @@
-# Lotto AI 큐레이터 — 설계 문서
-
-> 작성일: 2026-04-15
-> 목표: 난잡한 lotto 랩을 **주간 AI 브리핑**을 축으로 재정리. 매주 월요일 아침 자동으로 "이번 주 5세트 + 내러티브 리포트"를 생성해 구매 의사결정 참고.
-
----
-
-## 1. 배경
-
-- 현재 lotto 랩은 분석(5가지)·추천(통계/히트맵/메타)·시뮬레이션·전략진화 등 기능이 풍부하지만 출력이 분산되어 "결국 뭘 사야 하지"가 한눈에 들어오지 않음.
-- `docs/lotto-premium-roadmap.md` Phase 1 방향(신뢰 기반 + 주간 리포트)을 AI 활용으로 압축 실행.
-
-## 2. 핵심 결정사항
-
-| 항목 | 결정 |
-|------|------|
-| AI 역할 | **큐레이터(Curator)** — 숫자 생성 X, 기존 엔진 후보 중 5세트 선별 + 내러티브 작성 |
-| 브리핑 형식 | **A+B 조합** — 리포트형 내러티브 + 최종 5세트 카드 |
-| 트리거 | **매주 월요일 07:00 자동 생성** (웹 UI 전용, 텔레그램 미전송) |
-| 로직 위치 | **agent-office `lotto` 에이전트** (lotto-backend는 엔진·저장소 역할만) |
-| 모델 | `claude-sonnet-4-5` (주 1회 호출, 품질 우선) — 환경변수 `LOTTO_CURATOR_MODEL` |
-| 사용량 노출 | 브리핑 카드 + 큐레이터 사용량 API(월간 집계) |
-
----
-
-## 3. 아키텍처
-
-```
-┌──────────────────────────────────────────────────────────────┐
-│ 월요일 07:00 APScheduler (agent-office) │
-│ → lotto 에이전트 curate_weekly 태스크 │
-│ │
-│ ┌─────────────────────────────────────────────────────┐ │
-│ │ 1. GET /api/lotto/curator/candidates?n=20 │ │
-│ │ 2. GET /api/lotto/curator/context │ │
-│ │ 3. Claude Sonnet 4.5 호출 (strict JSON out) │ │
-│ │ 4. 스키마·번호 검증 + 1회 재시도 │ │
-│ │ 5. POST /api/lotto/briefing (저장) │ │
-│ └─────────────────────────────────────────────────────┘ │
-│ │
-│ 사용자는 웹에서: │
-│ GET /api/lotto/briefing/latest (최신 표시) │
-│ POST /api/agent-office/command {agent:"lotto", …} (수동) │
-└──────────────────────────────────────────────────────────────┘
-```
-
-서비스 경계: **lotto-backend = 데이터·엔진 / agent-office = AI 판단**.
-
----
-
-## 4. Backend (lotto-backend)
-
-### 4.1 신규 API
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/lotto/curator/candidates` | 큐레이터용 후보 N세트 + 세트별 피처 |
-| GET | `/api/lotto/curator/context` | 주간 맥락(핫/콜드·직전 회차 분석·내 최근 성과) |
-| POST | `/api/lotto/briefing` | 큐레이터 결과 저장 |
-| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
-| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
-| GET | `/api/lotto/briefing?limit=10` | 브리핑 이력 |
-| GET | `/api/lotto/curator/usage?days=30` | 큐레이터 토큰·비용 집계 |
-
-### 4.2 `GET /curator/candidates` 응답 구조
-
-```json
-{
- "draw_no": 1180,
- "generated_at": "2026-04-13T07:00:00Z",
- "candidates": [
- {
- "numbers": [3, 14, 22, 29, 35, 41],
- "source": "simulation" | "meta" | "heatmap" | "statistics",
- "features": {
- "odd_count": 3,
- "even_count": 3,
- "low_count": 3, // 1~22
- "high_count": 3, // 23~45
- "range_distribution": [1,1,1,1,1,1], // 1-10,11-20,...,41-45
- "has_consecutive": true,
- "hot_number_count": 1, // context.hot_numbers 교집합
- "cold_number_count": 2, // context.cold_numbers 교집합
- "sum": 144,
- "historical_match_avg": 2.3 // 이 세트가 과거 실제 회차와 평균 몇 개 일치
- }
- }
- ]
-}
-```
-
-중복 제거: 6숫자 정렬 튜플 기준 set 해시. 각 세트의 `source`는 가장 먼저 포함시킨 엔진.
-
-### 4.3 `GET /curator/context` 응답 구조
-
-```json
-{
- "draw_no": 1180,
- "hot_numbers": [3, 17, 28], // 최근 10회 과출현 top
- "cold_numbers": [7, 22, 41], // 최근 30회 미출현 top
- "last_draw_summary": "1179회: 7, 12, 18, 24, 31, 40 (홀4짝2, 저4고2)",
- "recent_analysis": {
- "avg_sum": 138,
- "avg_odd_count": 2.8
- },
- "my_recent_performance": [
- { "draw_no": 1177, "purchased_sets": 5, "best_match": 3 },
- { "draw_no": 1178, "purchased_sets": 5, "best_match": 2 },
- { "draw_no": 1179, "purchased_sets": 5, "best_match": 4 }
- ]
-}
-```
-
-### 4.4 신규 테이블 `lotto_briefings`
-
-```sql
-CREATE TABLE lotto_briefings (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- draw_no INTEGER UNIQUE NOT NULL,
- picks TEXT NOT NULL, -- JSON: 5세트 + reason + risk_tag
- narrative TEXT NOT NULL, -- JSON: headline/summary_3lines/hot_cold/warnings
- confidence INTEGER NOT NULL, -- 0~100
- model TEXT NOT NULL,
- tokens_input INTEGER DEFAULT 0,
- tokens_output INTEGER DEFAULT 0,
- cache_read INTEGER DEFAULT 0,
- cache_write INTEGER DEFAULT 0,
- latency_ms INTEGER DEFAULT 0,
- source TEXT NOT NULL DEFAULT 'auto', -- 'auto' | 'manual'
- generated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
-);
-CREATE INDEX idx_briefings_draw ON lotto_briefings(draw_no DESC);
-```
-
-### 4.5 파일 구조 정리
-
-`backend/app/main.py` 933줄 → 라우터 분리:
-- `backend/app/routers/briefing.py` — briefing CRUD + curator usage
-- `backend/app/routers/curator.py` — candidates / context
-- `backend/app/curator_helpers.py` — 후보 중복 제거, 피처 계산, 맥락 추출
-
-기존 `main.py`는 라우터 등록과 앱 조립만 담당(목표 ~300줄).
-
----
-
-## 5. agent-office `lotto` 에이전트
-
-### 5.1 파일 구조
-
-```
-agent-office/app/
- agents/lotto.py # LottoAgent (BaseAgent 상속)
- curator/
- __init__.py
- pipeline.py # curate_weekly() 메인 플로우
- prompt.py # system prompt + 출력 스키마 정의
- schema.py # pydantic 응답 모델 + 검증
- service.py # lotto-backend 호출 래퍼 (httpx)
-```
-
-`service_proxy.py`에 `lotto_candidates()`, `lotto_context()`, `lotto_save_briefing()` 메서드 추가.
-
-### 5.2 태스크 타입
-
-- `curate_weekly` — 자동/수동 공통. 파라미터 없음(draw_no 자동 계산).
-
-### 5.3 큐레이터 규칙 (system prompt 요지)
-
-```
-당신은 로또 번호 큐레이터입니다. 후보 20세트 중 5세트를 다음 규칙으로 선별합니다.
-
-선별 규칙:
-- 5세트의 리스크 분포: 안정 2 · 균형 2 · 공격 1 (유연 ±1)
-- 홀짝 비율, 저/고 구간, 연속번호 포함 여부가 세트끼리 겹치지 않도록 다양성 확보
-- hot_number_count와 cold_number_count 모두 0인 세트는 최소 1개
-- 후보 외 번호 사용 절대 금지
-- 각 세트 reason은 40자 이내 한 줄 (해당 세트 피처와 context 값만 근거)
-
-출력은 반드시 아래 JSON 스키마로만:
-{
- "picks": [
- {"numbers":[...], "risk_tag":"안정"|"균형"|"공격", "reason":"..."}
- ],
- "narrative": {
- "headline": "...",
- "summary_3lines": ["...","...","..."],
- "hot_cold_comment": "...",
- "warnings": "..." // 없으면 빈 문자열
- },
- "confidence": 0-100
-}
-```
-
-### 5.4 파이프라인 의사코드
-
-```python
-async def curate_weekly(draw_no: int) -> dict:
- candidates = await service.lotto_candidates(n=20)
- context = await service.lotto_context()
- prompt = build_prompt(candidates, context, draw_no)
-
- result, usage = await call_claude(prompt, model=LOTTO_CURATOR_MODEL)
- parsed = validate(result) # 실패 시 1회 재시도
- if parsed is None:
- raise CuratorError("schema validation failed after retry")
-
- await service.lotto_save_briefing({
- "draw_no": draw_no,
- "picks": parsed.picks,
- "narrative": parsed.narrative,
- "confidence": parsed.confidence,
- "model": LOTTO_CURATOR_MODEL,
- "tokens_input": usage.input,
- "tokens_output": usage.output,
- "cache_read": usage.cache_read,
- "cache_write": usage.cache_write,
- "latency_ms": usage.latency_ms,
- "source": "auto" | "manual",
- })
- return {"ok": True, "draw_no": draw_no, ...}
-```
-
-### 5.5 검증 로직 (`schema.py`)
-
-- pydantic 모델로 형식 검증
-- 번호 제약: 각 세트 정확히 6개 · 중복 없음 · 1~45 범위
-- 세트 수: 정확히 5
-- 번호가 **candidates 내에 존재하는 조합인지** 대조 (환각 차단)
-- risk_tag 분포가 규칙에서 ±1 이상 벗어나면 경고 로그(차단은 안 함)
-- 실패 시 errors 리스트 담아 1회 재시도(프롬프트에 에러 피드백 포함)
-
-### 5.6 스케줄러
-
-`scheduler.py`에 추가:
-```python
-scheduler.add_job(_run_lotto_curate, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate")
-```
-
-### 5.7 상태 표시
-
-agent-office 메인 UI에 lotto 에이전트 카드가 추가되어 `idle` / `working` / `error` 상태 실시간 표시(기존 BaseAgent 패턴).
-
----
-
-## 6. Frontend (web-ui)
-
-### 6.1 새 탭 구조
-
-```
-Lotto
-├─ 🗓 이번 주 브리핑 (기본)
-├─ 📊 분석·통계
-└─ 💰 구매·성과
-```
-
-`Functions.jsx` 460줄 → 탭 라우터 ~80줄로 축소. 각 탭은 `pages/lotto/tabs/BriefingTab.jsx`, `AnalysisTab.jsx`, `PurchaseTab.jsx`.
-
-### 6.2 신규 컴포넌트 (`components/briefing/`)
-
-- **BriefingHeader.jsx** — 회차 번호, 생성 시각, 신뢰도 바, 재생성 버튼, **사용 토큰 칩**(`42K in · 1.2K out · $0.18`)
-- **BriefingSummary.jsx** — 3줄 요약 + 핫/콜드 블록 + 주의사항
-- **PickSetCard.jsx** — 6볼 + risk 뱃지(🟢안정/🟡균형/🔴공격) + reason + "구매 기록" CTA
-- **BriefingEmpty.jsx** — 브리핑 없을 때 placeholder + "지금 생성" 버튼
-- **CuratorUsageFooter.jsx** — 페이지 하단 mini 카드. 최근 30일 호출 수·토큰·추정 비용·캐시 히트율
-
-### 6.3 훅
-
-- **useBriefing.js**
- - `GET /api/lotto/briefing/latest`
- - `regenerate()`: `POST /api/agent-office/command {agent:"lotto", action:"curate_now"}` → 3초 간격 최대 40회(=2분) 폴링으로 신규 briefing 확인
- - 로딩/에러 상태 분리, 월요일 07:00 이후인데 브리핑 없으면 빈 상태 CTA
-- **useCuratorUsage.js** — `GET /api/lotto/curator/usage?days=30`
-
-### 6.4 기존 컴포넌트 처리
-
-| 컴포넌트 | 조치 |
-|---------|------|
-| `FrequencyChart`, `MetricBlock`, `PersonalAnalysisPanel`, `ReportPanel` | 분석 탭으로 이동 |
-| `PurchasePanel`, `PerformanceBanner` | 구매 탭으로 이동 |
-| `CombinedRecommendPanel`, `ConfidenceRing` | 제거 후보 — 정리 패스에서 실제 참조 없으면 삭제 |
-
-### 6.5 토큰·비용 노출 정책
-
-- **브리핑 카드 헤더**: 이번 브리핑 1건의 in/out 토큰 + 추정 비용 (Sonnet 4.5 단가 기준 계산 — 상수로 프론트에 보유, `$3/$15 per 1M tokens`)
-- **페이지 하단 푸터**: 최근 30일 누적 — 호출 수, 총 토큰, 추정 비용, 캐시 히트율
-- **Agent Office 사이드**: 기존 `GET /api/agent-office/agents/lotto/token-usage` 자동 상속
-
-### 6.6 모바일
-
-브리핑 탭 세로 스택 기본. PickSetCard는 한 행 1카드 + 6볼 flex-wrap. 헤더 토큰 칩은 768px 이하에서 축약 표시(`$0.18`만).
-
----
-
-## 7. 환경변수
-
-| 변수 | 기본값 | 위치 |
-|------|--------|------|
-| `ANTHROPIC_API_KEY` | (없음) | agent-office (이미 존재) |
-| `LOTTO_CURATOR_MODEL` | `claude-sonnet-4-5` | agent-office |
-| `LOTTO_BACKEND_URL` | `http://lotto-backend:8000` | agent-office (service_proxy) |
-
----
-
-## 8. 에러·폴백
-
-| 상황 | 처리 |
-|------|------|
-| lotto-backend 후보 API 실패 | 에이전트 상태 `error` + 로그 + 슬랙/알림 없음(주 1회라 로그 충분) |
-| Claude 호출 실패 | 1회 재시도 후 실패 시 error 저장, 기존 최신 브리핑 유지 |
-| JSON 스키마 검증 실패 | 피드백 포함 1회 재시도 → 실패 시 error |
-| 월요일 생성 자체가 누락 | 사용자가 웹에서 수동 재생성 버튼으로 보완 가능 |
-
----
-
-## 9. 구현 순서
-
-1. **Backend**: curator 엔드포인트 + briefing CRUD + 라우터 분리
-2. **Agent-office**: lotto 에이전트 + curator pipeline + 월요일 스케줄러
-3. **Frontend**: BriefingTab + 컴포넌트 + 훅 + 탭 재배치
-4. **미사용 정리 패스**: 아래 "10. 정리 대상" 후보를 실제 참조 grep → 제거
-
----
-
-## 10. 정리 대상 (최종 패스에서 검증 후 제거)
-
-### Frontend
-- `components/CombinedRecommendPanel.jsx`
-- `components/ConfidenceRing.jsx`
-- `Functions.jsx` 내 인라인 레이아웃 로직 (탭 분리 후 잔재)
-
-### Backend
-- `strategy_evolver.py` 중 실제 사용되지 않는 EMA 서브 함수
-- 주간 리포트 관련 `weekly_reports` 테이블 — 브리핑이 대체하므로 드롭 후보
-- `best_picks` 교체 로직 중 큐레이터 전환 후 사용 안 되는 경로
-
-### DB 드롭 후보
-- `weekly_reports` (브리핑이 대체)
-- `simulation_candidates` (best_picks만 있으면 충분한지 사용처 grep 후 결정)
-
-정리 패스는 **실제 import/참조 grep → 없으면 제거 → 테스트 → 커밋** 순서로 별도 커밋 분리.
-
----
-
-## 11. 성공 기준
-
-- 월요일 07:00 브리핑이 자동 생성되고, 웹 페이지 진입 1초 안에 5세트 + 3줄 요약이 보인다.
-- 큐레이터는 candidates 내 세트만 선택한다(환각 0건).
-- 브리핑 카드에 이번 건 토큰/비용, 페이지 하단에 30일 누적 사용량이 표시된다.
-- 기존 난잡한 패널이 분석/구매 탭으로 정돈되고 브리핑 탭이 기본 진입점이다.
-- 미사용 테이블·컴포넌트가 최종 정리 패스에서 제거된다.
diff --git a/docs/superpowers/specs/2026-04-23-responsive-web-design.md b/docs/superpowers/specs/2026-04-23-responsive-web-design.md
deleted file mode 100644
index 9233d15..0000000
--- a/docs/superpowers/specs/2026-04-23-responsive-web-design.md
+++ /dev/null
@@ -1,360 +0,0 @@
-# 반응형 웹 UI/UX 전면 개선 설계
-
-> 모바일에서 UI 짤림 현상 해결 + 풀 모바일 경험 적용
-> 작성일: 2026-04-23
-> 리뷰 반영: 2026-04-23 (라우트 경로 수정, breakpoint 예외 명시, 구현 복잡도 보완)
-
----
-
-## 1. 목표
-
-- 전체 15개 뷰(12개 라우트 + 3개 서브라우트)에서 모바일 UI 짤림 현상 해결
-- 현재 다크 네온 사이버펑크 디자인 톤 유지
-- 모바일 전용 UX 패턴 추가 (바텀 네비게이션, 스와이프, 풀다운 리프레시, FAB, 바텀시트)
-- 기능적 손실 없이 반응형 적용
-
-**대상 뷰 목록 (routes.jsx 기준):**
-
-| # | 라우트 | 컴포넌트 | 비고 |
-|---|--------|---------|------|
-| 1 | `/` | Home | |
-| 2 | `/lotto` | Lotto | 3탭 (Briefing/Analysis/Purchase) |
-| 3 | `/stock` | Stock | |
-| 4 | `/stock/trade` | StockTrade | 서브라우트 |
-| 5 | `/travel` | Travel | |
-| 6 | `/blog` | Blog | |
-| 7 | `/blog-lab` | BlogMarketing | |
-| 8 | `/realestate` | Subscription | |
-| 9 | `/music` | MusicStudio | |
-| 10 | `/todo` | Todo | |
-| 11 | `/agent-office` | AgentOffice | |
-| 12 | `/lab` | EffectLab | |
-| 13 | `/lab/sword-stream` | SwordStream | 서브라우트 |
-| 14 | `/lab/day-calc` | DayCalc | 서브라우트 |
-
-> Note: `RealEstate.jsx` (`/realestate/property`)는 routes.jsx에 미등록 상태. 반응형 스코프에서 제외.
-
----
-
-## 2. 접근 방식
-
-**글로벌 모바일 시스템 구축 → 주요 페이지 적용 → 전체 페이지 확장**
-
-1. 공통 모바일 인프라(컴포넌트, breakpoint, 앱 셸) 구축
-2. 주요 4개 페이지 (홈, 로또, 주식, 여행) 우선 적용
-3. 나머지 페이지 확장 적용
-
----
-
-## 3. 글로벌 모바일 인프라
-
-### 3-1. Breakpoint 시스템 통일
-
-현재 53개 미디어 쿼리에서 다양한 값이 혼재. 4단계로 통일:
-
-| 이름 | 값 | 용도 |
-|------|-----|------|
-| sm | 480px | 소형 폰 |
-| md | 768px | 태블릿/대형 폰 (주요 분기점) |
-| lg | 1024px | 소형 데스크톱 |
-| xl | 1280px | 대형 데스크톱 |
-
-기존 미디어 쿼리의 비표준 값(640px, 900px, 960px, 1100px 등)은 기능 손실 없이 가장 가까운 표준 breakpoint로 정리한다.
-
-**허용 예외 (이동 시 시각적 회귀 발생):**
-
-| 기존 값 | 파일 | 사유 |
-|---------|------|------|
-| 420px | Stock.css (4곳) | 소형 폰 전용 패딩/라벨 축소, 480px로 이동 시 중간 기기에서 불필요한 축소 |
-| 520px | Stock.css (1곳) | 지표 카드 특수 레이아웃 |
-| 700px | Stock.css (1곳) | AI 코치 설정 그리드, 768px로 이동 시 태블릿에서 조기 축소 |
-
-위 값들은 해당 페이지 CSS에서 기존 값을 유지한다.
-
-### 3-2. 바텀 네비게이션 바 (`BottomNav`)
-
-- 768px 이하에서 사이드바 대신 표시
-- 주요 5개 메뉴 아이콘 + "더보기" 메뉴 (나머지 페이지)
-- 현재 페이지 활성 표시 — 네온 시안 글로우 유지
-- 사이드바는 모바일에서 완전히 숨김 (기존 햄버거→슬라이드 방식 제거)
-- 높이: 56~64px
-- `env(safe-area-inset-bottom)` 대응 (노치/홈 인디케이터 기기)
-- `index.html`에 `viewport-fit=cover` 추가 필요: ` `
-- 더보기 메뉴: 탭 시 위로 펼쳐지는 오버레이 패널
-
-**사이드바→바텀네비 마이그레이션 상세:**
-- `Navbar.jsx`: 768px 이하에서 사이드바 렌더링 제거, `sidebar-toggle` 버튼 제거
-- `Navbar.css`: `.sidebar` transform/transition 미디어 쿼리 제거, `.sidebar__overlay` 제거
-- `Navbar.jsx` useEffect: `body.overflow = 'hidden'` 토글 로직 정리
-- `App.jsx`에서 `BottomNav` 컴포넌트 조건부 렌더링 (`useIsMobile()` 기반)
-
-**더보기 메뉴 내용 (나머지 네비게이션 항목):**
-
-| 순서 | 아이콘 | 라벨 | 경로 |
-|------|--------|------|------|
-| 1 | 음악 | 뮤직 | `/music` |
-| 2 | 로봇 | 에이전트 | `/agent-office` |
-| 3 | 블로그 | 블로그 | `/blog` |
-| 4 | 마케팅 | 블로그랩 | `/blog-lab` |
-| 5 | 건물 | 청약 | `/realestate` |
-| 6 | 체크 | TODO | `/todo` |
-| 7 | 실험 | 이펙트랩 | `/lab` |
-
-**기본 5개 메뉴 구성:**
-
-| 순서 | 아이콘 | 라벨 | 경로 |
-|------|--------|------|------|
-| 1 | 홈 | 홈 | `/` |
-| 2 | 클로버 | 로또 | `/lotto` |
-| 3 | 차트 | 주식 | `/stock` |
-| 4 | 카메라 | 여행 | `/travel` |
-| 5 | 더보기 | 메뉴 | 오버레이 |
-
-### 3-3. 공통 모바일 컴포넌트
-
-| 컴포넌트 | 파일 | 역할 |
-|---------|------|------|
-| `BottomNav` | `src/components/BottomNav.jsx` | 하단 고정 네비게이션 |
-| `PullToRefresh` | `src/components/PullToRefresh.jsx` | 터치 풀다운 새로고침 래퍼 |
-| `SwipeableView` | `src/components/SwipeableView.jsx` | 좌우 스와이프 탭/뷰 전환 |
-| `FAB` | `src/components/FAB.jsx` | 플로팅 액션 버튼 (바텀 네비 위 배치) |
-| `MobileSheet` | `src/components/MobileSheet.jsx` | 바텀시트 모달 (드래그 핸들, 스냅 포인트) |
-
-**공통 훅 (신규 `src/hooks/` 디렉토리 생성):**
-
-> 기존 훅은 페이지별 디렉토리에 colocate (`src/pages/lotto/hooks/` 등).
-> 모바일 인프라 훅은 여러 페이지에서 공유하므로 `src/hooks/`에 배치한다.
-
-| 훅 | 파일 | 역할 |
-|----|------|------|
-| `useIsMobile` | `src/hooks/useIsMobile.js` | 768px 이하 감지 (matchMedia) |
-| `useSwipe` | `src/hooks/useSwipe.js` | 터치 스와이프 방향·거리 감지 |
-
-**경량 라이브러리 활용:**
-- `react-swipeable` (~3KB gzipped): SwipeableView/useSwipe 기반으로 활용 — 터치 velocity, threshold snap, 방향 판별을 직접 구현하지 않음
-- PullToRefresh: 터치 이벤트 직접 구현하되, iOS Safari rubber-banding 및 `overscroll-behavior: contain` 대응 필수
-- MobileSheet: CSS `transform` + `touch-action: none`으로 구현, 스냅 포인트 2단계 (50%, 90%)
-
-### 3-4. 앱 셸 레이아웃 변경
-
-```
-데스크톱: [사이드바 240px] [콘텐츠]
-모바일: [탑바 56px]
- [콘텐츠 (padding-bottom: 바텀네비 높이)]
- [바텀 네비 56-64px]
-```
-
-- 콘텐츠 영역에 `padding-bottom` 추가 (바텀 네비 겹침 방지)
-- 탑바: 현재 구조 유지, 페이지 타이틀 + 액션 버튼 영역
-- `body` overflow: 모바일에서 auto (현재와 동일)
-
----
-
-## 4. 주요 페이지별 모바일 설계
-
-### 4-1. 홈 (Home) — `/`
-
-| 영역 | 데스크톱 | 모바일 (≤768px) |
-|------|---------|-----------------|
-| 히어로 | 2컬럼 그리드 | 1컬럼 스택, 타이틀 축소 |
-| 네비 카드 그리드 | auto-fill minmax(180px) | 2컬럼 고정, 카드 높이 축소 |
-| TODO 보드 | 3컬럼 칸반 | 스와이프 탭 (Todo/진행중/완료) |
-| 블로그 포스트 | 카드 그리드 | 1컬럼 리스트 |
-| 프로필 섹션 | 사이드 카드 | 하단 접이식 패널 |
-
-- 풀다운 리프레시: 블로그 포스트 갱신
-- FAB: 없음 (네비게이션 허브)
-
-### 4-2. 로또 (Lotto) — `/lotto`
-
-| 영역 | 데스크톱 | 모바일 |
-|------|---------|--------|
-| 3탭 구조 | 상단 탭바 | 스와이프 탭 전환 |
-| 브리핑 탭 | 카드 레이아웃 | 1컬럼, 볼 크기 36→32px |
-| 분석 탭 | 그리드 카드 | 1컬럼 스택 |
-| 구매 이력 테이블 | 6컬럼 그리드 | 가로 스크롤 테이블 + 행 터치 바텀시트 |
-| 번호 추천 카드 | 다중 그리드 | 1컬럼, 볼 간격 조정 |
-| 전략 차트 | 넓은 차트 | 가로스크롤 또는 축소 |
-
-- FAB: "추천받기" (빠른 번호 추천)
-- 풀다운 리프레시: 브리핑/분석 데이터 갱신
-
-### 4-3. 주식 (Stock / StockTrade) — `/stock`, `/stock/trade`
-
-**Stock (뉴스/지표)**
-
-| 영역 | 데스크톱 | 모바일 |
-|------|---------|--------|
-| 헤더 | 2컬럼 | 1컬럼 스택 |
-| 뉴스 그리드 | auto-fit minmax(260px) | 1컬럼 카드 리스트 |
-| 필터 | 가로 나열 | 가로 스크롤 칩 바 |
-| 지표 카드 | 그리드 | 가로 스크롤 카드 캐러셀 |
-
-**StockTrade (매매)**
-
-| 영역 | 데스크톱 | 모바일 |
-|------|---------|--------|
-| 포트폴리오 테이블 | 넓은 테이블 | 카드형 리스트 (종목별 카드) |
-| 매도 이력 | 테이블 | 가로 스크롤 + 행 터치 바텀시트 |
-| 자산 차트 | 넓은 recharts | 풀 너비, 축 라벨 축소 |
-| 예수금 섹션 | 인라인 | 접이식 카드 |
-
-- FAB: "종목 추가" (Stock), "매도 기록" (StockTrade)
-- 풀다운 리프레시: 뉴스/포트폴리오 갱신
-
-### 4-4. 여행 (Travel) — `/travel`
-
-| 영역 | 데스크톱 | 모바일 |
-|------|---------|--------|
-| 지역 선택 | Leaflet 지도 | 높이 50vh→35vh, 핀치 줌 |
-| 사진 그리드 | 다중 컬럼 | 2컬럼 → 1컬럼 (≤480px) |
-| 사진 상세 | 모달 | 풀스크린 뷰어 + 스와이프 넘기기 |
-| 지역 필터 | 드롭다운 | 바텀시트 지역 선택 |
-
-- 풀다운 리프레시: 사진 목록 갱신
-- FAB: 없음
-
----
-
-## 5. 나머지 페이지 모바일 설계
-
-### 5-1. 블로그 (Blog) — `/blog`
-
-| 영역 | 모바일 변경 |
-|------|------------|
-| 글 목록 | 1컬럼 리스트형 |
-| 글 상세 | 풀 너비, 폰트 크기 조정 |
-| 태그 필터 | 가로 스크롤 칩 바 |
-| 작성/수정 폼 | 풀 너비, 툴바 축소 |
-
-- FAB: "글 쓰기"
-- 풀다운 리프레시: 글 목록 갱신
-
-### 5-2. 블로그 마케팅 (BlogMarketing) — `/blog-lab`
-
-| 영역 | 모바일 변경 |
-|------|------------|
-| 대시보드 지표 | 2컬럼 → 1컬럼 (≤480px) |
-| 파이프라인 테이블 | 카드형 리스트 (상태 배지) |
-| 키워드 분석 | 접이식 아코디언 |
-| 수익 내역 | 가로 스크롤 테이블 |
-
-- FAB: "키워드 분석"
-- 풀다운 리프레시: 대시보드 갱신
-
-### 5-3. 부동산 청약 (Subscription) — `/realestate`
-
-| 영역 | 모바일 변경 |
-|------|------------|
-| 공고 목록 | 1컬럼 카드 리스트 |
-| 필터 | 바텀시트 필터 패널 |
-| 공고 상세 | 바텀시트 상세보기 |
-| 매칭 결과 | 1컬럼, 점수 강조 |
-| 대시보드 | 2컬럼 그리드 |
-
-- FAB: "공고 등록"
-- 풀다운 리프레시: 공고/매칭 갱신
-
-### 5-4. 뮤직 스튜디오 (MusicStudio) — `/music`
-
-| 영역 | 모바일 변경 |
-|------|------------|
-| 헤더 | 1컬럼, 타이틀 클램프 축소 |
-| 생성 폼 | 풀 너비 스택 |
-| 라이브러리 | 1컬럼 리스트 (앨범아트 + 제목) |
-| 플레이어 | 미니 플레이어 바텀 고정 (높이 56px, 바텀 네비 위 = bottom: 64px) |
-| 가사 에디터 | 풀 너비 |
-| 레이더 위젯 | 중앙 정렬 |
-
-- FAB: "음악 생성"
-- 풀다운 리프레시: 라이브러리 갱신
-- 미니 플레이어 표시 시 콘텐츠 padding-bottom: 바텀네비(64px) + 미니플레이어(56px) = 120px
-
-### 5-5. TODO — `/todo`
-
-| 영역 | 모바일 변경 |
-|------|------------|
-| 칸반 보드 | 스와이프 탭 (Todo/진행중/완료) |
-| 할일 카드 | 스와이프로 상태 변경 |
-| 입력 폼 | FAB → 바텀시트 입력 폼 |
-
-- FAB: "할일 추가"
-
-### 5-6. 에이전트 오피스 (AgentOffice) — `/agent-office`
-
-| 영역 | 모바일 변경 |
-|------|------------|
-| 캔버스 오피스 | 풀스크린 캔버스, 핀치 줌/패닝 |
-| 에이전트 패널 | 바텀시트 에이전트 상세 |
-| 작업 로그 | 바텀시트 로그 뷰 |
-| 명령 입력 | 하단 입력 바 (채팅 UX) |
-| WebSocket 상태 | 탑바에 연결 상태 아이콘 |
-
-### 5-7. 이펙트 랩 — `/lab`, `/lab/day-calc`, `/lab/sword-stream`
-
-| 페이지 | 모바일 변경 |
-|--------|------------|
-| EffectLab 허브 | 카드 그리드 → 1컬럼 리스트 |
-| DayCalc | 풀 너비 스택, 네이티브 날짜 피커 |
-| SwordStream | 풀스크린 캔버스, 터치 인터랙션 유지, 오버레이 축소 |
-
----
-
-## 6. 터치 타겟 가이드라인
-
-- 모든 터치 타겟: 최소 44×44px (Apple HIG 기준)
-- 버튼 간 간격: 최소 8px
-- FAB 크기: 56×56px
-- 바텀 네비 아이템: 최소 48×48px 터치 영역
-
----
-
-## 7. 성능 고려사항
-
-- 모바일에서 글로우/그라디언트 효과: box-shadow 개수 줄이기 (3중→1중)
-- `background-attachment: fixed` → 모바일에서 `scroll` (현재 적용됨, 유지)
-- 이미지: `loading="lazy"` 속성 확인
-- 스와이프/터치 이벤트: passive listener 사용
-- 바텀시트 애니메이션: `transform` + `will-change` 사용 (layout thrashing 방지)
-- 신규 애니메이션(스와이프, 바텀시트, 풀다운)은 `prefers-reduced-motion: reduce` 쿼리 존중 — Travel.css, MusicStudio.css 기존 패턴과 통일
-
-### 주의: Stock.css / StockTrade.jsx 커플링
-
-`StockTrade.jsx`는 `Stock.css`의 스타일을 공유한다. Stock.css의 반응형 수정은 StockTrade에도 영향을 미치므로, 반드시 두 페이지를 함께 검증해야 한다.
-
----
-
-## 8. 구현 순서
-
-### Phase 1: 글로벌 인프라
-
-**Phase 1a: Breakpoint 정리 (기존 CSS만 수정, 신규 코드 없음)**
-1. Breakpoint 시스템 통일 — 각 CSS 파일의 비표준 미디어 쿼리를 표준 값으로 정리
-2. `index.html`에 `viewport-fit=cover` 추가
-3. 회귀 테스트: 정리 후 각 페이지 데스크톱/모바일 확인
-
-**Phase 1b: 공통 컴포넌트 & 앱 셸**
-4. `react-swipeable` 패키지 설치
-5. `src/hooks/` 디렉토리 생성 + `useIsMobile`, `useSwipe` 훅 구현
-6. `BottomNav` 컴포넌트 구현 + 사이드바 모바일 제거 마이그레이션 (Navbar.jsx/css 수정)
-7. `PullToRefresh`, `SwipeableView`, `FAB`, `MobileSheet` 컴포넌트 구현
-8. 앱 셸 레이아웃 수정 (App.jsx, App.css)
-
-### Phase 2: 주요 페이지 적용
-9. 홈 페이지 반응형 개선
-10. 로또 페이지 반응형 개선
-11. 주식 페이지 (Stock + StockTrade 함께 검증) 반응형 개선
-12. 여행 페이지 반응형 개선
-
-### Phase 3: 나머지 페이지 확장
-13. 블로그 (`/blog`) + 블로그 마케팅 (`/blog-lab`)
-14. 부동산 청약 (`/realestate`)
-15. 뮤직 스튜디오 (`/music`)
-16. TODO (`/todo`)
-17. 에이전트 오피스 (`/agent-office`)
-18. 이펙트 랩 (`/lab` + `/lab/day-calc` + `/lab/sword-stream`)
-
-### Phase 4: 검증
-19. 전체 뷰 모바일 UI 검증 — 대상 뷰포트: 360px (Galaxy S), 390px (iPhone 14), 768px (iPad), 1024px (데스크톱)
-20. `prefers-reduced-motion` 동작 확인
-21. 터치 타겟 크기 검증 (44×44px 최소)
diff --git a/docs/superpowers/specs/2026-04-24-travel-gallery-redesign.md b/docs/superpowers/specs/2026-04-24-travel-gallery-redesign.md
deleted file mode 100644
index ef6b430..0000000
--- a/docs/superpowers/specs/2026-04-24-travel-gallery-redesign.md
+++ /dev/null
@@ -1,313 +0,0 @@
-# Travel Gallery Redesign — Design Spec
-
-## Goal
-
-Travel 여행 기록 갤러리를 앨범 기반 진입 + Masonry 그리드 + HERO 확대 라이트박스로 리디자인한다. 모놀리식 1,024줄 컴포넌트를 7-8개 집중된 파일로 분리하고, 시네마틱 여행 감성을 강화한다.
-
-## Scope
-
-- **포함**: 프론트엔드 리디자인 (컴포넌트 분리 + 새 UX/UI)
-- **포함**: 동영상 탭 UI 셸 (플레이스홀더)
-- **제외**: 백엔드 동영상 API (별도 후속 스펙)
-- **제외**: 핀치 줌 (복잡도 대비 효과 낮음)
-
-## Architecture
-
-점진적 리팩토링 — 기존 API 호출/캐싱/페이지네이션 로직을 `useTravelData` 훅으로 추출하여 재활용하고, UI 레이어를 새로 구성한다. 라우팅 변경 없이 React 상태 기반으로 앨범 진입/이탈을 관리한다.
-
-## Tech Stack
-
-- React 18 (기존)
-- Leaflet + react-leaflet (기존, 미니맵으로 축소)
-- react-swipeable (기존, 라이트박스 스와이프)
-- SwipeableView 컴포넌트 (기존, 사진/영상 탭)
-- CSS columns (Masonry 레이아웃)
-- IntersectionObserver (무한스크롤 + 스크롤 리빌)
-- Web Animations API / CSS transitions (shared element transition)
-
----
-
-## 1. Component Structure & File Layout
-
-```
-src/pages/travel/
-├── Travel.jsx # 메인 컨테이너 (미니맵 + 앨범 카드 리스트)
-├── Travel.css # 전체 레이아웃 + CSS 변수
-├── AlbumCard.jsx # 여행지 앨범 카드
-├── AlbumCard.css
-├── AlbumDetail.jsx # 앨범 상세 (탭 + Masonry)
-├── AlbumDetail.css
-├── MasonryGrid.jsx # Masonry 레이아웃 + 무한스크롤
-├── MasonryGrid.css
-├── HeroLightbox.jsx # HERO 확대 전환 라이트박스
-├── HeroLightbox.css
-├── MiniMap.jsx # Leaflet 미니맵
-├── MiniMap.css
-├── VideoTab.jsx # 영상 탭 UI 셸
-├── VideoTab.css
-└── useTravelData.js # API 호출 + 캐싱 + 페이지네이션 훅
-```
-
-### Responsibilities
-
-| 파일 | 책임 |
-|------|------|
-| `Travel.jsx` | 페이지 레이아웃, 지역 필터 상태, 앨범 선택 상태 관리 |
-| `useTravelData.js` | API fetch, 10분 TTL 캐시, 앨범별 그룹핑, 페이지네이션 |
-| `MiniMap.jsx` | Leaflet 지도 렌더링, GeoJSON 폴리곤, 지역 클릭 이벤트 발행 |
-| `AlbumCard.jsx` | 대표 사진 + 앨범명 + 사진 수 뱃지, 호버 효과 |
-| `AlbumDetail.jsx` | 앨범 오버레이, 진입/이탈 애니메이션, 사진/영상 탭 전환 |
-| `MasonryGrid.jsx` | CSS columns Masonry, IntersectionObserver 무한스크롤 + 스크롤 리빌 |
-| `HeroLightbox.jsx` | shared element transition, 좌우 스와이프, 썸네일 스트립 |
-| `VideoTab.jsx` | "영상 기능 준비 중" 플레이스홀더 |
-
-### Page Flow
-
-```
-Travel.jsx (메인)
- ├── MiniMap (상단, 접기/펼치기 가능)
- │ └── 지역 클릭 → selectedRegion 상태 변경 → 앨범 필터
- ├── AlbumCard[] (여행지 카드 리스트)
- │ └── 클릭 → AlbumDetail (오버레이)
- │ ├── [사진 탭] MasonryGrid
- │ │ └── 사진 클릭 → HeroLightbox
- │ └── [영상 탭] VideoTab
- └── useTravelData (데이터 레이어)
-```
-
----
-
-## 2. Main View — MiniMap + Album Card List
-
-### MiniMap
-
-- 높이: 데스크톱 200px, 모바일 150px
-- GeoJSON 지역 폴리곤 유지 (기존 MapLayer 로직 추출)
-- 클릭 시 해당 지역 앨범만 필터링
-- 선택된 지역: 지역별 악센트 컬러로 하이라이트
-- "전체 보기" 버튼으로 필터 해제
-- 접기/펼치기 토글 (기본: 펼침)
-- 접힌 상태: 높이 0 + overflow hidden, 토글 버튼만 표시
-
-### Album Card List
-
-- **카드 구성**: 대표 사진 배경 (object-fit: cover) + 앨범 이름 + 사진 수 뱃지
-- **대표 사진**: 앨범 첫 번째 사진의 썸네일 URL
-- **카드 레이아웃**: `display: grid`
- - 데스크톱 (>1024px): 3열
- - 태블릿 (769px-1024px): 2열
- - 모바일 (<=768px): 1열
-- **카드 높이**: 데스크톱 240px, 모바일 200px
-- **호버**: scale(1.03) + 지역 악센트 글로우
-- **지역 필터 전환**: fade 애니메이션 (opacity 300ms)
-
-### Album Data Grouping
-
-백엔드 API 변경 없이 프론트에서 처리:
-
-1. 각 region에 대해 `GET /api/travel/photos?region={id}&page=1&size=1` 호출
-2. 응답의 `total` 필드로 사진 수 확보, `items[0]`으로 대표 사진 확보
-3. region_map.json의 albums 목록에서 앨범명 추출
-4. 기존 10분 TTL 캐시 로직 재활용
-
----
-
-## 3. Album Detail — Masonry Grid + Tabs + Transitions
-
-### Entry Animation (Shared Element Transition)
-
-1. 앨범 카드 클릭 시 `getBoundingClientRect()`로 카드 시작 위치 캡처
-2. 카드 clone을 `position: fixed`로 생성
-3. clone을 `inset: 0` (풀스크린)으로 animate (400ms, cubic-bezier(0.4, 0, 0.2, 1))
-4. 애니메이션 완료 → clone 제거, AlbumDetail 오버레이 표시
-
-### Exit Animation
-
-1. 뒤로가기/닫기 클릭
-2. AlbumDetail을 숨기고, 원래 카드 위치로 역재생 (400ms)
-3. 애니메이션 완료 → 앨범 카드 리스트로 복귀
-
-### Photo/Video Tabs
-
-- 앨범 상세 상단에 "사진 | 영상" 탭 바
-- 기존 `SwipeableView` 컴포넌트 재활용 (모바일 스와이프 전환)
-- 영상 탭: VideoTab 컴포넌트 (플레이스홀더)
-
-### Masonry Grid (Photo Tab)
-
-- **레이아웃**: CSS `column-count` 기반
- - 데스크톱 (>1024px): 4열
- - 태블릿 (769px-1024px): 3열
- - 모바일 (<=768px): 2열
-- **사진 비율**: 원본 유지 (`width: 100%`, `height: auto`)
-- **갭**: `column-gap: 8px`, 각 사진 `margin-bottom: 8px`
-- **break-inside**: `avoid` (사진이 컬럼 경계에 걸리지 않도록)
-- **무한 스크롤**: IntersectionObserver 센티널, rootMargin 300px, page size 20
-- **스크롤 리빌**: 뷰포트 진입 시 아래에서 20px 올라오며 fade-in, 사진마다 50ms 지연
-- **lazy loading**: `loading="lazy"` 속성, 첫 8장은 `loading="eager"`
-
-### Video Tab (Shell)
-
-- 중앙 정렬된 비디오 아이콘 + "영상 기능 준비 중" 텍스트
-- 앰버 톤 텍스트, 세리프 폰트
-- 백엔드 동영상 API 완성 시 이 컴포넌트 내부만 교체
-
----
-
-## 4. HERO Lightbox
-
-### Shared Element Transition (Photo → Fullscreen)
-
-1. Masonry에서 사진 클릭 → `getBoundingClientRect()`로 시작 위치 캡처
-2. 사진 clone을 `position: fixed`로 생성
-3. clone을 화면 중앙 + 최대 크기로 animate (350ms, cubic-bezier(0.4, 0, 0.2, 1))
-4. 애니메이션 완료 → clone 제거, 라이트박스 UI 표시
-5. 배경은 `#000` opacity 0→1 동시 전환
-
-### Fullscreen Viewer
-
-- **배경**: 순수 블랙 `#000`, z-index 3000
-- **사진**: `max-width: 100%`, `max-height: calc(100vh - 140px)`, `object-fit: contain`
-- **좌우 탐색**:
- - 데스크톱: 좌우 화살표 버튼 (hover 시 표시)
- - 모바일: react-swipeable로 좌우 스와이프
- - 키보드: ArrowLeft/ArrowRight
-- **하단 썸네일 스트립**:
- - 높이 68px, 썸네일 52x52px
- - 활성 썸네일: 앰버 테두리 (2px solid)
- - 활성 썸네일 자동 센터링 (smooth scroll)
- - 필름 퍼포레이션 장식 제거 (간소화)
-- **메타 정보**: 사진 위 또는 아래에 앨범명 + 파일명 (앰버 텍스트, 14px)
-- **닫기**:
- - X 버튼 (우상단)
- - 아래로 스와이프 (모바일, threshold 100px)
- - ESC 키
- - 닫기 시 역재생 transition → 원래 그리드 위치로 복귀
-
-### Slide Animation (이전/다음)
-
-- 좌우 전환 시 현재 사진이 나가고 새 사진이 들어오는 slide 애니메이션
-- 280ms, cubic-bezier(0.25, 0.46, 0.45, 0.94)
-- 방향에 따라 왼쪽/오른쪽에서 진입
-
----
-
-## 5. Visual Design — Cinematic Travel Aesthetic
-
-### Color System
-
-- **베이스 배경**: `#0f0c09` (깊은 다크)
-- **베이스 텍스트**: `#f5e6c8` (따뜻한 앰버)
-- **뮤트 텍스트**: `rgba(245,230,200,0.5)`
-- **라인/테두리**: `rgba(245,230,200,0.08)`
-- **지역별 악센트**:
- - 일본: `#c73e1d` (주홍)
- - 유럽: `#2563eb` (코발트)
- - 동남아: `#059669` (에메랄드)
- - 국내: `#d97706` (호박)
- - 기타: 기본 앰버 `#d4a574`
-- 악센트 적용: 앨범 카드 호버 글로우, 미니맵 지역 하이라이트, 탭 활성 상태
-
-### Typography
-
-- **제목/앨범명**: `Cormorant Garamond`, serif (기존 유지)
-- **메타 정보/뱃지**: `Space Mono`, monospace (기존 유지)
-- **앨범 카드 제목**: 데스크톱 24px, 모바일 18px
-- **사진 수 뱃지**: 11px 모노, `rgba(15,12,9,0.7)` 배경 위 앰버 텍스트
-
-### Album Card Visual
-
-- 대표 사진 위 하단 30% 그라디언트: `linear-gradient(transparent, rgba(15,12,9,0.85))`
-- 그라디언트 위에 앨범명 + 사진 수
-- `border-radius: 12px`
-- `border: 1px solid rgba(245,230,200,0.08)`
-- 호버: `box-shadow: 0 0 20px rgba({accent}, 0.15)` + `transform: scale(1.03)`
-
-### Masonry Photo Style
-
-- `border-radius: 4px`
-- 호버: `filter: brightness(1.08)` + `cursor: zoom-in`
-- 스크롤 리빌: translateY(20px) + opacity(0) → translateY(0) + opacity(1), 사진마다 50ms 지연
-
-### Lightbox Visual
-
-- 배경: `#000`
-- 메타 텍스트: 앰버 `#f5e6c8`, 세리프 폰트, 14px
-- 썸네일 스트립: 활성 아이템에 앰버 2px 테두리
-- 카운터: "3 / 156" 형태, 우상단, 모노스페이스
-
----
-
-## 6. Responsive Design
-
-### Breakpoints
-
-| 구간 | 앨범 카드 | Masonry 열 | 미니맵 높이 |
-|------|----------|-----------|-----------|
-| >1024px | 3열 | 4열 | 200px |
-| 769-1024px | 2열 | 3열 | 200px |
-| <=768px | 1열 | 2열 | 150px |
-
-### Mobile Specifics
-
-- 앨범 상세: `position: fixed; inset: 0` (풀스크린 오버레이)
-- 라이트박스: 100dvh, 화살표 버튼 숨김 (스와이프로 대체)
-- 미니맵: 기본 접힘 (모바일에서 공간 절약)
-- 하단 네비게이션 고려: `padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom))`
-
----
-
-## 7. Reduced Motion
-
-`prefers-reduced-motion: reduce` 적용 시:
-
-- shared element transition (앨범 진입/이탈, 라이트박스 열기/닫기) → 즉시 fade (opacity 0→1, 150ms)
-- 스크롤 리빌 애니메이션 → 즉시 표시 (opacity 1, transform none)
-- 카드 호버 scale → 없음 (색상 변화만 유지)
-- 슬라이드 전환 → 즉시 교체 (fade)
-- 미니맵 접기/펼치기 → 즉시 전환
-
----
-
-## 8. Data Flow
-
-```
-useTravelData hook
- ├── fetchRegions() → GET /api/travel/regions
- ├── fetchAlbums(region?) → GET /api/travel/photos?region={id}&page=1&size=1 (per region)
- ├── fetchPhotos(region, page) → GET /api/travel/photos?region={id}&page={n}&size=20
- └── cache (Map, 10min TTL) → 기존 캐시 로직 재활용
-
-State:
- - regions: GeoJSON[]
- - albums: { id, name, region, coverThumb, totalPhotos }[]
- - selectedRegion: string | null
- - selectedAlbum: string | null
- - photos: Photo[]
- - page, hasNext, loading, loadingMore
-```
-
-### API Contract (기존 유지, 변경 없음)
-
-```
-GET /api/travel/regions
-→ GeoJSON FeatureCollection
-
-GET /api/travel/photos?region=japan&page=1&size=20
-→ { region, page, size, total, has_next, items: [{ album, file, url, thumb, mtime }] }
-
-POST /api/travel/reload
-→ { status: "ok" }
-```
-
----
-
-## 9. Performance Considerations
-
-- **앨범 카드 대표 사진**: page=1&size=1로 최소 데이터만 요청
-- **Masonry 이미지**: 썸네일(480x480) 사용, 라이트박스에서만 원본 로드
-- **무한 스크롤**: 20개씩 점진적 로드, rootMargin 300px 선제 로드
-- **lazy loading**: 브라우저 네이티브 `loading="lazy"`
-- **캐시**: 10분 TTL, 리전 단위
-- **스크롤 리빌**: IntersectionObserver 단일 인스턴스로 배치 감시
-- **shared element transition**: `will-change: transform` 적용, 합성 레이어로 GPU 가속
diff --git a/docs/superpowers/specs/2026-04-24-travel-proxy-perf-design.md b/docs/superpowers/specs/2026-04-24-travel-proxy-perf-design.md
deleted file mode 100644
index 16f9d7e..0000000
--- a/docs/superpowers/specs/2026-04-24-travel-proxy-perf-design.md
+++ /dev/null
@@ -1,203 +0,0 @@
-# Travel-Proxy 성능 개선 설계
-
-## 목표
-
-travel-proxy의 파일 스캔 기반 아키텍처를 SQLite 인덱스 DB로 전환하여 수천 장의 사진을 무난하게 처리하고, 앨범 커버 지정 + 썸네일 사전 생성을 지원한다.
-
-## 배경
-
-현재 travel-proxy는 `os.scandir`으로 NAS 폴더를 매번 스캔하고, 메모리 캐시(TTL 300초)로 결과를 보관한다. 사진 수백 장에서는 문제없지만, 수천 장이면:
-- 캐시 만료 시 1~2초 스캔 지연
-- 콜드 스타트(컨테이너 재시작) 시 첫 요청 느림
-- 전체 리스트를 메모리에 상주
-- 썸네일이 첫 요청 시 동기 생성되어 초기 로딩 지연
-
-## 아키텍처
-
-### 변경 전
-
-```
-API 요청 → os.scandir(폴더) → 메모리 캐시 → 슬라이싱 페이지네이션
- ↓
- 썸네일 온디맨드 생성 (Pillow)
-```
-
-### 변경 후
-
-```
-수동 sync 버튼 → 폴더 스캔 → travel.db 동기화 + 썸네일 사전 생성
- ↓
-API 요청 → SQLite 쿼리 (인덱스) → 페이지네이션
-```
-
-### 파일 구조
-
-| 파일 | 역할 |
-|------|------|
-| `main.py` | FastAPI 라우트 (기존 + 신규) |
-| `db.py` (신규) | SQLite 스키마 정의, 쿼리 헬퍼 |
-| `indexer.py` (신규) | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
-
-기존 `main.py`의 `scan_album`, `ensure_thumb`, 메모리 캐시 로직이 `indexer.py`와 `db.py`로 이동하고, `main.py`는 라우트만 남는다.
-
-## DB 스키마
-
-```sql
-CREATE TABLE photos (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- album TEXT NOT NULL,
- filename TEXT NOT NULL,
- mtime REAL NOT NULL,
- has_thumb INTEGER DEFAULT 0,
- indexed_at TEXT NOT NULL,
- UNIQUE(album, filename)
-);
-
-CREATE INDEX idx_photos_album ON photos(album);
-
-CREATE TABLE album_covers (
- album TEXT PRIMARY KEY,
- filename TEXT NOT NULL,
- updated_at TEXT NOT NULL
-);
-```
-
-### 설계 포인트
-
-- `photos` 테이블에 URL/thumb 경로를 저장하지 않음 — 런타임에 `MEDIA_BASE` + album + filename으로 조합 (환경변수 변경에 유연)
-- `mtime`으로 변경 감지 — 동기화 시 파일이 삭제됐거나 mtime이 바뀌면 갱신
-- `album_covers`가 비어있으면 해당 앨범의 첫 번째 사진이 자동 커버
-
-## API 설계
-
-### 기존 API 변경
-
-| 엔드포인트 | 변경 내용 |
-|-----------|----------|
-| `GET /api/travel/photos` | 내부 로직만 변경 (os.scandir → DB 쿼리). 응답 형식 동일 |
-| `GET /api/travel/regions` | 변경 없음 |
-| `POST /api/travel/reload` | 제거 (sync로 대체) |
-| `GET /media/travel/.thumb/{album}/{filename}` | 유지 — 동기화 시 이미 썸네일 생성되므로 Pillow 호출 빈도 대폭 감소. 미생성 분 폴백으로 온디맨드 생성 유지 |
-
-### 신규 API
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| `POST` | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
-| `GET` | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
-| `PUT` | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
-
-### POST /api/travel/sync
-
-폴더를 스캔하여 DB와 동기화하고, 미생성 썸네일을 일괄 생성한다.
-
-**요청**: 바디 없음
-
-**응답**:
-```json
-{
- "added": 42,
- "removed": 3,
- "thumbs_generated": 42,
- "duration_sec": 12.5
-}
-```
-
-**동기 실행** — 수동 트리거이므로 BackgroundTask 불필요, 응답에 결과 포함.
-
-### GET /api/travel/albums
-
-앨범 목록과 각 앨범의 사진 수, 커버 정보를 반환한다.
-
-**응답**:
-```json
-[
- {
- "album": "오사카",
- "count": 342,
- "cover_url": "/media/travel/오사카/IMG_3281.jpg",
- "cover_thumb": "/media/travel/.thumb/오사카/IMG_3281.jpg"
- }
-]
-```
-
-커버가 지정되지 않은 앨범은 첫 번째 사진(album + filename 정렬 기준)이 자동 커버.
-
-### PUT /api/travel/albums/{album}/cover
-
-특정 사진을 앨범 커버로 지정한다.
-
-**요청**:
-```json
-{
- "filename": "IMG_3281.jpg"
-}
-```
-
-**응답**:
-```json
-{
- "album": "오사카",
- "filename": "IMG_3281.jpg",
- "cover_url": "/media/travel/오사카/IMG_3281.jpg",
- "cover_thumb": "/media/travel/.thumb/오사카/IMG_3281.jpg"
-}
-```
-
-**검증**: 해당 album + filename 조합이 photos 테이블에 존재하는지 확인. 없으면 404.
-
-## 동기화 로직 (indexer.py)
-
-### sync 프로세스
-
-1. `region_map.json`에서 전체 앨범 폴더 목록 수집
-2. 각 폴더 `os.scandir` → `{album, filename, mtime}` 세트 수집
-3. DB와 비교:
- - DB에 없는 파일 → INSERT (`added`)
- - DB에 있지만 폴더에 없는 파일 → DELETE (`removed`)
- - mtime이 다른 파일 → UPDATE + `has_thumb=0` (변경됨)
-4. `has_thumb=0`인 파일 → 썸네일 생성 → `has_thumb=1`로 갱신
-5. 결과 반환: `{added, removed, thumbs_generated, duration_sec}`
-
-### 삭제된 커버 처리
-
-커버로 지정된 사진이 폴더에서 삭제되면 `album_covers`에서도 제거 → 자동으로 첫 번째 사진 폴백.
-
-### 성능
-
-- NAS Celeron J4025 기준, 2,000장 최초 동기화 + 썸네일 생성 예상: 3~5분
-- 이후 동기화는 변경분만 처리 → 수초 이내
-
-## 앨범 커버 지정 UX
-
-프론트엔드 앨범 상세 페이지에서 사진을 길게 누르거나 우클릭 → "커버로 설정" 메뉴. `PUT /api/travel/albums/{album}/cover` 호출.
-
-프론트엔드 변경은 이 스펙 범위 밖 — 백엔드 API만 제공하고, 프론트 연동은 별도 작업.
-
-## 기존 API 호환성
-
-- `GET /api/travel/photos` 응답 형식 (`items`, `total`, `has_next`, `matched_albums`) 완전히 유지
-- 프론트엔드 `useTravelData` 훅은 수정 없이 동작
-- `GET /api/travel/albums`는 선택적 개선용 — 프론트가 앨범 카드 커버를 표시할 때 활용
-
-## Docker 변경
-
-- `travel.db` 저장 위치: 썸네일 볼륨 내 `/data/thumbs/travel.db` (추가 볼륨 불필요)
-- `requirements.txt`에 `aiosqlite` 추가 불필요 — 동기 sqlite3 표준 라이브러리 사용
-- Dockerfile 변경 없음
-
-### docker-compose.yml 변경
-
-기존 볼륨에 DB를 함께 저장하므로 추가 볼륨 불필요:
-```yaml
-volumes:
- - ${PHOTO_PATH}:/data/travel:ro
- - ${RUNTIME_PATH}\travel-thumbs:/data/thumbs:rw # travel.db도 여기에 저장
-```
-
-## 제거되는 코드
-
-- `main.py`의 `CACHE`, `CACHE_TTL`, `META_MTIME_CACHE` 딕셔너리 및 관련 로직
-- `main.py`의 `scan_album()` 함수 (indexer.py로 이동)
-- `main.py`의 `ensure_thumb()` 함수 (indexer.py로 이동, 온디맨드 폴백은 유지)
-- `POST /api/travel/reload` 엔드포인트 (sync로 대체)
diff --git a/docs/superpowers/specs/2026-04-27-agent-office-v2-design.md b/docs/superpowers/specs/2026-04-27-agent-office-v2-design.md
deleted file mode 100644
index 1cd725b..0000000
--- a/docs/superpowers/specs/2026-04-27-agent-office-v2-design.md
+++ /dev/null
@@ -1,497 +0,0 @@
-# Agent Office v2 — Pixel Office UX 대규모 업데이트 설계
-
-> 참고 프로젝트: `pixel-agents` (VS Code 확장, React 19 + Canvas 2D)
-> 대상: `web-ui/src/pages/agent-office/` (프론트엔드) + `web-backend/agent-office/` (백엔드)
-
----
-
-## 1. 목표
-
-기존 대시보드 칼럼 중심 UI를 **전체 화면 픽셀 오피스** 중심으로 전환하여, "가상 오피스를 사용한다"는 몰입감을 제공한다.
-
-### 핵심 변경
-
-- 캔버스가 메인 화면을 차지하고, 에이전트 클릭 시 사이드 패널로 상세 정보 표시
-- BFS 경로 탐색 + 풀 배회 시스템으로 에이전트에 생동감 부여
-- 3가지 오피스 테마 프리셋 (Modern / Retro / Minimal)
-- 캐릭터 프로시저럴 고도화 + 스프라이트 로더 설계 (점진적 전환)
-
-### 변경하지 않는 것
-
-- 백엔드 FSM 5상태 (`idle`, `working`, `waiting`, `reporting`, `break`)
-- WebSocket 프로토콜 메시지 타입 (init, agent_state, task_complete, agent_move, notification, command_result)
-- REST API 엔드포인트
-- 텔레그램 봇 연동
-
----
-
-## 2. 화면 구성
-
-### 2.1 데스크톱 레이아웃
-
-```
-┌──────────────────────────────────────────────────┬──────────────┐
-│ [Agent Office] ● Connected [Theme ▾] [Zoom] │ │
-├──────────────────────────────────────────────────┤ Side Panel │
-│ │ 320px │
-│ │ │
-│ Pixel Office Canvas │ [Agent hdr] │
-│ (flex: 1, 전체 높이) │ [Tabs····] │
-│ │ [Content ] │
-│ - 에이전트 클릭 → 패널 열림 │ [·········] │
-│ - 빈 공간 클릭 → 패널 닫힘 │ │
-│ │ │
-└──────────────────────────────────────────────────┴──────────────┘
-```
-
-- **상단 바**: 타이틀, WebSocket 연결 상태(●), 테마 드롭다운, 줌 컨트롤 (1x~4x)
-- **캔버스**: `flex: 1`로 남은 공간 전체 차지, `imageSmoothingEnabled = false`
-- **사이드 패널**: 320px 고정폭, 에이전트 클릭 시 슬라이드 인, X 버튼 또는 빈 공간 클릭으로 닫힘
-- **패널 닫힘 시**: 캔버스가 전체 너비로 확장
-
-### 2.2 모바일 레이아웃 (< 768px)
-
-```
-┌──────────────────────────┐
-│ [≡] Agent Office ● Conn │
-├──────────────────────────┤
-│ │
-│ Pixel Office Canvas │
-│ (전체 화면) │
-│ 핀치 줌 + 패닝 │
-│ │
-│ │
-├──────────────────────────┤ ← 바텀 시트 (드래그)
-│ [Agent Header] │
-│ [Tabs: Cmd|Task|Tok|Log]│
-│ [Content area] │
-└──────────────────────────┘
-```
-
-- 캔버스: 전체 화면, 터치 핀치 줌/패닝
-- 사이드 패널 → 바텀 시트 (에이전트 탭 시 올라옴, 아래로 드래그 시 닫힘)
-- 상단 바: 햄버거 메뉴로 테마/줌 접기
-
----
-
-## 3. 사이드 패널 구조
-
-### 3.1 헤더
-
-```
-┌─────────────────────────────┐
-│ [🎵 32x32] 음악 프로듀서 │
-│ ● working - ... │
-└─────────────────────────────┘
-```
-
-- 에이전트 아이콘 (emoji 기반, 32x32 색상 배경)
-- display_name + 현재 상태 + state_detail
-
-### 3.2 탭 구성
-
-| 탭 | 내용 |
-|----|------|
-| **Commands** (기본) | Quick Action 버튼 (에이전트별 고유), Custom Command 입력, Approval UI (waiting 상태 시) |
-| **Tasks** | 최근 작업 이력 (상태 배지, 타임스탬프, 결과 펼치기) |
-| **Tokens** | 일간/주간 토큰 사용량 차트, 캐시 히트율 |
-| **Logs** | 에이전트 로그 스트림 (level별 색상, 자동 스크롤) |
-
-### 3.3 에이전트별 Quick Actions
-
-| 에이전트 | 버튼 |
-|---------|------|
-| Stock | Fetch News, Add Alert, Test Telegram |
-| Music | Compose, Check Credits |
-| Blog | Research, Add Keyword, List Keywords |
-| Realestate | Fetch Matches, Dashboard |
-| Lotto | Curate Now, Status |
-
----
-
-## 4. 캔버스 엔진
-
-### 4.1 타일맵
-
-- **그리드**: 32 × 20 타일 (기존 20×14에서 확장)
-- **타일 크기**: 32px × 32px (기본), 줌에 따라 스케일
-- **타일 타입**: VOID(0), FLOOR(1), WALL(2), FURNITURE(3)
-- **렌더링 순서**: 바닥 → 벽 → 가구 → 에이전트 (Y좌표 Z-sorting) → 오버레이
-
-### 4.2 오피스 레이아웃 (고정)
-
-```
-WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW (W=Wall)
-W..............................W
-W...[Stock]...[Music]..........W
-W...desk+mon..desk+inst........W
-W..............................W
-W...[Blog]....[RE]....[Lotto]..W
-W...desk+mon..desk+mon.desk+monW
-W..............................W
-W..............................W
-W..........[Meeting]...........W
-W..........table 4x2...........W
-W..............................W
-W..............................W
-W....[Coffee]...[Sofa]........W
-W....machine....couch.........W
-W..............................W
-W...[Plants]......[Bookshelf]..W
-W..............................W
-W..............................W
-WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
-```
-
-- 각 에이전트 구역에 테마별 소품 (Stock: 모니터 3대, Music: 악기, Blog: 서류 등)
-- 중앙: 회의 테이블 (4x2 타일)
-- 하단: 휴게실 구역 (커피 머신 + 소파)
-- waypoint 정의: `desk_stock`, `desk_music`, `desk_blog`, `desk_realestate`, `desk_lotto`, `meeting`, `break_room`, `coffee`
-
-### 4.3 줌 & 패닝
-
-- 줌 레벨: 1x, 2x, 3x, 4x (정수 배율만, 픽셀 선명도 유지)
-- 데스크톱: 마우스 휠 줌, 드래그 패닝
-- 모바일: 핀치 줌, 터치 패닝
-- 기본값: 캔버스 크기에 맞춰 자동 fit
-
-### 4.4 게임 루프
-
-```javascript
-function gameLoop(timestamp) {
- const dt = (timestamp - lastTime) / 1000;
- lastTime = timestamp;
-
- update(dt); // 에이전트 이동, 애니메이션 프레임 업데이트
- render(); // 타일맵 → 가구 → 에이전트(Y-sort) → 오버레이
-
- requestAnimationFrame(gameLoop);
-}
-```
-
-- 60fps requestAnimationFrame
-- `imageSmoothingEnabled = false` (픽셀 선명도)
-- devicePixelRatio 반영
-
----
-
-## 5. 에이전트 캐릭터 시스템
-
-### 5.1 프로시저럴 렌더링 (Phase 1)
-
-- 해상도: 16 × 32px (기존 8×16에서 2배 확대)
-- 에이전트별 고유 색상 (기존 유지)
-- 애니메이션 프레임:
-
-| 상태 | 프레임 수 | 속도 | 설명 |
-|------|----------|------|------|
-| idle | 2 | 0.8s/frame | 미세 움직임 (숨쉬기) |
-| walk | 4 | 0.15s/frame | 걷기 사이클 [0,1,2,1] |
-| type | 2 | 0.3s/frame | 타이핑 (팔 움직임) |
-| wait | 2 | 0.5s/frame | 좌우 흔들림 (wobble) |
-| break | 2 | 1.0s/frame | 커피 마시기 / 졸기 |
-
-- 4방향 스프라이트: DOWN, UP, RIGHT, LEFT (LEFT = RIGHT 좌우반전)
-
-### 5.2 스프라이트 로더 (Phase 2 준비)
-
-```javascript
-class SpriteLoader {
- constructor() {
- this.sprites = new Map(); // agent_id → spritesheet Image
- this.fallback = 'procedural';
- }
-
- async load(agentId, sheetUrl) { /* PNG 로드 */ }
-
- draw(ctx, agentId, state, direction, frame, x, y) {
- if (this.sprites.has(agentId)) {
- // 스프라이트시트에서 프레임 추출하여 그리기
- } else {
- // 프로시저럴 폴백
- ProceduralSprite.draw(ctx, agentId, state, direction, frame, x, y);
- }
- }
-}
-```
-
-- 스프라이트시트 규격: 각 프레임 16×32px, 가로로 프레임 나열
-- 행: 방향 (DOWN/UP/RIGHT), 열: 상태별 프레임
-- PNG 없으면 프로시저럴 폴백 → 에셋 제작 전에도 완전 동작
-
----
-
-## 6. 이동 시스템
-
-### 6.1 BFS 경로 탐색
-
-```javascript
-function findPath(grid, start, goal) {
- // 4방향 BFS (상하좌우, 대각선 없음)
- // blocked 타일(가구, 벽) 회피
- // 반환: [{col, row}, ...] 경로 배열
-}
-```
-
-- 가구 footprint → `blocked[]` 배열로 타일 마킹
-- 의자/책상 뒤 타일은 walkable (backgroundTiles 개념)
-- 경로 없으면 제자리 유지
-
-### 6.2 이동 파라미터
-
-| 파라미터 | 값 | 설명 |
-|---------|-----|------|
-| WALK_SPEED | 48 px/sec | pixel-agents 참고 |
-| moveProgress | 0~1 | 현재 타일 → 다음 타일 선형 보간 |
-| direction | DOWN/UP/RIGHT/LEFT | 이동 방향 → 스프라이트 방향 결정 |
-
-### 6.3 배회 로직 (idle 상태)
-
-```
-idle 진입
- → 3~8초 대기 (seatTimer)
- → 자리에서 일어남
- → 인접 floor 타일로 랜덤 이동
- → 3~6회 반복 (wanderCount)
- → 자리로 BFS 복귀
- → 2~20초 자리에서 휴식 (restTimer)
- → 반복
-```
-
-### 6.4 상태 전환 시 이동 시퀀스
-
-| 전환 | 동작 |
-|------|------|
-| `* → working` | 배회 중단, 자기 책상으로 BFS 이동 → 도착 후 type 애니메이션 |
-| `* → waiting` | 자기 책상에서 wobble 애니메이션 + 말풍선 |
-| `* → reporting` | 자기 책상에서 빠른 type 애니메이션 |
-| `idle (배회 중)` | 랜덤 floor 타일로 이동, wanderCount 소진 시 복귀 |
-| `* → break` | 휴게실(break_room/coffee) waypoint로 BFS 이동 → break 애니메이션 |
-| `break → idle` | 자기 책상으로 BFS 이동 → idle 루프 시작 |
-
----
-
-## 7. 오버레이 시스템
-
-캔버스 위에 HTML이 아닌 Canvas 2D로 직접 렌더링.
-
-### 7.1 항상 표시
-
-- **이름 라벨**: 에이전트 아래, 에이전트 색상 텍스트, 12px
-- **상태 배지**: 이름 아래, 배경색 + 텍스트 ("working", "idle", "break")
-
-### 7.2 조건부 표시
-
-- **말풍선**: `waiting` 상태에서만, 에이전트 위에 "승인 대기!" 텍스트
- - 둥근 사각형 배경 (#fbbf24), 아래 삼각형 꼬리
- - 2초 페이드인, 상태 변경 시 즉시 사라짐
-- **알림 배지**: 미확인 notification 있을 때, 에이전트 우상단에 빨간 원 + 숫자
-
-### 7.3 렌더링 순서
-
-```
-1. 타일맵 (바닥 + 벽)
-2. 가구 (Y-sort)
-3. 에이전트 (Y-sort, 가구와 혼합)
-4. 오버레이 (말풍선, 이름, 배지) — 항상 최상위
-```
-
----
-
-## 8. 테마 시스템
-
-### 8.1 테마 데이터 구조
-
-```javascript
-const THEMES = {
- modern: {
- name: 'Modern',
- wall: { color: '#1a1a2e', border: '#333', accent: '#8b5cf6' },
- floor: { color1: '#2a2a3e', color2: '#323248' },
- furniture: { desk: '#3a3a5a', chair: '#4c1d95', monitor: '#5555aa', shelf: '#2a2a4e' },
- decor: { plant: '#22c55e', pot: '#4a3a2a', lamp: '#fbbf24', ledStrip: '#8b5cf6' },
- lighting: { ambient: 'rgba(139,92,246,0.05)', glow: 'rgba(139,92,246,0.15)' }
- },
- retro: {
- name: 'Retro',
- wall: { color: '#2a1a0a', border: '#6a4a2a', accent: '#cc8844' },
- floor: { color1: '#4a3a1a', color2: '#3a2a10' },
- furniture: { desk: '#6a4a1a', chair: '#8a5a2a', monitor: '#555555', shelf: '#5a3a1a' },
- decor: { plant: '#44aa44', pot: '#6a4a2a', lamp: '#ffcc66', brick: '#8a5a2a' },
- lighting: { ambient: 'rgba(255,200,100,0.05)', glow: 'rgba(255,200,100,0.2)' }
- },
- minimal: {
- name: 'Minimal',
- wall: { color: '#fafafa', border: '#ddd', accent: '#3b82f6' },
- floor: { color1: '#e8e8e8', color2: '#f0f0f0' },
- furniture: { desk: '#ffffff', chair: '#e0e0e0', monitor: '#333333', shelf: '#f5f5f5' },
- decor: { plant: '#86efac', pot: '#ffffff', lamp: '#fbbf24', window: '#e0f0ff' },
- lighting: { ambient: 'rgba(59,130,246,0.03)', glow: 'rgba(255,255,255,0.1)' }
- }
-};
-```
-
-### 8.2 테마 적용 방식
-
-- `TileMap.render(theme)` — 바닥/벽 색상을 theme에서 읽어 렌더링
-- `FurnitureRenderer.draw(type, theme)` — 가구별 프로시저럴 렌더링에 theme 팔레트 적용
-- 테마 전환 시 전체 캔버스 리렌더 (레이아웃 변경 없음)
-- 사용자 선택은 `localStorage`에 저장, 기본값: `modern`
-
-### 8.3 테마별 고유 데코
-
-| 테마 | 고유 요소 |
-|------|----------|
-| Modern | LED 스트립 (벽 하단), 네온 글로우, 미니멀 화분 |
-| Retro | 벽돌 텍스처, CRT 모니터, 책장(컬러풀 책), 탁상 램프 |
-| Minimal | 창문(자연광), 다육이, 깔끔한 화이트 선반 |
-
----
-
-## 9. 히트 테스팅 & 인터랙션
-
-### 9.1 클릭 처리
-
-```javascript
-canvas.onclick = (e) => {
- const {col, row} = screenToTile(e.offsetX, e.offsetY, zoom, pan);
-
- // 1. 에이전트 히트 테스트 (역순, 최상위 우선)
- const agent = agents.findLast(a =>
- Math.abs(a.x - col) < 1 && Math.abs(a.y - row) < 1.5
- );
-
- if (agent) {
- openSidePanel(agent.id);
- return;
- }
-
- // 2. 빈 공간 → 패널 닫기
- closeSidePanel();
-};
-```
-
-### 9.2 호버 (데스크톱만)
-
-- 에이전트 위 호버 시 커서 `pointer`로 변경
-- 툴팁 불필요 (이름+배지가 항상 표시되므로)
-
----
-
-## 10. WebSocket 연동
-
-기존 프로토콜 100% 유지. 프론트엔드에서 메시지 수신 시 캔버스 상태만 추가 업데이트.
-
-| 메시지 타입 | 캔버스 반응 |
-|------------|-----------|
-| `agent_state` | 해당 에이전트 FSM 상태 전환 → 애니메이션/위치 변경 트리거 |
-| `agent_move` | target에 따라 BFS 경로 계산 → 이동 시작 |
-| `task_complete` | 에이전트 상태를 idle로 전환 |
-| `notification` | 에이전트 위 알림 배지 카운트 증가 |
-| `init` | 모든 에이전트 초기 위치/상태 설정 |
-
-### agent_state 수신 시 이동 로직
-
-```javascript
-function onAgentState(agentId, newState) {
- const agent = agents.get(agentId);
-
- switch (newState) {
- case 'working':
- case 'waiting':
- case 'reporting':
- // 자리에 있지 않으면 자리로 이동
- if (!agent.isAtDesk()) agent.moveTo(agent.deskWaypoint);
- break;
- case 'break':
- agent.moveTo('break_room');
- break;
- case 'idle':
- // 배회 루프 시작
- agent.startWandering();
- break;
- }
-
- agent.setState(newState);
-}
-```
-
----
-
-## 11. 파일 구조 (프론트엔드)
-
-```
-src/pages/agent-office/
-├── AgentOffice.jsx # 루트 컴포넌트 (재작성)
-├── AgentOffice.css # 스타일 (재작성)
-├── hooks/
-│ ├── useAgentManager.js # WebSocket + 상태 (기존 확장)
-│ └── useOfficeCanvas.js # 캔버스 셋업 (재작성)
-├── components/
-│ ├── TopBar.jsx # 상단 바 (신규)
-│ ├── SidePanel.jsx # 사이드 패널 컨테이너 (신규)
-│ ├── CommandTab.jsx # Commands 탭 (AgentColumn 리팩토링)
-│ ├── TaskTab.jsx # Tasks 탭 (AgentColumn에서 분리)
-│ ├── TokenTab.jsx # Tokens 탭 (신규)
-│ ├── LogTab.jsx # Logs 탭 (신규)
-│ ├── ApprovalCard.jsx # 승인 UI 카드 (신규)
-│ └── MobileBottomSheet.jsx # 모바일 바텀 시트 (신규)
-├── canvas/
-│ ├── OfficeRenderer.js # 게임 루프 + 렌더 파이프라인 (재작성)
-│ ├── TileMap.js # 타일맵 렌더링 + 테마 적용 (재작성)
-│ ├── FurnitureRenderer.js # 가구 프로시저럴 렌더링 (신규)
-│ ├── AgentSprite.js # 에이전트 이동 + 애니메이션 (재작성)
-│ ├── ProceduralSprite.js # 프로시저럴 캐릭터 렌더링 (SpriteSheet 리팩토링)
-│ ├── SpriteLoader.js # 스프라이트시트 로더 + 폴백 (신규)
-│ ├── Pathfinder.js # BFS 경로 탐색 (신규)
-│ ├── OverlayRenderer.js # 이름, 배지, 말풍선 (신규)
-│ └── themes.js # 테마 데이터 (신규)
-├── assets/
-│ ├── office-map.json # 32x20 맵 데이터 (재작성)
-│ └── sprites/ # Phase 2 스프라이트시트 PNG (빈 디렉토리)
-```
-
-### 삭제 대상
-
-- `components/AgentColumn.jsx` → CommandTab + TaskTab으로 분리
-- `components/CommandColumn.jsx` → SidePanel 내 CommandTab으로 통합
-- `components/ChatPanel.jsx` → 미사용, 삭제
-- `components/DocumentPanel.jsx` → LogTab으로 대체
-- `canvas/SpriteSheet.js` → ProceduralSprite.js로 리팩토링
-
----
-
-## 12. 백엔드 변경사항
-
-**없음.** 기존 WebSocket 프로토콜과 REST API를 그대로 사용한다.
-
-단, `agent_move` 메시지가 break 전환 시에도 정확히 발송되는지 확인 필요:
-- `base.py`의 `check_idle_break()` → `transition('break')` → WebSocket broadcast에 `agent_move` 포함 여부 확인
-- 필요 시 `transition()` 메서드에서 break 상태 전환 시 `agent_move` 메시지 추가
-
----
-
-## 13. 구현 순서 (Phase 개요)
-
-| Phase | 내용 | 의존성 |
-|-------|------|--------|
-| **1. 캔버스 엔진** | 게임 루프, 타일맵, 줌/팬, 테마 시스템 | 없음 |
-| **2. 에이전트 시스템** | 프로시저럴 캐릭터, BFS 경로 탐색, 상태별 애니메이션, 배회 로직 | Phase 1 |
-| **3. 오버레이** | 이름 라벨, 상태 배지, 말풍선, 알림 배지 | Phase 2 |
-| **4. 사이드 패널** | 4탭 구성, Quick Actions, Approval UI | Phase 1 |
-| **5. 페이지 통합** | AgentOffice.jsx 재작성, WebSocket 연동, 히트 테스팅 | Phase 1-4 |
-| **6. 모바일 대응** | 바텀 시트, 핀치 줌, 터치 이벤트, 반응형 | Phase 5 |
-| **7. 스프라이트 로더** | SpriteLoader 구현, 폴백 연결 | Phase 2 |
-
----
-
-## 14. 성공 기준
-
-- [ ] 전체 화면 캔버스에서 5명의 에이전트가 상태에 맞게 애니메이션
-- [ ] idle 에이전트가 사무실을 배회하다 자리로 복귀
-- [ ] break 에이전트가 휴게실로 이동하여 휴식
-- [ ] 에이전트 클릭 시 사이드 패널 열림, 4탭 모두 동작
-- [ ] Commands 탭에서 명령 전송 + 승인/거부 동작
-- [ ] 3가지 테마 전환 동작, localStorage에 저장
-- [ ] 모바일에서 바텀 시트 + 핀치 줌 동작
-- [ ] 기존 WebSocket 프로토콜과 100% 호환
diff --git a/docs/superpowers/specs/2026-04-27-personal-service-migration-design.md b/docs/superpowers/specs/2026-04-27-personal-service-migration-design.md
deleted file mode 100644
index fa8b325..0000000
--- a/docs/superpowers/specs/2026-04-27-personal-service-migration-design.md
+++ /dev/null
@@ -1,220 +0,0 @@
-# Personal 서비스 마이그레이션 설계
-
-## 개요
-
-기존 `portfolio` 서비스를 `personal`로 리네이밍하고, lotto-backend에 있던 Blog/Todo 기능을 personal 서비스로 통합한다.
-
-**목표**: 신규 컨테이너 없이, 개인 콘텐츠(포트폴리오 + 블로그 + 투두)를 하나의 서비스로 통합
-
-**제약**: 기존 데이터 무손실 이전 필수
-
----
-
-## 아키텍처
-
-### 변경 전
-
-```
-lotto-backend (lotto.db)
-├── 로또 API (/api/lotto/*)
-├── 블로그 API (/api/blog/posts) ← 이전 대상
-└── 투두 API (/api/todos) ← 이전 대상
-
-portfolio (portfolio.db)
-└── 포트폴리오 API (/api/profile/*)
-```
-
-### 변경 후
-
-```
-lotto-backend (lotto.db)
-└── 로또 API (/api/lotto/*) ← Blog/Todo 라우트 제거
-
-personal (personal.db)
-├── 포트폴리오 API (/api/profile/*)
-├── 블로그 API (/api/blog/posts) ← 통합
-└── 투두 API (/api/todos) ← 통합
-```
-
----
-
-## 서비스 속성
-
-| 항목 | 현재 (portfolio) | 변경 후 (personal) |
-|------|-----------------|-------------------|
-| 디렉토리 | `portfolio/` | `personal/` |
-| 컨테이너명 | `portfolio` | `personal` |
-| 포트 | 18850 | 18850 (유지) |
-| DB 파일 | `data/portfolio/portfolio.db` | `data/personal/personal.db` |
-| API prefix | `/api/profile/` | `/api/profile/` + `/api/todos` + `/api/blog/` |
-
----
-
-## DB 스키마
-
-personal.db에 기존 5테이블 + 신규 2테이블:
-
-### 기존 테이블 (portfolio에서 이관)
-- `profile` — 프로필 (id=1 싱글턴)
-- `careers` — 경력
-- `projects` — 프로젝트
-- `skills` — 기술스택
-- `introductions` — 자기소개
-
-### 신규 추가 테이블 (lotto-backend에서 이관)
-
-```sql
-CREATE TABLE IF NOT EXISTS todos (
- id TEXT PRIMARY KEY
- DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2)))),
- title TEXT NOT NULL,
- description TEXT,
- status TEXT NOT NULL DEFAULT 'todo'
- CHECK(status IN ('todo','in_progress','done')),
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
-);
-CREATE INDEX IF NOT EXISTS idx_todos_created ON todos(created_at DESC);
-
-CREATE TABLE IF NOT EXISTS blog_posts (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- title TEXT NOT NULL,
- body TEXT NOT NULL DEFAULT '',
- excerpt TEXT NOT NULL DEFAULT '',
- tags TEXT NOT NULL DEFAULT '[]',
- date TEXT NOT NULL DEFAULT (date('now','localtime')),
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
-);
-CREATE INDEX IF NOT EXISTS idx_blog_date ON blog_posts(date DESC);
-```
-
----
-
-## API 엔드포인트 (personal 서비스 전체)
-
-### 포트폴리오 (기존 유지)
-| 메서드 | 경로 | 인증 | 설명 |
-|--------|------|------|------|
-| GET | `/api/profile/public` | - | 공개 데이터 일괄 조회 |
-| POST | `/api/profile/auth` | - | 비밀번호 인증 → 토큰 |
-| GET/PUT | `/api/profile/profile` | Bearer | 프로필 조회/수정 |
-| GET/POST | `/api/profile/careers` | Bearer | 경력 목록/추가 |
-| PUT/DELETE | `/api/profile/careers/{id}` | Bearer | 경력 수정/삭제 |
-| GET/POST | `/api/profile/projects` | Bearer | 프로젝트 목록/추가 |
-| PUT/DELETE | `/api/profile/projects/{id}` | Bearer | 프로젝트 수정/삭제 |
-| GET/POST | `/api/profile/skills` | Bearer | 기술 목록/추가 |
-| PUT/DELETE | `/api/profile/skills/{id}` | Bearer | 기술 수정/삭제 |
-| GET/POST | `/api/profile/introductions` | Bearer | 자기소개 목록/추가 |
-| PUT/DELETE | `/api/profile/introductions/{id}` | Bearer | 자기소개 수정/삭제 |
-| PATCH | `/api/profile/introductions/{id}/main` | Bearer | 메인 자기소개 지정 |
-
-### 투두 (lotto-backend에서 이전, 인증 없음)
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/todos` | 전체 목록 |
-| POST | `/api/todos` | 생성 |
-| DELETE | `/api/todos/done` | 완료 일괄 삭제 |
-| PUT | `/api/todos/{id}` | 수정 |
-| DELETE | `/api/todos/{id}` | 삭제 |
-
-### 블로그 (lotto-backend에서 이전, 인증 없음)
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/blog/posts` | 목록 (`{"posts": [...]}`) |
-| POST | `/api/blog/posts` | 생성 |
-| PUT | `/api/blog/posts/{id}` | 수정 |
-| DELETE | `/api/blog/posts/{id}` | 삭제 |
-
----
-
-## Nginx 라우팅 변경
-
-```nginx
-# 추가: /api/todos → personal
-location /api/todos {
- resolver 127.0.0.11 valid=10s;
- set $personal_backend personal:8000;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_pass http://$personal_backend$request_uri;
-}
-
-# 추가: /api/blog/ → personal
-location /api/blog/ {
- resolver 127.0.0.11 valid=10s;
- set $personal_backend personal:8000;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_pass http://$personal_backend$request_uri;
-}
-
-# 변경: portfolio → personal
-location /api/profile/ {
- resolver 127.0.0.11 valid=10s;
- set $personal_backend personal:8000;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_pass http://$personal_backend$request_uri;
-}
-```
-
-기존 `/api/` catch-all은 lotto-backend로 유지 (todos/blog 요청은 위의 더 구체적인 location에서 먼저 매칭).
-
----
-
-## 인프라 변경
-
-### docker-compose.yml
-- `portfolio` 서비스 → `personal`로 리네이밍
-- 볼륨: `${RUNTIME_PATH}/data/personal:/app/data`
-- 환경변수 동일 (PORTFOLIO_EDIT_PASSWORD 등)
-
-### deploy.sh / deploy-nas.sh
-- SERVICES, BUILD_TARGETS, CONTAINER_NAMES 등에서 `portfolio` → `personal` 변경
-- DATA_DIRS에서 `portfolio` → `personal` 변경
-
-### lotto-backend 정리
-- `main.py`에서 Blog/Todo 라우트 + Pydantic 모델 제거 (약 100줄)
-- `db.py`에서 Blog/Todo CRUD 함수 제거 (약 130줄)
-- `db.py`의 `init_db()`에서 todos/blog_posts 테이블 생성 코드는 유지 (기존 DB 호환)
-
----
-
-## 배포 순서 (안전 우선)
-
-1. **코드 개발** — personal 서비스 + lotto-backend 정리 + 인프라 변경
-2. **git push** — 자동 배포 트리거
-3. **NAS에서 데이터 디렉토리 준비** — `mkdir -p data/personal`
-4. **기존 portfolio.db 이동** — `cp data/portfolio/portfolio.db data/personal/personal.db`
-5. **lotto.db에서 Blog/Todo 데이터 복사**:
- ```bash
- sqlite3 data/lotto.db ".dump todos" | sqlite3 data/personal/personal.db
- sqlite3 data/lotto.db ".dump blog_posts" | sqlite3 data/personal/personal.db
- ```
-6. **컨테이너 재시작** — `docker compose restart personal`
-7. **검증** — API 호출로 데이터 건수 대조
-8. **lotto.db 원본 테이블** — 삭제하지 않고 당분간 유지
-
----
-
-## 프론트엔드
-
-변경 없음. 모든 API 호출이 상대경로(`/api/todos`, `/api/blog/posts`, `/api/profile/`)이므로 nginx 라우팅 변경만으로 자동 적용.
-
----
-
-## 리스크
-
-- **낮음**: Blog/Todo는 lotto 테이블과 FK/공유 쿼리 없음
-- **롤백**: lotto.db 원본 테이블 유지 + nginx 라우팅 원복으로 즉시 롤백 가능
-- **다운타임**: nginx reload 순간 (~1초)
diff --git a/docs/superpowers/specs/2026-04-27-portfolio-design.md b/docs/superpowers/specs/2026-04-27-portfolio-design.md
deleted file mode 100644
index d4989bb..0000000
--- a/docs/superpowers/specs/2026-04-27-portfolio-design.md
+++ /dev/null
@@ -1,355 +0,0 @@
-# Portfolio Service Design Spec
-
-> 개인 포트폴리오 정식 서비��. 취업/이직용 이력서 + 개인 브랜딩 쇼케이스 겸용.
-
----
-
-## 1. 서비스 개요
-
-| 항목 | 값 |
-|------|-----|
-| 서비스명 | portfolio |
-| 경로 | `web-backend/portfolio/` |
-| 컨테이너 | `portfolio` |
-| 내부 포트 | 8000 |
-| 외부 포트 | 18850 |
-| DB | `/app/data/portfolio.db` (SQLite) |
-| Nginx 프록시 | `/api/portfolio/` → `portfolio:8000` |
-| 프레임워크 | FastAPI (Python 3.12) |
-| 프론트 경로 | `/portfolio` |
-
-### 목적
-
-- 프로필, 경력, 프로젝트, 기술스택을 웹에서 관리하고 공개 전시
-- 자기소개 글을 다중 버전으로 관리 (메인 1개 지정, 클립보드 복사)
-- 이력서 PDF 내보내기
-- 홈 페이지에 요약 카드로 연동
-
----
-
-## 2. DB 스키마
-
-### `profile` (1행, upsert)
-
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | INTEGER PK | 항상 1 |
-| name | TEXT | 이름 (한글) |
-| name_en | TEXT | 이름 (영문) |
-| role | TEXT | 직함 (한글) |
-| role_en | TEXT | 직함 (영문) |
-| email | TEXT | 이메일 |
-| phone | TEXT | 전화번호 |
-| github_url | TEXT | GitHub URL |
-| blog_url | TEXT | 블로그 URL |
-| photo_url | TEXT | 프로필 사진 URL |
-| bio | TEXT | 간단 소개 (3줄 정도) |
-| updated_at | TEXT | ISO8601 |
-
-### `careers` (경력 이력)
-
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | INTEGER PK AUTOINCREMENT | |
-| category | TEXT | `company` \| `education` \| `etc` |
-| organization | TEXT | 회사/기관명 |
-| role | TEXT | 직함/전공 |
-| description | TEXT | 설명 |
-| start_date | TEXT | YYYY-MM |
-| end_date | TEXT | YYYY-MM 또는 빈 문자열(현재) |
-| sort_order | INTEGER | 정렬 순서 (낮을수록 위) |
-| created_at | TEXT | ISO8601 |
-| updated_at | TEXT | ISO8601 |
-
-### `projects` (프로젝트)
-
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | INTEGER PK AUTOINCREMENT | |
-| category | TEXT | `company` \| `personal` \| `academy` |
-| title | TEXT | 프로젝트명 |
-| description | TEXT | 설명 |
-| tech_stack | TEXT | JSON 배열 `["Python", "FastAPI", ...]` |
-| role | TEXT | 담당 역할 |
-| start_date | TEXT | YYYY-MM |
-| end_date | TEXT | YYYY-MM 또는 빈 문자열 |
-| url | TEXT | 프로젝트 URL (선택) |
-| image_url | TEXT | 대표 이미지 URL (선택) |
-| sort_order | INTEGER | 정렬 순서 |
-| created_at | TEXT | ISO8601 |
-| updated_at | TEXT | ISO8601 |
-
-### `skills` (기술 스택)
-
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | INTEGER PK AUTOINCREMENT | |
-| category | TEXT | `language` \| `framework` \| `infra` \| `tool` |
-| name | TEXT | 기술명 |
-| level | INTEGER | 숙련도 1~5 |
-| sort_order | INTEGER | 정렬 순서 |
-
-### `introductions` (자기소개 글)
-
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | INTEGER PK AUTOINCREMENT | |
-| title | TEXT | 버전명 (예: "이직용 짧은 버전") |
-| content | TEXT | 본문 |
-| is_main | INTEGER | 0 \| 1 (메인 자기소개 지정, 항상 1개만 1) |
-| created_at | TEXT | ISO8601 |
-| updated_at | TEXT | ISO8601 |
-
----
-
-## 3. API 설계
-
-### 공개 API (인증 불필요)
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/portfolio/public` | 전체 공개 데이터 일괄 조회 (profile + careers + projects + skills + 메인 자기소개) |
-
-응답 형태:
-```json
-{
- "profile": { ... },
- "careers": [ ... ],
- "projects": [ ... ],
- "skills": [ ... ],
- "main_introduction": { "id": 1, "title": "...", "content": "..." }
-}
-```
-
-### 인증 API
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| POST | `/api/portfolio/auth` | 비밀번호 검증 → 세션 토큰 반환 |
-
-- 요청: `{ "password": "..." }`
-- 응답: `{ "token": "uuid-string", "expires_in": 86400 }`
-- 환경변수: `PORTFOLIO_EDIT_PASSWORD`
-- 토큰: UUID, 서버 메모리 딕셔너리 저장, 24시간 TTL
-- 실패: 401
-
-### 편집 API (Authorization: Bearer {token} 필요)
-
-**Profile:**
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/portfolio/profile` | 프로필 조회 |
-| PUT | `/api/portfolio/profile` | 프로필 수정 (upsert) |
-
-**Careers:**
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/portfolio/careers` | 경력 목록 |
-| POST | `/api/portfolio/careers` | 경력 추가 |
-| PUT | `/api/portfolio/careers/{id}` | 경력 수정 |
-| DELETE | `/api/portfolio/careers/{id}` | 경력 삭제 |
-
-**Projects:**
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/portfolio/projects` | 프로젝트 목록 |
-| POST | `/api/portfolio/projects` | 프로젝트 추가 |
-| PUT | `/api/portfolio/projects/{id}` | 프로젝트 수정 |
-| DELETE | `/api/portfolio/projects/{id}` | 프로젝트 삭제 |
-
-**Skills:**
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/portfolio/skills` | 기술 목록 |
-| POST | `/api/portfolio/skills` | 기술 추가 |
-| PUT | `/api/portfolio/skills/{id}` | 기술 수정 |
-| DELETE | `/api/portfolio/skills/{id}` | 기술 삭제 |
-
-**Introductions:**
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/portfolio/introductions` | 자기소개 전체 목록 |
-| POST | `/api/portfolio/introductions` | 자기소개 추가 |
-| PUT | `/api/portfolio/introductions/{id}` | 자기소개 수정 |
-| DELETE | `/api/portfolio/introductions/{id}` | 자기소개 삭제 |
-| PATCH | `/api/portfolio/introductions/{id}/main` | 메인 자기소개 지정 (기존 is_main=1 → 0 리셋) |
-
----
-
-## 4. 인증 흐름
-
-```
-편집 버튼 클릭
- → 토큰 없음 → 비밀번호 모달 표시
- → POST /api/portfolio/auth { password }
- → 성공: 토큰을 React state에 저장 (새로고침 시 재인증)
- → 이후 편집 API 호출에 Authorization: Bearer {token} 포함
- → 토큰 만료/불일치 시 401 → 재인증 모달
-```
-
-서버 측:
-- `_auth_tokens: dict[str, float]` 메모리 딕셔너리 (token → expiry timestamp)
-- FastAPI Depends로 토큰 검증 미들웨어
-- 서버 재시작 시 토큰 소멸 (재인증 필요, 보안상 적절)
-
----
-
-## 5. 프론트엔드 구조
-
-### 라우팅
-
-`routes.jsx`에 추가:
-- navLink: `{ id: 'portfolio', label: 'Portfolio', path: '/portfolio', subtitle: 'RESUME', accent: '#06b6d4' }`
-- appRoute: `{ path: 'portfolio', element: }`
-
-### 파일 구조
-
-```
-src/pages/portfolio/
- Portfolio.jsx — 메인 페이지 (3탭 컨테이너)
- Portfolio.css — 스타일
- ProfileTab.jsx — 탭 1: 프로필 & 이력 & 기술스택
- ProjectTab.jsx — 탭 2: 프로젝트
- IntroTab.jsx — 탭 3: 자기소개 관리
- usePortfolio.js — API 호출 + 인증 상태 관리 훅
- PasswordModal.jsx — 비밀번호 입력 모달
- ResumeView.jsx — PDF 출력 전용 레이아웃 (print CSS)
-```
-
-### 탭 1: 프로필 & 이력
-
-**보기 모드:**
-- 프로필 카드 (사진, 이름, 역할, 바이오, 연락처 아이콘 링크)
-- 경력 타임라인 (category별 그룹: 회사 → 교육 → 기타, sort_order 순)
-- 기술 스택 (category별 그룹, level 바 표시)
-- "이력서 PDF 내보내기" 버튼
-
-**편집 모드:**
-- 프로필: 인라인 편집 (input/textarea)
-- 경력: 추가/편집/삭제/순서 변경
-- 기술: 추가/편집/삭제/순서 변경
-
-### 탭 2: 프로젝트
-
-**보기 모드:**
-- 카테고리 필터 버튼 (전체 / 회사 / 개인 / 아카데미)
-- 프로젝트 카드 그리드: 제목, 설명(2줄 clamp), 기술스택 태그, 기간, 링크 아이콘
-
-**편집 모드:**
-- 프로젝트 추가/편집/삭제 폼
-- tech_stack: 태그 입력 UI (쉼표 또는 엔터로 추가)
-
-### 탭 3: 자기소개 관리
-
-- 자기소개 글 리스트 (메인 표시: 별 배지)
-- 각 항목: 제목, 미리보기(3줄), 수정일
-- 액션 버튼: 복사(클립보드) / 편집 / 메인 지정 / 삭제
-- 상단: "새 글 작성" 버튼 → 인라인 폼 또는 MobileSheet
-- 복사 버튼: `navigator.clipboard.writeText()` → "복사됨!" 피드백 1.5초
-
-### 편집 모드 진입
-
-- 각 탭 우상단 "편집" 토글 버튼
-- 첫 클릭 시 PasswordModal 표시 → 인증 성공 → 편집 UI 노출
-- 인증 토큰은 usePortfolio 훅에서 관리 (React state, 새로고침 시 소멸)
-
----
-
-## 6. 홈 페이지 연동
-
-### 변경 내용
-
-현재 Home.jsx Profile 섹션(하드코딩)을 요약 카드로 교체:
-
-- `GET /api/portfolio/public` fetch
-- 성공 시: 이름, 역할, 바이오, 기술태그 상위 8개, 대표 프로젝트 3개 카드
-- "포트폴리오 보기 →" 링크 버튼
-- 실패 시: 기존 하드코딩 프로필 폴백 (서비스 미가동 대응)
-
----
-
-## 7. PDF 내보내기
-
-### 방식
-
-`window.print()` + `@media print` 전용 CSS
-
-- ResumeView.jsx: 이력서 레이아웃 전용 컴포넌트
-- "PDF 내보내기" 버튼 → ResumeView를 화면에 렌더링 → `window.print()` → 숨김
-- 프린트 CSS: 네비/탭/편집버튼 숨기고, A4 1~2페이지 레이아웃 렌더링
-
-### 이력서 레이아웃 (A4)
-
-```
-┌──────────────────────────────┐
-│ [사진] 박재오 │
-│ Server Developer │
-│ email | github │
-├──────────────────────────────┤
-│ ABOUT │
-│ (메인 자기소개 또는 bio) │
-├──────────────────────────────┤
-│ EXPERIENCE │
-│ - 현대오토에버 (2023~현재) │
-│ - 롯데정보통신 (2020~2023) │
-│ - SSAFY 1기 (2019) │
-├──────────────────────────────┤
-│ PROJECTS │
-│ - 프로젝트 카드 목록 │
-├──────────────────────────────┤
-│ SKILLS │
-│ [태그 나열] │
-└──────────────────────────────┘
-```
-
----
-
-## 8. Docker / Nginx 변경
-
-### docker-compose.yml 추가
-
-```yaml
-portfolio:
- build: ./portfolio
- container_name: portfolio
- restart: unless-stopped
- volumes:
- - ${RUNTIME_PATH:-.}/data:/app/data
- environment:
- - PORTFOLIO_EDIT_PASSWORD=${PORTFOLIO_EDIT_PASSWORD}
- ports:
- - "18850:8000"
-```
-
-### Nginx 추가
-
-```nginx
-location /api/portfolio/ {
- proxy_pass http://portfolio:8000/api/portfolio/;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
-}
-```
-
----
-
-## 9. Backlog (향후)
-
-- Blog CRUD (`/api/blog/posts`) → portfolio 서비스로 이전
-- Todo CRUD (`/api/todos`) → portfolio 서비스로 이전
-- 이전 완료 후 lotto-backend에서 해당 테이블/라우트 제거
-- Nginx 라우팅 변경 (`/api/blog/`, `/api/todos` → portfolio)
-
----
-
-## 10. 모바일 대응
-
-- 기존 프로젝트 패턴 그대로: `useIsMobile()` + SwipeableView 3탭
-- 편집 모드: MobileSheet 활용
-- 자기소개 복사: 모바일에서도 `navigator.clipboard` 동작
-- PDF: 모바일에서는 "PDF 내보내기" 대신 "공유" 또는 브라우저 인쇄 기능 활용
diff --git a/docs/superpowers/specs/2026-04-28-realestate-frontend-targeting-design.md b/docs/superpowers/specs/2026-04-28-realestate-frontend-targeting-design.md
deleted file mode 100644
index 6884c7c..0000000
--- a/docs/superpowers/specs/2026-04-28-realestate-frontend-targeting-design.md
+++ /dev/null
@@ -1,397 +0,0 @@
-# 청약 타겟팅 프론트엔드 설계 — 자치구 5티어 + 알림 설정
-
-> 대상: `web-ui/src/pages/subscription/`
-> 백엔드 의존: 2026-04-28-realestate-targeting-enhancement-design.md (이미 배포됨)
-> 후속 별도 스펙: Subscription.jsx 분할 리팩토링, 5축 progress bar, 추가 알림 채널
-
----
-
-## 1. 목표
-
-백엔드 청약 타겟팅 고도화로 추가된 3 프로필 필드(`preferred_districts`, `min_match_score`, `notify_enabled`)를 프론트 UI에 노출한다. 매칭 결과·공고 카드에는 자치구 + 5티어 뱃지를, 상세 모달에는 매칭 사유 텍스트를 추가해 사용자가 점수의 근거를 즉시 이해할 수 있게 한다.
-
-### 핵심 변경
-
-- **ProfileTab**: 자치구 5티어 분류(드래그&드롭, PC 전용) + 임계값 슬라이더 + 알림 토글
-- **모바일**: 자치구 분류는 read-only — "PC에서 편집해주세요" 안내
-- **카드 표시**: AnnouncementCard / 매칭 카드에 district 뱃지 + 5티어 뱃지(reasons에서 derive)
-- **상세 모달**: AnnouncementDetail에 "매칭 분석" 섹션 (점수 + reasons 텍스트 + 자격)
-
-### 변경하지 않는 것
-
-- Subscription.jsx 자체 분할 — 본 스코프 외(별도 리팩토링)
-- 백엔드 응답 형태 — 모든 필요 데이터는 이미 응답에 포함됨
-- 5축 점수 분해 시각화 — 백엔드 응답 변경 필요(별도)
-- 알림 채널 추가 — 텔레그램 외 이메일/Slack은 별도
-
----
-
-## 2. 컴포넌트 분할
-
-### 2.1 신규 컴포넌트 2개
-
-| 파일 | 책임 | 추정 크기 |
-|------|------|----------|
-| `web-ui/src/pages/subscription/components/DistrictTierEditor.jsx` | 자치구 5티어 드래그&드롭 + 모바일 read-only | ~180줄 |
-| `web-ui/src/pages/subscription/components/NotificationSettings.jsx` | 임계값 슬라이더 + 알림 토글 + 미리보기 | ~80줄 |
-
-ProfileTab(현재 343줄)에 그대로 추가하면 단일 함수가 거대화되어 가독성·유지보수가 떨어진다. 의미 단위로 분할.
-
-### 2.2 변경 받는 기존 컴포넌트
-
-| 컴포넌트 (파일: Subscription.jsx) | 변경 |
-|----|------|
-| ProfileTab (956~1299줄) | 신규 컴포넌트 2개 import + 자치구 섹션 / 알림 설정 섹션 렌더 + handleSave에서 신규 3필드 송신 |
-| AnnouncementCard (315~389줄) | district 뱃지 + 5티어 뱃지(`extractTier(reasons)`) |
-| AnnouncementDetail (390~595줄) | "매칭 분석" 섹션 추가 (점수 + reasons + eligible_types) |
-| MatchesTab (763~955줄) | 매치 카드에 district + 5티어 뱃지 + reasons 표시 |
-| 모듈 상단 | `DEFAULT_PROFILE`에 신규 3필드 기본값 추가, `extractTier` 헬퍼 함수 |
-
-### 2.3 스타일
-
-- `Subscription.css`: 5티어 뱃지 5 클래스(`.sub-chip--tier-S`~`D`), 드래그&드롭 hover/dragover, 슬라이더, 토글, district 뱃지
-
-### 2.4 기각된 대안
-
-| 대안 | 기각 사유 |
-|------|-----------|
-| 단일 파일에 모든 신규 UI | ProfileTab이 500줄+ 거대화, 디버깅 어려움 |
-| Subscription.jsx 자체 분할 | 본 작업 스코프 외, 별도 리팩토링이 적절 |
-| `react-dnd` 도입 | 의존성 +50KB, 모바일 어차피 사용 안 함. YAGNI |
-| 5칼럼 체크박스 그리드 | 모바일/데스크톱 둘 다 무난하지만 드래그&드롭이 더 직관적이라 채택 안 함 |
-
----
-
-## 3. DistrictTierEditor 컴포넌트
-
-### 3.1 인터페이스
-
-```jsx
- setProfile({...profile, preferred_districts: next})}
-/>
-```
-
-`value`가 비어있거나 누락되면 빈 객체 fallback. `onChange`는 새 객체를 항상 한 번 호출(부모는 setState만 처리).
-
-### 3.2 상수
-
-```jsx
-const SEOUL_DISTRICTS = [
- "강남구","강동구","강북구","강서구","관악구",
- "광진구","구로구","금천구","노원구","도봉구",
- "동대문구","동작구","마포구","서대문구","서초구",
- "성동구","성북구","송파구","양천구","영등포구",
- "용산구","은평구","종로구","중구","중랑구",
-];
-
-const TIERS = [
- { key: "S", label: "S", weight: "100%" },
- { key: "A", label: "A", weight: "80%" },
- { key: "B", label: "B", weight: "60%" },
- { key: "C", label: "C", weight: "40%" },
- { key: "D", label: "D", weight: "20%" },
-];
-
-const EMPTY_TIERS = { S:[], A:[], B:[], C:[], D:[] };
-```
-
-### 3.3 데스크톱 레이아웃 (≥768px)
-
-```
-┌─ 자치구 우선순위 ─────────────────────────────────────────┐
-│ 미할당 (드래그해서 분류) │
-│ [강서구] [노원구] [도봉구] [중랑구] [관악구] ... │
-│ │
-│ ┌─ S 100% ─┐ ┌─ A 80% ─┐ ┌─ B 60% ─┐ ┌─ C 40% ─┐ ┌─ D 20% ─┐│
-│ │[강남구]× │ │[송파구]× │ │ │ │ │ │ ││
-│ │[서초구]× │ │[마포구]× │ │ │ │ │ │ ││
-│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘│
-└────────────────────────────────────────────────────────────┘
-```
-
-- 5티어는 가로 5칼럼 그리드(`grid-template-columns: repeat(5, 1fr)`)
-- 미할당 풀은 그리드 위, 가로 wrap
-- 자치구 칩은 `` + `× ` (`×` 클릭 시 미할당으로 복귀)
-- 각 티어 슬롯은 dropzone(`onDragOver` + `onDrop`)
-- 미할당 풀도 dropzone(드래그해서 떨어뜨리면 해당 티어에서 제거)
-
-### 3.4 모바일 레이아웃 (<768px) — read-only
-
-```
-┌─ 자치구 우선순위 ──────────────┐
-│ S 100% 강남구, 서초구 │
-│ A 80% 송파구, 마포구 │
-│ B 60% (없음) │
-│ C 40% (없음) │
-│ D 20% (없음) │
-│ │
-│ ✏️ 자치구 분류는 PC에서 편집 │
-└──────────────────────────────────┘
-```
-
-분기 로직:
-
-```jsx
-const [isDesktop, setIsDesktop] = useState(
- typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches
-);
-useEffect(() => {
- const mq = window.matchMedia("(min-width: 768px)");
- const handler = (e) => setIsDesktop(e.matches);
- mq.addEventListener("change", handler);
- return () => mq.removeEventListener("change", handler);
-}, []);
-```
-
-`isDesktop=false`면 read-only 뷰만 렌더, 드래그 핸들러는 등록하지 않음.
-
-### 3.5 핵심 로직
-
-```jsx
-const handleDrop = (district, targetTier /* null = 미할당 */) => {
- const current = value || EMPTY_TIERS;
- const next = { ...EMPTY_TIERS };
- for (const t of Object.keys(EMPTY_TIERS)) {
- next[t] = (current[t] || []).filter(d => d !== district);
- }
- if (targetTier) {
- next[targetTier] = [...next[targetTier], district];
- }
- onChange(next);
-};
-
-const unassigned = SEOUL_DISTRICTS.filter(d =>
- !TIERS.some(t => (value?.[t.key] || []).includes(d))
-);
-```
-
-`onChange`는 새 객체를 통째로 전달(immutable update).
-
-### 3.6 드래그&드롭 이벤트 (HTML5 native)
-
-| 이벤트 | 핸들러 |
-|--------|--------|
-| `onDragStart` (chip) | `e.dataTransfer.setData("district", districtName)` |
-| `onDragOver` (zone) | `e.preventDefault()` (drop 허용) |
-| `onDrop` (zone) | `e.preventDefault()` + `handleDrop(e.dataTransfer.getData("district"), tierKey)` |
-
-외부 라이브러리 없음.
-
----
-
-## 4. NotificationSettings 컴포넌트
-
-### 4.1 인터페이스
-
-```jsx
- setProfile({...profile, ...patch})}
-/>
-```
-
-`onChange` 호출 예시: 슬라이더 변경 시 `onChange({ min_match_score: 75 })`, 토글 시 `onChange({ notify_enabled: false })`.
-
-### 4.2 레이아웃
-
-```
-┌─ 🔔 알림 설정 ────────────────────────────────┐
-│ 텔레그램 알림 ●━━━○ ON │
-│ 매칭 임계값 ▬▬▬▬▬▬●▬▬▬ 70점 │
-│ 0 50 100 │
-│ │
-│ 💡 70점 이상 매치 시 텔레그램에 자동 알림합니다│
-└────────────────────────────────────────────────┘
-```
-
-### 4.3 컨트롤
-
-- 토글: ` ` + 사용자 정의 CSS (Subscription.css에 `.sub-toggle` 신설)
-- 슬라이더: ` ` + 우측 숫자 라벨
-- 미리보기: `notify_enabled === false` 일 때 경고 톤 메시지("알림 OFF — 메시지가 발송되지 않습니다")
-
-### 4.4 저장 동작
-
-각 컨트롤 변경 시 `onChange`로 부모 state만 업데이트. 실제 PUT 요청은 ProfileTab 기존 "저장" 버튼이 일괄 처리(다른 모든 필드와 동일 패턴).
-
-### 4.5 카운트 미리보기 (스코프 외)
-
-"현재 임계값 통과 매치 N건" 같은 카운트 미리보기는 본 스펙에서 다루지 않는다. `dashboard.new_match_count`는 "미확인 매칭"이라 임계값 통과와 의미가 다르고, 정확한 카운트를 위해서는 백엔드에 `dashboard.pass_count` 필드 신설이 필요하다. 후속 스펙으로 분리.
-
----
-
-## 5. 카드 표시 변경
-
-### 5.1 헬퍼 함수 (Subscription.jsx 모듈 상단)
-
-```jsx
-function extractTier(reasons) {
- for (const r of reasons || []) {
- const m = r.match(/자치구 ([SABCD])티어/);
- if (m) return m[1];
- }
- return null;
-}
-```
-
-- 백엔드 응답 변경 없이 reasons 배열에서 티어 도출
-- reasons 형식 예시: `"자치구 S티어: 강남구 (+25)"` (백엔드 matcher.py의 fmt와 일치)
-- 광역만 매칭(legacy 모드)이면 티어 없음 → `null`
-
-### 5.2 AnnouncementCard
-
-기존 카드 제목/지역 라인 옆 또는 메타 라인에 추가:
-
-```jsx
-{item.district && (
- {item.district}
-)}
-{(() => {
- const tier = extractTier(item.match_reasons);
- return tier ? (
-
- {tier}티어
-
- ) : null;
-})()}
-```
-
-`item.match_reasons`는 매칭 결과가 있는 경우만 존재. 없으면 뱃지 미표시(공고 목록 탭에서 매칭 결과 없는 카드).
-
-### 5.3 AnnouncementDetail
-
-상세 모달 하단에 새 섹션:
-
-```
-┌─ 매칭 분석 ─────────────────────────────────┐
-│ ⭐ 점수: 90점 / 100점 │
-│ │
-│ 💡 매칭 사유 │
-│ • 광역 일치: 서울특별시 │
-│ • 자치구 S티어: 강남구 (+25) │
-│ • 예산 범위 내 모델 존재 (최고가 7.2억원) │
-│ • 자격 유형 2개: 일반1순위, 특별-신혼부부 │
-│ │
-│ ✓ 신청 자격 │
-│ [일반1순위] [특별-신혼부부] │
-└──────────────────────────────────────────────┘
-```
-
-`item.match_score`, `item.match_reasons`, `item.eligible_types`는 이미 응답에 포함됨(get_unnotified_matches는 물론 get_matches/get_announcement도 enrich_items 거침). 매칭 결과가 없는 공고에는 이 섹션 자체를 렌더하지 않음(`item.match_score` 존재 여부로 분기).
-
-### 5.4 MatchesTab
-
-매치 카드는 이미 매칭 데이터를 받지만 district + 5티어 뱃지 표시가 부족할 가능성 높음. AnnouncementCard와 동일한 helper(`extractTier`)로 일관 표시. 카드 클릭 시 AnnouncementDetail 모달이 reasons 노출.
-
-### 5.5 5티어 뱃지 색상 (Subscription.css 신설)
-
-```css
-.sub-chip--tier-S { background:#fee2e2; color:#dc2626; border-color:#fca5a5; }
-.sub-chip--tier-A { background:#fef3c7; color:#d97706; border-color:#fcd34d; }
-.sub-chip--tier-B { background:#d1fae5; color:#059669; border-color:#6ee7b7; }
-.sub-chip--tier-C { background:#dbeafe; color:#2563eb; border-color:#93c5fd; }
-.sub-chip--tier-D { background:#ede9fe; color:#7c3aed; border-color:#c4b5fd; }
-.sub-chip--district { background:#f3f4f6; color:#374151; border-color:#d1d5db; }
-```
-
----
-
-## 6. ProfileTab 통합
-
-### 6.1 DEFAULT_PROFILE 갱신
-
-Subscription.jsx 모듈 상단의 `DEFAULT_PROFILE` 상수에 3 필드 default 추가:
-
-```jsx
-const DEFAULT_PROFILE = {
- // ... 기존 필드
- preferred_regions: '',
- preferred_types: '',
- min_area: '',
- max_area: '',
- max_price: '',
- // 신규
- preferred_districts: {},
- min_match_score: 70,
- notify_enabled: true,
-};
-```
-
-### 6.2 ProfileTab 렌더 추가 위치
-
-자치구 섹션은 기존 "선호 조건" 섹션 다음, 알림 설정 섹션은 그 다음에 배치(저장 버튼 위):
-
-```jsx
- handleChange("preferred_districts", next)}
-/>
-
- setProfile(prev => ({ ...prev, ...patch }))}
-/>
-```
-
-### 6.3 handleSave 변경
-
-신규 3 필드는 변환 없이 그대로 PUT body에 포함:
-
-```jsx
-// 기존 변환 로직 다음에
-payload.preferred_districts = profile.preferred_districts || {};
-payload.min_match_score = profile.min_match_score ?? null;
-payload.notify_enabled = profile.notify_enabled ?? null;
-```
-
-JSON 형태(객체)는 백엔드 ProfileUpdate 모델에서 `Dict[str, List[str]]`로 받음(이미 구현됨).
-
----
-
-## 7. 테스트 전략
-
-`web-ui` 레포는 단위 테스트 인프라가 빈약(컨벤션 확인 필요). 본 작업의 검증:
-
-| 영역 | 검증 방식 |
-|------|-----------|
-| 빌드 | `npm run build` warning/error 없음 |
-| 데스크톱 자치구 편집 | 미할당 풀 → S 슬롯 드래그 → 저장 → 새로고침 → 유지 확인 |
-| 자치구 티어 이동 | S → A로 드래그 → S에서 사라지고 A에 등장 |
-| 자치구 해제 | × 버튼 또는 미할당 풀로 드래그 → 미할당 풀에 복귀 |
-| 모바일 read-only | 개발자 도구 < 768px → 편집 영역 숨김 + 안내 메시지 표시 |
-| 임계값 슬라이더 | 0→100 조절, 즉시 미리보기 텍스트 갱신, 저장·새로고침 후 유지 |
-| 알림 토글 | OFF 시 경고 톤 안내 표시 |
-| 매칭 카드 | district 뱃지 + 5티어 뱃지 표시 (해당 데이터 있는 경우) |
-| 상세 모달 | 매칭 분석 섹션의 점수 + reasons + 자격 표시 |
-| 회귀 | 기존 프로필 필드(나이/청약통장/특공 등) 입력·저장 정상 |
-
-`scripts/dev.bat` 또는 `cd web-ui && npm run dev`로 dev server 실행 후 브라우저에서 수동 검증.
-
-배포는 frontend 별도 절차: `cd web-ui && npm run release:nas` (NAS Z 드라이브에 robocopy).
-
----
-
-## 8. 스코프
-
-### 본 스펙 범위
-
-- ✅ DistrictTierEditor 신규 컴포넌트
-- ✅ NotificationSettings 신규 컴포넌트
-- ✅ ProfileTab 신규 3 필드 통합 + 저장
-- ✅ AnnouncementCard / MatchesTab district + 5티어 뱃지
-- ✅ AnnouncementDetail 매칭 분석 섹션
-- ✅ Subscription.css 5티어 뱃지 + 드래그 영역 + 토글 + 슬라이더 스타일
-- ✅ 모바일 read-only fallback
-
-### 후속 별도 스펙
-
-- ❌ Subscription.jsx 자체 분할 (1354줄 → 별도 리팩토링)
-- ❌ 5축 점수 분해 progress bar (백엔드 응답에 5축 점수 추가 필요)
-- ❌ 임계값 통과 매치 카운트 미리보기 (`dashboard.pass_count` 백엔드 신설 필요)
-- ❌ 자치구 5티어 자동 추천 (사용자 가점·예산 기반)
-- ❌ 알림 채널 추가 (이메일/Slack)
-- ❌ 모바일 자치구 편집 지원 (touch backend 필요 시)
diff --git a/docs/superpowers/specs/2026-04-28-realestate-targeting-enhancement-design.md b/docs/superpowers/specs/2026-04-28-realestate-targeting-enhancement-design.md
deleted file mode 100644
index 539dc42..0000000
--- a/docs/superpowers/specs/2026-04-28-realestate-targeting-enhancement-design.md
+++ /dev/null
@@ -1,479 +0,0 @@
-# 청약 서비스 타겟팅 고도화 설계
-
-> 대상: `web-backend/realestate-lab/` + `web-backend/agent-office/`
-> 후속 별도 스펙: 프론트 자치구 입력 UI(`web-ui`), 청약 가점 vs 커트라인 비교, 서울 외 광역 자치구 파싱
-
----
-
-## 1. 목표
-
-현재 청약 서비스가 1) 완료된 공고까지 무차별 수집하고, 2) 매칭이 binary라 단지별 의미 있는 점수 차이가 없으며, 3) 데일리 리포트라 "발견 즉시"의 가치를 못 살리는 문제를 해결한다.
-
-### 핵심 변경
-
-- **수집**: 모집공고 30일 이전 + 이미 `완료` 상태인 공고는 저장하지 않음. 90일 경과 완료 공고 자동 정리.
-- **단일 SoT**: `user_profile.preferred_regions`를 수집·조회·매칭의 단일 기준점으로 사용 (서울 default).
-- **매칭**: 자치구 5티어 가중치(S=100% / A=80% / B=60% / C=40% / D=20%) 도입. 자격 점수 미세 조정.
-- **알림**: 데일리 리포트 폐기. "신규 매칭 + 임계값 통과" 즉시 텔레그램 푸시. realestate-lab → agent-office HTTP push.
-
-### 변경하지 않는 것
-
-- 공공데이터 API 엔드포인트 5종 구성
-- 매칭 총점 100점 체계
-- 텔레그램 봇 토큰·formatter는 agent-office에 단일 보관
-- realestate-lab의 09:00 / 00:00 cron 스케줄(기존 그대로 유지, 트리거 로직만 변경)
-
----
-
-## 2. 아키텍처 변경 개요
-
-### 2.1 변경 포인트
-
-| # | 위치 | 변경 |
-|---|------|------|
-| 1 | `realestate-lab/collector.py` | API 호출 시 모집공고일 윈도우 사전 적용. 응답 시 `완료` 상태 skip. 자치구 파싱. 90일 경과 완료 공고 정리. |
-| 2 | `realestate-lab/db.py` | `user_profile`에 3컬럼, `announcements`에 `district`, `match_results`에 `notified_at` 추가. `delete_old_completed_announcements()` 신규. |
-| 3 | `realestate-lab/matcher.py` | 자치구 5티어 가중치 + 자격 점수 재배분. binary → 자치구 그라디언트. |
-| 4 | `realestate-lab` 신규 모듈 | `notifier.py`: 임계값 통과 신규 매칭 추출 + agent-office push. `notified_at` 멱등 마킹. |
-| 5 | `agent-office/agents/realestate.py` | 데일리 cron 폐기. `on_new_matches(matches)` 신규. 메시지 fmt + 인라인 키보드. |
-| 6 | `agent-office/main.py` | `POST /api/agent-office/realestate/notify` 신규 엔드포인트. |
-
-### 2.2 데이터 흐름
-
-```
-[09:00 cron] realestate-lab.scheduled_collect()
- ├─ collect_all()
- │ ├─ API 호출 (RCRIT_PBLANC_DE_FROM = today − 30일)
- │ ├─ 응답 파싱 + district 추출
- │ ├─ status='완료' skip → upsert
- │ └─ delete_old_completed_announcements(grace_days=90)
- ├─ run_matching() // 5티어 가중치 적용
- └─ notify_new_matches()
- ├─ SELECT match_results WHERE notified_at IS NULL
- │ AND match_score >= profile.min_match_score
- │ AND profile.notify_enabled = 1
- ├─ POST agent-office /api/agent-office/realestate/notify
- └─ 성공 → UPDATE notified_at = now()
-
-[agent-office] POST /api/agent-office/realestate/notify
- └─ RealestateAgent.on_new_matches(matches)
- ├─ formatter로 텔레그램 텍스트 + 인라인 키보드 빌드
- └─ telegram_bot.send_message()
-```
-
-### 2.3 기각된 대안
-
-| 대안 | 기각 사유 |
-|------|-----------|
-| 매칭 로직을 agent-office에 이식 | 두 서비스에 매칭 코드 복제 → 동기화 부담 |
-| 완료 공고 즉시 삭제 | 사용자가 회고 못 함. 90일 grace 채택 |
-| agent-office가 realestate-lab을 폴링 | 트래픽 + 지연 |
-| realestate-lab이 직접 텔레그램 호출 | 토큰·formatter 분산. 봇 단일 책임 위반 |
-| 가격·면적 그라디언트 곡선 | 점수 해석 어려움. binary 유지 (자치구 1축에만 곡선 적용) |
-
----
-
-## 3. DB 스키마 변경
-
-### 3.1 `user_profile` — 3컬럼 추가
-
-```sql
-ALTER TABLE user_profile ADD COLUMN preferred_districts TEXT NOT NULL DEFAULT '{}';
-ALTER TABLE user_profile ADD COLUMN min_match_score INTEGER NOT NULL DEFAULT 70;
-ALTER TABLE user_profile ADD COLUMN notify_enabled INTEGER NOT NULL DEFAULT 1;
-```
-
-- **`preferred_districts`**: JSON. 5티어 분류.
- ```json
- {"S": ["강남구", "서초구"], "A": ["송파구", "마포구"], "B": [], "C": [], "D": []}
- ```
- 모든 티어 비어있으면 자치구 기준 미설정으로 간주 (기존 호환 동작).
-- **`min_match_score`**: 알림 트리거 임계값(0~100). 기본 70.
-- **`notify_enabled`**: 텔레그램 푸시 ON/OFF. 0이면 알림 전체 차단.
-
-### 3.2 `announcements` — `district` 컬럼 추가
-
-```sql
-ALTER TABLE announcements ADD COLUMN district TEXT;
-CREATE INDEX IF NOT EXISTS idx_ann_district ON announcements(district);
-```
-
-- collector가 응답의 `HSSPLY_ADRES` / `region_name`을 정규식 파싱하여 채움.
-- 서울 외 지역, 파싱 실패 → NULL.
-
-### 3.3 `match_results` — `notified_at` 컬럼 추가
-
-```sql
-ALTER TABLE match_results ADD COLUMN notified_at TEXT;
-```
-
-- NULL이면 미알림. 알림 송신 후 `strftime('%Y-%m-%dT%H:%M:%fZ','now')` 기록.
-- 기존 `is_new`(사용자가 UI에서 봤는지)와 의미 분리.
-
-### 3.4 신규 함수
-
-```python
-def delete_old_completed_announcements(grace_days: int = 90) -> int:
- """winner_date + grace_days 경과한 status='완료' 공고를 삭제.
- winner_date가 NULL인 행은 안전하게 보존(수동 검토 대상).
- match_results는 FK CASCADE로 자동 삭제. 삭제된 건수 반환.
- """
-```
-
-```python
-def get_unnotified_matches(min_score: int) -> list[dict]:
- """notified_at IS NULL AND match_score >= min_score 인 매칭 + 공고 정보 조인 반환."""
-```
-
-```python
-def mark_matches_notified(match_ids: list[int]) -> None:
- """notified_at = now() 일괄 업데이트."""
-```
-
-### 3.5 마이그레이션 패턴
-
-기존 db.py의 `init_db()` 안에서 try/except로 컬럼 존재 여부 검사 후 ALTER (운영 DB 무중단).
-
----
-
-## 4. collector 변경
-
-### 4.1 모집공고일 윈도우 사전 좁힘
-
-```python
-def collect_all() -> dict:
- today = date.today()
- date_from = (today - timedelta(days=30)).strftime("%Y%m%d")
-
- for detail_ep, model_ep in DETAIL_ENDPOINTS:
- rows = _api_call(detail_ep, params={
- # 공공데이터 API 파라미터명은 엔드포인트별로 다를 수 있음.
- # 구현 시 한국부동산원 API 스펙 확인 후 정확한 키 적용.
- "RCRIT_PBLANC_DE_FROM": date_from,
- })
- # ...
-```
-
-> ⚠️ **구현 시 검증 필요**: `ApplyhomeInfoDetailSvc`의 5개 엔드포인트가 모두 모집공고일 필터 파라미터를 지원하지 않을 수 있음. 미지원 시 응답 수신 후 클라이언트 측에서 `parsed["rcrit_date"] < date_from` skip하는 fallback을 적용.
-
-### 4.2 `완료` 상태 skip
-
-```python
-parsed = _parse_apt_detail(raw)
-parsed["district"] = _extract_district(parsed)
-
-status = compute_status(
- parsed.get("receipt_start", ""),
- parsed.get("receipt_end", ""),
- parsed.get("winner_date", ""),
-)
-if status == "완료":
- continue # DB 자원 절감
-
-# 일정 정보 없는 공고 skip (기존 로직 유지)
-has_dates = any(parsed.get(f) for f in (...))
-if not has_dates:
- continue
-
-upsert_announcement(parsed)
-```
-
-### 4.3 자치구 추출
-
-```python
-DISTRICT_PATTERN = re.compile(r"(?:서울특별시|서울시|서울)\s+(\S+?(?:구|군))")
-
-def _extract_district(parsed: dict) -> str | None:
- for src in (parsed.get("address"), parsed.get("region_name")):
- if not src:
- continue
- m = DISTRICT_PATTERN.search(src)
- if m:
- return m.group(1)
- return None
-```
-
-### 4.4 정리 + 매칭 + 알림 트리거
-
-```python
-def collect_all() -> dict:
- # ... 위 수집 로직
- save_collect_log(new_count, total_count)
- return {"new_count": new_count, "total_count": total_count}
-
-
-def scheduled_collect():
- """09:00 cron — 수집 + 정리 + 매칭 + 알림"""
- collect_all()
- deleted = delete_old_completed_announcements(grace_days=90)
- logger.info("정리: %d건 삭제", deleted)
- run_matching()
- notify_new_matches() # NEW
-```
-
----
-
-## 5. matcher 변경
-
-### 5.1 가중치 재배분 (총 100점 유지)
-
-| 축 | 기존 | 신규 |
-|----|------|------|
-| 지역 | 30 | **35** (광역 10 + 자치구 가중 0~25) |
-| 주택유형 | 10 | 10 |
-| 면적 | 15 | 15 |
-| 가격 | 15 | 15 |
-| 자격 | 30 | **25** |
-
-### 5.2 지역 점수 (35점)
-
-```python
-TIER_WEIGHTS = {"S": 1.00, "A": 0.80, "B": 0.60, "C": 0.40, "D": 0.20}
-
-def _region_score(profile: dict, ann: dict) -> tuple[int, list[str]]:
- region_name = ann.get("region_name") or ""
- district = ann.get("district") or ""
- preferred_regions = profile.get("preferred_regions") or []
- preferred_districts = profile.get("preferred_districts") or {}
-
- region_match = bool(region_name and any(r in region_name for r in preferred_regions))
- if not region_match:
- return 0, []
-
- # 자치구 기준 미설정 → 광역만으로 풀 점수 (기존 호환)
- has_districts = any(preferred_districts.get(t) for t in TIER_WEIGHTS)
- if not has_districts:
- return 35, [f"선호 지역 일치: {region_name}"]
-
- score = 10
- reasons = [f"광역 일치: {region_name}"]
-
- for tier, weight in TIER_WEIGHTS.items():
- if district in (preferred_districts.get(tier) or []):
- tier_score = round(25 * weight)
- score += tier_score
- reasons.append(f"자치구 {tier}티어: {district} (+{tier_score})")
- break
-
- return score, reasons
-```
-
-### 5.3 자격 점수 (25점)
-
-```python
-def _eligibility_score(eligible_types: list[str]) -> int:
- if not eligible_types:
- return 0
- score = 15 # 첫 자격
- score += min((len(eligible_types) - 1) * 5, 10) # 추가 자격당 +5, 최대 +10
- return score
-```
-
-다른 축(주택유형 10, 면적 15, 가격 15)은 기존 binary 로직 유지.
-
-### 5.4 매칭 결과 저장
-
-`run_matching()`은 기존 흐름 유지. `match_results.notified_at`은 손대지 않음 (notifier가 관리).
-
----
-
-## 6. 알림 흐름
-
-### 6.1 realestate-lab 측 — `notifier.py`
-
-```python
-import os
-import requests
-from .db import get_unnotified_matches, mark_matches_notified, get_profile
-
-AGENT_OFFICE_URL = os.getenv("AGENT_OFFICE_URL", "http://agent-office:8000")
-
-
-def notify_new_matches() -> dict:
- profile = get_profile()
- if not profile or not profile.get("notify_enabled"):
- return {"sent": 0, "skipped": "notify_disabled"}
-
- threshold = profile.get("min_match_score", 70)
- matches = get_unnotified_matches(threshold)
- if not matches:
- return {"sent": 0}
-
- try:
- resp = requests.post(
- f"{AGENT_OFFICE_URL}/api/agent-office/realestate/notify",
- json={"matches": matches},
- timeout=15,
- )
- resp.raise_for_status()
- body = resp.json()
- sent_ids = body.get("sent_ids", [])
- if sent_ids:
- mark_matches_notified(sent_ids)
- return body
- except requests.RequestException as e:
- logger.error("알림 push 실패: %s", e)
- return {"sent": 0, "error": str(e)}
-```
-
-알림 push 실패 시 `notified_at`을 채우지 않아 다음 사이클에서 재시도된다.
-
-### 6.2 agent-office 측 — 신규 엔드포인트
-
-```python
-# agent-office/main.py
-@app.post("/api/agent-office/realestate/notify")
-async def realestate_notify(body: dict):
- matches = body.get("matches", [])
- agent = registry.get("realestate")
- result = await agent.on_new_matches(matches)
- return result
-```
-
-```python
-# agents/realestate.py
-async def on_new_matches(self, matches: list[dict]) -> dict:
- if not matches:
- return {"sent": 0, "sent_ids": []}
-
- text = telegram_formatter.format_realestate_matches(matches)
- keyboard = telegram_formatter.build_match_keyboard(matches)
- tg = await telegram_bot.send_message(text, reply_markup=keyboard)
-
- if not tg.get("ok"):
- return {"sent": 0, "sent_ids": [], "error": tg.get("error")}
-
- sent_ids = [m["id"] for m in matches]
- return {"sent": len(matches), "sent_ids": sent_ids, "message_id": tg.get("message_id")}
-```
-
-### 6.3 텔레그램 메시지 포맷
-
-**3건 이상 — 묶음 카드**
-
-```
-🏢 새 청약 매칭 3건
-
-⭐ 92점 — 디에이치 강남 [S]
-📍 서울 강남구 (분양가상한제) · 32~45㎡ · 6.2~9.8억
-📅 청약 05/15(수) ~ 05/19(일)
-
-⭐ 78점 — 마포 푸르지오 [A]
-📍 서울 마포구 · 59~84㎡ · 8.0~11.5억
-📅 청약 05/22(수) ~ 05/26(일)
-
-⭐ 72점 — 송파 데시앙 [A]
-📍 서울 송파구 · 39~59㎡ · 5.8~7.9억
-📅 청약 05/27(월) ~ 05/30(목)
-
-[전체 보기]
-```
-
-**1~2건 — 풀 카드**
-
-```
-⭐ 90점 — 디에이치 강남 [S]
-📍 서울 강남구 (분양가상한제)
-🏠 32~45㎡ · 6.2~9.8억
-📅 청약 05/15(수) ~ 05/19(일)
-✓ 자격: 일반1순위, 특별-신혼부부
-💡 광역 일치 / 자치구 S티어 / 예산 범위 / 자격 2개
-
-[🔖 북마크] [📄 공고 보기]
-```
-
-### 6.4 인라인 키보드 콜백
-
-| 버튼 | 콜백 동작 |
-|------|-----------|
-| `[🔖 북마크]` | `PATCH /api/realestate/announcements/{id}/bookmark` (기존 endpoint) |
-| `[📄 공고 보기]` | `pblanc_url` (텔레그램 URL 버튼) |
-| `[전체 보기]` | 대시보드 deep link (`/realestate?tab=matches`) |
-
-agent-office의 텔레그램 webhook(`/api/agent-office/telegram/webhook`)이 callback_query를 받아 service_proxy로 realestate-lab API 호출.
-
-### 6.5 기존 RealestateAgent 동작 정리
-
-```python
-# agent-office/scheduler.py — 09:15 데일리 cron 제거
-# scheduler.add_job(realestate_agent.on_schedule, ...) ← REMOVE
-```
-
-`RealestateAgent.on_schedule()`은 호출 지점이 사라지므로 제거. `on_command("fetch_matches")`는 수동 트리거(텔레그램 슬래시 명령)용으로 보존하되 `on_new_matches()`를 직접 호출하도록 단순화.
-
-### 6.6 환경변수
-
-| 변수 | 위치 | 기본값 |
-|------|------|--------|
-| `AGENT_OFFICE_URL` | realestate-lab `.env` | `http://agent-office:8000` |
-| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | agent-office (기존) | (기존) |
-
-docker-compose의 사내 네트워크로 호출되므로 외부 노출 없음.
-
----
-
-## 7. API 변경 요약
-
-### 7.1 realestate-lab
-
-| 메서드 | 경로 | 변경 |
-|--------|------|------|
-| PUT | `/api/realestate/profile` | body에 `preferred_districts`, `min_match_score`, `notify_enabled` 수용 |
-| GET | `/api/realestate/profile` | 응답에 위 3필드 포함 |
-| GET | `/api/realestate/announcements` | 응답 item에 `district` 포함 |
-| GET | `/api/realestate/announcements/{id}` | 응답에 `district` 포함 |
-| GET | `/api/realestate/matches` | 응답 item에 `notified_at` 포함 (디버깅용) |
-
-### 7.2 agent-office
-
-| 메서드 | 경로 | 변경 |
-|--------|------|------|
-| POST | `/api/agent-office/realestate/notify` | **신규** — realestate-lab 전용 push 수신 |
-
-### 7.3 Pydantic 모델 확장
-
-```python
-# realestate-lab/app/models.py
-class ProfileUpdate(BaseModel):
- # ... 기존 필드
- preferred_districts: Optional[Dict[str, List[str]]] = None
- min_match_score: Optional[int] = Field(default=None, ge=0, le=100)
- notify_enabled: Optional[bool] = None
-```
-
----
-
-## 8. 테스트 전략
-
-| 영역 | 테스트 항목 |
-|------|-------------|
-| `_extract_district` | "서울특별시 강남구 도곡동" → `"강남구"`, "서울 송파구" → `"송파구"`, "부산 해운대구" → NULL, "" → NULL |
-| `compute_status` | 변경 없음. 기존 테스트 유지 |
-| `_region_score` | 광역 미매칭 / 광역만 매칭 + 자치구 미설정 / S~D 티어별 / 광역 매칭 + 비선호 자치구 — 5케이스 |
-| `_eligibility_score` | 자격 0개 / 1개 / 3개 / 5개 — 점수 단조 증가 + 25 상한 |
-| `delete_old_completed_announcements` | winner_date 91일 전 → 삭제, 89일 전 → 보존, status≠'완료' → 보존 |
-| collector 사전 좁힘 | mock API 응답으로 30일 윈도우 외 데이터 skip 확인. `완료` skip 확인 |
-| `notify_new_matches` 멱등성 | `notified_at` 채워진 매치는 push 후보 제외, push 실패 시 `notified_at` 미기록 → 다음 사이클 재시도 |
-| agent-office push endpoint | mock telegram client로 `format_realestate_matches` 호출 + send 검증 |
-| 알림 임계값 필터 | min_match_score=70, score=69 → push 대상 외 / score=70 → 포함 |
-| `notify_enabled=0` | push 자체 skip |
-
-NAS Docker는 git push 자동 배포이므로 별도 절차 없음. ALTER TABLE은 init_db에서 try/except 패턴으로 운영 DB 무중단 적용.
-
----
-
-## 9. 스코프
-
-### 본 스펙 범위
-
-- ✅ realestate-lab: collector, matcher, db 변경, notifier 신규
-- ✅ agent-office: `/realestate/notify` 엔드포인트, `on_new_matches` 메소드, 메시지 formatter
-- ✅ 기존 데일리 RealestateAgent cron 폐기
-
-### 후속 별도 스펙
-
-- ❌ 프론트(`web-ui`) 자치구 5티어 입력 UI (별도 frontend 스펙)
-- ❌ 청약 가점 vs 공고별 예상 커트라인 비교 (외부 데이터 의존성, 별도 연구)
-- ❌ 서울 외 광역(부산 해운대구 등) 자치구 파싱 확장
-- ❌ 매칭 임계값 변경 후 재발송 트리거 (`POST /notifications/resend`)
-- ❌ 자치구별 매칭 분포 대시보드 위젯
diff --git a/docs/superpowers/specs/2026-05-01-music-lab-youtube-monetization-design.md b/docs/superpowers/specs/2026-05-01-music-lab-youtube-monetization-design.md
deleted file mode 100644
index a9c349e..0000000
--- a/docs/superpowers/specs/2026-05-01-music-lab-youtube-monetization-design.md
+++ /dev/null
@@ -1,359 +0,0 @@
-# music-lab YouTube 수익화 고도화 설계
-
-> 작성일: 2026-05-01
-> 범위: music-lab + agent-office 확장
-
----
-
-## 1. 개요
-
-Suno API로 생성한 음악을 YouTube 업로드 가능한 완성 영상으로 만들고, 시장 수요 분석을 통해 수익이 나는 콘텐츠를 정기적으로 생산하는 파이프라인 구축.
-
-**핵심 목표:**
-- 시장 조사 자동화 → 만들 만한 장르/스타일 추천
-- 음악 + 영상 합성 → YouTube 업로드 패키지(MP4 + 메타데이터) 자동 생성
-- 수익 추적 → 채널별·장르별·국가별 RPM 분석
-- **Phase 1**: 파일 내보내기(수동 업로드) → **Phase 3**: YouTube API 자동 업로드
-
----
-
-## 2. 결정 사항 요약
-
-| 항목 | 결정 |
-|------|------|
-| 자동화 수준 | 반자동 — 수집·추천 자동, 생성·업로드 수동 트리거 |
-| 업로드 방식 | Phase 1: 파일 내보내기, Phase 3: YouTube API |
-| 영상 포맷 | 오디오 비주얼라이저 + AI 이미지 슬라이드쇼 |
-| 시장 조사 데이터 | YouTube 트렌드 + Google Trends + Billboard (해외 시장 포함) |
-| 음악 언어 전략 | 인스트루멘탈 + 영어 가사 혼합 |
-| 이미지 소스 | Suno 커버이미지 + Pexels/Unsplash (추후 Stable Diffusion) |
-| 주력 해외 시장 | 브라질, 인도네시아, 멕시코, 글로벌 |
-
----
-
-## 3. 아키텍처
-
-```
-[외부 데이터 소스]
- YouTube Data API v3 · Google Trends · Billboard · Pexels/Unsplash
- ↓ 매일 09:00 스케줄
-[agent-office :18900]
- YouTubeResearchAgent (신규)
- - 국가별 트렌딩 수집·분석
- - POST /api/music/market/ingest → music-lab push
- - 매주 월요일 08:00 텔레그램 인사이트 리포트
- ↓
-[music-lab :18600]
- 기존: 음악 생성 · 라이브러리
- 신규: 시장 데이터 저장 · 영상 제작 파이프라인 · 수익화 추적
- ↓
-[내보내기 패키지]
- output.mp4 + thumbnail.jpg + metadata.json
- (Phase 3: YouTube API 자동 업로드)
-```
-
-**변경 없는 것:** 컨테이너 수, 포트 배정, Nginx 라우팅 (경로 1개 추가 제외)
-
----
-
-## 4. DB 스키마 (신규)
-
-### 4-1. music.db 신규 테이블
-
-#### `market_trends`
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | INTEGER PK | |
-| source | TEXT | `'youtube'` \| `'google_trends'` \| `'billboard'` |
-| country | TEXT | `'BR'` \| `'ID'` \| `'MX'` \| `'US'` \| `'KR'` … |
-| genre | TEXT | 장르 문자열 |
-| keyword | TEXT | 검색 키워드 |
-| score | REAL | 정규화 인기도 (0.0~1.0) |
-| rank | INTEGER | 차트 순위 (nullable) |
-| metadata | TEXT | JSON — 추가 원본 데이터 |
-| collected_at | TEXT | ISO8601 |
-
-인덱스: `(country, source, collected_at DESC)`
-
-#### `trend_reports`
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | INTEGER PK | |
-| report_date | TEXT UNIQUE | YYYY-MM-DD |
-| top_genres | TEXT | JSON 배열 `[{genre, score, countries}]` |
-| top_keywords | TEXT | JSON 배열 |
-| recommended_styles | TEXT | JSON `[{genre, prompt, countries, reason}]` |
-| insights | TEXT | AI 분석 텍스트 |
-| created_at | TEXT | ISO8601 |
-
-#### `video_projects`
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | INTEGER PK | |
-| track_id | INTEGER FK | → music_library.id |
-| format | TEXT | `'visualizer'` \| `'slideshow'` |
-| status | TEXT | `'pending'` \| `'rendering'` \| `'done'` \| `'failed'` |
-| output_path | TEXT | MP4 로컬 경로 |
-| output_url | TEXT | `/media/videos/…` 서빙 URL |
-| thumbnail_path | TEXT | JPG 로컬 경로 |
-| target_countries | TEXT | JSON 배열 `['BR', 'ID']` |
-| yt_title | TEXT | Claude API 생성 제목 (최대 100자) |
-| yt_description | TEXT | Claude API 생성 설명 (해시태그 포함) |
-| yt_tags | TEXT | JSON 배열 (10-15개, 국가별 현지화) |
-| render_params | TEXT | JSON — 렌더링 파라미터 (색상, 전환 효과 등) |
-| error | TEXT | 실패 시 에러 메시지 |
-| created_at | TEXT | ISO8601 |
-| completed_at | TEXT | ISO8601 (nullable) |
-
-#### `revenue_records`
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | INTEGER PK | |
-| video_project_id | INTEGER FK | → video_projects.id (nullable) |
-| yt_video_id | TEXT | YouTube 영상 ID |
-| record_month | TEXT | YYYY-MM |
-| views | INTEGER | 조회수 |
-| watch_hours | REAL | 시청 시간 (시간 단위) |
-| revenue_usd | REAL | 수익 (USD) |
-| rpm_usd | REAL | revenue / views * 1000 |
-| country | TEXT | 국가별 분석용 (nullable) |
-| source | TEXT | `'manual'` \| `'youtube_api'` |
-| created_at | TEXT | ISO8601 |
-
-### 4-2. agent_office.db 신규 테이블
-
-#### `youtube_research_jobs`
-| 컬럼 | 타입 | 설명 |
-|------|------|------|
-| id | INTEGER PK | |
-| status | TEXT | `'running'` \| `'completed'` \| `'failed'` |
-| countries | TEXT | JSON 배열 — 수집 대상 국가 |
-| trends_collected | INTEGER | 수집된 트렌드 건수 |
-| report_id | INTEGER | 생성된 trend_reports.id (nullable) |
-| error | TEXT | 실패 시 에러 |
-| started_at | TEXT | ISO8601 |
-| completed_at | TEXT | ISO8601 (nullable) |
-
----
-
-## 5. 신규 API 엔드포인트
-
-### 5-1. music-lab — 시장 조사
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/music/market/trends` | 트렌드 목록 (`country`, `genre`, `source`, `days` 필터) |
-| GET | `/api/music/market/report/latest` | 최신 분석 리포트 + 추천 스타일 |
-| GET | `/api/music/market/report` | 리포트 이력 |
-| POST | `/api/music/market/ingest` | agent-office → 트렌드 데이터 수신 |
-| GET | `/api/music/market/suggest` | 트렌드 기반 제작 아이디어 추천 |
-
-### 5-2. music-lab — 영상 제작
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| POST | `/api/music/video-project` | 프로젝트 생성 (`track_id`, `format`, `target_countries`) |
-| GET | `/api/music/video-projects` | 프로젝트 목록 |
-| GET | `/api/music/video-project/{id}` | 프로젝트 상세 + 렌더링 상태 |
-| POST | `/api/music/video-project/{id}/render` | 렌더링 시작 (BackgroundTask) |
-| GET | `/api/music/video-project/{id}/export` | 내보내기 패키지 (MP4 URL + metadata JSON) |
-| DELETE | `/api/music/video-project/{id}` | 프로젝트 삭제 |
-
-### 5-3. music-lab — 수익화 추적
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| GET | `/api/music/revenue` | 수익 기록 (`yt_video_id`, `year_month` 필터) |
-| POST | `/api/music/revenue` | 수익 기록 추가 |
-| PUT | `/api/music/revenue/{id}` | 수익 기록 수정 |
-| DELETE | `/api/music/revenue/{id}` | 수익 기록 삭제 |
-| GET | `/api/music/revenue/dashboard` | 총수익·RPM·장르별·국가별 집계 |
-
-### 5-4. agent-office — YouTube 리서치
-
-| 메서드 | 경로 | 설명 |
-|--------|------|------|
-| POST | `/api/agent-office/youtube/research` | 수동 리서치 트리거 (`countries` 지정 가능) |
-| GET | `/api/agent-office/youtube/research/status` | 마지막 실행 상태 + 수집 건수 |
-
----
-
-## 6. 영상 제작 파이프라인
-
-### 6-1. 오디오 비주얼라이저 (`format: 'visualizer'`)
-
-```
-MP3 (file_path) + 배경 이미지 (cover_images[0] 우선, 없으면 장르별 그라디언트 기본 배경)
- → FFmpeg showwaves 필터 (1920×1080, 음파 오버레이)
- → H.264 + AAC MP4
- → 썸네일 추출 (5초 지점 프레임)
- → Claude API 메타데이터 생성
-```
-
-핵심 FFmpeg 명령:
-```bash
-ffmpeg -loop 1 -i cover.jpg -i audio.mp3 \
- -filter_complex \
- "[1:a]showwaves=s=1920x200:mode=cline:colors=0xFF4444[wave]; \
- [0:v][wave]overlay=0:880[out]" \
- -map "[out]" -map 1:a \
- -c:v libx264 -c:a aac -shortest output.mp4
-```
-
-적합 장르: Lo-fi, Ambient, Study Music, Phonk
-
-### 6-2. AI 이미지 슬라이드쇼 (`format: 'slideshow'`)
-
-```
-① 키워드 추출 (genre + moods + prompt → 검색어)
-② 이미지 수집
- - Pexels API: 키워드 검색 4-6장 (무료 200req/시간)
- - Suno 커버이미지: cover_images 필드에서 1-2장
-③ 이미지당 표시 시간 = track.duration_sec / 이미지 수
-④ FFmpeg xfade 전환 (fade, 1초)
-⑤ H.264 + AAC MP4 출력
-⑥ 썸네일 추출 + Claude API 메타데이터 생성
-```
-
-### 6-3. 공통 후처리
-
-**Claude API 메타데이터 생성:**
-- 입력: `genre`, `moods`, `lyrics`, `target_countries`
-- 출력:
- - `yt_title`: 최대 100자, SEO 최적화, 국가 감안
- - `yt_description`: 해시태그 + 타임스탬프 + 링크 플레이스홀더
- - `yt_tags`: 10-15개, 현지어 포함 (예: 브라질 타겟 → `"música relaxante"`, `"estudo música"`)
-
-**내보내기 패키지:**
-```
-/data/videos/{project_id}/
- output.mp4 ← 최종 영상
- thumbnail.jpg ← 썸네일
- metadata.json ← {title, description, tags, target_countries, category}
-```
-
----
-
-## 7. YouTubeResearchAgent (agent-office)
-
-**파일:** `agents/youtube.py`
-
-**데이터 수집 (매일 09:00):**
-1. YouTube Data API v3 — 국가별 (`BR`, `ID`, `MX`, `US`, `KR`) 트렌딩 음악 카테고리 50개
-2. pytrends — 장르별 Google Trends 점수 (최근 7일)
-3. Billboard Hot 100 스크래핑 — 글로벌 차트 상위 20
-
-**분석 → trend_reports 생성:**
-- 소스별 score 정규화 후 장르 클러스터링
-- `recommended_styles` 생성: `{genre, suno_prompt, target_countries, reason}`
-- Claude API로 `insights` 텍스트 생성
-
-**push → music-lab:**
-```
-POST http://music-lab:8000/api/music/market/ingest
-body: {trends: [...], report: {...}}
-```
-
-**스케줄러:**
-- 매일 09:00 — `youtube_research_job`
-- 매주 월요일 08:00 — 주간 인사이트 텔레그램 발송
-
----
-
-## 8. 인프라 변경사항
-
-| 대상 | 변경 내용 |
-|------|-----------|
-| `music-lab/Dockerfile` | `RUN apt-get install -y ffmpeg` 추가 |
-| `nginx/default.conf` | `/media/videos/` → `/data/videos/` 경로 추가 |
-| `music-lab/requirements.txt` | `anthropic`, `Pillow` 추가 |
-| `agent-office/requirements.txt` | `google-api-python-client`, `pytrends` 추가 |
-| `.env` | `PEXELS_API_KEY`, `YOUTUBE_DATA_API_KEY` 추가 |
-| `docker-compose.yml` | music-lab volume에 `/data/videos` 마운트 추가 |
-
-**CLAUDE.md 업데이트 필요:**
-- Nginx: `/media/videos/` 경로 추가
-- music-lab API 목록에 신규 16개 추가 (시장조사 5 + 영상제작 6 + 수익화 5), agent-office 2개 추가
-- agent-office 스케줄러에 youtube_research_job 추가
-
----
-
-## 9. 수익화 전략
-
-### 9-1. YouTube 광고 수익 (CPM 기준)
-
-| 국가 | CPM 범위 |
-|------|---------|
-| 브라질 | $1.5 ~ $4 |
-| 인도네시아 | $1.0 ~ $2.5 |
-| 미국 | $3.0 ~ $8.0 |
-| 한국 | $2.0 ~ $5.0 |
-
-Lo-fi / Ambient은 긴 시청 시간 유도 → RPM 유리. 인스트루멘탈은 언어 장벽 없음.
-
-### 9-2. 국가별 장르 전략
-
-| 국가 | 주력 장르 |
-|------|-----------|
-| 브라질 | Funk, Phonk, Lo-fi |
-| 인도네시아 | Pop, Study Music, Lo-fi |
-| 멕시코 | Latin Pop, Reggaeton |
-| 글로벌 | Ambient, Cinematic |
-
-### 9-3. 업로드 목표
-
-- **주 3-5개** 영상 업로드 (시스템 안정화 후 일 1개 목표)
-- 영상 **50개** 누적 → 수익 활성화 (구독자 1,000 + 시청 4,000시간)
-- 영상 **200개** 누적 → 월 $100+ 수동 수익 목표
-
----
-
-## 10. 구현 로드맵
-
-### Phase 1 — 영상 제작 파이프라인 (약 2-3주)
-
-**music-lab 백엔드:**
-- `video_producer.py` — FFmpeg 래퍼 (비주얼라이저 + 슬라이드쇼)
-- `market.py` — 트렌드 데이터 수신·저장·조회·추천
-- `monetization.py` — 수익화 추적 CRUD
-- DB 마이그레이션: `video_projects`, `revenue_records`
-- 신규 API 12개 (영상 제작 6 + 수익화 5 + market ingest 1)
-- Dockerfile `ffmpeg` 추가
-- Nginx `/media/videos/` 경로 추가
-
-### Phase 2 — 시장 조사 자동화 (약 1-2주)
-
-**agent-office:**
-- `agents/youtube.py` (YouTubeResearchAgent)
-- YouTube Data API v3 연동
-- pytrends 연동
-- Billboard 스크래핑
-- 스케줄러 등록 (매일 09:00, 매주 월요일 08:00)
-- `youtube_research_jobs` DB 테이블
-- 신규 API 2개 + agent-office API 2개
-
-**music-lab:**
-- DB 마이그레이션: `market_trends`, `trend_reports`
-- 신규 API 4개 (트렌드 조회 3 + 추천 1)
-
-### Phase 3 — YouTube API 자동 업로드 (채널 안정화 후)
-
-- YouTube Data API OAuth 2.0 인증
-- 동영상 업로드·썸네일 설정 자동화
-- YouTube Studio 수익 데이터 자동 수집 (`source: 'youtube_api'`)
-- 텔레그램 업로드 완료 알림
-
----
-
-## 11. 신규 파일 목록
-
-### music-lab/app/
-- `video_producer.py` — FFmpeg 비주얼라이저·슬라이드쇼 렌더링
-- `market.py` — 시장 트렌드 수신·저장·조회·추천
-- `monetization.py` — 수익 기록 CRUD·대시보드
-
-### agent-office/app/agents/
-- `youtube.py` — YouTubeResearchAgent
-
-### agent-office/app/
-- `youtube_researcher.py` — YouTube/Trends/Billboard 데이터 수집 로직
diff --git a/docs/superpowers/specs/2026-05-01-music-youtube-tab-frontend-design.md b/docs/superpowers/specs/2026-05-01-music-youtube-tab-frontend-design.md
deleted file mode 100644
index 1547fd5..0000000
--- a/docs/superpowers/specs/2026-05-01-music-youtube-tab-frontend-design.md
+++ /dev/null
@@ -1,208 +0,0 @@
-# Music YouTube Tab Frontend — Design Spec
-
-**Date:** 2026-05-01
-**Repo:** `web-page` (React + Vite SPA at `/Users/jaeohpark/development/web-page/`)
-
----
-
-## 1. Goal
-
-MusicStudio 페이지에 **🎯 YouTube 탭**을 추가한다. 기존 4개 탭(Create / Lyrics / Library / Remix) 옆에 하나 더 붙이며, 탭 내부에 3개의 서브탭을 둔다.
-
-- **🎬 영상 제작** — 트랙 선택 → 포맷·국가 설정 → 렌더링 → 내보내기
-- **💰 수익 추적** — 수동 수익 기록 입력 + 장르별 RPM 차트 + 기록 테이블
-- **📊 시장 트렌드** — agent-office가 매일 수집한 YouTube/Trends/Billboard 데이터 표시 + AI 프롬프트 추천
-
----
-
-## 2. 영향 파일
-
-### 수정
-| 파일 | 변경 내용 |
-|------|-----------|
-| `src/pages/music/MusicStudio.jsx` | tab 상태에 `'youtube'` 추가, 탭 버튼 추가, YoutubeTab 렌더링 |
-| `src/api.js` | 비디오 프로젝트 / 수익 / 시장 트렌드 API 함수 추가 |
-
-### 신규 생성
-| 파일 | 역할 |
-|------|------|
-| `src/pages/music/components/YoutubeTab.jsx` | YouTube 탭 루트 컴포넌트 (서브탭 상태 관리) |
-| `src/pages/music/components/VideoProjectsTab.jsx` | 🎬 영상 제작 서브탭 |
-| `src/pages/music/components/RevenueTab.jsx` | 💰 수익 추적 서브탭 |
-| `src/pages/music/components/TrendsTab.jsx` | 📊 시장 트렌드 서브탭 |
-
----
-
-## 3. 컴포넌트 계층
-
-```
-MusicStudio
-└── [tab === 'youtube']
- └── YoutubeTab
- ├── subtab 상태: 'video' | 'revenue' | 'trends'
- ├── [subtab === 'video'] → VideoProjectsTab
- ├── [subtab === 'revenue'] → RevenueTab
- └── [subtab === 'trends'] → TrendsTab
-```
-
-**YoutubeTab props:**
-- `library: Array` — 라이브러리 트랙 목록 (MusicStudio에서 내려줌, 트랙 선택 드롭다운용)
-- `initialTrackId?: string` — Library 탭의 "영상 만들기" 버튼 클릭 시 pre-select용
-
----
-
-## 4. 서브탭 상세
-
-### 4-1. VideoProjectsTab (`subtab === 'video'`)
-
-**① 새 영상 만들기 패널**
-- 트랙 선택 드롭다운 (`library` prop에서 목록, `title` 표시)
-- 형식 선택: `비주얼라이저` | `슬라이드쇼` (toggle)
-- 타겟 국가 칩: BR / US / ID / MX / KR (복수 선택 가능)
-- "프로젝트 생성" 버튼 → `POST /api/music/video-project`
-
-**② 영상 프로젝트 목록**
-- `GET /api/music/video-projects` 폴링 (렌더링 중인 프로젝트 있을 때 5초 간격)
-- 상태별 표시:
- - `pending` — "대기" 배지 + "▶ 렌더" 버튼 → `POST /api/music/video-project/:id/render`
- - `rendering` — "처리중" 배지 + 진행 바 (시작 시각 기준 경과 시간 표시)
- - `done` — "✓ 완료" 배지 + "↓ 내보내기" 버튼
- - `failed` — "실패" 배지 (빨간색)
-
-**③ 내보내기 패키지 (done 상태 프로젝트 선택 시)**
-- `GET /api/music/video-project/:id/export` → `{mp4_url, thumbnail_url, metadata}`
-- mp4 다운로드 링크, thumbnail 다운로드 링크, metadata.json 미리보기 (title / tags / target)
-
-**상태 관리:**
-```js
-const [projects, setProjects] = useState([])
-const [selectedTrackId, setSelectedTrackId] = useState(initialTrackId ?? '')
-const [format, setFormat] = useState('visualizer')
-const [countries, setCountries] = useState(['BR'])
-const [creating, setCreating] = useState(false)
-const [exportData, setExportData] = useState(null) // 선택된 done 프로젝트의 export
-```
-
----
-
-### 4-2. RevenueTab (`subtab === 'revenue'`)
-
-**대시보드 카드 (3개)**
-- `GET /api/music/revenue/dashboard` → `{total_revenue_usd, total_views, avg_rpm}`
-- 총 수익 / 총 조회수 / 가중평균 RPM
-
-**장르별 RPM 바 차트**
-- `GET /api/music/revenue` → 레코드 목록에서 장르별로 RPM 집계
-- 바 차트 (CSS 기반, 라이브러리 없음) — genre / rpm / color 매핑
-
-**수익 기록 추가 폼**
-- 필드: `yt_video_id`, `record_month` (YYYY-MM), `revenue_usd`, `views`, `country`
-- "저장" → `POST /api/music/revenue`
-- 성공 시 목록 + 대시보드 리프레시
-
-**수익 기록 테이블**
-- `GET /api/music/revenue` — 영상 제목 / 월 / 수익 / 조회수 / RPM
-- 행 클릭 → 수정 폼 인라인 펼침
-- 삭제 버튼 → `DELETE /api/music/revenue/:id`
-
-**장르 추론:** `yt_video_id`는 자유 입력이고 장르 컬럼이 DB에 없으므로, `genre` 필드를 수익 기록 폼에 optional 셀렉트로 추가한다. DB 스키마에 이미 없으면 프론트에서만 관리하지 않고, API 명세 확인 후 처리.
-
-> **참고:** `revenue_records` 테이블에 `genre` 컬럼이 없다. 차트는 `yt_video_id`별 집계만 가능. 장르별 RPM 차트는 "영상별 RPM 비교"로 레이블을 바꿔서 구현한다.
-
-**상태 관리:**
-```js
-const [dashboard, setDashboard] = useState(null)
-const [records, setRecords] = useState([])
-const [form, setForm] = useState({ yt_video_id:'', record_month:'', revenue_usd:'', views:'', country:'BR' })
-const [editingId, setEditingId] = useState(null)
-```
-
----
-
-### 4-3. TrendsTab (`subtab === 'trends'`)
-
-**수집 상태 바**
-- `GET /api/music/market/report/latest` → `{report_date, created_at, top_genres, recommended_styles}`
-- 마지막 수집 일시 + 트렌드 수 표시
-- "↻ 수동 수집" 버튼 → `POST /api/agent-office/youtube/research` (body: `{}`)
-
-**오늘의 인기 장르 Top 5**
-- `top_genres` 배열에서 상위 5개 렌더링
-- 각 항목: 장르명 / 대상 국가 플래그 / 점수 바
-
-**AI 추천 Suno 프롬프트**
-- `GET /api/music/market/suggest` → `[{genre, suno_prompt, target_countries, reason}]`
-- 카드 형태, 프롬프트 클릭 시 클립보드 복사
-
-**트렌드 리포트 이력**
-- `GET /api/music/market/report` → 날짜 목록
-- 날짜 클릭 → 해당 날짜 리포트 상세 표시 (top_genres + recommended_styles)
-
-**상태 관리:**
-```js
-const [latestReport, setLatestReport] = useState(null)
-const [reports, setReports] = useState([])
-const [suggestions, setSuggestions] = useState([])
-const [selectedReport, setSelectedReport] = useState(null)
-const [researching, setResearching] = useState(false)
-```
-
----
-
-## 5. API 추가 목록 (`src/api.js`)
-
-```js
-// 기존 api.js 헬퍼: apiGet / apiPost / apiPut / apiDelete (plain fetch 래퍼)
-
-// Video Projects
-export const createVideoProject = (data) => apiPost('/api/music/video-project', data)
-export const getVideoProjects = () => apiGet('/api/music/video-projects')
-export const renderVideoProject = (id) => apiPost(`/api/music/video-project/${id}/render`)
-export const exportVideoProject = (id) => apiGet(`/api/music/video-project/${id}/export`)
-export const deleteVideoProject = (id) => apiDelete(`/api/music/video-project/${id}`)
-
-// Revenue
-export const getRevenueDashboard = () => apiGet('/api/music/revenue/dashboard')
-export const getRevenueRecords = () => apiGet('/api/music/revenue')
-export const addRevenueRecord = (data) => apiPost('/api/music/revenue', data)
-export const updateRevenueRecord = (id, data) => apiPut(`/api/music/revenue/${id}`, data)
-export const deleteRevenueRecord = (id) => apiDelete(`/api/music/revenue/${id}`)
-
-// Market Trends
-export const getLatestTrendReport = () => apiGet('/api/music/market/report/latest')
-export const getTrendReports = () => apiGet('/api/music/market/report')
-export const getMarketSuggestions = () => apiGet('/api/music/market/suggest')
-export const triggerYoutubeResearch = () => apiPost('/api/agent-office/youtube/research', {})
-```
-
----
-
-## 6. Library 탭 연동
-
-`MusicStudio.jsx`의 `LibraryCard` 컴포넌트에 **"🎬 영상 만들기"** 버튼 추가:
-
-```jsx
- {
- setTab('youtube')
- setInitialTrackId(track.id)
-}}>🎬 영상 만들기
-```
-
-`initialTrackId` 상태를 MusicStudio 루트에 두고 YoutubeTab에 prop으로 내려준다. VideoProjectsTab이 마운트되면 해당 트랙을 드롭다운에 pre-select.
-
----
-
-## 7. 스타일 가이드
-
-기존 MusicStudio.css의 다크 테마 변수 재사용:
-- 배경: `#111827` / `#0d1117` / `#1f2937`
-- 강조색: `#22c55e` (초록, 완료·생성), `#f59e0b` (노랑, 처리중), `#3b82f6` (파랑, 수익), `#a855f7` (보라, 트렌드)
-- 새 CSS 클래스는 `MusicStudio.css`에 추가 (별도 파일 없음)
-
----
-
-## 8. 범위 외 (Out of scope)
-
-- YouTube Analytics OAuth 자동 동기화 (나중에 확장)
-- 영상 업로드 자동화 (YouTube Data API write scope)
-- 차트 라이브러리 도입 (CSS 바로 구현)
diff --git a/docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.md b/docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.md
deleted file mode 100644
index 27ca5ed..0000000
--- a/docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.md
+++ /dev/null
@@ -1,446 +0,0 @@
-# packs-lab 인프라 통합 + admin mint-token 설계
-
-> 대상: `web-backend/packs-lab/`
-> 외부 의존: Supabase(`pack_files` 테이블) + Vercel SaaS(HMAC 호출자)
-> 후속 별도 스펙: Vercel-side admin UI / 사용자 다운로드 / cleanup cron / multi-admin
-
----
-
-## 1. 목표
-
-`packs-lab`은 NAS 자료 다운로드 자동화 백엔드. Synology DSM 공유 링크 발급 + 5GB 멀티파트 업로드 수신을 담당하고, Vercel SaaS와 HMAC으로 통신한다. 사용자 인증은 Vercel이 Supabase로 처리하고 본 서비스는 외부 인증을 다루지 않는다.
-
-이미 코드(HMAC 미들웨어 / DSM client / 4 라우트)는 작성되어 있으나 인프라 통합 + Supabase 스키마 + admin upload 토큰 발급 흐름이 빠져 있어 운영 가능 상태가 아니다. 본 스펙은 그 갭을 메운다.
-
-### 핵심 변경
-
-- **신규 라우트**: `POST /api/packs/admin/mint-token` (Vercel HMAC → 일회성 업로드 토큰)
-- **Supabase DDL**: `pack_files` 테이블 + 활성·삭제 인덱스
-- **인프라**: docker-compose `packs-lab` 서비스 등록(18950) + nginx `/api/packs/` 5GB 통과 + `.env.example` 6+1 환경변수
-- **테스트**: routes 통합 + DSM client mock
-- **문서**: web-backend / workspace CLAUDE.md 5곳 갱신
-- **DELETE 라우트 docstring**: "DSM 공유 정리" 표현을 "DSM 공유 자동 만료"로 수정 (실제 동작과 일치)
-
-### 변경하지 않는 것
-
-- 기존 `auth.py` (`mint_upload_token` 그대로 활용)
-- 기존 `dsm_client.py`
-- 기존 `routes.py`의 sign-link / upload / list / delete 본문
-- DSM 공유 추적 테이블 — 4시간 자동 만료로 충분(브레인스토밍 결정)
-
----
-
-## 2. 컴포넌트 + 통신 흐름
-
-### 2.1 변경 받는 파일
-
-| 영역 | 파일 | 변경 |
-|------|------|------|
-| 백엔드 | `packs-lab/app/routes.py` | DELETE docstring 수정 + admin mint-token 라우트 추가 |
-| 백엔드 | `packs-lab/app/models.py` | `MintTokenRequest`, `MintTokenResponse` 스키마 추가 |
-| 백엔드 | `packs-lab/app/auth.py` | 변경 없음 (기존 `mint_upload_token` 활용) |
-| 테스트 | `packs-lab/tests/conftest.py` (신규) | autouse `BACKEND_HMAC_SECRET` 셋팅 |
-| 테스트 | `packs-lab/tests/test_routes.py` (신규) | 5 라우트 통합 테스트 |
-| 테스트 | `packs-lab/tests/test_dsm_client.py` (신규) | DSM 7.x API mock 테스트 |
-| DB | `packs-lab/supabase/pack_files.sql` (신규) | DDL + 인덱스 |
-| 인프라 | `docker-compose.yml` | `packs-lab` 서비스 추가 |
-| 인프라 | `nginx/default.conf` | `/api/packs/` 라우팅 (`client_max_body_size 5G` + streaming) |
-| 인프라 | `.env.example` | 6+1 신규 환경변수 |
-| 문서 | `web-backend/CLAUDE.md` | 1·4·5·8·9 섹션 갱신 |
-| 문서 | `workspace/CLAUDE.md` | 컨테이너 표 한 줄 추가 |
-
-### 2.2 통신 흐름
-
-**ADMIN 업로드**
-
-```
-Vercel admin UI ─────→ Vercel API (HMAC 헤더 추가)
- │
- ▼
- POST /api/packs/admin/mint-token
- │
- backend: verify_request_hmac
- │
- mint_upload_token({tier, label, filename, size_bytes, jti, expires_at})
- │
-Vercel ←─────────────── token ──────┘
- │
- ▼
-admin browser → POST /api/packs/upload
- Authorization: Bearer
- multipart body (≤5GB)
- │
- backend: verify_upload_token + JTI mark
- │
- 파일 저장 (PACK_BASE_DIR/{tier}/{filename})
- │
- Supabase INSERT pack_files
-```
-
-**사용자 다운로드**
-
-```
-사용자 → Vercel SaaS (Supabase auth + tier·결제 검증)
- │
- ▼
- POST /api/packs/sign-link (HMAC + file_path)
- │
- backend: verify_request_hmac
- │
- DSM Sharing.create (4시간 만료)
- │
-사용자 ← Vercel ← 다운로드 URL (4시간 유효)
-```
-
-### 2.3 기각된 대안
-
-| 대안 | 기각 사유 |
-|------|-----------|
-| Vercel-side 토큰 발급 | 토큰 포맷 양쪽 분산, 변경 시 동기화 부담 |
-| admin browser → backend 직접 HMAC | admin browser에 secret 노출, 보안 약화 |
-| DSM 공유 추적 테이블 | 4시간 자동 만료로 충분, YAGNI |
-| Resumable multipart upload | 5GB는 단일 stream으로 충분, 복잡도 증가 |
-| `pack_files.min_tier`를 PostgreSQL ENUM | tier 추가 시 ALTER TYPE 번거로움. text+CHECK 채택 |
-
----
-
-## 3. `POST /api/packs/admin/mint-token`
-
-### 3.1 Pydantic 스키마 (`models.py` 추가)
-
-```python
-class MintTokenRequest(BaseModel):
- """Vercel → backend: admin upload 토큰 발급 요청."""
- tier: PackTier
- label: str = Field(..., max_length=200)
- filename: str = Field(..., max_length=255)
- size_bytes: int = Field(..., gt=0, le=5 * 1024 * 1024 * 1024)
-
-
-class MintTokenResponse(BaseModel):
- token: str
- expires_at: datetime
- jti: str
-```
-
-### 3.2 라우트 본문 (`routes.py` 추가)
-
-```python
-import time, uuid
-from datetime import datetime, timezone
-
-from .auth import mint_upload_token, verify_request_hmac
-from .models import MintTokenRequest, MintTokenResponse
-
-UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
-
-@router.post("/admin/mint-token", response_model=MintTokenResponse)
-async def mint_token(
- request: Request,
- x_timestamp: str = Header(""),
- x_signature: str = Header(""),
-):
- body = await request.body()
- verify_request_hmac(body, x_timestamp, x_signature)
- payload = MintTokenRequest.model_validate_json(body)
- _check_filename(payload.filename) # upload 라우트와 동일 검증
-
- jti = str(uuid.uuid4())
- expires_ts = int(time.time()) + UPLOAD_TOKEN_TTL_SEC
- token = mint_upload_token({
- "tier": payload.tier,
- "label": payload.label,
- "filename": payload.filename,
- "size_bytes": payload.size_bytes,
- "jti": jti,
- "expires_at": expires_ts,
- })
- return MintTokenResponse(
- token=token,
- expires_at=datetime.fromtimestamp(expires_ts, tz=timezone.utc),
- jti=jti,
- )
-```
-
-### 3.3 결정 근거
-
-| 항목 | 값 | 근거 |
-|------|-----|------|
-| TTL default | 1800s (30분) | 5GB 업로드 시작 + 진행 시간 여유. 1Gbps에서 약 40s, 50Mbps에서 약 14분 |
-| TTL env override | `UPLOAD_TOKEN_TTL_SEC` | 운영 중 조정 가능 |
-| filename 검증 | upload와 동일 (`_check_filename`) | 토큰 발급 시점에 미리 거부 → admin UI 즉시 피드백 |
-| jti 응답 포함 | yes | admin이 업로드 결과 추적용 |
-| Vercel ↔ backend | HMAC (`X-Timestamp` + `X-Signature`) | 다른 admin 라우트와 동일 패턴 |
-| admin browser ↔ backend | Bearer token (단발성 jti) | 기존 upload 라우트 그대로 |
-
-### 3.4 DELETE 라우트 docstring 수정
-
-`routes.py` 모듈 docstring에서:
-
-```diff
-- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete + DSM 공유 정리
-+ DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
-```
-
-`delete_file` 함수에는 변경 없음.
-
----
-
-## 4. Supabase `pack_files` DDL
-
-**파일**: `packs-lab/supabase/pack_files.sql` (신규, 운영 배포 시 Supabase SQL editor에서 실행)
-
-```sql
--- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
-create table if not exists public.pack_files (
- id uuid primary key default gen_random_uuid(),
- min_tier text not null check (min_tier in ('starter','pro','master')),
- label text not null,
- file_path text not null unique, -- NAS 절대경로, 동일 경로 중복 방지
- filename text not null,
- size_bytes bigint not null check (size_bytes > 0),
- sort_order integer not null default 0,
- uploaded_at timestamptz not null default now(),
- deleted_at timestamptz
-);
-
--- list 라우트의 hot path: deleted_at IS NULL + tier/order 정렬
-create index if not exists pack_files_active_idx
- on public.pack_files (min_tier, sort_order)
- where deleted_at is null;
-
--- soft-deleted 통계 / cleanup 잡 대비
-create index if not exists pack_files_deleted_at_idx
- on public.pack_files (deleted_at)
- where deleted_at is not null;
-```
-
-### 4.1 필드 결정 근거
-
-| 필드 | 타입 / 제약 | 근거 |
-|------|------------|------|
-| `id` | uuid PK + `gen_random_uuid()` default | routes.py가 client-side `uuid.uuid4()` 생성하지만 default도 둬 fallback |
-| `min_tier` | text + CHECK | enum 대신 text+CHECK가 PostgreSQL에서 더 유연 |
-| `file_path` | text NOT NULL UNIQUE | 같은 tier/filename 충돌은 파일시스템에서 잡지만 DB 레벨도 보강 |
-| `size_bytes` | bigint + CHECK > 0 | 5GB는 int 범위 안이지만 미래 대비 bigint |
-| `sort_order` | int NOT NULL default 0 | routes INSERT가 sort_order 미지정 → 0 기본 |
-| `uploaded_at` | timestamptz default now() | routes 코드가 `res.data[0]["uploaded_at"]` 그대로 응답에 사용 — DB가 채워줌 |
-| `deleted_at` | nullable | soft delete |
-
-### 4.2 RLS
-
-비활성. backend가 `service_role` key 사용하므로 RLS 우회. Vercel/사용자 직접 접근 없음 → unsafe 아님.
-
----
-
-## 5. 인프라 통합
-
-### 5.1 `docker-compose.yml` — `packs-lab` 서비스
-
-```yaml
- packs-lab:
- build:
- context: ./packs-lab
- dockerfile: Dockerfile
- container_name: packs-lab
- restart: unless-stopped
- ports:
- - "18950:8000"
- environment:
- TZ: Asia/Seoul
- DSM_HOST: ${DSM_HOST}
- DSM_USER: ${DSM_USER}
- DSM_PASS: ${DSM_PASS}
- BACKEND_HMAC_SECRET: ${BACKEND_HMAC_SECRET}
- SUPABASE_URL: ${SUPABASE_URL}
- SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_KEY}
- UPLOAD_TOKEN_TTL_SEC: ${UPLOAD_TOKEN_TTL_SEC:-1800}
- volumes:
- - ${PACK_DATA_PATH:-./data/packs}:/volume1/docker/webpage/media/packs
-```
-
-| 결정 | 값 | 근거 |
-|------|-----|------|
-| 포트 | 18950 | 18800(realestate) → 18900(agent-office) → 18950(packs) 순차 |
-| `PACK_BASE_DIR` 마운트 | 컨테이너 경로 `/volume1/docker/webpage/media/packs` 고정 | routes.py 하드코딩 경로 |
-| `PACK_DATA_PATH` env | default `./data/packs` (로컬), NAS `.env`에 `/volume1/docker/webpage/media/packs` 명시 | 운영/로컬 분리 |
-
-### 5.2 `nginx/default.conf` — `/api/packs/` 라우팅
-
-```nginx
-location /api/packs/ {
- proxy_pass http://packs-lab:8000;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
-
- # 5GB 멀티파트 업로드 대응
- client_max_body_size 5G;
- proxy_request_buffering off; # 스트리밍 통과 (메모리/디스크 buffer 회피)
- proxy_read_timeout 1800s;
- proxy_send_timeout 1800s;
-}
-```
-
-| 결정 | 근거 |
-|------|------|
-| `client_max_body_size 5G` | 라우트 단위 — 다른 location은 default 유지 |
-| `proxy_request_buffering off` | 5GB 파일을 nginx가 모두 받고 backend에 forward하면 ~5GB 디스크 buffer 발생 |
-| `proxy_read/send_timeout 1800s` | 30분 — 업로드 토큰 TTL과 일치, 느린 업링크에서 5GB 전송 여유 |
-
-### 5.3 `.env.example` — 6+1 신규 환경변수
-
-```bash
-# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
-# Synology DSM 7.x 인증 (공유 링크 발급용)
-DSM_HOST=https://gahusb.synology.me:5001
-DSM_USER=
-DSM_PASS=
-
-# Vercel SaaS ↔ backend HMAC 시크릿 (양쪽 동일 값)
-BACKEND_HMAC_SECRET=
-
-# Supabase pack_files 테이블 접근 (service_role 키, RLS 우회)
-SUPABASE_URL=https://.supabase.co
-SUPABASE_SERVICE_KEY=
-
-# admin upload 토큰 TTL (초). default 1800 = 30분
-UPLOAD_TOKEN_TTL_SEC=1800
-
-# 로컬 개발: ./data/packs / NAS 운영: /volume1/docker/webpage/media/packs
-PACK_DATA_PATH=./data/packs
-```
-
-### 5.4 NAS 디렉토리 준비
-
-운영 첫 배포 시 SSH로 1회:
-
-```bash
-mkdir -p /volume1/docker/webpage/media/packs/{starter,pro,master}
-chown -R PUID:PGID /volume1/docker/webpage/media/packs
-```
-
-PUID/PGID는 `.env`의 기존 값 사용.
-
----
-
-## 6. 테스트 전략
-
-기존 `tests/test_auth.py` 유지. 신규 3 파일.
-
-### 6.1 `tests/conftest.py` (신규)
-
-```python
-import pytest
-
-@pytest.fixture(autouse=True)
-def _hmac_secret(monkeypatch):
- """모든 테스트에서 동일한 HMAC secret 사용."""
- monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
-```
-
-### 6.2 `tests/test_routes.py` (신규) — 통합 테스트
-
-DSM·Supabase 모두 mock. `pytest`, `monkeypatch`, `unittest.mock`, `fastapi.testclient.TestClient` 사용.
-
-| 테스트 | 검증 |
-|--------|------|
-| `test_sign_link_hmac_required` | timestamp/signature 헤더 누락 → 401 |
-| `test_sign_link_outside_base_dir` | file_path가 `PACK_BASE_DIR` 외부 → 400 |
-| `test_sign_link_calls_dsm` | mock된 `create_share_link` 호출 검증, URL 응답 |
-| `test_mint_token_hmac_required` | HMAC 누락 → 401 |
-| `test_mint_token_returns_valid_token` | 발급된 token이 `verify_upload_token`으로 통과 |
-| `test_mint_token_invalid_filename` | 확장자 미허용 → 400 |
-| `test_upload_token_required` | Authorization Bearer 누락 → 401 |
-| `test_upload_size_mismatch` | 토큰 size_bytes ≠ 실제 → 400 |
-| `test_upload_jti_replay` | 같은 토큰 두 번 → 두 번째 409 |
-| `test_list_returns_active_only` | mock supabase 응답에서 deleted_at NULL만 반환 |
-| `test_delete_soft_deletes` | mock supabase update에 deleted_at ISO timestamp 들어감 |
-
-### 6.3 `tests/test_dsm_client.py` (신규)
-
-httpx mock(`respx` 또는 `MockTransport`) 또는 `monkeypatch.setattr` 패치.
-
-| 테스트 | 검증 |
-|--------|------|
-| `test_create_share_link_login_logout` | login → Sharing.create → logout 순서 |
-| `test_create_share_link_returns_url_and_expiry` | 응답 파싱 |
-| `test_dsm_login_failure_raises` | login API success=false → DSMError |
-| `test_dsm_share_failure_logs_out` | Sharing.create 실패해도 logout 호출 (try/finally) |
-
----
-
-## 7. 문서 갱신
-
-### 7.1 `web-backend/CLAUDE.md` — 5곳
-
-**1. 1.프로젝트 개요**
-
-```diff
-- 서비스: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
-+ 서비스: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
-```
-
-**2. 4.Docker 서비스 표** — 신규 행
-
-```
-| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
-```
-
-**3. 5.Nginx 라우팅 표** — 신규 행
-
-```
-| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 (`client_max_body_size 5G` + `proxy_request_buffering off`) |
-```
-
-**4. 8.로컬 개발 표** — 신규 행
-
-```
-| Packs Lab | http://localhost:18950 |
-```
-
-**5. 9.서비스별** — `### packs-lab (packs-lab/)` 신규 섹션
-
-내용:
-- 용도 (NAS DSM 공유링크 + 5GB 업로드 + Vercel HMAC, 사용자 인증은 Vercel이 Supabase로 처리)
-- 환경변수 6+1개
-- DB는 외부 Supabase `pack_files` (DDL은 `packs-lab/supabase/pack_files.sql`)
-- 파일 구조: `main.py`, `auth.py`, `dsm_client.py`, `routes.py`, `models.py`
-- API 표 5개:
- - `POST /api/packs/sign-link` (Vercel HMAC → DSM Sharing.create)
- - `POST /api/packs/admin/mint-token` (Vercel HMAC → upload 토큰)
- - `POST /api/packs/upload` (Bearer token → multipart 5GB)
- - `GET /api/packs/list` (Vercel HMAC → 활성 파일 목록)
- - `DELETE /api/packs/{file_id}` (Vercel HMAC → soft delete)
-
-### 7.2 `workspace/CLAUDE.md`
-
-컨테이너 표에 한 줄 추가:
-
-```
-| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (Vercel SaaS와 HMAC 통신) |
-```
-
----
-
-## 8. 스코프
-
-### 본 spec 범위
-
-- ✅ admin mint-token 라우트 신설
-- ✅ Supabase `pack_files` DDL
-- ✅ docker-compose / nginx / .env.example / NAS 디렉토리 마운트
-- ✅ tests (auth 유지 + routes 통합 + dsm_client mock)
-- ✅ CLAUDE.md 2곳 갱신
-- ✅ DELETE 라우트 docstring 수정
-
-### 후속 별도 spec
-
-- ❌ Vercel SaaS-side admin UI / 사용자 다운로드 UI / Supabase pricing & user 테이블
-- ❌ DSM 공유 추적 (즉시 차단 필요시)
-- ❌ deleted_at + N일 후 실제 파일 삭제 cron
-- ❌ multi-admin 토큰 발급 권한 분리
-- ❌ resumable multipart 업로드 (5GB tus 등)
-- ❌ pack_files sort_order 편집 endpoint (admin UI 단계)
-- ❌ monitoring (업로드 실패율, DSM API latency)