Files
web-page/docs/superpowers/plans/2026-05-11-lotto-curator-evolution-plan.md
gahusb 42073a5bf3 docs(plan): Lotto Curator Evolution 구현 plan
23 task로 분해 (TDD 사이클 + 빈번한 commit):
- Phase A (1-2): weekly_review 테이블 + 4계층 마이그레이션
- Phase B (3-5): 채점 보조 함수 + 통합 잡 + cron
- Phase C (6-8): review/bulk/briefing 라우터
- Phase D (9-12): 큐레이터 4계층 스키마 + 회고 + pipeline
- Phase E (13-15): 텔레그램 알림 + webhook + cron 변경
- Phase F (16-19): api 헬퍼 + 훅 + DecisionCard
- Phase G (20-22): 자료실 강등 + 자동채점 표시 + 추세 차트
- Phase H (23): 1주차 운영 점검

스펙→코드베이스 보정 사항(테이블명/기존 컬럼/기존 자동채점) plan 상단에 명시
2026-05-11 04:26:00 +09:00

102 KiB
Raw Blame History

Lotto Curator Evolution Implementation Plan

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: 매주 큐레이터가 한 번 더 똑똑해지도록 — 자기·사용자 회고 + 4계층 위계(코어/보너스/확장/풀, 5~20세트) + 결정 카드 단일 화면을 만든다.

Architecture: lotto-backend(SQLite + FastAPI)에 weekly_review 테이블과 채점 잡을 추가하고, agent-office의 Claude 큐레이터에 회고 컨텍스트와 4계층 출력 스키마를 더하며, 텔레그램으로 헤드라인을 푸시한다. 프론트는 BriefingTab을 단일 DecisionCard 화면으로 재구성하고 분석탭은 자료실로 강등한다.

Tech Stack: Python (FastAPI · SQLite · APScheduler · httpx · pydantic), React 18 (Vite · react-router-dom), Anthropic Claude API (prompt caching), Telegram Bot API.


스펙 → 코드베이스 보정 사항

스펙 작성 시점 가정과 실제 코드가 다른 부분. 이 plan은 실제 코드 기준으로 작성됨.

스펙 가정 실제 코드 보정
테이블명 briefings lotto_briefings 모든 SQL/쿼리에 lotto_briefings 사용
테이블명 lotto_purchase purchase_history 모든 SQL/쿼리에 purchase_history 사용
신규 컬럼 numbers 추가 필요 이미 존재 (numbers TEXT NOT NULL DEFAULT '[]') 추가 안 함
신규 컬럼 match_count / auto_graded 이미 존재 (results TEXT JSON에 correct, checked INTEGER) 추가 안 함 — 기존 활용
신규 컬럼 curator_tier / curator_role 기존 source_strategy + source_detail JSON 활용 source_strategy=curator_core/curator_bonus/curator_extended/curator_pool, source_detail JSON에 {"tier", "role", "set_index"}
별도 app/db/migrations/*.sql 디렉토리 기존 패턴은 app/db.py init_db()_ensure_column 마이그레이션 SQL 파일 만들지 말고 init_db에 추가
자동 채점 잡 신규 작성 purchase_manager.check_purchases_for_draw(drw_no) 이미 있음 (RANK_PRIZE 자동) 신규 잡은 기존 함수 호출 + 큐레이터 자기 채점 + weekly_review INSERT만 추가
큐레이터 cron 월 09:00 신규 등록 scheduler.py 에 이미 day_of_week="mon", hour=7, minute=0 등록됨 시간만 09:00으로 수정

File Structure (실제 코드 기준)

백엔드 — web-backend/lotto/

파일 종류 책임
app/db.py 수정 init_db() 안에 weekly_review 테이블 + lotto_briefings.picks 4계층 마이그레이션. 신규 헬퍼 함수: save_review, get_review, get_reviews_range, get_latest_review, list_reviews, bulk_insert_purchases_from_briefing
app/curator_helpers.py 수정 collect_candidates(n=30) 기본값 변경, build_context()retrospective 키 합치기 (build_retrospective 호출)
app/retrospective.py 신규 build_retrospective(target_draw_no) — review 1건 + 추세 3건 → 큐레이터 컨텍스트 dict
app/jobs/__init__.py 신규 빈 패키지
app/jobs/grade_weekly_review.py 신규 통합 채점 잡 — 기존 check_purchases_for_draw 호출 + 큐레이터 picks vs 추첨 비교 + 패턴 갭 계산 + weekly_review UPSERT + 4등 이상 발견 시 webhook 호출
app/jobs/grading_helpers.py 신규 단위 함수: score_picks_against_draw, summarize_pattern, compute_pattern_delta
app/routers/briefing.py 수정 BriefingRequest 스키마에 4계층 picks 필드 + narrative.retrospective + tier_rationale 수용
app/routers/review.py 신규 GET /api/lotto/review/latest, GET /api/lotto/review/history?limit=N
app/routers/purchase.py 수정 POST /api/lotto/purchase/bulk — body {draw_no, tier_mode, sets, amount} → briefings 로드 후 source_strategy=curator_<tier> 로 INSERT
app/main.py 수정 review 라우터 등록, 채점 잡 cron 등록 (일 03:00)

agent-office — web-backend/agent-office/

파일 종류 책임
app/curator/schema.py 수정 CuratorOutput 4계층 picks + tier_rationale + narrative.retrospective 필드
app/curator/prompt.py 수정 SYSTEM_PROMPT 회고·계층 규칙 추가, build_user_message 에 retrospective 포함
app/curator/pipeline.py 수정 curate_weekly() 에서 lotto_candidates(n=30) 호출, 4계층 직렬화하여 save_briefing
app/agents/lotto.py 수정 큐레이션 성공 후 텔레그램 알림 호출
app/notifiers/__init__.py 신규 빈 패키지
app/notifiers/telegram_lotto.py 신규 send_curator_briefing(briefing) + send_prize_alert(prize_event)
app/routers/__init__.py 신규 (없으면) 빈 패키지
app/routers/notify.py 신규 POST /api/agent-office/notify/lotto-prize
app/service_proxy.py 수정 lotto_review_latest(), lotto_reviews_range(start, end) 헬퍼 추가
app/scheduler.py 수정 lotto_curate cron 시간 07:00 → 09:00
app/main.py 수정 notify 라우터 등록

프론트엔드 — web-ui/src/

파일 종류 책임
pages/lotto/Functions.jsx 수정 분석탭 라벨 변경
pages/lotto/tabs/BriefingTab.jsx 수정 DecisionCard 단일로 재구성
pages/lotto/tabs/AnalysisTab.jsx 수정 모든 패널을 <details> 로 감싸 첫 진입 시 접힘
pages/lotto/components/decision/DecisionCard.jsx 신규 결정 카드 메인
pages/lotto/components/decision/RetrospectiveBox.jsx 신규 회고 박스
pages/lotto/components/decision/TierModeToggle.jsx 신규 4단계 칩 토글
pages/lotto/components/decision/TierSection.jsx 신규 한 계층(타이틀+사유+5장)
pages/lotto/components/decision/PickCard.jsx 신규 한 세트 카드
pages/lotto/components/decision/BulkPurchaseButton.jsx 신규 원클릭 구매
pages/lotto/components/decision/decision.css 신규 결정 카드 스타일
pages/lotto/hooks/useBriefing.js 수정 4계층 + retrospective 수용
pages/lotto/hooks/useReview.js 신규 weekly_review 로드
pages/lotto/hooks/usePurchases.js 수정 bulkPurchase() 추가
pages/lotto/components/PurchasePanel.jsx 수정 results JSON 표시(자동 채점) + 4등 이상 플래그
pages/lotto/components/PurchaseTrendChart.jsx 신규 4주 추세(너 vs 큐레이터)
api.js 수정 getLatestReview, getReviewHistory, bulkPurchase

작업 흐름

각 Task 는 Files 블록 → 단계별 체크박스 → 마지막에 commit. TDD 사이클이 의미있는 곳에서만 사용(SQL 마이그레이션·UI 등 단순 변경은 검증으로 대체). 모든 명령은 PowerShell 또는 Bash 어느 쪽이든 표시된 그대로 동작.


Task 1 — weekly_review 테이블 + 헬퍼

Files:

  • Modify: web-backend/lotto/app/db.py (init_db 안에 추가, 파일 하단에 헬퍼 함수)

  • Step 1: init_db()lotto_briefings 블록 직후에 weekly_review 테이블 생성 추가

web-backend/lotto/app/db.py init_db() 끝부분(idx_briefings_draw 인덱스 다음 줄)에 추가:

        # ── weekly_review 테이블 (큐레이터 자기 평가 + 사용자 패턴 갭) ────────
        conn.execute("""
            CREATE TABLE IF NOT EXISTS weekly_review (
                id                    INTEGER PRIMARY KEY AUTOINCREMENT,
                draw_no               INTEGER UNIQUE NOT NULL,
                curator_avg_match     REAL,
                curator_best_tier     TEXT,
                curator_best_match    INTEGER,
                curator_5plus_prizes  INTEGER,
                user_avg_match        REAL,
                user_best_match       INTEGER,
                user_5plus_prizes     INTEGER,
                user_pattern_summary  TEXT,
                draw_pattern_summary  TEXT,
                pattern_delta         TEXT,
                created_at            TEXT NOT NULL DEFAULT (datetime('now','localtime'))
            )
        """)
        conn.execute("CREATE INDEX IF NOT EXISTS idx_review_draw ON weekly_review(draw_no DESC)")
  • Step 2: 헬퍼 함수 추가 (db.py 파일 맨 아래)
def save_review(data: Dict[str, Any]) -> int:
    with _conn() as conn:
        cur = conn.execute(
            """
            INSERT INTO weekly_review (
                draw_no,
                curator_avg_match, curator_best_tier, curator_best_match, curator_5plus_prizes,
                user_avg_match, user_best_match, user_5plus_prizes,
                user_pattern_summary, draw_pattern_summary, pattern_delta
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(draw_no) DO UPDATE SET
                curator_avg_match=excluded.curator_avg_match,
                curator_best_tier=excluded.curator_best_tier,
                curator_best_match=excluded.curator_best_match,
                curator_5plus_prizes=excluded.curator_5plus_prizes,
                user_avg_match=excluded.user_avg_match,
                user_best_match=excluded.user_best_match,
                user_5plus_prizes=excluded.user_5plus_prizes,
                user_pattern_summary=excluded.user_pattern_summary,
                draw_pattern_summary=excluded.draw_pattern_summary,
                pattern_delta=excluded.pattern_delta
            """,
            (
                data["draw_no"],
                data.get("curator_avg_match"), data.get("curator_best_tier"),
                data.get("curator_best_match"), data.get("curator_5plus_prizes"),
                data.get("user_avg_match"), data.get("user_best_match"),
                data.get("user_5plus_prizes"),
                data.get("user_pattern_summary"), data.get("draw_pattern_summary"),
                data.get("pattern_delta"),
            ),
        )
        return cur.lastrowid


def _review_row(r) -> Optional[Dict[str, Any]]:
    if not r:
        return None
    return {
        "id": r["id"],
        "draw_no": r["draw_no"],
        "curator_avg_match": r["curator_avg_match"],
        "curator_best_tier": r["curator_best_tier"],
        "curator_best_match": r["curator_best_match"],
        "curator_5plus_prizes": r["curator_5plus_prizes"],
        "user_avg_match": r["user_avg_match"],
        "user_best_match": r["user_best_match"],
        "user_5plus_prizes": r["user_5plus_prizes"],
        "user_pattern_summary": r["user_pattern_summary"],
        "draw_pattern_summary": r["draw_pattern_summary"],
        "pattern_delta": r["pattern_delta"],
        "created_at": r["created_at"],
    }


def get_review(draw_no: int) -> Optional[Dict[str, Any]]:
    with _conn() as conn:
        r = conn.execute("SELECT * FROM weekly_review WHERE draw_no=?", (draw_no,)).fetchone()
    return _review_row(r)


def get_latest_review() -> Optional[Dict[str, Any]]:
    with _conn() as conn:
        r = conn.execute("SELECT * FROM weekly_review ORDER BY draw_no DESC LIMIT 1").fetchone()
    return _review_row(r)


def get_reviews_range(start_drw: int, end_drw: int) -> List[Dict[str, Any]]:
    with _conn() as conn:
        rows = conn.execute(
            "SELECT * FROM weekly_review WHERE draw_no BETWEEN ? AND ? ORDER BY draw_no ASC",
            (start_drw, end_drw),
        ).fetchall()
    return [_review_row(r) for r in rows]


def list_reviews(limit: int = 10) -> List[Dict[str, Any]]:
    with _conn() as conn:
        rows = conn.execute(
            "SELECT * FROM weekly_review ORDER BY draw_no DESC LIMIT ?",
            (limit,),
        ).fetchall()
    return [_review_row(r) for r in rows]
  • Step 3: 컨테이너 재시작 후 테이블 생성 확인
cd C:/Users/jaeoh/Desktop/workspace/web-backend
docker compose restart lotto-backend
docker compose exec lotto-backend python -c "import sqlite3; c=sqlite3.connect('/app/data/lotto.db'); print(c.execute('PRAGMA table_info(weekly_review)').fetchall())"

기대: 13개 컬럼 (id, draw_no, curator_avg_match, ..., created_at) 출력.

  • Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/db.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): weekly_review 테이블 + CRUD 헬퍼"

Task 2 — lotto_briefings.picks 4계층 마이그레이션

Files:

  • Modify: web-backend/lotto/app/db.py (init_db 안에 1회 변환 로직 추가)

기존 picks 컬럼은 JSON 리스트(예: [{numbers,risk_tag,reason}, ...]). 4계층 객체로 변환: {"core": [...], "bonus": [], "extended": [], "pool": []}.

  • Step 1: 변환 헬퍼 추가 (db.py init_db() 의 weekly_review 블록 바로 다음)
        # ── lotto_briefings.picks 4계층 마이그레이션 (1회 변환) ───────────────
        # 기존: picks가 JSON 리스트 [{numbers,risk_tag,reason}]
        # 신규: picks가 JSON 객체 {core:[...], bonus:[], extended:[], pool:[]}
        rows = conn.execute("SELECT id, picks FROM lotto_briefings").fetchall()
        for r in rows:
            try:
                p = json.loads(r["picks"])
                if isinstance(p, list):
                    new_picks = {"core": p, "bonus": [], "extended": [], "pool": []}
                    conn.execute(
                        "UPDATE lotto_briefings SET picks=? WHERE id=?",
                        (json.dumps(new_picks, ensure_ascii=False), r["id"]),
                    )
            except (json.JSONDecodeError, TypeError):
                continue
  • Step 2: 추가로 tier_rationale 컬럼 신설 (선택적, JSON 저장용)
        _ensure_column(conn, "lotto_briefings", "tier_rationale",
                       "ALTER TABLE lotto_briefings ADD COLUMN tier_rationale TEXT NOT NULL DEFAULT '{}'")

narrative 컬럼에는 retrospective가 추가될 텐데 narrative 자체가 JSON이므로 컬럼 추가 불필요.

  • Step 3: 변환 검증
docker compose restart lotto-backend
docker compose exec lotto-backend python -c "
import sqlite3, json
c = sqlite3.connect('/app/data/lotto.db')
rows = c.execute('SELECT id, picks FROM lotto_briefings LIMIT 3').fetchall()
for r in rows:
    p = json.loads(r[1])
    print(r[0], type(p).__name__, list(p.keys()) if isinstance(p, dict) else 'list')
"

기대: dict ['core', 'bonus', 'extended', 'pool'] 또는 빈 DB면 출력 없음.

  • Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/db.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): lotto_briefings.picks 4계층 객체로 마이그레이션 + tier_rationale 컬럼"

Task 3 — 채점 보조 함수 (단위 테스트 우선)

Files:

  • Create: web-backend/lotto/app/jobs/__init__.py

  • Create: web-backend/lotto/app/jobs/grading_helpers.py

  • Create: web-backend/lotto/tests/test_grading_helpers.py

  • Step 1: 빈 패키지 파일

# web-backend/lotto/app/jobs/__init__.py
  • Step 2: 실패 테스트 작성

web-backend/lotto/tests/test_grading_helpers.py:

from app.jobs.grading_helpers import (
    score_picks_against_draw,
    summarize_pattern,
    compute_pattern_delta,
)


def test_score_picks_against_draw_basic():
    win_nums = [3, 11, 17, 25, 33, 41]
    bonus = 8
    picks = [
        {"numbers": [3, 11, 17, 25, 33, 41], "risk_tag": "안정"},  # 6 일치
        {"numbers": [1, 2, 3, 4, 5, 6],      "risk_tag": "공격"},   # 1 일치
        {"numbers": [3, 11, 17, 4, 5, 6],    "risk_tag": "안정"},   # 3 일치 → 5등
    ]
    out = score_picks_against_draw(picks, win_nums, bonus)
    assert out["avg_match"] == (6 + 1 + 3) / 3
    assert out["best_match"] == 6
    assert out["five_plus_prizes"] == 2  # 3개 이상 카운트(5등 이상)
    assert out["best_tier"] == "안정"


def test_summarize_pattern():
    nums = [3, 11, 17, 25, 33, 41]
    s = summarize_pattern(nums)
    # 저번호(<=22) 3개, 고번호 3개, 홀짝 4:2(3,11,17,25,33,41 → 홀 4 짝 2 — 41 홀, 33 홀, 25 홀, 17 홀, 11 홀, 3 홀 → 다 홀? 다시: 3홀, 11홀, 17홀, 25홀, 33홀, 41홀 모두 홀)
    # 모두 홀수이므로 홀:짝 = 6:0
    assert s["low_count"] == 3
    assert s["odd_count"] == 6
    assert s["sum"] == 130


def test_compute_pattern_delta_picks_dominant_axis():
    # 사용자가 평균 저번호 4.2개 / 추첨 평균 3 → 저번호 편향 +1.2
    user = {"low_avg": 4.2, "odd_avg": 3.4, "sum_avg": 124}
    draw = {"low_avg": 3.0, "odd_avg": 3.0, "sum_avg": 142}
    delta = compute_pattern_delta(user, draw)
    assert "저번호" in delta or "low" in delta
    assert "+1.2" in delta or "1.2" in delta
  • Step 3: 실패 확인
cd C:/Users/jaeoh/Desktop/workspace/web-backend/lotto
docker compose exec lotto-backend pytest tests/test_grading_helpers.py -v

기대: ImportError 또는 ModuleNotFoundError.

  • Step 4: 구현

web-backend/lotto/app/jobs/grading_helpers.py:

"""채점 보조 — 일치 수 계산, 패턴 요약, 패턴 갭."""
from typing import List, Dict, Any

LOW_HIGH_CUT = 22  # curator_helpers.py 와 동일


def score_picks_against_draw(picks: List[Dict[str, Any]],
                              win_nums: List[int],
                              bonus: int) -> Dict[str, Any]:
    """4계층 중 한 그룹(예: core_picks 5세트) vs 추첨 결과 채점.

    picks 는 [{numbers, risk_tag, reason}] 리스트.
    """
    if not picks:
        return {"avg_match": None, "best_match": 0, "five_plus_prizes": 0, "best_tier": None}

    win_set = set(win_nums)
    matches = []
    for p in picks:
        nums = p.get("numbers") or []
        m = len(set(nums) & win_set)
        matches.append((m, p.get("risk_tag")))

    avg = sum(m for m, _ in matches) / len(matches)
    best_match, best_tier = max(matches, key=lambda x: x[0])
    five_plus = sum(1 for m, _ in matches if m >= 3)  # 5등 이상

    # tier별 평균 → 가장 잘 맞은 risk_tag
    tier_scores: Dict[str, List[int]] = {}
    for m, t in matches:
        if t:
            tier_scores.setdefault(t, []).append(m)
    if tier_scores:
        best_tier = max(tier_scores.items(),
                        key=lambda kv: sum(kv[1]) / len(kv[1]))[0]

    return {
        "avg_match": round(avg, 2),
        "best_match": best_match,
        "five_plus_prizes": five_plus,
        "best_tier": best_tier,
    }


def summarize_pattern(nums: List[int]) -> Dict[str, int]:
    """한 세트의 패턴 요약 — 저/고, 홀/짝, 합계."""
    nums = sorted(nums)
    odd = sum(1 for n in nums if n % 2 == 1)
    low = sum(1 for n in nums if n <= LOW_HIGH_CUT)
    return {
        "odd_count": odd,
        "even_count": 6 - odd,
        "low_count": low,
        "high_count": 6 - low,
        "sum": sum(nums),
    }


def aggregate_pattern_summaries(summaries: List[Dict[str, int]]) -> Dict[str, float]:
    """여러 세트의 패턴 요약 → 평균(low_avg, odd_avg, sum_avg)."""
    if not summaries:
        return {"low_avg": None, "odd_avg": None, "sum_avg": None}
    n = len(summaries)
    return {
        "low_avg": round(sum(s["low_count"] for s in summaries) / n, 2),
        "odd_avg": round(sum(s["odd_count"] for s in summaries) / n, 2),
        "sum_avg": round(sum(s["sum"] for s in summaries) / n, 1),
    }


def compute_pattern_delta(user_summary: Dict[str, float],
                          draw_summary: Dict[str, float]) -> str:
    """사용자 평균 vs 추첨 패턴의 가장 큰 격차 1~2개를 한 줄로."""
    if not user_summary or user_summary.get("low_avg") is None:
        return ""
    deltas = []
    if user_summary.get("low_avg") is not None and draw_summary.get("low_avg") is not None:
        d = round(user_summary["low_avg"] - draw_summary["low_avg"], 2)
        if abs(d) >= 0.5:
            sign = "+" if d > 0 else ""
            deltas.append(("저번호", d, f"저번호 편향 {sign}{d}"))
    if user_summary.get("sum_avg") is not None and draw_summary.get("sum_avg") is not None:
        d = round(user_summary["sum_avg"] - draw_summary["sum_avg"], 1)
        if abs(d) >= 10:
            sign = "+" if d > 0 else ""
            deltas.append(("합계", d, f"합계 {sign}{d}"))
    if user_summary.get("odd_avg") is not None and draw_summary.get("odd_avg") is not None:
        d = round(user_summary["odd_avg"] - draw_summary["odd_avg"], 2)
        if abs(d) >= 0.5:
            sign = "+" if d > 0 else ""
            deltas.append(("홀짝", d, f"홀짝 {sign}{d}"))
    deltas.sort(key=lambda x: -abs(x[1]))
    return " / ".join(d[2] for d in deltas[:2])
  • Step 5: 테스트 통과 확인

test_summarize_pattern 의 기대값을 실제로 계산: nums=[3,11,17,25,33,41]. 모두 홀수 → odd=6, 저번호(≤22): 3,11,17 → 3개. 합계 130. 기대값과 일치.

docker compose exec lotto-backend pytest tests/test_grading_helpers.py -v

기대: 3 PASS.

  • Step 6: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/jobs/__init__.py lotto/app/jobs/grading_helpers.py lotto/tests/test_grading_helpers.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): 채점 보조 함수 — 일치 수·패턴 요약·델타"

Task 4 — grade_weekly_review 통합 잡

Files:

  • Create: web-backend/lotto/app/jobs/grade_weekly_review.py

  • Create: web-backend/lotto/tests/test_grade_weekly_review.py

  • Step 1: 통합 테스트 작성

web-backend/lotto/tests/test_grade_weekly_review.py:

import json
import pytest
from app import db
from app.jobs.grade_weekly_review import run_weekly_grading


@pytest.fixture(autouse=True)
def setup_db(tmp_path, monkeypatch):
    test_db = tmp_path / "test.db"
    monkeypatch.setattr(db, "DB_PATH", str(test_db))
    db.init_db()
    yield


def _seed_draw(drw_no=1153):
    db.upsert_draw({
        "drw_no": drw_no, "drw_date": "2026-05-09",
        "n1": 3, "n2": 11, "n3": 17, "n4": 25, "n5": 33, "n6": 41, "bonus": 8,
    })


def _seed_briefing(drw_no=1153):
    picks = {
        "core": [
            {"numbers": [3, 11, 17, 25, 33, 41], "risk_tag": "안정", "reason": "x"},  # 6
            {"numbers": [1, 2, 3, 4, 5, 6],      "risk_tag": "안정", "reason": "x"},  # 1
            {"numbers": [3, 11, 17, 4, 5, 6],    "risk_tag": "균형", "reason": "x"},  # 3
            {"numbers": [11, 25, 33, 7, 8, 9],   "risk_tag": "균형", "reason": "x"},  # 3
            {"numbers": [3, 11, 17, 25, 33, 9],  "risk_tag": "공격", "reason": "x"},  # 5
        ],
        "bonus": [], "extended": [], "pool": [],
    }
    db.save_briefing({
        "draw_no": drw_no, "picks": picks,
        "narrative": {"headline": "h", "summary_3lines": ["a", "b", "c"], "retrospective": ""},
        "confidence": 70, "model": "test",
    })


def test_grade_with_curator_only_no_purchase():
    _seed_draw()
    _seed_briefing()
    run_weekly_grading(1153)
    rev = db.get_review(1153)
    assert rev is not None
    assert rev["curator_avg_match"] == round((6+1+3+3+5)/5, 2)
    assert rev["curator_best_match"] == 6
    assert rev["curator_5plus_prizes"] == 4  # 6,3,3,5 ≥3 (네 개)
    assert rev["user_avg_match"] is None  # 구매 없음


def test_grade_with_no_briefing():
    _seed_draw()
    run_weekly_grading(1153)
    rev = db.get_review(1153)
    assert rev is not None
    assert rev["curator_avg_match"] is None
  • Step 2: 실패 확인
docker compose exec lotto-backend pytest tests/test_grade_weekly_review.py -v

기대: ImportError 또는 ModuleNotFoundError.

  • Step 3: 구현

web-backend/lotto/app/jobs/grade_weekly_review.py:

"""주간 회고 채점 통합 잡 — 일요일 03:00 KST 실행.

1) 기존 purchase_manager.check_purchases_for_draw() 로 사용자 구매 자동 채점
2) 큐레이터 4계층 picks vs 추첨 결과 비교
3) 패턴 요약·갭 계산
4) weekly_review UPSERT
5) 4등 이상 발견 시 agent-office webhook 호출
"""
import json
import logging
import os
from typing import Optional

import httpx

from .. import db
from ..purchase_manager import check_purchases_for_draw
from .grading_helpers import (
    score_picks_against_draw,
    summarize_pattern,
    aggregate_pattern_summaries,
    compute_pattern_delta,
)

logger = logging.getLogger("lotto-backend")

AGENT_OFFICE_URL = os.environ.get("AGENT_OFFICE_URL", "http://agent-office:8000")


def _flatten_curator_picks(briefing: dict) -> list:
    """4계층 picks 를 모두 합쳐 단일 리스트(score 계산용)."""
    picks = briefing.get("picks") or {}
    if isinstance(picks, list):
        return picks
    out = []
    for tier in ("core", "bonus", "extended", "pool"):
        out.extend(picks.get(tier) or [])
    return out


def _curator_score(briefing: dict, win_nums: list, bonus: int) -> dict:
    if not briefing:
        return {}
    flat = _flatten_curator_picks(briefing)
    if not flat:
        return {}
    return score_picks_against_draw(flat, win_nums, bonus)


def _user_score(drw_no: int, win_nums: list) -> dict:
    purchases = db.get_purchases(draw_no=drw_no)
    if not purchases:
        return {}
    matches = []
    win_set = set(win_nums)
    pattern_summaries = []
    for p in purchases:
        for nums in (p.get("numbers") or []):
            if not nums:
                continue
            m = len(set(nums) & win_set)
            matches.append(m)
            pattern_summaries.append(summarize_pattern(nums))
    if not matches:
        return {}
    return {
        "avg_match": round(sum(matches) / len(matches), 2),
        "best_match": max(matches),
        "five_plus_prizes": sum(1 for m in matches if m >= 3),
        "pattern_avg": aggregate_pattern_summaries(pattern_summaries),
    }


def _trigger_prize_alert(drw_no: int, match_count: int, numbers: list, purchase_id: int) -> None:
    try:
        with httpx.Client(timeout=10) as client:
            client.post(
                f"{AGENT_OFFICE_URL}/api/agent-office/notify/lotto-prize",
                json={
                    "draw_no": drw_no,
                    "match_count": match_count,
                    "numbers": numbers,
                    "purchase_id": purchase_id,
                },
            )
    except Exception as e:
        logger.warning(f"[grade_weekly_review] prize alert webhook failed: {e}")


def run_weekly_grading(drw_no: int) -> dict:
    """주어진 회차에 대해 채점 잡 1회 실행. 멱등."""
    draw = db.get_draw(drw_no)
    if not draw:
        logger.warning(f"[grade_weekly_review] draw {drw_no} not found, skip")
        return {"ok": False, "reason": "no draw"}

    win_nums = [draw["n1"], draw["n2"], draw["n3"], draw["n4"], draw["n5"], draw["n6"]]
    bonus = draw["bonus"]

    # 1) 사용자 구매 자동 채점 (기존 인프라)
    try:
        check_purchases_for_draw(drw_no)
    except Exception as e:
        logger.warning(f"[grade_weekly_review] check_purchases_for_draw failed: {e}")

    # 2) 4등 이상 발견 시 webhook
    purchases = db.get_purchases(draw_no=drw_no, checked=True)
    for p in purchases:
        for r in (p.get("results") or []):
            if r.get("correct", 0) >= 4:
                _trigger_prize_alert(drw_no, r["correct"], r["numbers"], p["id"])

    # 3) 큐레이터 자기 평가
    briefing = db.get_briefing(drw_no)
    cur = _curator_score(briefing, win_nums, bonus)

    # 4) 사용자 평가 (재로드, 구매가 다 채점된 후 패턴 계산)
    usr = _user_score(drw_no, win_nums)

    # 5) 추첨 패턴 요약 + 델타
    draw_summary = summarize_pattern(win_nums)
    draw_pattern = {
        "low_avg": draw_summary["low_count"],
        "odd_avg": draw_summary["odd_count"],
        "sum_avg": draw_summary["sum"],
    }
    user_pattern = usr.get("pattern_avg", {})
    delta = compute_pattern_delta(user_pattern, draw_pattern) if user_pattern else ""

    # 6) UPSERT
    payload = {
        "draw_no": drw_no,
        "curator_avg_match": cur.get("avg_match"),
        "curator_best_tier": cur.get("best_tier"),
        "curator_best_match": cur.get("best_match"),
        "curator_5plus_prizes": cur.get("five_plus_prizes"),
        "user_avg_match": usr.get("avg_match"),
        "user_best_match": usr.get("best_match"),
        "user_5plus_prizes": usr.get("five_plus_prizes"),
        "user_pattern_summary": json.dumps(user_pattern, ensure_ascii=False) if user_pattern else None,
        "draw_pattern_summary": json.dumps(draw_pattern, ensure_ascii=False),
        "pattern_delta": delta,
    }
    rid = db.save_review(payload)
    logger.info(f"[grade_weekly_review] saved review id={rid} for draw {drw_no}")
    return {"ok": True, "review_id": rid}


def run_for_latest() -> dict:
    """가장 최근 sync된 추첨 회차로 채점 — cron 진입점."""
    latest = db.get_latest_draw()
    if not latest:
        return {"ok": False, "reason": "no draws"}
    return run_weekly_grading(latest["drw_no"])
  • Step 4: 테스트 통과 확인
docker compose exec lotto-backend pytest tests/test_grade_weekly_review.py -v

기대: 2 PASS.

  • Step 5: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/jobs/grade_weekly_review.py lotto/tests/test_grade_weekly_review.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): grade_weekly_review 통합 잡 — 큐레이터 자기평가 + 패턴 갭"

Task 5 — 채점 잡 cron 등록

Files:

  • Modify: web-backend/lotto/app/main.py (APScheduler 또는 컨테이너 cron 패턴 확인)

기존 lotto-backend 의 스케줄링 방식을 먼저 확인. APScheduler 사용 안 한다면 docker-compose.yml 의 cron 컨테이너 또는 app/main.py 의 startup hook 활용.

  • Step 1: 기존 스케줄링 패턴 확인
grep -rn "schedule\|cron\|BackgroundTasks" C:/Users/jaeoh/Desktop/workspace/web-backend/lotto/app/main.py
ls C:/Users/jaeoh/Desktop/workspace/web-backend/lotto-backend.dockerfile 2>/dev/null
cat C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml | grep -A 20 "lotto-backend:"
  • Step 2: 가장 보편적 패턴 — app/main.py startup 에서 APScheduler 등록

web-backend/lotto/app/main.py 상단:

from apscheduler.schedulers.asyncio import AsyncIOScheduler
from .jobs.grade_weekly_review import run_for_latest as grade_run_for_latest

FastAPI 앱 생성 후 startup 이벤트:

_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")


@app.on_event("startup")
async def _startup_scheduler():
    # 매주 일요일 03:00 KST — 추첨 다음날 새벽
    _scheduler.add_job(grade_run_for_latest, "cron",
                       day_of_week="sun", hour=3, minute=0,
                       id="grade_weekly_review")
    _scheduler.start()

만약 기존 sync 잡이 cron 컨테이너로 돌고 있다면, 같은 컨테이너의 crontab 에 라인 추가하거나 별도 sync 후 호출.

  • Step 3: requirements 확인
grep apscheduler C:/Users/jaeoh/Desktop/workspace/web-backend/lotto/requirements.txt

없으면 추가:

apscheduler>=3.10

그리고 컨테이너 빌드:

cd C:/Users/jaeoh/Desktop/workspace/web-backend
docker compose build lotto-backend && docker compose up -d lotto-backend
  • Step 4: 수동 트리거 검증
docker compose exec lotto-backend python -c "
from app.jobs.grade_weekly_review import run_for_latest
print(run_for_latest())
"

기대: {'ok': True, 'review_id': N} 또는 {'ok': False, 'reason': 'no draws'}.

  • Step 5: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/main.py lotto/requirements.txt
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): 일 03:00 KST 채점 잡 APScheduler 등록"

Task 6 — review router

Files:

  • Create: web-backend/lotto/app/routers/review.py

  • Modify: web-backend/lotto/app/main.py (라우터 등록)

  • Step 1: 라우터 작성

web-backend/lotto/app/routers/review.py:

"""주간 회고(weekly_review) 조회 엔드포인트."""
from fastapi import APIRouter, HTTPException
from .. import db

router = APIRouter(prefix="/api/lotto/review")


@router.get("/latest")
def latest():
    r = db.get_latest_review()
    if not r:
        raise HTTPException(404, "no review yet")
    return r


@router.get("/history")
def history(limit: int = 10):
    return {"reviews": db.list_reviews(limit)}


@router.get("/{draw_no}")
def get_one(draw_no: int):
    r = db.get_review(draw_no)
    if not r:
        raise HTTPException(404, f"no review for draw {draw_no}")
    return r
  • Step 2: main.py 에 라우터 등록

app/main.pyapp.include_router(...) 들 사이에 추가:

from .routers import review as review_router
app.include_router(review_router.router)
  • Step 3: 호출 검증
docker compose restart lotto-backend
curl -s http://localhost:18000/api/lotto/review/latest
curl -s "http://localhost:18000/api/lotto/review/history?limit=5"

기대: review 없으면 404. 있으면 JSON.

  • Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/routers/review.py lotto/app/main.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): review 라우터 — latest/history/by-draw"

Task 7 — POST /api/lotto/purchase/bulk 엔드포인트

Files:

  • Modify: web-backend/lotto/app/routers/purchase.py (또는 main.py 가 직접 핸들링하면 그쪽)
  • Modify: web-backend/lotto/app/db.py (bulk_insert_purchases_from_briefing)

기존 purchase 라우터 위치 확인 필요.

  • Step 1: 기존 purchase 라우터 위치 찾기
grep -rn "/api/lotto/purchase" C:/Users/jaeoh/Desktop/workspace/web-backend/lotto/app/ --include="*.py"
  • Step 2: db.py 에 bulk insert 헬퍼 추가
def bulk_insert_purchases_from_briefing(draw_no: int, tier_mode: str, amount: int) -> Dict[str, Any]:
    """tier_mode 에 해당하는 큐레이터 picks 를 purchase_history 에 일괄 INSERT.

    tier_mode: "core" | "core_bonus" | "core_bonus_extended" | "full"
    """
    briefing = get_briefing(draw_no)
    if not briefing:
        return {"ok": False, "reason": "briefing not found"}

    picks = briefing.get("picks") or {}
    if isinstance(picks, list):
        # 마이그레이션 이전 형태
        picks = {"core": picks, "bonus": [], "extended": [], "pool": []}

    tier_chain = {
        "core": ["core"],
        "core_bonus": ["core", "bonus"],
        "core_bonus_extended": ["core", "bonus", "extended"],
        "full": ["core", "bonus", "extended", "pool"],
    }.get(tier_mode)
    if not tier_chain:
        return {"ok": False, "reason": f"unknown tier_mode: {tier_mode}"}

    inserted_ids = []
    with _conn() as conn:
        for tier in tier_chain:
            for idx, pick in enumerate(picks.get(tier) or []):
                source_strategy = f"curator_{tier}"
                source_detail = json.dumps({
                    "tier": tier,
                    "role": pick.get("risk_tag"),
                    "set_index": idx,
                    "draw_no": draw_no,
                }, ensure_ascii=False)
                numbers_json = json.dumps([pick.get("numbers")], ensure_ascii=False)
                cur = conn.execute(
                    """INSERT INTO purchase_history
                       (draw_no, amount, sets, prize, note, numbers, is_real, source_strategy, source_detail)
                       VALUES (?, ?, 1, 0, '', ?, 1, ?, ?)""",
                    (draw_no, 1000, numbers_json, source_strategy, source_detail),
                )
                inserted_ids.append(cur.lastrowid)
    return {"ok": True, "inserted_ids": inserted_ids, "sets": len(inserted_ids)}
  • Step 3: bulk 엔드포인트 추가

기존 purchase 라우터(찾은 파일)에 또는 web-backend/lotto/app/routers/purchase.py (없으면 신규)에:

from pydantic import BaseModel
from fastapi import APIRouter, HTTPException
from .. import db

router = APIRouter(prefix="/api/lotto/purchase")


class BulkPurchaseRequest(BaseModel):
    draw_no: int
    tier_mode: str  # core | core_bonus | core_bonus_extended | full
    sets: int       # 검증용 — 실제 INSERT는 briefing 기준
    amount: int     # 검증용


@router.post("/bulk", status_code=201)
def bulk_purchase(body: BulkPurchaseRequest):
    result = db.bulk_insert_purchases_from_briefing(
        body.draw_no, body.tier_mode, body.amount
    )
    if not result["ok"]:
        raise HTTPException(400, result["reason"])
    return result
  • Step 4: main.py 에 라우터 등록 (이미 등록된 purchase 라우터에 흡수했다면 생략)

  • Step 5: 검증

docker compose restart lotto-backend
# 사전 조건: lotto_briefings 에 1153회 레코드 있어야 함
curl -s -X POST http://localhost:18000/api/lotto/purchase/bulk \
  -H "Content-Type: application/json" \
  -d '{"draw_no":1153,"tier_mode":"core","sets":5,"amount":5000}'

기대: {"ok":true,"inserted_ids":[...],"sets":5} 또는 briefing 없으면 400.

  • Step 6: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/db.py lotto/app/routers/purchase.py lotto/app/main.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): POST /api/lotto/purchase/bulk — 결정카드 원클릭 기록"

Task 8 — BriefingRequest 4계층 수용

Files:

  • Modify: web-backend/lotto/app/routers/briefing.py
  • Modify: web-backend/lotto/app/db.py (save_briefing)

기존 save_briefingpicks 를 단일 JSON 으로 저장. 4계층 dict 수용하도록.

  • Step 1: 기존 save_briefing 확인

db.py 957행 부근:

def save_briefing(data: Dict[str, Any]) -> int:
    with _conn() as conn:
        # 기존 INSERT 문 확인
        ...
  • Step 2: BriefingRequest 스키마 변경

web-backend/lotto/app/routers/briefing.py:

from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from .. import db

router = APIRouter(prefix="/api/lotto")


class TierRationale(BaseModel):
    bonus: str = ""
    extended: str = ""
    pool: str = ""


class BriefingPicks(BaseModel):
    core: List[Dict[str, Any]] = Field(default_factory=list)
    bonus: List[Dict[str, Any]] = Field(default_factory=list)
    extended: List[Dict[str, Any]] = Field(default_factory=list)
    pool: List[Dict[str, Any]] = Field(default_factory=list)


class BriefingRequest(BaseModel):
    draw_no: int
    picks: BriefingPicks
    narrative: Dict[str, Any]
    tier_rationale: TierRationale = Field(default_factory=TierRationale)
    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}


# 기존 GET 엔드포인트들 그대로 유지
@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)
  • Step 3: db.save_briefing 수정 — picks dict 직렬화 + tier_rationale 컬럼 사용

db.pysave_briefing 함수를 다음과 같이 수정:

def save_briefing(data: Dict[str, Any]) -> int:
    picks_json = json.dumps(data["picks"], ensure_ascii=False)
    narrative_json = json.dumps(data["narrative"], ensure_ascii=False)
    tier_rationale_json = json.dumps(data.get("tier_rationale") or {}, ensure_ascii=False)
    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, tier_rationale)
            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,
                tier_rationale=excluded.tier_rationale,
                generated_at=datetime('now','localtime')
            """,
            (
                data["draw_no"], picks_json, narrative_json,
                data["confidence"], data["model"],
                data.get("tokens_input", 0), data.get("tokens_output", 0),
                data.get("cache_read", 0), data.get("cache_write", 0),
                data.get("latency_ms", 0), data.get("source", "auto"),
                tier_rationale_json,
            ),
        )
        return cur.lastrowid

기존 _briefing_rowtier_rationale 컬럼 파싱 추가:

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"]),
        "tier_rationale": json.loads(r["tier_rationale"]) if r["tier_rationale"] else {},
        "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"],
    }
  • Step 4: 검증 (POST + GET 왕복)
docker compose restart lotto-backend
curl -s -X POST http://localhost:18000/api/lotto/briefing \
  -H "Content-Type: application/json" \
  -d '{
    "draw_no": 9999,
    "picks": {"core":[],"bonus":[],"extended":[],"pool":[]},
    "narrative": {"headline":"테스트","summary_3lines":["a","b","c"],"retrospective":""},
    "tier_rationale": {"bonus":"x","extended":"y","pool":"z"},
    "confidence": 70,
    "model": "test"
  }'
curl -s http://localhost:18000/api/lotto/briefing/9999

기대: 첫 호출 {"ok":true,"id":N}, 두 번째는 4계층 picks + tier_rationale 포함된 JSON.

  • Step 5: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/routers/briefing.py lotto/app/db.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): briefing API 4계층 picks + tier_rationale 수용"

Task 9 — 큐레이터 출력 스키마 (4계층 + retrospective)

Files:

  • Modify: web-backend/agent-office/app/curator/schema.py

  • Step 1: 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 TierRationale(BaseModel):
    bonus: str = Field(max_length=40)
    extended: str = Field(max_length=40)
    pool: str = Field(max_length=40)


class Narrative(BaseModel):
    headline: str
    summary_3lines: List[str] = Field(min_length=3, max_length=3)
    hot_cold_comment: str = ""
    warnings: str = ""
    retrospective: str = Field(default="", max_length=80)


class CuratorOutput(BaseModel):
    core_picks: List[Pick]     = Field(min_length=5, max_length=5)
    bonus_picks: List[Pick]    = Field(min_length=5, max_length=5)
    extended_picks: List[Pick] = Field(min_length=5, max_length=5)
    pool_picks: List[Pick]     = Field(min_length=5, max_length=5)
    tier_rationale: TierRationale
    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)
    candidate_set = {tuple(sorted(c)) for c in candidate_numbers}
    all_picks = (
        out.core_picks + out.bonus_picks + out.extended_picks + out.pool_picks
    )
    # 중복 픽 검증
    pick_keys = [tuple(p.numbers) for p in all_picks]
    if len(pick_keys) != len(set(pick_keys)):
        raise ValueError("duplicate picks across tiers")
    # 후보에 없는 번호 조합 금지
    for p in all_picks:
        if tuple(p.numbers) not in candidate_set:
            raise ValueError(f"pick {p.numbers} not in candidates")
    return out
  • Step 2: 단위 테스트로 검증

web-backend/agent-office/tests/test_curator_schema.py (없으면 신규):

import pytest
from app.curator.schema import validate_response


def _pick(nums, role="안정"):
    return {"numbers": nums, "risk_tag": role, "reason": "x"}


def _make_payload(core, bonus, ext, pool):
    return {
        "core_picks": core, "bonus_picks": bonus,
        "extended_picks": ext, "pool_picks": pool,
        "tier_rationale": {"bonus": "a", "extended": "b", "pool": "c"},
        "narrative": {
            "headline": "h",
            "summary_3lines": ["1", "2", "3"],
            "retrospective": "지난주 평균 1.8",
        },
        "confidence": 70,
    }


def test_valid_4tier():
    pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
    cores = [_pick(pool[i]) for i in range(5)]
    bonus = [_pick(pool[i]) for i in range(5, 10)]
    ext   = [_pick(pool[i]) for i in range(10, 15)]
    pl    = [_pick(pool[i]) for i in range(15, 20)]
    out = validate_response(_make_payload(cores, bonus, ext, pl), pool)
    assert len(out.core_picks) == 5
    assert out.narrative.retrospective.startswith("지난주")


def test_duplicate_pick_rejected():
    pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
    cores = [_pick(pool[0])] * 5  # 중복
    bonus = [_pick(pool[i]) for i in range(5, 10)]
    ext   = [_pick(pool[i]) for i in range(10, 15)]
    pl    = [_pick(pool[i]) for i in range(15, 20)]
    with pytest.raises(ValueError, match="duplicate"):
        validate_response(_make_payload(cores, bonus, ext, pl), pool)


def test_pick_not_in_candidates_rejected():
    pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
    foreign = [40, 41, 42, 43, 44, 45]
    cores = [_pick(foreign)] + [_pick(pool[i]) for i in range(1, 5)]
    bonus = [_pick(pool[i]) for i in range(5, 10)]
    ext   = [_pick(pool[i]) for i in range(10, 15)]
    pl    = [_pick(pool[i]) for i in range(15, 20)]
    with pytest.raises(ValueError, match="not in candidates"):
        validate_response(_make_payload(cores, bonus, ext, pl), pool)
  • Step 3: 테스트 실행
docker compose exec agent-office pytest tests/test_curator_schema.py -v

기대: 3 PASS.

  • Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add agent-office/app/curator/schema.py agent-office/tests/test_curator_schema.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(curator): 4계층 picks + tier_rationale + narrative.retrospective 스키마"

Task 10 — 큐레이터 SYSTEM_PROMPT (회고 + 계층 규칙)

Files:

  • Modify: web-backend/agent-office/app/curator/prompt.py

  • Step 1: prompt.py 전면 교체

"""큐레이터 system/user 프롬프트. system은 정적이므로 캐시 대상."""
import json


SYSTEM_PROMPT = """당신은 로또 번호 큐레이터입니다.
주어진 후보 30세트 중 4계층(코어 5, 보너스 5, 확장 5, 풀 5) 총 20세트를 선별합니다.

계층별 큐레이션 규칙:
- core_picks (5):     안정 2 / 균형 2 / 공격 1. 그 주 주축. 홀짝·저고·구간 분포가 세트끼리 겹치지 않게.
- bonus_picks (5):    코어 분배의 공백을 메우는 5세트. 코어가 공격 1뿐이면 보너스에 공격 +2 식.
- extended_picks (5): 코어·보너스에 없는 시각 — 합계 극단(80↓ / 180↑) / 콜드 4주 누적 / 4주 미등장 번호 노출.
- pool_picks (5):     이번 주 한 번도 누르지 않은 패턴 — 연속 3개 / 동일 끝자리 / 5수 균등(각 끝자리 5개씩) 등.
- tier_rationale 의 3개 키(bonus·extended·pool)에 각각 30자 이내 한국어 사유.

공통 규칙:
- 후보에 없는 번호 조합은 절대 사용 금지. 모든 픽은 candidates 중 하나와 정확히 일치해야 함.
- 4계층 사이에 중복 픽 금지 (총 20세트는 모두 서로 달라야 함).
- 각 픽 reason 은 한국어 40자 이내. 해당 픽의 features 와 context 만 근거로.
- 중립형(hot_number_count=0 이고 cold_number_count=0) 세트를 코어에 최소 1개 포함.

회고 규칙:
- context.retrospective 가 있으면 narrative.retrospective 에 한 줄(60자 이내)로 작성.
- 회고는 큐레이터 자기 결과(curator_avg, best_tier) + 사용자 결과(user_avg, pattern_delta) 둘 다 짚을 것.
- 이번 주 코어 분배는 회고에 근거해 조정. 조정 사유는 narrative.headline 에 한 줄로.
  예: "지난 주 너 저번호 편향 → 보너스 고번호 보강"
- context.retrospective 가 없으면 narrative.retrospective 는 빈 문자열.

narrative 규칙:
- headline:        한 줄, 이번 주 추첨 전망 + 조정 사유.
- summary_3lines:  정확히 3개 항목.
- hot_cold_comment: hot/cold 번호 한 줄 논평.
- warnings:        주의사항 없으면 빈 문자열.
- retrospective:   회고 한 줄 또는 빈 문자열.

출력은 반드시 JSON 하나, 그 외 어떤 텍스트도 금지. 스키마:
{
  "core_picks":     [{"numbers":[...], "risk_tag":"안정"|"균형"|"공격", "reason": str}, ...5개],
  "bonus_picks":    [...5개],
  "extended_picks": [...5개],
  "pool_picks":     [...5개],
  "tier_rationale": {"bonus": str, "extended": str, "pool": str},
  "narrative": {
    "headline": str,
    "summary_3lines": [str, str, str],
    "hot_cold_comment": str,
    "warnings": str,
    "retrospective": str
  },
  "confidence": int (0~100)
}
"""


def build_user_message(draw_no: int, candidates: list, context: dict) -> str:
    payload = {
        "draw_no": draw_no,
        "context": context,  # hot_numbers, cold_numbers, last_draw_summary, my_recent_performance, retrospective
        "candidates": candidates,
    }
    return (
        f"이번 회차: {draw_no}\n"
        f"아래 데이터로 4계층 20세트를 큐레이션하고 위 스키마로만 응답하세요.\n\n"
        f"```json\n{json.dumps(payload, ensure_ascii=False)}\n```"
    )
  • Step 2: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add agent-office/app/curator/prompt.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(curator): SYSTEM_PROMPT 회고 + 4계층 규칙"

Task 11 — build_retrospective + service_proxy 헬퍼

Files:

  • Modify: web-backend/agent-office/app/service_proxy.py

  • Create: web-backend/agent-office/app/curator/retrospective.py

  • Create: web-backend/agent-office/tests/test_retrospective.py

  • Step 1: service_proxy 에 review 헬퍼 추가

web-backend/agent-office/app/service_proxy.py 의 lotto 섹션 아래:

async def lotto_review_latest() -> Optional[Dict[str, Any]]:
    from .config import LOTTO_BACKEND_URL
    resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/review/latest")
    if resp.status_code == 404:
        return None
    resp.raise_for_status()
    return resp.json()


async def lotto_review_by_draw(draw_no: int) -> Optional[Dict[str, Any]]:
    from .config import LOTTO_BACKEND_URL
    resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/review/{draw_no}")
    if resp.status_code == 404:
        return None
    resp.raise_for_status()
    return resp.json()


async def lotto_reviews_history(limit: int = 10) -> List[Dict[str, Any]]:
    from .config import LOTTO_BACKEND_URL
    resp = await _client.get(
        f"{LOTTO_BACKEND_URL}/api/lotto/review/history",
        params={"limit": limit},
    )
    resp.raise_for_status()
    return resp.json().get("reviews", [])
  • Step 2: retrospective.py 작성
"""큐레이션 직전 호출 — review 1건 + 추세 3건 → 컨텍스트 dict."""
import json
from typing import Optional, Dict, Any
from .. import service_proxy


def _detect_bias(reviews: list) -> str:
    """3주↑ 같은 방향 패턴 편향이 유지되면 한 줄로."""
    deltas = [r.get("pattern_delta") or "" for r in reviews if r.get("pattern_delta")]
    if len(deltas) < 2:
        return ""
    # 단순 휴리스틱 — 같은 키워드("저번호" 등)가 2회 이상이면 지속 편향
    keywords = ["저번호", "고번호", "합계", "홀짝"]
    persistent = []
    for kw in keywords:
        cnt = sum(1 for d in deltas if kw in d)
        if cnt >= max(2, len(deltas) - 1):
            persistent.append(kw)
    return " · ".join(persistent)


async def build_retrospective(target_draw_no: int) -> Optional[Dict[str, Any]]:
    """target_draw_no(이번 주) 직전 회차의 review + 그 앞 3회 추세."""
    last = await service_proxy.lotto_review_by_draw(target_draw_no - 1)
    if not last:
        return None

    history = await service_proxy.lotto_reviews_history(limit=4)
    # history 는 desc 정렬 → last 와 그 이전 3건 분리
    others = [r for r in history if r["draw_no"] < target_draw_no - 1][:3]
    series = [last] + others

    cur_avgs = [r["curator_avg_match"] for r in series if r.get("curator_avg_match") is not None]
    usr_avgs = [r["user_avg_match"]    for r in series if r.get("user_avg_match")    is not None]

    return {
        "last_draw": {
            "draw_no": last["draw_no"],
            "curator_avg": last.get("curator_avg_match"),
            "curator_best_tier": last.get("curator_best_tier"),
            "user_avg": last.get("user_avg_match"),
            "user_5plus": last.get("user_5plus_prizes"),
            "pattern_delta": last.get("pattern_delta") or "",
        },
        "trend_4w": {
            "curator_avg_4w": round(sum(cur_avgs) / len(cur_avgs), 2) if cur_avgs else None,
            "user_avg_4w":    round(sum(usr_avgs) / len(usr_avgs), 2) if usr_avgs else None,
            "user_persistent_bias": _detect_bias(series),
        },
    }
  • Step 3: 단위 테스트

web-backend/agent-office/tests/test_retrospective.py:

import pytest
from unittest.mock import AsyncMock, patch
from app.curator.retrospective import build_retrospective, _detect_bias


def test_detect_bias_persistent_low():
    reviews = [
        {"pattern_delta": "저번호 편향 +1.2 / 합계 -18"},
        {"pattern_delta": "저번호 편향 +0.8"},
        {"pattern_delta": "저번호 편향 +1.0 / 홀짝 +0.5"},
    ]
    assert "저번호" in _detect_bias(reviews)


def test_detect_bias_no_persistence():
    reviews = [
        {"pattern_delta": "저번호 편향 +1.2"},
        {"pattern_delta": "고번호 편향 +0.8"},
    ]
    assert _detect_bias(reviews) == ""


@pytest.mark.asyncio
async def test_build_retrospective_with_data():
    with patch("app.service_proxy.lotto_review_by_draw", new=AsyncMock(return_value={
        "draw_no": 1153, "curator_avg_match": 1.8, "curator_best_tier": "안정",
        "user_avg_match": 2.0, "user_5plus_prizes": 1, "pattern_delta": "저번호 편향 +1.2",
    })), patch("app.service_proxy.lotto_reviews_history", new=AsyncMock(return_value=[
        {"draw_no": 1153, "curator_avg_match": 1.8, "user_avg_match": 2.0, "pattern_delta": "저번호 편향 +1.2"},
        {"draw_no": 1152, "curator_avg_match": 1.6, "user_avg_match": 1.5, "pattern_delta": "저번호 편향 +0.8"},
        {"draw_no": 1151, "curator_avg_match": 1.7, "user_avg_match": 1.8, "pattern_delta": "저번호 편향 +1.0"},
        {"draw_no": 1150, "curator_avg_match": 1.9, "user_avg_match": 2.2, "pattern_delta": ""},
    ])):
        out = await build_retrospective(1154)
        assert out["last_draw"]["draw_no"] == 1153
        assert out["trend_4w"]["curator_avg_4w"] == round((1.8+1.6+1.7+1.9)/4, 2)
        assert "저번호" in out["trend_4w"]["user_persistent_bias"]


@pytest.mark.asyncio
async def test_build_retrospective_no_review():
    with patch("app.service_proxy.lotto_review_by_draw", new=AsyncMock(return_value=None)):
        out = await build_retrospective(1154)
        assert out is None
  • Step 4: 테스트 실행
docker compose exec agent-office pytest tests/test_retrospective.py -v

기대: 4 PASS. (pytest-asyncio 설치 필요 — 없으면 requirements 에 추가)

  • Step 5: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add agent-office/app/service_proxy.py agent-office/app/curator/retrospective.py agent-office/tests/test_retrospective.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(curator): build_retrospective + lotto review service proxy"

Task 12 — pipeline.py 4계층 직렬화 + retrospective 빌드

Files:

  • Modify: web-backend/agent-office/app/curator/pipeline.py

  • Step 1: curate_weekly 함수 교체

"""큐레이터 파이프라인 — 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
from .retrospective import build_retrospective


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": 8192,  # 4계층 20세트 + narrative + retrospective 수용
        "system": system_blocks,
        "messages": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
    }
    started = time.monotonic()
    async with httpx.AsyncClient(timeout=180) 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()
    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]:
    cand_resp = await service_proxy.lotto_candidates(n=30)  # ← 30 으로 확장
    draw_no = cand_resp["draw_no"]
    candidates = cand_resp["candidates"]
    context = await service_proxy.lotto_context()

    retrospective = await build_retrospective(draw_no)

    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", []),
        "retrospective": retrospective,
    })

    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):
        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": {
            "core":     [p.model_dump() for p in validated.core_picks],
            "bonus":    [p.model_dump() for p in validated.bonus_picks],
            "extended": [p.model_dump() for p in validated.extended_picks],
            "pool":     [p.model_dump() for p in validated.pool_picks],
        },
        "narrative": validated.narrative.model_dump(),
        "tier_rationale": validated.tier_rationale.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"]},
        "payload": payload,  # 텔레그램 알림용
    }
  • Step 2: 검증 (수동 트리거)
docker compose restart agent-office
docker compose exec agent-office python -c "
import asyncio
from app.curator.pipeline import curate_weekly
print(asyncio.run(curate_weekly(source='manual')))
"

기대: ANTHROPIC_API_KEY 설정 시 정상 큐레이션. 응답에 confidence + payload.picks 4계층.

  • Step 3: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add agent-office/app/curator/pipeline.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(curator): pipeline 4계층 직렬화 + retrospective 컨텍스트 + N=30"

Task 13 — 텔레그램 알림 (telegram_lotto)

Files:

  • Create: web-backend/agent-office/app/notifiers/__init__.py

  • Create: web-backend/agent-office/app/notifiers/telegram_lotto.py

  • Step 1: 빈 패키지

# web-backend/agent-office/app/notifiers/__init__.py
  • Step 2: 기존 텔레그램 인프라 확인
grep -rn "send_message\|TELEGRAM" C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office/app/telegram/ --include="*.py" | head -20
  • Step 3: telegram_lotto.py 작성

web-backend/agent-office/app/notifiers/telegram_lotto.py:

"""로또 큐레이션·당첨 알림 — 텔레그램 푸시."""
import logging
from typing import Dict, Any

from ..telegram.messaging import send_message  # 기존 인프라 함수명, Step 2 결과로 보정

logger = logging.getLogger("agent-office")

LOTTO_URL = "https://gahusb.synology.me/lotto"


def _format_briefing(payload: Dict[str, Any]) -> str:
    draw_no = payload["draw_no"]
    nar = payload["narrative"]
    conf = payload["confidence"]

    # 분배 칩 — core 5세트의 risk_tag 빈도
    core = payload["picks"]["core"]
    role_count = {"안정": 0, "균형": 0, "공격": 0}
    for p in core:
        role_count[p["risk_tag"]] = role_count.get(p["risk_tag"], 0) + 1
    chip = " · ".join(f"{k} {v}" for k, v in role_count.items() if v)

    msg = [
        f"🎟 {draw_no}회 · 큐레이션 떴음",
        "",
        f"\"{nar['headline']}\"",
        f"신뢰도 {conf} · 분배 {chip}",
    ]
    retro = nar.get("retrospective") or ""
    if retro:
        msg += ["", f"▸ 회고: {retro}"]
    msg += ["", f"👉 결정 카드 보러가기  ({LOTTO_URL})"]
    return "\n".join(msg)


def _format_prize_alert(event: Dict[str, Any]) -> str:
    return (
        "🚨 로또 당첨 가능성!\n"
        f"{event['draw_no']}회 — {event['match_count']}개 일치\n"
        f"번호: {', '.join(str(n) for n in event['numbers'])}\n"
        "동행복권에서 즉시 확인하세요."
    )


async def send_curator_briefing(payload: Dict[str, Any]) -> None:
    text = _format_briefing(payload)
    try:
        await send_message(text)
    except Exception as e:
        logger.warning(f"[telegram_lotto] briefing send failed: {e}")


async def send_prize_alert(event: Dict[str, Any]) -> None:
    text = _format_prize_alert(event)
    try:
        await send_message(text)
    except Exception as e:
        logger.warning(f"[telegram_lotto] prize alert send failed: {e}")

Step 2 결과에 따라 from ..telegram.messaging import send_message 경로/이름이 다를 수 있다. 기존 코드에서 async def send_message 또는 동기 함수가 있는 위치 확인 후 import 보정.

  • Step 4: 단위 테스트 (포맷 검증)

web-backend/agent-office/tests/test_telegram_lotto_format.py:

from app.notifiers.telegram_lotto import _format_briefing, _format_prize_alert


def test_briefing_with_retrospective():
    payload = {
        "draw_no": 1154,
        "confidence": 72,
        "narrative": {
            "headline": "안정 +1, 콜드 누적 보강",
            "summary_3lines": ["a", "b", "c"],
            "retrospective": "너 2.0 / 나 1.8 — 저번호 편향",
        },
        "picks": {
            "core": [
                {"risk_tag": "안정"}, {"risk_tag": "안정"}, {"risk_tag": "안정"},
                {"risk_tag": "균형"}, {"risk_tag": "공격"},
            ],
            "bonus": [], "extended": [], "pool": [],
        },
    }
    text = _format_briefing(payload)
    assert "1154회" in text
    assert "신뢰도 72" in text
    assert "안정 3" in text
    assert "회고: 너 2.0" in text


def test_briefing_without_retrospective():
    payload = {
        "draw_no": 1, "confidence": 50,
        "narrative": {"headline": "h", "summary_3lines": ["a","b","c"], "retrospective": ""},
        "picks": {"core": [{"risk_tag":"안정"}]*5, "bonus":[],"extended":[],"pool":[]},
    }
    text = _format_briefing(payload)
    assert "회고" not in text


def test_prize_alert():
    text = _format_prize_alert({"draw_no": 1154, "match_count": 5, "numbers": [3,11,17,25,33,8]})
    assert "5개 일치" in text
    assert "3, 11, 17, 25, 33, 8" in text
docker compose exec agent-office pytest tests/test_telegram_lotto_format.py -v

기대: 3 PASS.

  • Step 5: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add agent-office/app/notifiers/__init__.py agent-office/app/notifiers/telegram_lotto.py agent-office/tests/test_telegram_lotto_format.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(curator): 텔레그램 큐레이션·당첨 알림 포맷터"

Task 14 — notify webhook 라우터 + main 등록

Files:

  • Create: web-backend/agent-office/app/routers/__init__.py (없으면)

  • Create: web-backend/agent-office/app/routers/notify.py

  • Modify: web-backend/agent-office/app/main.py

  • Step 1: 라우터 작성

web-backend/agent-office/app/routers/notify.py:

"""다른 서비스가 트리거하는 웹훅 — 현재 lotto-backend → 텔레그램 푸시."""
from typing import List
from fastapi import APIRouter
from pydantic import BaseModel
from ..notifiers.telegram_lotto import send_prize_alert

router = APIRouter(prefix="/api/agent-office/notify")


class LottoPrizeEvent(BaseModel):
    draw_no: int
    match_count: int
    numbers: List[int]
    purchase_id: int


@router.post("/lotto-prize")
async def lotto_prize(body: LottoPrizeEvent):
    await send_prize_alert(body.model_dump())
    return {"ok": True}
  • Step 2: main.py 라우터 등록
from .routers import notify as notify_router
app.include_router(notify_router.router)
  • Step 3: 검증
docker compose restart agent-office
curl -s -X POST http://localhost:18900/api/agent-office/notify/lotto-prize \
  -H "Content-Type: application/json" \
  -d '{"draw_no":1153,"match_count":5,"numbers":[3,11,17,25,33,8],"purchase_id":42}'

기대: {"ok":true} + 텔레그램 메시지 수신 (봇 토큰 정상일 때).

  • Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add agent-office/app/routers/notify.py agent-office/app/main.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(agent-office): /api/agent-office/notify/lotto-prize 웹훅"

Task 15 — lotto_agent 큐레이션 후 텔레그램 + cron 시간 변경

Files:

  • Modify: web-backend/agent-office/app/agents/lotto.py

  • Modify: web-backend/agent-office/app/scheduler.py

  • Step 1: lotto_agent — 큐레이션 성공 후 텔레그램 호출

web-backend/agent-office/app/agents/lotto.py_run 메서드 수정:

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={
            k: v for k, v in result.items() if k != "payload"
        })
        await self.transition("reporting", f"#{result['draw_no']} 브리핑 저장 완료")
        add_log(self.agent_id, f"큐레이션 완료: #{result['draw_no']} conf={result['confidence']}", task_id=task_id)

        # 텔레그램 헤드라인 푸시 (실패해도 큐레이션은 성공으로 마감)
        try:
            from ..notifiers.telegram_lotto import send_curator_briefing
            await send_curator_briefing(result["payload"])
        except Exception as e:
            add_log(self.agent_id, f"텔레그램 알림 실패: {e}", level="warning", task_id=task_id)

        await self.transition("idle", "대기 중")
        return {"ok": True, **{k: v for k, v in result.items() if k != "payload"}}
    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: scheduler.py — 월 07:00 → 월 09:00
# 기존
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate")
# 신규
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=0, id="lotto_curate")
  • Step 3: 수동 트리거로 통합 검증

agent-office API 또는 직접 호출:

docker compose restart agent-office
curl -s -X POST http://localhost:18900/api/agent-office/command \
  -H "Content-Type: application/json" \
  -d '{"agent_id":"lotto","action":"curate_now","params":{}}'

기대: 큐레이션 진행 + 텔레그램 알림 수신 + 사이트 /api/lotto/briefing/latest 에 4계층 picks 저장됨.

  • Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add agent-office/app/agents/lotto.py agent-office/app/scheduler.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(curator): 큐레이션 후 텔레그램 자동 푸시 + cron 09:00 변경"

Task 16 — 프론트 api.js 헬퍼

Files:

  • Modify: web-ui/src/api.js

  • Step 1: 헬퍼 추가 (파일 하단)

// === 주간 회고 (weekly_review) ===
export const getLatestReview = () => apiGet('/api/lotto/review/latest').catch(e => {
    if (e?.status === 404) return null;
    throw e;
});

export const getReviewHistory = (limit = 4) =>
    apiGet(`/api/lotto/review/history?limit=${limit}`).then(d => d.reviews || []);

// === 큐레이터 4계층 원클릭 구매 ===
export const bulkPurchase = ({ draw_no, tier_mode, sets, amount }) =>
    apiPost('/api/lotto/purchase/bulk', { draw_no, tier_mode, sets, amount });
  • Step 2: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/api.js
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(api): review + bulkPurchase 헬퍼"

Task 17 — useReview 훅 + useBriefing 4계층 수용

Files:

  • Create: web-ui/src/pages/lotto/hooks/useReview.js

  • Modify: web-ui/src/pages/lotto/hooks/useBriefing.js

  • Step 1: useReview

import { useEffect, useState } from 'react';
import { getLatestReview, getReviewHistory } from '../../../api';

export default function useReview() {
    const [latest, setLatest] = useState(null);
    const [history, setHistory] = useState([]);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        let cancel = false;
        Promise.all([getLatestReview(), getReviewHistory(4)])
            .then(([l, h]) => {
                if (cancel) return;
                setLatest(l);
                setHistory(h);
            })
            .catch(() => {})
            .finally(() => !cancel && setLoading(false));
        return () => { cancel = true; };
    }, []);

    return { latest, history, loading };
}
  • Step 2: useBriefing 변경 — picks 가 dict 인 케이스 수용

기존 useBriefing.js 의 briefing 데이터에서 picks 가 객체(4계층) 또는 리스트(구 데이터) 둘 다 들어올 수 있으므로 정규화:

// useBriefing.js 의 fetch 결과 가공 부분에 추가
const normalizePicks = (picks) => {
    if (Array.isArray(picks)) {
        return { core: picks, bonus: [], extended: [], pool: [] };
    }
    return {
        core: picks?.core || [],
        bonus: picks?.bonus || [],
        extended: picks?.extended || [],
        pool: picks?.pool || [],
    };
};

// fetch 후
setBriefing({ ...data, picks: normalizePicks(data.picks) });
  • Step 3: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/lotto/hooks/useReview.js src/pages/lotto/hooks/useBriefing.js
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(lotto): useReview 훅 + useBriefing 4계층 정규화"

Task 18 — DecisionCard 하위 컴포넌트 (Pick/Tier/Toggle/Retro)

Files:

  • Create: web-ui/src/pages/lotto/components/decision/PickCard.jsx

  • Create: web-ui/src/pages/lotto/components/decision/TierSection.jsx

  • Create: web-ui/src/pages/lotto/components/decision/TierModeToggle.jsx

  • Create: web-ui/src/pages/lotto/components/decision/RetrospectiveBox.jsx

  • Create: web-ui/src/pages/lotto/components/decision/decision.css

  • Step 1: PickCard.jsx

const ROLE_COLOR = { '안정': 'stable', '균형': 'balance', '공격': 'aggro' };

export default function PickCard({ pick, index, total }) {
    const role = pick.risk_tag;
    return (
        <div className="lc-set">
            <div className="lc-set__head">
                <span className={`lc-set__role lc-set__role--${ROLE_COLOR[role]}`}> {role}</span>
                <span className="lc-set__idx">Set {index + 1} / {total}</span>
            </div>
            <div className="lc-balls">
                {pick.numbers.map(n => (
                    <span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
                ))}
            </div>
            <p className="lc-set__reason">{pick.reason}</p>
        </div>
    );
}
  • Step 2: TierSection.jsx
import PickCard from './PickCard';

const TIER_TITLE = {
    core: '코어 (필수, 5세트)',
    bonus: '보너스 (+5)',
    extended: '확장 (+5)',
    pool: '풀 (+5)',
};

export default function TierSection({ tier, picks, rationale, indexBase = 0, totalSets }) {
    if (!picks?.length) return null;
    return (
        <section className={`lc-tier lc-tier--${tier}`}>
            <header className="lc-tier__head">
                <h4>{TIER_TITLE[tier]}</h4>
                {rationale && tier !== 'core' && (
                    <p className="lc-tier__rationale">{rationale}</p>
                )}
            </header>
            {picks.map((p, i) => (
                <PickCard key={i} pick={p} index={indexBase + i} total={totalSets} />
            ))}
        </section>
    );
}
  • Step 3: TierModeToggle.jsx
const MODES = [
    { key: 'core',                label: '코어',     sets: 5,  amount: 5000 },
    { key: 'core_bonus',          label: '+ 보너스', sets: 10, amount: 10000 },
    { key: 'core_bonus_extended', label: '+ 확장',   sets: 15, amount: 15000 },
    { key: 'full',                label: '+ 풀',     sets: 20, amount: 20000 },
];

export default function TierModeToggle({ value, onChange }) {
    return (
        <div className="lc-toggle" role="tablist">
            {MODES.map((m, i) => (
                <button
                    key={m.key}
                    role="tab"
                    aria-selected={value === m.key}
                    className={`lc-toggle__chip ${value === m.key ? 'is-active' : ''}`}
                    onClick={() => onChange(m.key)}
                >
                    <span className="lc-toggle__dots">{'●'.repeat(i + 1) + '○'.repeat(3 - i)}</span>
                    <span className="lc-toggle__lbl">{m.label}</span>
                    <span className="lc-toggle__sub">{m.sets}세트 · {m.amount.toLocaleString()}</span>
                </button>
            ))}
        </div>
    );
}

export { MODES };
  • Step 4: RetrospectiveBox.jsx
export default function RetrospectiveBox({ briefing, review }) {
    const retro = briefing?.narrative?.retrospective;
    if (!retro) return null;
    const drawNo = review?.draw_no ?? (briefing?.draw_no ? briefing.draw_no - 1 : null);
    return (
        <aside className="lc-retro">
            <p className="lc-retro__time"> 지난  {drawNo ? `${drawNo}회` : ''} 회고</p>
            <p className="lc-retro__body">{retro}</p>
        </aside>
    );
}
  • Step 5: decision.css — 결정 카드 스타일
.lc-card { max-width: 720px; margin: 0 auto; background: linear-gradient(180deg, #161220 0%, #1a1426 100%);
  border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 24px; color: #ece6f7; }
.lc-head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 14px; }
.lc-eyebrow { font-size: 10px; letter-spacing: 2px; opacity: 0.5; text-transform: uppercase; margin: 0 0 4px; }
.lc-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.lc-conf { display: flex; flex-direction: column; align-items: flex-end; }
.lc-conf__num { font-family: 'Courier New', monospace; font-size: 28px; font-weight: 700; color: #b8a8ff; letter-spacing: -0.04em; }
.lc-conf__lbl { font-size: 9px; letter-spacing: 1.5px; opacity: 0.55; }
.lc-retro { background: rgba(184, 168, 255, 0.06); border-left: 2px solid rgba(184, 168, 255, 0.4);
  padding: 10px 14px; margin: 14px 0; border-radius: 4px; }
.lc-retro__time { font-size: 9px; letter-spacing: 1.5px; color: #b8a8ff; opacity: 0.7; margin: 0 0 4px; }
.lc-retro__body { font-size: 13px; line-height: 1.55; opacity: 0.85; margin: 0; }
.lc-headline { font-size: 16px; font-weight: 600; line-height: 1.5; margin: 18px 0 4px; }
.lc-headline-3 { font-size: 12px; opacity: 0.65; line-height: 1.55; margin: 0 0 18px; }
.lc-balance { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px;
  background: rgba(255,255,255,0.03); border-radius: 8px; margin-bottom: 16px; font-size: 11px; }
.lc-balance__chips { display: flex; gap: 8px; }
.lc-chip { padding: 3px 8px; border-radius: 100px; font-weight: 600; font-size: 11px; }
.lc-chip--stable { background: rgba(80, 200, 120, 0.15); color: #76e09a; }
.lc-chip--balance { background: rgba(255, 200, 80, 0.15); color: #ffce6e; }
.lc-chip--aggro { background: rgba(255, 100, 130, 0.15); color: #ff8aa0; }
.lc-toggle { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 16px 0; }
.lc-toggle__chip { padding: 10px 8px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);
  border-radius: 10px; color: #ece6f7; cursor: pointer; display: flex; flex-direction: column; gap: 4px; align-items: center; }
.lc-toggle__chip.is-active { background: rgba(184, 168, 255, 0.15); border-color: rgba(184, 168, 255, 0.5); }
.lc-toggle__dots { letter-spacing: 2px; font-size: 10px; opacity: 0.7; }
.lc-toggle__lbl { font-size: 12px; font-weight: 600; }
.lc-toggle__sub { font-size: 10px; opacity: 0.55; }
.lc-tier { margin-bottom: 14px; }
.lc-tier__head { padding: 8px 0; border-top: 1px dashed rgba(255,255,255,0.1); margin-bottom: 8px; }
.lc-tier:first-of-type .lc-tier__head { border-top: none; }
.lc-tier__head h4 { font-size: 12px; font-weight: 600; margin: 0 0 4px; opacity: 0.75; letter-spacing: 0.5px; }
.lc-tier__rationale { font-size: 11px; opacity: 0.55; margin: 0; }
.lc-set { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px;
  padding: 14px; margin-bottom: 10px; }
.lc-set__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.lc-set__role { font-size: 11px; font-weight: 600; letter-spacing: 0.5px; }
.lc-set__role--stable { color: #76e09a; }
.lc-set__role--balance { color: #ffce6e; }
.lc-set__role--aggro { color: #ff8aa0; }
.lc-set__idx { font-size: 10px; opacity: 0.4; }
.lc-balls { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.lc-set__reason { font-size: 12px; opacity: 0.7; line-height: 1.45; margin: 0; }
.lc-actions { display: flex; gap: 10px; margin-top: 18px; }
@media (max-width: 480px) {
  .lc-toggle { grid-template-columns: repeat(2, 1fr); }
}
  • Step 6: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/lotto/components/decision/
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(lotto): DecisionCard 하위 컴포넌트(Pick/Tier/Toggle/Retro) + 스타일"

Task 19 — BulkPurchaseButton + DecisionCard 메인 + BriefingTab 교체

Files:

  • Create: web-ui/src/pages/lotto/components/decision/BulkPurchaseButton.jsx

  • Create: web-ui/src/pages/lotto/components/decision/DecisionCard.jsx

  • Modify: web-ui/src/pages/lotto/tabs/BriefingTab.jsx

  • Step 1: BulkPurchaseButton.jsx

import { useState } from 'react';
import { bulkPurchase } from '../../../../api';
import { MODES } from './TierModeToggle';

export default function BulkPurchaseButton({ drawNo, tierMode, onSuccess }) {
    const [busy, setBusy] = useState(false);
    const mode = MODES.find(m => m.key === tierMode) || MODES[0];

    const onClick = async () => {
        if (busy) return;
        setBusy(true);
        try {
            await bulkPurchase({
                draw_no: drawNo,
                tier_mode: tierMode,
                sets: mode.sets,
                amount: mode.amount,
            });
            onSuccess?.();
            alert(`${mode.sets}세트 구매 기록 완료!`);
        } catch (e) {
            alert(`구매 기록 실패: ${e?.message || e}`);
        } finally {
            setBusy(false);
        }
    };

    return (
        <button className="lc-btn lc-btn--prim" onClick={onClick} disabled={busy || !drawNo}>
            {busy ? '저장 중...' : `이대로 ${mode.sets}세트 구매했음`}
        </button>
    );
}
  • Step 2: DecisionCard.jsx
import { useEffect, useMemo, useState } from 'react';
import RetrospectiveBox from './RetrospectiveBox';
import TierModeToggle, { MODES } from './TierModeToggle';
import TierSection from './TierSection';
import BulkPurchaseButton from './BulkPurchaseButton';
import './decision.css';

const TIER_CHAIN = {
    core: ['core'],
    core_bonus: ['core', 'bonus'],
    core_bonus_extended: ['core', 'bonus', 'extended'],
    full: ['core', 'bonus', 'extended', 'pool'],
};

const STORAGE_KEY = 'lotto.tier_mode';

export default function DecisionCard({ briefing, review, onPurchaseSuccess }) {
    const [tierMode, setTierMode] = useState(() =>
        localStorage.getItem(STORAGE_KEY) || 'core'
    );

    useEffect(() => {
        localStorage.setItem(STORAGE_KEY, tierMode);
    }, [tierMode]);

    const visibleTiers = TIER_CHAIN[tierMode];

    const totalSets = useMemo(
        () => visibleTiers.reduce((sum, t) => sum + (briefing?.picks?.[t]?.length || 0), 0),
        [briefing, visibleTiers]
    );

    // 분배 칩 — 보이는 계층의 risk_tag 합산
    const balance = useMemo(() => {
        const acc = { '안정': 0, '균형': 0, '공격': 0 };
        for (const t of visibleTiers) {
            for (const p of (briefing?.picks?.[t] || [])) {
                if (acc[p.risk_tag] !== undefined) acc[p.risk_tag]++;
            }
        }
        return acc;
    }, [briefing, visibleTiers]);

    if (!briefing) return null;

    let cursor = 0;

    return (
        <div className="lc-card">
            <header className="lc-head">
                <div>
                    <p className="lc-eyebrow">Curator Briefing · {briefing.draw_no}</p>
                    <h3 className="lc-title">{briefing.narrative.headline}</h3>
                </div>
                <div className="lc-conf">
                    <div className="lc-conf__num">{briefing.confidence}</div>
                    <div className="lc-conf__lbl">CONFIDENCE</div>
                </div>
            </header>

            <RetrospectiveBox briefing={briefing} review={review} />

            <p className="lc-headline-3">
                {(briefing.narrative.summary_3lines || []).join(' · ')}
            </p>

            <div className="lc-balance">
                <div className="lc-balance__chips">
                    {balance['안정'] > 0 && <span className="lc-chip lc-chip--stable">안정 ×{balance['안정']}</span>}
                    {balance['균형'] > 0 && <span className="lc-chip lc-chip--balance">균형 ×{balance['균형']}</span>}
                    {balance['공격'] > 0 && <span className="lc-chip lc-chip--aggro">공격 ×{balance['공격']}</span>}
                </div>
            </div>

            <TierModeToggle value={tierMode} onChange={setTierMode} />

            {visibleTiers.map(tier => {
                const picks = briefing.picks?.[tier] || [];
                const idxBase = cursor;
                cursor += picks.length;
                return (
                    <TierSection
                        key={tier}
                        tier={tier}
                        picks={picks}
                        rationale={briefing.tier_rationale?.[tier]}
                        indexBase={idxBase}
                        totalSets={totalSets}
                    />
                );
            })}

            <div className="lc-actions">
                <BulkPurchaseButton
                    drawNo={briefing.draw_no}
                    tierMode={tierMode}
                    onSuccess={onPurchaseSuccess}
                />
            </div>
        </div>
    );
}
  • Step 3: BriefingTab.jsx — DecisionCard 단일 화면
import useBriefing from '../hooks/useBriefing';
import useReview from '../hooks/useReview';
import DecisionCard from '../components/decision/DecisionCard';
import BriefingEmpty from '../components/briefing/BriefingEmpty';

export default function BriefingTab() {
    const { briefing, loading, error, regenerating, regenerate } = useBriefing();
    const { latest: review } = useReview();

    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">
            <DecisionCard briefing={briefing} review={review} />
        </div>
    );
}
  • Step 4: 검증 (브라우저)
cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm run dev

브라우저에서 http://localhost:3007/lotto 열기. 브리핑 탭이 결정 카드로 보이는지 + 모드 토글 동작 + 새로고침 후 마지막 모드 기억하는지 확인.

  • Step 5: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/lotto/components/decision/BulkPurchaseButton.jsx src/pages/lotto/components/decision/DecisionCard.jsx src/pages/lotto/tabs/BriefingTab.jsx
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(lotto): DecisionCard + BulkPurchaseButton, BriefingTab 단일 화면 재구성"

Task 20 — 분석 탭 → 자료실 라벨 + 첫 진입 접힘

Files:

  • Modify: web-ui/src/pages/lotto/Functions.jsx

  • Modify: web-ui/src/pages/lotto/tabs/AnalysisTab.jsx

  • Step 1: 탭 라벨 변경

Functions.jsx:

const TABS = [
    { id: 'briefing', label: '🗓 이번 주 브리핑' },
    { id: 'analysis', label: '📚 자료실 / Deep Dive' },  // ← 변경
    { id: 'purchase', label: '💰 구매·성과' },
];
  • Step 2: AnalysisTab — 모든 패널을 <details> 로 감싸 첫 진입 시 접힘

AnalysisTab.jsx 의 각 <section className="lotto-panel"> 또는 CombinedRecommendPanel, ReportPanel, PersonalAnalysisPanel 등을 다음 패턴으로 감싸기:

<details className="lotto-section-fold">
    <summary>섹션 제목 (펼치기)</summary>
    {/* 기존 내용 */}
</details>

대표 예 — Latest Draw 섹션:

<details className="lotto-section-fold">
    <summary>최신 회차</summary>
    <section className="lotto-panel">
        {/* 기존 panel head + body */}
    </section>
</details>

같은 패턴을 시뮬레이션 추천, 통계 분석, 전체 회차 번호 분포, 내 번호 패턴, 수동 추천, 추천 히스토리에 모두 적용.

기존 PerformanceBanner(<PerformanceBanner perf={ld.perfStats} />)는 그대로 노출(자료실의 단축 신뢰도 칭찬). DecisionCard 헤더의 신뢰도와 의미가 다르므로 중복 아님.

  • Step 3: CSS 한 줄 추가 (Lotto.css)
.lotto-section-fold { margin-bottom: 14px; }
.lotto-section-fold > summary { cursor: pointer; padding: 12px 16px; background: rgba(255,255,255,0.03);
  border-radius: 10px; font-weight: 600; font-size: 14px; opacity: 0.85; }
.lotto-section-fold[open] > summary { margin-bottom: 12px; opacity: 1; }
  • Step 4: 브라우저 확인

자료실 탭 클릭 → 모든 패널이 접힌 상태 → 각 클릭으로 펼침.

  • Step 5: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/lotto/Functions.jsx src/pages/lotto/tabs/AnalysisTab.jsx src/pages/lotto/Lotto.css
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(lotto): 분석탭 → 자료실 라벨 + 첫 진입 모든 패널 접힘"

Task 21 — PurchasePanel 자동 채점 표시 + 4등 이상 플래그

Files:

  • Modify: web-ui/src/pages/lotto/hooks/usePurchases.js
  • Modify: web-ui/src/pages/lotto/components/PurchasePanel.jsx

기존 purchase_history.results JSON 컬럼에는 각 세트의 correct 가 들어있음(자동 채점 결과). 행에 표시.

  • Step 1: usePurchases — bulkPurchase 추가
// usePurchases.js 안 적당한 위치에
import { bulkPurchase as apiBulkPurchase } from '../../../api';

// 훅 반환에 추가
const handleBulkPurchase = useCallback(async (params) => {
    const result = await apiBulkPurchase(params);
    await refresh(); // 목록 갱신
    return result;
}, [refresh]);

return {
    ...,
    handleBulkPurchase,
};
  • Step 2: PurchasePanel — 행에 results 표시

기존 lotto-purchase-row 안에 추가:

{/* 기존 컬럼들 */}
<span className="lotto-purchase-row__hits">
    {(rec.results || []).map((r, i) => (
        <span key={i} className={`hit-badge hit-${r.correct}`}>{r.correct}</span>
    ))}
    {(rec.results || []).some(r => r.correct >= 4) && (
        <span className="prize-flag">🚨 4 확인 필요</span>
    )}
</span>

CSS:

.hit-badge { display: inline-block; min-width: 16px; padding: 1px 4px; margin-right: 2px;
  font-size: 10px; border-radius: 4px; background: rgba(255,255,255,0.06); text-align: center; }
.hit-badge.hit-3 { background: rgba(80, 200, 120, 0.2); color: #76e09a; }
.hit-badge.hit-4 { background: rgba(255, 200, 80, 0.25); color: #ffce6e; font-weight: 700; }
.hit-badge.hit-5, .hit-badge.hit-6 { background: rgba(255, 100, 130, 0.3); color: #ff8aa0; font-weight: 700; }
.prize-flag { font-size: 10px; color: #ff8aa0; margin-left: 6px; }
  • Step 3: 검증 — 자동 채점된 회차의 구매 기록이 보이는지

브라우저 → 구매 탭 → 채점된 행에 일치 수 배지 + 4등 이상 시 플래그.

  • Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/lotto/hooks/usePurchases.js src/pages/lotto/components/PurchasePanel.jsx src/pages/lotto/Lotto.css
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(lotto): 구매탭에 자동 채점 일치수 배지 + 4등↑ 플래그"

Task 22 — PurchaseTrendChart (4주 추세)

Files:

  • Create: web-ui/src/pages/lotto/components/PurchaseTrendChart.jsx

  • Modify: web-ui/src/pages/lotto/tabs/PurchaseTab.jsx (마운트)

  • Step 1: PurchaseTrendChart

import { useEffect, useState } from 'react';
import { getReviewHistory } from '../../../api';

export default function PurchaseTrendChart() {
    const [reviews, setReviews] = useState([]);
    useEffect(() => {
        getReviewHistory(4).then(rs => setReviews(rs.reverse())); // asc
    }, []);

    if (reviews.length === 0) return null;

    const maxAvg = Math.max(
        ...reviews.flatMap(r => [r.curator_avg_match || 0, r.user_avg_match || 0]),
        2.5
    );
    const w = 320, h = 80, pad = 16;
    const xs = (i) => pad + (i / Math.max(reviews.length - 1, 1)) * (w - 2 * pad);
    const ys = (v) => v == null ? null : h - pad - (v / maxAvg) * (h - 2 * pad);

    const line = (key) => reviews
        .map((r, i) => ({ x: xs(i), y: ys(r[key]) }))
        .filter(p => p.y != null)
        .map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`)
        .join(' ');

    return (
        <section className="lotto-panel">
            <div className="lotto-panel__head">
                <div>
                    <p className="lotto-panel__eyebrow">Trend (last 4 weeks)</p>
                    <h3> vs 큐레이터 평균 일치 </h3>
                </div>
            </div>
            <svg width={w} height={h} className="trend-chart">
                <path d={line('curator_avg_match')} stroke="#b8a8ff" strokeWidth="2" fill="none" />
                <path d={line('user_avg_match')}    stroke="#76e09a" strokeWidth="2" fill="none" />
            </svg>
            <div className="trend-legend">
                <span><span className="dot dot--curator" /> 큐레이터</span>
                <span><span className="dot dot--user" /> </span>
            </div>
        </section>
    );
}

CSS:

.trend-chart { display: block; margin: 0 auto; }
.trend-legend { display: flex; gap: 16px; justify-content: center; font-size: 11px; opacity: 0.7; margin-top: 8px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
.dot--curator { background: #b8a8ff; }
.dot--user { background: #76e09a; }
  • Step 2: PurchaseTab 에 마운트
import PurchaseTrendChart from '../components/PurchaseTrendChart';

// PurchasePanel 위 또는 아래에
<PurchaseTrendChart />
<PurchasePanel ... />
  • Step 3: 검증

구매 탭에서 추세 차트가 보이는지 (review 데이터 1건 이상 있을 때).

  • Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/lotto/components/PurchaseTrendChart.jsx src/pages/lotto/tabs/PurchaseTab.jsx src/pages/lotto/Lotto.css
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(lotto): 구매탭 4주 추세 차트(너 vs 큐레이터 평균 일치)"

Task 23 — 운영 점검 체크리스트 (배포 후 1주차)

이건 새 코드가 아니라 수동 검증. 체크리스트 README 추가.

Files:

  • Create: web-backend/lotto/docs/operations-week1.md (워크스페이스 사용자 문서, 백엔드 쪽)

  • Step 1: 체크리스트 작성

# Lotto Curator Evolution — 1주차 운영 점검

## 일요일 (추첨 다음날)
- [ ] 03:05 KST: lotto-backend 로그에 `[grade_weekly_review] saved review id=N` 출력 확인
- [ ] `curl http://localhost:18000/api/lotto/review/latest` → JSON 정상
- [ ] purchase_history 의 직전 회차 행이 `checked=1`, `total_prize` 채워졌는지

## 월요일
- [ ] 09:05 KST: agent-office 로그에 `큐레이션 완료: #NNNN` + `[telegram_lotto] briefing` 출력
- [ ] 텔레그램 봇 채팅에 헤드라인 알림 도착 (회고 단락 포함/생략 정확)
- [ ] `curl http://localhost:18000/api/lotto/briefing/latest` → 4계층 picks(core/bonus/extended/pool 각 5세트) + tier_rationale + narrative.retrospective

## 사이트 확인
- [ ] http://localhost:3007/lotto 브리핑 탭 결정 카드 정상 렌더
- [ ] 모드 토글 4단계 동작 (5/10/15/20 펼침/접힘)
- [ ] localStorage `lotto.tier_mode` 마지막 선택 기억 (새로고침 후 유지)
- [ ] "이대로 N세트 구매" 클릭 → 토스트 + 구매탭 갱신
- [ ] 자료실 탭 첫 진입 시 모든 패널 접힘
- [ ] 구매탭 추세 차트 1주차에는 점 1개, 2주차부터 라인 형성

## 실패 케이스
- [ ] 큐레이션 실패(Anthropic API 다운): agent-office 로그 + lotto_agent state=idle, 에러 텔레그램
- [ ] 4등 이상 발견: 별도 텔레그램 푸시 도착 (3개 이하만 있으면 미발송)
- [ ] briefing 없는 회차에 bulk purchase 시도: 400 응답, 토스트 표시

## cron 시간 조정 (필요 시)
- 채점 잡: `lotto/app/main.py` `_scheduler.add_job(..., hour=3, minute=0)`
- 큐레이션: `agent-office/app/scheduler.py` `add_job(_run_lotto_schedule, ..., hour=9, minute=0)`
  • Step 2: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/docs/operations-week1.md
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "docs(lotto): 1주차 운영 점검 체크리스트"

종합 검증 — 전체 흐름 한 번 돌려보기

이 plan 의 모든 task 가 끝난 후 1회 통합 점검.

  • lotto-backend + agent-office 재기동
cd C:/Users/jaeoh/Desktop/workspace/web-backend
docker compose restart lotto-backend agent-office
  • 수동 채점 잡 실행 (직전 회차에 대해)
docker compose exec lotto-backend python -c "from app.jobs.grade_weekly_review import run_for_latest; print(run_for_latest())"

기대: {'ok': True, 'review_id': N}

  • 수동 큐레이션 트리거
curl -s -X POST http://localhost:18900/api/agent-office/command \
  -H "Content-Type: application/json" \
  -d '{"agent_id":"lotto","action":"curate_now","params":{}}'

기대: 큐레이션 완료 + 텔레그램 메시지 + briefing 저장.

  • 프론트 결정 카드 확인
cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm run dev

브라우저 → 결정 카드 + 모드 토글 + 회고 박스 + 자료실 접힘 + 구매탭 추세 모두 확인.

  • 배포
cd C:/Users/jaeoh/Desktop/workspace
scripts\deploy.bat

Self-Review 결과

스펙 vs plan 매핑 점검:

스펙 섹션 매핑되는 Task
4. 결정 카드 (브리핑 탭 메인) Task 18 (하위 컴포넌트), 19 (DecisionCard 메인 + BriefingTab)
4. 4계층 위계 + localStorage 기억 Task 18 (TierModeToggle.MODES), 19 (DecisionCard STORAGE_KEY)
4. 분석 탭 자료실 강등 Task 20
5. weekly_review 테이블 Task 1
5. lotto_briefings.picks 4계층 Task 2
6. 큐레이터 출력 스키마 Task 9
6. SYSTEM_PROMPT 회고/계층 규칙 Task 10
6. build_retrospective + service_proxy Task 11
6. 후보 풀 N=30 Task 12 (lotto_candidates(n=30))
7. 자동 채점 잡 Task 3 (보조 함수) + Task 4 (통합 잡)
7. 일치 3개 → prize 5000 기존 purchase_manager.RANK_PRIZE 활용 (Task 4)
7. 일치 4+ → webhook Task 4 (_trigger_prize_alert)
8. 텔레그램 큐레이션 알림 Task 13 (포맷) + Task 15 (lotto_agent 호출)
8. 4등 이상 별도 알림 webhook 라우터 Task 14
9. 프론트 파일 변경 맵 Task 16~22
10. 백엔드 파일 변경 맵 Task 18 (lotto), Task 915 (agent-office)
11. API 추가 — review 라우터 Task 6
11. API — purchase/bulk Task 7
11. API — briefing 4계층 수용 Task 8
11. API — agent-office notify webhook Task 14
12. 에러 처리 Task 4 (try/except + None 처리), Task 9 (스키마 검증), Task 15 (텔레그램 실패 흡수)
13. 테스트 Task 3 (단위), 4 (통합), 9 (스키마), 11 (retrospective), 13 (포맷)
14. 운영 점검 1주차 Task 23

커버되지 않은 스펙 항목: 없음.

Placeholder scan: TBD/TODO 없음. 모든 step에 코드 또는 명령어 포함. 단, Task 5 와 Task 7 은 기존 코드 위치에 따라 약간 분기되는 검증 단계가 있어서 Step 1 에 grep 명령으로 확인하는 절차가 들어있음(이건 placeholder가 아니라 조건부 분기를 위한 컨텍스트 수집).

Type consistency:

  • tier_mode 키는 core | core_bonus | core_bonus_extended | full 로 백엔드(Task 7), 프론트(Task 18, 19) 모두 동일.
  • picks 4계층 키는 core / bonus / extended / pool 로 통일 (Task 1~12 전부).
  • tier_rationale 키는 bonus / extended / pool (core 는 자명하므로 제외) — Task 8, 9, 12, 18 전부 동일.
  • 채점 함수 출력 키 avg_match / best_match / five_plus_prizes / best_tier Task 3 → Task 4 일관.
  • purchase_history.results JSON 의 correct 키 Task 21 에서 활용 — 기존 purchase_manager.py 와 동일.