1854 lines
60 KiB
Markdown
1854 lines
60 KiB
Markdown
# 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·스케줄·환경변수 반영
|