# 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` 와 동일.