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

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

2838 lines
102 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Lotto Curator Evolution Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 매주 큐레이터가 한 번 더 똑똑해지도록 — 자기·사용자 회고 + 4계층 위계(코어/보너스/확장/풀, 5~20세트) + 결정 카드 단일 화면을 만든다.
**Architecture:** lotto-backend(SQLite + FastAPI)에 `weekly_review` 테이블과 채점 잡을 추가하고, agent-office의 Claude 큐레이터에 회고 컨텍스트와 4계층 출력 스키마를 더하며, 텔레그램으로 헤드라인을 푸시한다. 프론트는 BriefingTab을 단일 DecisionCard 화면으로 재구성하고 분석탭은 자료실로 강등한다.
**Tech Stack:** Python (FastAPI · SQLite · APScheduler · httpx · pydantic), React 18 (Vite · react-router-dom), Anthropic Claude API (prompt caching), Telegram Bot API.
---
## 스펙 → 코드베이스 보정 사항
스펙 작성 시점 가정과 실제 코드가 다른 부분. 이 plan은 실제 코드 기준으로 작성됨.
| 스펙 가정 | 실제 코드 | 보정 |
|---|---|---|
| 테이블명 `briefings` | `lotto_briefings` | 모든 SQL/쿼리에 `lotto_briefings` 사용 |
| 테이블명 `lotto_purchase` | `purchase_history` | 모든 SQL/쿼리에 `purchase_history` 사용 |
| 신규 컬럼 `numbers` 추가 필요 | 이미 존재 (`numbers TEXT NOT NULL DEFAULT '[]'`) | 추가 안 함 |
| 신규 컬럼 `match_count` / `auto_graded` | 이미 존재 (`results TEXT` JSON에 `correct`, `checked INTEGER`) | 추가 안 함 — 기존 활용 |
| 신규 컬럼 `curator_tier` / `curator_role` | 기존 `source_strategy` + `source_detail` JSON 활용 | source_strategy=`curator_core`/`curator_bonus`/`curator_extended`/`curator_pool`, source_detail JSON에 `{"tier", "role", "set_index"}` |
| 별도 `app/db/migrations/*.sql` 디렉토리 | 기존 패턴은 `app/db.py` `init_db()``_ensure_column` | 마이그레이션 SQL 파일 만들지 말고 init_db에 추가 |
| 자동 채점 잡 신규 작성 | `purchase_manager.check_purchases_for_draw(drw_no)` 이미 있음 (RANK_PRIZE 자동) | 신규 잡은 *기존 함수 호출* + 큐레이터 자기 채점 + weekly_review INSERT만 추가 |
| 큐레이터 cron 월 09:00 신규 등록 | `scheduler.py` 에 이미 `day_of_week="mon", hour=7, minute=0` 등록됨 | 시간만 09:00으로 *수정* |
---
## File Structure (실제 코드 기준)
### 백엔드 — `web-backend/lotto/`
| 파일 | 종류 | 책임 |
|---|---|---|
| `app/db.py` | 수정 | `init_db()` 안에 `weekly_review` 테이블 + `lotto_briefings.picks` 4계층 마이그레이션. 신규 헬퍼 함수: `save_review`, `get_review`, `get_reviews_range`, `get_latest_review`, `list_reviews`, `bulk_insert_purchases_from_briefing` |
| `app/curator_helpers.py` | 수정 | `collect_candidates(n=30)` 기본값 변경, `build_context()``retrospective` 키 합치기 (`build_retrospective` 호출) |
| `app/retrospective.py` | 신규 | `build_retrospective(target_draw_no)` — review 1건 + 추세 3건 → 큐레이터 컨텍스트 dict |
| `app/jobs/__init__.py` | 신규 | 빈 패키지 |
| `app/jobs/grade_weekly_review.py` | 신규 | 통합 채점 잡 — 기존 `check_purchases_for_draw` 호출 + 큐레이터 picks vs 추첨 비교 + 패턴 갭 계산 + weekly_review UPSERT + 4등 이상 발견 시 webhook 호출 |
| `app/jobs/grading_helpers.py` | 신규 | 단위 함수: `score_picks_against_draw`, `summarize_pattern`, `compute_pattern_delta` |
| `app/routers/briefing.py` | 수정 | `BriefingRequest` 스키마에 4계층 picks 필드 + `narrative.retrospective` + `tier_rationale` 수용 |
| `app/routers/review.py` | 신규 | `GET /api/lotto/review/latest`, `GET /api/lotto/review/history?limit=N` |
| `app/routers/purchase.py` | 수정 | `POST /api/lotto/purchase/bulk` — body `{draw_no, tier_mode, sets, amount}` → briefings 로드 후 source_strategy=`curator_<tier>` 로 INSERT |
| `app/main.py` | 수정 | review 라우터 등록, 채점 잡 cron 등록 (일 03:00) |
### agent-office — `web-backend/agent-office/`
| 파일 | 종류 | 책임 |
|---|---|---|
| `app/curator/schema.py` | 수정 | `CuratorOutput` 4계층 picks + `tier_rationale` + `narrative.retrospective` 필드 |
| `app/curator/prompt.py` | 수정 | SYSTEM_PROMPT 회고·계층 규칙 추가, build_user_message 에 retrospective 포함 |
| `app/curator/pipeline.py` | 수정 | `curate_weekly()` 에서 `lotto_candidates(n=30)` 호출, 4계층 직렬화하여 save_briefing |
| `app/agents/lotto.py` | 수정 | 큐레이션 성공 후 텔레그램 알림 호출 |
| `app/notifiers/__init__.py` | 신규 | 빈 패키지 |
| `app/notifiers/telegram_lotto.py` | 신규 | `send_curator_briefing(briefing)` + `send_prize_alert(prize_event)` |
| `app/routers/__init__.py` | 신규 (없으면) | 빈 패키지 |
| `app/routers/notify.py` | 신규 | `POST /api/agent-office/notify/lotto-prize` |
| `app/service_proxy.py` | 수정 | `lotto_review_latest()`, `lotto_reviews_range(start, end)` 헬퍼 추가 |
| `app/scheduler.py` | 수정 | `lotto_curate` cron 시간 07:00 → 09:00 |
| `app/main.py` | 수정 | notify 라우터 등록 |
### 프론트엔드 — `web-ui/src/`
| 파일 | 종류 | 책임 |
|---|---|---|
| `pages/lotto/Functions.jsx` | 수정 | 분석탭 라벨 변경 |
| `pages/lotto/tabs/BriefingTab.jsx` | 수정 | DecisionCard 단일로 재구성 |
| `pages/lotto/tabs/AnalysisTab.jsx` | 수정 | 모든 패널을 `<details>` 로 감싸 첫 진입 시 접힘 |
| `pages/lotto/components/decision/DecisionCard.jsx` | 신규 | 결정 카드 메인 |
| `pages/lotto/components/decision/RetrospectiveBox.jsx` | 신규 | 회고 박스 |
| `pages/lotto/components/decision/TierModeToggle.jsx` | 신규 | 4단계 칩 토글 |
| `pages/lotto/components/decision/TierSection.jsx` | 신규 | 한 계층(타이틀+사유+5장) |
| `pages/lotto/components/decision/PickCard.jsx` | 신규 | 한 세트 카드 |
| `pages/lotto/components/decision/BulkPurchaseButton.jsx` | 신규 | 원클릭 구매 |
| `pages/lotto/components/decision/decision.css` | 신규 | 결정 카드 스타일 |
| `pages/lotto/hooks/useBriefing.js` | 수정 | 4계층 + retrospective 수용 |
| `pages/lotto/hooks/useReview.js` | 신규 | weekly_review 로드 |
| `pages/lotto/hooks/usePurchases.js` | 수정 | `bulkPurchase()` 추가 |
| `pages/lotto/components/PurchasePanel.jsx` | 수정 | results JSON 표시(자동 채점) + 4등 이상 플래그 |
| `pages/lotto/components/PurchaseTrendChart.jsx` | 신규 | 4주 추세(너 vs 큐레이터) |
| `api.js` | 수정 | `getLatestReview`, `getReviewHistory`, `bulkPurchase` |
---
## 작업 흐름
각 Task 는 **Files** 블록 → 단계별 체크박스 → 마지막에 commit. TDD 사이클이 의미있는 곳에서만 사용(SQL 마이그레이션·UI 등 단순 변경은 검증으로 대체). 모든 명령은 PowerShell 또는 Bash 어느 쪽이든 표시된 그대로 동작.
---
## Task 1 — `weekly_review` 테이블 + 헬퍼
**Files:**
- Modify: `web-backend/lotto/app/db.py` (init_db 안에 추가, 파일 하단에 헬퍼 함수)
- [ ] **Step 1: `init_db()` 안 `lotto_briefings` 블록 직후에 `weekly_review` 테이블 생성 추가**
`web-backend/lotto/app/db.py` `init_db()` 끝부분(`idx_briefings_draw` 인덱스 다음 줄)에 추가:
```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 (
<div className="lc-set">
<div className="lc-set__head">
<span className={`lc-set__role lc-set__role--${ROLE_COLOR[role]}`}> {role}</span>
<span className="lc-set__idx">Set {index + 1} / {total}</span>
</div>
<div className="lc-balls">
{pick.numbers.map(n => (
<span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
))}
</div>
<p className="lc-set__reason">{pick.reason}</p>
</div>
);
}
```
- [ ] **Step 2: TierSection.jsx**
```jsx
import PickCard from './PickCard';
const TIER_TITLE = {
core: '코어 (필수, 5세트)',
bonus: '보너스 (+5)',
extended: '확장 (+5)',
pool: '풀 (+5)',
};
export default function TierSection({ tier, picks, rationale, indexBase = 0, totalSets }) {
if (!picks?.length) return null;
return (
<section className={`lc-tier lc-tier--${tier}`}>
<header className="lc-tier__head">
<h4>{TIER_TITLE[tier]}</h4>
{rationale && tier !== 'core' && (
<p className="lc-tier__rationale">{rationale}</p>
)}
</header>
{picks.map((p, i) => (
<PickCard key={i} pick={p} index={indexBase + i} total={totalSets} />
))}
</section>
);
}
```
- [ ] **Step 3: TierModeToggle.jsx**
```jsx
const MODES = [
{ key: 'core', label: '코어', sets: 5, amount: 5000 },
{ key: 'core_bonus', label: '+ 보너스', sets: 10, amount: 10000 },
{ key: 'core_bonus_extended', label: '+ 확장', sets: 15, amount: 15000 },
{ key: 'full', label: '+ 풀', sets: 20, amount: 20000 },
];
export default function TierModeToggle({ value, onChange }) {
return (
<div className="lc-toggle" role="tablist">
{MODES.map((m, i) => (
<button
key={m.key}
role="tab"
aria-selected={value === m.key}
className={`lc-toggle__chip ${value === m.key ? 'is-active' : ''}`}
onClick={() => onChange(m.key)}
>
<span className="lc-toggle__dots">{'●'.repeat(i + 1) + '○'.repeat(3 - i)}</span>
<span className="lc-toggle__lbl">{m.label}</span>
<span className="lc-toggle__sub">{m.sets}세트 · {m.amount.toLocaleString()}</span>
</button>
))}
</div>
);
}
export { MODES };
```
- [ ] **Step 4: RetrospectiveBox.jsx**
```jsx
export default function RetrospectiveBox({ briefing, review }) {
const retro = briefing?.narrative?.retrospective;
if (!retro) return null;
const drawNo = review?.draw_no ?? (briefing?.draw_no ? briefing.draw_no - 1 : null);
return (
<aside className="lc-retro">
<p className="lc-retro__time"> 지난 {drawNo ? `${drawNo}` : ''} 회고</p>
<p className="lc-retro__body">{retro}</p>
</aside>
);
}
```
- [ ] **Step 5: decision.css — 결정 카드 스타일**
```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 (
<button className="lc-btn lc-btn--prim" onClick={onClick} disabled={busy || !drawNo}>
{busy ? '저장 중...' : `이대로 ${mode.sets}세트 구매했음`}
</button>
);
}
```
- [ ] **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 (
<div className="lc-card">
<header className="lc-head">
<div>
<p className="lc-eyebrow">Curator Briefing · {briefing.draw_no}</p>
<h3 className="lc-title">{briefing.narrative.headline}</h3>
</div>
<div className="lc-conf">
<div className="lc-conf__num">{briefing.confidence}</div>
<div className="lc-conf__lbl">CONFIDENCE</div>
</div>
</header>
<RetrospectiveBox briefing={briefing} review={review} />
<p className="lc-headline-3">
{(briefing.narrative.summary_3lines || []).join(' · ')}
</p>
<div className="lc-balance">
<div className="lc-balance__chips">
{balance['안정'] > 0 && <span className="lc-chip lc-chip--stable">안정 ×{balance['안정']}</span>}
{balance['균형'] > 0 && <span className="lc-chip lc-chip--balance">균형 ×{balance['균형']}</span>}
{balance['공격'] > 0 && <span className="lc-chip lc-chip--aggro">공격 ×{balance['공격']}</span>}
</div>
</div>
<TierModeToggle value={tierMode} onChange={setTierMode} />
{visibleTiers.map(tier => {
const picks = briefing.picks?.[tier] || [];
const idxBase = cursor;
cursor += picks.length;
return (
<TierSection
key={tier}
tier={tier}
picks={picks}
rationale={briefing.tier_rationale?.[tier]}
indexBase={idxBase}
totalSets={totalSets}
/>
);
})}
<div className="lc-actions">
<BulkPurchaseButton
drawNo={briefing.draw_no}
tierMode={tierMode}
onSuccess={onPurchaseSuccess}
/>
</div>
</div>
);
}
```
- [ ] **Step 3: BriefingTab.jsx — DecisionCard 단일 화면**
```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 <div className="briefing-empty"><p>로딩 ...</p></div>;
if (!briefing) return <BriefingEmpty regenerating={regenerating} onRegenerate={regenerate} error={error} />;
return (
<div className="briefing-tab">
<DecisionCard briefing={briefing} review={review} />
</div>
);
}
```
- [ ] **Step 4: 검증 (브라우저)**
```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 — 모든 패널을 `<details>` 로 감싸 첫 진입 시 접힘**
`AnalysisTab.jsx` 의 각 `<section className="lotto-panel">` 또는 `CombinedRecommendPanel`, `ReportPanel`, `PersonalAnalysisPanel` 등을 다음 패턴으로 감싸기:
```jsx
<details className="lotto-section-fold">
<summary>섹션 제목 (펼치기)</summary>
{/* 기존 내용 */}
</details>
```
대표 예 — Latest Draw 섹션:
```jsx
<details className="lotto-section-fold">
<summary>최신 회차</summary>
<section className="lotto-panel">
{/* 기존 panel head + body */}
</section>
</details>
```
같은 패턴을 시뮬레이션 추천, 통계 분석, 전체 회차 번호 분포, 내 번호 패턴, 수동 추천, 추천 히스토리에 모두 적용.
기존 `PerformanceBanner`(`<PerformanceBanner perf={ld.perfStats} />`)는 *그대로 노출*(자료실의 단축 신뢰도 칭찬). DecisionCard 헤더의 신뢰도와 의미가 다르므로 중복 아님.
- [ ] **Step 3: CSS 한 줄 추가 (Lotto.css)**
```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
{/* 기존 컬럼들 */}
<span className="lotto-purchase-row__hits">
{(rec.results || []).map((r, i) => (
<span key={i} className={`hit-badge hit-${r.correct}`}>{r.correct}</span>
))}
{(rec.results || []).some(r => r.correct >= 4) && (
<span className="prize-flag">🚨 4 확인 필요</span>
)}
</span>
```
CSS:
```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 (
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Trend (last 4 weeks)</p>
<h3> vs 큐레이터 평균 일치 </h3>
</div>
</div>
<svg width={w} height={h} className="trend-chart">
<path d={line('curator_avg_match')} stroke="#b8a8ff" strokeWidth="2" fill="none" />
<path d={line('user_avg_match')} stroke="#76e09a" strokeWidth="2" fill="none" />
</svg>
<div className="trend-legend">
<span><span className="dot dot--curator" /> 큐레이터</span>
<span><span className="dot dot--user" /> </span>
</div>
</section>
);
}
```
CSS:
```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 위 또는 아래에
<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` 와 동일.