# 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}회 브리핑

{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에 자동 생성됩니다.

{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 (
{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·스케줄·환경변수 반영