From adb5cdb54e2ae61c69c7dd79ecff4a92fca0c5ec Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 15 Apr 2026 03:45:14 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20lotto=20AI=20curator=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84/=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-04-15-lotto-ai-curator.md | 1853 +++++++++++++++++ 1 file changed, 1853 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-15-lotto-ai-curator.md diff --git a/docs/superpowers/plans/2026-04-15-lotto-ai-curator.md b/docs/superpowers/plans/2026-04-15-lotto-ai-curator.md new file mode 100644 index 0000000..d14e07f --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-lotto-ai-curator.md @@ -0,0 +1,1853 @@ +# 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·스케줄·환경변수 반영