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 상단에 명시
2838 lines
102 KiB
Markdown
2838 lines
102 KiB
Markdown
# 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` 와 동일.
|