diff --git a/docs/superpowers/plans/2026-05-11-lotto-curator-evolution-plan.md b/docs/superpowers/plans/2026-05-11-lotto-curator-evolution-plan.md new file mode 100644 index 0000000..826b520 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-lotto-curator-evolution-plan.md @@ -0,0 +1,2837 @@ +# 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_` 로 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` | 수정 | 모든 패널을 `
` 로 감싸 첫 진입 시 접힘 | +| `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` 인덱스 다음 줄)에 추가: + +```python + # ── 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 파일 맨 아래)** + +```python +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: 컨테이너 재시작 후 테이블 생성 확인** + +```bash +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** + +```bash +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 블록 바로 다음)** + +```python + # ── 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 저장용)** + +```python + _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: 변환 검증** + +```bash +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** + +```bash +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: 빈 패키지 파일** + +```python +# web-backend/lotto/app/jobs/__init__.py +``` + +- [ ] **Step 2: 실패 테스트 작성** + +`web-backend/lotto/tests/test_grading_helpers.py`: + +```python +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: 실패 확인** + +```bash +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`: + +```python +"""채점 보조 — 일치 수 계산, 패턴 요약, 패턴 갭.""" +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. 기대값과 일치. + +```bash +docker compose exec lotto-backend pytest tests/test_grading_helpers.py -v +``` + +기대: 3 PASS. + +- [ ] **Step 6: Commit** + +```bash +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`: + +```python +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: 실패 확인** + +```bash +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`: + +```python +"""주간 회고 채점 통합 잡 — 일요일 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: 테스트 통과 확인** + +```bash +docker compose exec lotto-backend pytest tests/test_grade_weekly_review.py -v +``` + +기대: 2 PASS. + +- [ ] **Step 5: Commit** + +```bash +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: 기존 스케줄링 패턴 확인** + +```bash +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` 상단: + +```python +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from .jobs.grade_weekly_review import run_for_latest as grade_run_for_latest +``` + +FastAPI 앱 생성 후 startup 이벤트: + +```python +_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 확인** + +```bash +grep apscheduler C:/Users/jaeoh/Desktop/workspace/web-backend/lotto/requirements.txt +``` + +없으면 추가: + +```text +apscheduler>=3.10 +``` + +그리고 컨테이너 빌드: + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-backend +docker compose build lotto-backend && docker compose up -d lotto-backend +``` + +- [ ] **Step 4: 수동 트리거 검증** + +```bash +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** + +```bash +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`: + +```python +"""주간 회고(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.py` 의 `app.include_router(...)` 들 사이에 추가: + +```python +from .routers import review as review_router +app.include_router(review_router.router) +``` + +- [ ] **Step 3: 호출 검증** + +```bash +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** + +```bash +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 라우터 위치 찾기** + +```bash +grep -rn "/api/lotto/purchase" C:/Users/jaeoh/Desktop/workspace/web-backend/lotto/app/ --include="*.py" +``` + +- [ ] **Step 2: db.py 에 bulk insert 헬퍼 추가** + +```python +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` (없으면 신규)에: + +```python +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: 검증** + +```bash +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** + +```bash +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_briefing` 은 `picks` 를 단일 JSON 으로 저장. 4계층 dict 수용하도록. + +- [ ] **Step 1: 기존 save_briefing 확인** + +`db.py` 957행 부근: + +```python +def save_briefing(data: Dict[str, Any]) -> int: + with _conn() as conn: + # 기존 INSERT 문 확인 + ... +``` + +- [ ] **Step 2: BriefingRequest 스키마 변경** + +`web-backend/lotto/app/routers/briefing.py`: + +```python +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.py` 의 `save_briefing` 함수를 다음과 같이 수정: + +```python +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_row` 도 `tier_rationale` 컬럼 파싱 추가: + +```python +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 왕복)** + +```bash +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** + +```bash +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 전면 교체** + +```python +from typing import List, Literal +from pydantic import BaseModel, Field, field_validator + + +class Pick(BaseModel): + numbers: List[int] = Field(min_length=6, max_length=6) + risk_tag: Literal["안정", "균형", "공격"] + reason: str = Field(max_length=80) + + @field_validator("numbers") + @classmethod + def _check_numbers(cls, v): + if len(set(v)) != 6: + raise ValueError("numbers must be 6 unique integers") + if any(n < 1 or n > 45 for n in v): + raise ValueError("numbers must be within 1..45") + return sorted(v) + + +class 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` (없으면 신규): + +```python +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: 테스트 실행** + +```bash +docker compose exec agent-office pytest tests/test_curator_schema.py -v +``` + +기대: 3 PASS. + +- [ ] **Step 4: Commit** + +```bash +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 전면 교체** + +```python +"""큐레이터 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** + +```bash +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 섹션 아래: + +```python +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 작성** + +```python +"""큐레이션 직전 호출 — 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`: + +```python +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: 테스트 실행** + +```bash +docker compose exec agent-office pytest tests/test_retrospective.py -v +``` + +기대: 4 PASS. (`pytest-asyncio` 설치 필요 — 없으면 requirements 에 추가) + +- [ ] **Step 5: Commit** + +```bash +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` 함수 교체** + +```python +"""큐레이터 파이프라인 — fetch → claude → validate → save.""" +import json +import time +from typing import Any, Dict + +import httpx + +from ..config import ANTHROPIC_API_KEY, LOTTO_CURATOR_MODEL +from .. import service_proxy +from .prompt import SYSTEM_PROMPT, build_user_message +from .schema import validate_response +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: 검증 (수동 트리거)** + +```bash +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** + +```bash +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: 빈 패키지** + +```python +# web-backend/agent-office/app/notifiers/__init__.py +``` + +- [ ] **Step 2: 기존 텔레그램 인프라 확인** + +```bash +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`: + +```python +"""로또 큐레이션·당첨 알림 — 텔레그램 푸시.""" +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`: + +```python +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 +``` + +```bash +docker compose exec agent-office pytest tests/test_telegram_lotto_format.py -v +``` + +기대: 3 PASS. + +- [ ] **Step 5: Commit** + +```bash +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`: + +```python +"""다른 서비스가 트리거하는 웹훅 — 현재 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 라우터 등록** + +```python +from .routers import notify as notify_router +app.include_router(notify_router.router) +``` + +- [ ] **Step 3: 검증** + +```bash +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** + +```bash +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` 메서드 수정: + +```python +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** + +```python +# 기존 +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 또는 직접 호출: + +```bash +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** + +```bash +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: 헬퍼 추가 (파일 하단)** + +```javascript +// === 주간 회고 (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** + +```bash +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** + +```javascript +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계층) 또는 리스트(구 데이터) 둘 다 들어올 수 있으므로 정규화: + +```javascript +// 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** + +```bash +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** + +```jsx +const ROLE_COLOR = { '안정': 'stable', '균형': 'balance', '공격': 'aggro' }; + +export default function PickCard({ pick, index, total }) { + const role = pick.risk_tag; + return ( +
+
+ ● {role} + Set {index + 1} / {total} +
+
+ {pick.numbers.map(n => ( + {n} + ))} +
+

{pick.reason}

+
+ ); +} +``` + +- [ ] **Step 2: TierSection.jsx** + +```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 ( +
+
+

{TIER_TITLE[tier]}

+ {rationale && tier !== 'core' && ( +

{rationale}

+ )} +
+ {picks.map((p, i) => ( + + ))} +
+ ); +} +``` + +- [ ] **Step 3: TierModeToggle.jsx** + +```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 ( +
+ {MODES.map((m, i) => ( + + ))} +
+ ); +} + +export { MODES }; +``` + +- [ ] **Step 4: RetrospectiveBox.jsx** + +```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 ( + + ); +} +``` + +- [ ] **Step 5: decision.css — 결정 카드 스타일** + +```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** + +```bash +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** + +```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 ( + + ); +} +``` + +- [ ] **Step 2: DecisionCard.jsx** + +```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 ( +
+
+
+

Curator Briefing · {briefing.draw_no}회

+

{briefing.narrative.headline}

+
+
+
{briefing.confidence}
+
CONFIDENCE
+
+
+ + + +

+ {(briefing.narrative.summary_3lines || []).join(' · ')} +

+ +
+
+ {balance['안정'] > 0 && 안정 ×{balance['안정']}} + {balance['균형'] > 0 && 균형 ×{balance['균형']}} + {balance['공격'] > 0 && 공격 ×{balance['공격']}} +
+
+ + + + {visibleTiers.map(tier => { + const picks = briefing.picks?.[tier] || []; + const idxBase = cursor; + cursor += picks.length; + return ( + + ); + })} + +
+ +
+
+ ); +} +``` + +- [ ] **Step 3: BriefingTab.jsx — DecisionCard 단일 화면** + +```jsx +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

로딩 중...

; + if (!briefing) return ; + + return ( +
+ +
+ ); +} +``` + +- [ ] **Step 4: 검증 (브라우저)** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-ui +npm run dev +``` + +브라우저에서 http://localhost:3007/lotto 열기. 브리핑 탭이 결정 카드로 보이는지 + 모드 토글 동작 + 새로고침 후 마지막 모드 기억하는지 확인. + +- [ ] **Step 5: Commit** + +```bash +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`: + +```jsx +const TABS = [ + { id: 'briefing', label: '🗓 이번 주 브리핑' }, + { id: 'analysis', label: '📚 자료실 / Deep Dive' }, // ← 변경 + { id: 'purchase', label: '💰 구매·성과' }, +]; +``` + +- [ ] **Step 2: AnalysisTab — 모든 패널을 `
` 로 감싸 첫 진입 시 접힘** + +`AnalysisTab.jsx` 의 각 `
` 또는 `CombinedRecommendPanel`, `ReportPanel`, `PersonalAnalysisPanel` 등을 다음 패턴으로 감싸기: + +```jsx +
+ 섹션 제목 (펼치기) + {/* 기존 내용 */} +
+``` + +대표 예 — Latest Draw 섹션: + +```jsx +
+ 최신 회차 +
+ {/* 기존 panel head + body */} +
+
+``` + +같은 패턴을 시뮬레이션 추천, 통계 분석, 전체 회차 번호 분포, 내 번호 패턴, 수동 추천, 추천 히스토리에 모두 적용. + +기존 `PerformanceBanner`(``)는 *그대로 노출*(자료실의 단축 신뢰도 칭찬). DecisionCard 헤더의 신뢰도와 의미가 다르므로 중복 아님. + +- [ ] **Step 3: CSS 한 줄 추가 (Lotto.css)** + +```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** + +```bash +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 추가** + +```javascript +// 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` 안에 추가: + +```jsx +{/* 기존 컬럼들 */} + + {(rec.results || []).map((r, i) => ( + {r.correct} + ))} + {(rec.results || []).some(r => r.correct >= 4) && ( + 🚨 4등↑ 확인 필요 + )} + +``` + +CSS: + +```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** + +```bash +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** + +```jsx +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 ( +
+
+
+

Trend (last 4 weeks)

+

너 vs 큐레이터 평균 일치 수

+
+
+ + + + +
+ 큐레이터 + +
+
+ ); +} +``` + +CSS: + +```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 에 마운트** + +```jsx +import PurchaseTrendChart from '../components/PurchaseTrendChart'; + +// PurchasePanel 위 또는 아래에 + + +``` + +- [ ] **Step 3: 검증** + +구매 탭에서 추세 차트가 보이는지 (review 데이터 1건 이상 있을 때). + +- [ ] **Step 4: Commit** + +```bash +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: 체크리스트 작성** + +```markdown +# 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** + +```bash +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 재기동** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-backend +docker compose restart lotto-backend agent-office +``` + +- [ ] **수동 채점 잡 실행 (직전 회차에 대해)** + +```bash +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}` + +- [ ] **수동 큐레이션 트리거** + +```bash +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 저장. + +- [ ] **프론트 결정 카드 확인** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-ui +npm run dev +``` + +브라우저 → 결정 카드 + 모드 토글 + 회고 박스 + 자료실 접힘 + 구매탭 추세 모두 확인. + +- [ ] **배포** + +```bash +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 1~8 (lotto), Task 9~15 (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` 와 동일.