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

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.pylotto_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.pyLOTTO_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.pyinit_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-backenddocker 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.pyget_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.pyAGENT_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 EXISTSinit_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·스케줄·환경변수 반영