Files
web-page-backend/docs/superpowers/plans/2026-04-15-lotto-ai-curator.md
2026-04-15 03:45:14 +09:00

1854 lines
60 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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": <n>, "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 (
<div className="briefing-header">
<div className="briefing-header-row">
<h2>🗓 #{briefing.draw_no} 브리핑</h2>
<button onClick={onRegenerate} disabled={regenerating}>
{regenerating ? '⏳ 생성 중...' : '🔄 다시 생성'}
</button>
</div>
<div className="briefing-meta">
<span>{genDate}</span>
<span className="briefing-confidence">
신뢰도 <strong>{briefing.confidence}</strong>/100
</span>
<span className="briefing-tokens">
{fmtTokens(briefing.tokens_input)} in · {fmtTokens(briefing.tokens_output)} out · {fmtUsd(cost)}
</span>
</div>
<div className="briefing-confidence-bar">
<div style={{ width: `${briefing.confidence}%` }} />
</div>
</div>
);
}
```
- [ ] **Step 3: BriefingSummary.jsx**
```jsx
export default function BriefingSummary({ narrative }) {
return (
<div className="briefing-summary">
<h3>{narrative.headline}</h3>
<ul className="briefing-3lines">
{narrative.summary_3lines.map((line, i) => <li key={i}>{line}</li>)}
</ul>
{narrative.hot_cold_comment && (
<p className="briefing-hotcold">🔥 {narrative.hot_cold_comment}</p>
)}
{narrative.warnings && (
<p className="briefing-warning"> {narrative.warnings}</p>
)}
</div>
);
}
```
- [ ] **Step 4: PickSetCard.jsx**
```jsx
const RISK_BADGE = { '안정': '🟢', '균형': '🟡', '공격': '🔴' };
export default function PickSetCard({ pick, index }) {
return (
<div className={`pick-card pick-card--${pick.risk_tag}`}>
<div className="pick-card-header">
<span className="pick-card-index">Set {index + 1}</span>
<span className="pick-card-risk">{RISK_BADGE[pick.risk_tag] || '⚪'} {pick.risk_tag}</span>
</div>
<div className="pick-card-balls">
{pick.numbers.map(n => (
<span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
))}
</div>
<p className="pick-card-reason">{pick.reason}</p>
</div>
);
}
```
- [ ] **Step 5: BriefingEmpty.jsx**
```jsx
export default function BriefingEmpty({ regenerating, onRegenerate, error }) {
return (
<div className="briefing-empty">
<p>아직 이번 브리핑이 없습니다.</p>
<p className="briefing-empty-hint">매주 월요일 07:00 자동 생성됩니다.</p>
<button onClick={onRegenerate} disabled={regenerating}>
{regenerating ? '⏳ 생성 중...' : '지금 생성'}
</button>
{error && <p className="briefing-error"> {error}</p>}
</div>
);
}
```
- [ ] **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 (
<div className="curator-usage-footer">
<span>최근 30 큐레이터:</span>
<span>{usage.calls} 호출</span>
<span>{fmtTokens(usage.tokens_input + usage.tokens_output)} tokens</span>
<span>{fmtUsd(cost)}</span>
<span>캐시 {(usage.cache_hit_rate * 100).toFixed(0)}%</span>
</div>
);
}
```
- [ ] **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 <div className="briefing-empty"><p>로딩 ...</p></div>;
if (!briefing) return <BriefingEmpty regenerating={regenerating} onRegenerate={regenerate} error={error} />;
return (
<div className="briefing-tab">
<BriefingHeader briefing={briefing} regenerating={regenerating} onRegenerate={regenerate} />
<BriefingSummary narrative={briefing.narrative} />
<div className="briefing-picks">
<h3>이번 5세트</h3>
{briefing.picks.map((p, i) => <PickSetCard key={i} pick={p} index={i} />)}
</div>
<CuratorUsageFooter />
</div>
);
}
```
- [ ] **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 <p>로딩...</p>;
if (data.error) return <p className="error">{data.error}</p>;
return (
<div className="analysis-tab">
<ReportPanel report={data.report} />
<PersonalAnalysisPanel analysis={data.personalAnalysis} />
<div className="analysis-metrics">
{/* 기존 Functions.jsx의 MetricBlock 호출부를 여기로 이동 */}
</div>
<FrequencyChart data={data.stats} />
</div>
);
}
```
**주의:** `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 (
<div className="purchase-tab">
<PerformanceBanner {...purchases.performance} />
<PurchasePanel {...purchases} />
</div>
);
}
```
훅 반환 필드 이름은 실제 `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 (
<div className="lotto-functions">
<nav className="lotto-tabs">
{TABS.map(t => (
<button
key={t.id}
className={tab === t.id ? 'active' : ''}
onClick={() => setTab(t.id)}
>{t.label}</button>
))}
</nav>
<div className="lotto-tab-body">
{tab === 'briefing' && <BriefingTab />}
{tab === 'analysis' && <AnalysisTab />}
{tab === 'purchase' && <PurchaseTab />}
</div>
</div>
);
}
```
- [ ] **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·스케줄·환경변수 반영