60 KiB
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 호출 맨 아래에 추가 (기존 테이블 생성 블록 뒤):
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 파일 맨 아래에 추가:
# --- 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: 커밋
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: 파일 생성
"""큐레이터용 후보 가공 — 여러 엔진 결과를 하나로 병합, 중복 제거, 피처 계산."""
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 파일 하단에 추가 (이미 있으면 스킵):
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: 커밋
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: 큐레이터 라우터 작성
"""큐레이터 입력 엔드포인트 — 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에서)
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: 라우터 작성
"""브리핑 저장/조회 + 큐레이터 사용량 엔드포인트."""
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: 커밋
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)에 추가:
from .routers import curator as curator_router
from .routers import briefing as briefing_router
- Step 2:
app = FastAPI(...)직후에 라우터 등록
app 인스턴스 생성 직후(CORS 미들웨어 추가 부근):
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: 커밋
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 하단에 추가:
# 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: 커밋
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 파일 하단에 추가:
# --- 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: 커밋
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:
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:
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: 커밋
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: 프롬프트 빌더 작성
"""큐레이터 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: 커밋
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: 파일 작성
"""큐레이터 파이프라인 — 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: 커밋
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:
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 추가. 예:
from .lotto import LottoAgent
...
# init_agents() 내부
AGENT_REGISTRY["lotto"] = LottoAgent()
- Step 3: DB seed에 lotto 추가
agent-office/app/db.py init_db() 내부의 seed 리스트에 추가:
for agent_id, name in [
("stock", "주식 트레이더"),
("music", "음악 프로듀서"),
("blog", "블로그 마케터"),
("realestate", "청약 애널리스트"),
("lotto", "로또 큐레이터"), # ← 추가
]:
- Step 4: 텔레그램 agent_registry에 lotto 메타 추가
agent-office/app/telegram/agent_registry.py의 AGENT_META 딕셔너리에 추가 (이모지는 🎱):
"lotto": {"emoji": "🎱", "display_name": "로또 큐레이터"},
- Step 5: 커밋
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 추가
async def _run_lotto_schedule():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.on_schedule()
init_scheduler() 내부에 추가:
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate")
- Step 2: 커밋
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 파일 하단에 추가:
// --- 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 레포)
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 작성
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 작성
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: 커밋
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:
// 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
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
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
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
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
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 하단에 추가 (디자인 토큰은 기존 스타일과 맞춤):
.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: 커밋
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 작성
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 하여 렌더링만 담당:
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
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 리팩토링 — 탭 라우터만
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)
.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: 커밋
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: 커밋
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 상단에 일회성 실행:
# 정리(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: 커밋
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: 커밋
# 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로 디버깅.
완료 기준
- 월요일 07:00 스케줄러가 자동으로
curate_weekly태스크 생성·실행 - Claude 응답이 candidates 외 번호를 쓰면 검증에서 차단 (테스트로 증명)
- 로또 페이지 첫 진입에 5세트·3줄 요약이 표시되고, 토큰·비용·캐시 히트율이 보인다
- 기존 Functions.jsx 460줄 → 탭 라우터 ~50줄로 축소
- 미사용 컴포넌트·테이블이 grep 검증 후 제거됨
- CLAUDE.md에 새 API·스케줄·환경변수 반영