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 상단에 명시
102 KiB
Lotto Curator Evolution Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 매주 큐레이터가 한 번 더 똑똑해지도록 — 자기·사용자 회고 + 4계층 위계(코어/보너스/확장/풀, 5~20세트) + 결정 카드 단일 화면을 만든다.
Architecture: lotto-backend(SQLite + FastAPI)에 weekly_review 테이블과 채점 잡을 추가하고, agent-office의 Claude 큐레이터에 회고 컨텍스트와 4계층 출력 스키마를 더하며, 텔레그램으로 헤드라인을 푸시한다. 프론트는 BriefingTab을 단일 DecisionCard 화면으로 재구성하고 분석탭은 자료실로 강등한다.
Tech Stack: Python (FastAPI · SQLite · APScheduler · httpx · pydantic), React 18 (Vite · react-router-dom), Anthropic Claude API (prompt caching), Telegram Bot API.
스펙 → 코드베이스 보정 사항
스펙 작성 시점 가정과 실제 코드가 다른 부분. 이 plan은 실제 코드 기준으로 작성됨.
| 스펙 가정 | 실제 코드 | 보정 |
|---|---|---|
테이블명 briefings |
lotto_briefings |
모든 SQL/쿼리에 lotto_briefings 사용 |
테이블명 lotto_purchase |
purchase_history |
모든 SQL/쿼리에 purchase_history 사용 |
신규 컬럼 numbers 추가 필요 |
이미 존재 (numbers TEXT NOT NULL DEFAULT '[]') |
추가 안 함 |
신규 컬럼 match_count / auto_graded |
이미 존재 (results TEXT JSON에 correct, checked INTEGER) |
추가 안 함 — 기존 활용 |
신규 컬럼 curator_tier / curator_role |
기존 source_strategy + source_detail JSON 활용 |
source_strategy=curator_core/curator_bonus/curator_extended/curator_pool, source_detail JSON에 {"tier", "role", "set_index"} |
별도 app/db/migrations/*.sql 디렉토리 |
기존 패턴은 app/db.py init_db() 안 _ensure_column |
마이그레이션 SQL 파일 만들지 말고 init_db에 추가 |
| 자동 채점 잡 신규 작성 | purchase_manager.check_purchases_for_draw(drw_no) 이미 있음 (RANK_PRIZE 자동) |
신규 잡은 기존 함수 호출 + 큐레이터 자기 채점 + weekly_review INSERT만 추가 |
| 큐레이터 cron 월 09:00 신규 등록 | scheduler.py 에 이미 day_of_week="mon", hour=7, minute=0 등록됨 |
시간만 09:00으로 수정 |
File Structure (실제 코드 기준)
백엔드 — web-backend/lotto/
| 파일 | 종류 | 책임 |
|---|---|---|
app/db.py |
수정 | init_db() 안에 weekly_review 테이블 + lotto_briefings.picks 4계층 마이그레이션. 신규 헬퍼 함수: save_review, get_review, get_reviews_range, get_latest_review, list_reviews, bulk_insert_purchases_from_briefing |
app/curator_helpers.py |
수정 | collect_candidates(n=30) 기본값 변경, build_context() 에 retrospective 키 합치기 (build_retrospective 호출) |
app/retrospective.py |
신규 | build_retrospective(target_draw_no) — review 1건 + 추세 3건 → 큐레이터 컨텍스트 dict |
app/jobs/__init__.py |
신규 | 빈 패키지 |
app/jobs/grade_weekly_review.py |
신규 | 통합 채점 잡 — 기존 check_purchases_for_draw 호출 + 큐레이터 picks vs 추첨 비교 + 패턴 갭 계산 + weekly_review UPSERT + 4등 이상 발견 시 webhook 호출 |
app/jobs/grading_helpers.py |
신규 | 단위 함수: score_picks_against_draw, summarize_pattern, compute_pattern_delta |
app/routers/briefing.py |
수정 | BriefingRequest 스키마에 4계층 picks 필드 + narrative.retrospective + tier_rationale 수용 |
app/routers/review.py |
신규 | GET /api/lotto/review/latest, GET /api/lotto/review/history?limit=N |
app/routers/purchase.py |
수정 | POST /api/lotto/purchase/bulk — body {draw_no, tier_mode, sets, amount} → briefings 로드 후 source_strategy=curator_<tier> 로 INSERT |
app/main.py |
수정 | review 라우터 등록, 채점 잡 cron 등록 (일 03:00) |
agent-office — web-backend/agent-office/
| 파일 | 종류 | 책임 |
|---|---|---|
app/curator/schema.py |
수정 | CuratorOutput 4계층 picks + tier_rationale + narrative.retrospective 필드 |
app/curator/prompt.py |
수정 | SYSTEM_PROMPT 회고·계층 규칙 추가, build_user_message 에 retrospective 포함 |
app/curator/pipeline.py |
수정 | curate_weekly() 에서 lotto_candidates(n=30) 호출, 4계층 직렬화하여 save_briefing |
app/agents/lotto.py |
수정 | 큐레이션 성공 후 텔레그램 알림 호출 |
app/notifiers/__init__.py |
신규 | 빈 패키지 |
app/notifiers/telegram_lotto.py |
신규 | send_curator_briefing(briefing) + send_prize_alert(prize_event) |
app/routers/__init__.py |
신규 (없으면) | 빈 패키지 |
app/routers/notify.py |
신규 | POST /api/agent-office/notify/lotto-prize |
app/service_proxy.py |
수정 | lotto_review_latest(), lotto_reviews_range(start, end) 헬퍼 추가 |
app/scheduler.py |
수정 | lotto_curate cron 시간 07:00 → 09:00 |
app/main.py |
수정 | notify 라우터 등록 |
프론트엔드 — web-ui/src/
| 파일 | 종류 | 책임 |
|---|---|---|
pages/lotto/Functions.jsx |
수정 | 분석탭 라벨 변경 |
pages/lotto/tabs/BriefingTab.jsx |
수정 | DecisionCard 단일로 재구성 |
pages/lotto/tabs/AnalysisTab.jsx |
수정 | 모든 패널을 <details> 로 감싸 첫 진입 시 접힘 |
pages/lotto/components/decision/DecisionCard.jsx |
신규 | 결정 카드 메인 |
pages/lotto/components/decision/RetrospectiveBox.jsx |
신규 | 회고 박스 |
pages/lotto/components/decision/TierModeToggle.jsx |
신규 | 4단계 칩 토글 |
pages/lotto/components/decision/TierSection.jsx |
신규 | 한 계층(타이틀+사유+5장) |
pages/lotto/components/decision/PickCard.jsx |
신규 | 한 세트 카드 |
pages/lotto/components/decision/BulkPurchaseButton.jsx |
신규 | 원클릭 구매 |
pages/lotto/components/decision/decision.css |
신규 | 결정 카드 스타일 |
pages/lotto/hooks/useBriefing.js |
수정 | 4계층 + retrospective 수용 |
pages/lotto/hooks/useReview.js |
신규 | weekly_review 로드 |
pages/lotto/hooks/usePurchases.js |
수정 | bulkPurchase() 추가 |
pages/lotto/components/PurchasePanel.jsx |
수정 | results JSON 표시(자동 채점) + 4등 이상 플래그 |
pages/lotto/components/PurchaseTrendChart.jsx |
신규 | 4주 추세(너 vs 큐레이터) |
api.js |
수정 | getLatestReview, getReviewHistory, bulkPurchase |
작업 흐름
각 Task 는 Files 블록 → 단계별 체크박스 → 마지막에 commit. TDD 사이클이 의미있는 곳에서만 사용(SQL 마이그레이션·UI 등 단순 변경은 검증으로 대체). 모든 명령은 PowerShell 또는 Bash 어느 쪽이든 표시된 그대로 동작.
Task 1 — weekly_review 테이블 + 헬퍼
Files:
-
Modify:
web-backend/lotto/app/db.py(init_db 안에 추가, 파일 하단에 헬퍼 함수) -
Step 1:
init_db()안lotto_briefings블록 직후에weekly_review테이블 생성 추가
web-backend/lotto/app/db.py init_db() 끝부분(idx_briefings_draw 인덱스 다음 줄)에 추가:
# ── weekly_review 테이블 (큐레이터 자기 평가 + 사용자 패턴 갭) ────────
conn.execute("""
CREATE TABLE IF NOT EXISTS weekly_review (
id INTEGER PRIMARY KEY AUTOINCREMENT,
draw_no INTEGER UNIQUE NOT NULL,
curator_avg_match REAL,
curator_best_tier TEXT,
curator_best_match INTEGER,
curator_5plus_prizes INTEGER,
user_avg_match REAL,
user_best_match INTEGER,
user_5plus_prizes INTEGER,
user_pattern_summary TEXT,
draw_pattern_summary TEXT,
pattern_delta TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_review_draw ON weekly_review(draw_no DESC)")
- Step 2: 헬퍼 함수 추가 (db.py 파일 맨 아래)
def save_review(data: Dict[str, Any]) -> int:
with _conn() as conn:
cur = conn.execute(
"""
INSERT INTO weekly_review (
draw_no,
curator_avg_match, curator_best_tier, curator_best_match, curator_5plus_prizes,
user_avg_match, user_best_match, user_5plus_prizes,
user_pattern_summary, draw_pattern_summary, pattern_delta
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(draw_no) DO UPDATE SET
curator_avg_match=excluded.curator_avg_match,
curator_best_tier=excluded.curator_best_tier,
curator_best_match=excluded.curator_best_match,
curator_5plus_prizes=excluded.curator_5plus_prizes,
user_avg_match=excluded.user_avg_match,
user_best_match=excluded.user_best_match,
user_5plus_prizes=excluded.user_5plus_prizes,
user_pattern_summary=excluded.user_pattern_summary,
draw_pattern_summary=excluded.draw_pattern_summary,
pattern_delta=excluded.pattern_delta
""",
(
data["draw_no"],
data.get("curator_avg_match"), data.get("curator_best_tier"),
data.get("curator_best_match"), data.get("curator_5plus_prizes"),
data.get("user_avg_match"), data.get("user_best_match"),
data.get("user_5plus_prizes"),
data.get("user_pattern_summary"), data.get("draw_pattern_summary"),
data.get("pattern_delta"),
),
)
return cur.lastrowid
def _review_row(r) -> Optional[Dict[str, Any]]:
if not r:
return None
return {
"id": r["id"],
"draw_no": r["draw_no"],
"curator_avg_match": r["curator_avg_match"],
"curator_best_tier": r["curator_best_tier"],
"curator_best_match": r["curator_best_match"],
"curator_5plus_prizes": r["curator_5plus_prizes"],
"user_avg_match": r["user_avg_match"],
"user_best_match": r["user_best_match"],
"user_5plus_prizes": r["user_5plus_prizes"],
"user_pattern_summary": r["user_pattern_summary"],
"draw_pattern_summary": r["draw_pattern_summary"],
"pattern_delta": r["pattern_delta"],
"created_at": r["created_at"],
}
def get_review(draw_no: int) -> Optional[Dict[str, Any]]:
with _conn() as conn:
r = conn.execute("SELECT * FROM weekly_review WHERE draw_no=?", (draw_no,)).fetchone()
return _review_row(r)
def get_latest_review() -> Optional[Dict[str, Any]]:
with _conn() as conn:
r = conn.execute("SELECT * FROM weekly_review ORDER BY draw_no DESC LIMIT 1").fetchone()
return _review_row(r)
def get_reviews_range(start_drw: int, end_drw: int) -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM weekly_review WHERE draw_no BETWEEN ? AND ? ORDER BY draw_no ASC",
(start_drw, end_drw),
).fetchall()
return [_review_row(r) for r in rows]
def list_reviews(limit: int = 10) -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM weekly_review ORDER BY draw_no DESC LIMIT ?",
(limit,),
).fetchall()
return [_review_row(r) for r in rows]
- Step 3: 컨테이너 재시작 후 테이블 생성 확인
cd C:/Users/jaeoh/Desktop/workspace/web-backend
docker compose restart lotto-backend
docker compose exec lotto-backend python -c "import sqlite3; c=sqlite3.connect('/app/data/lotto.db'); print(c.execute('PRAGMA table_info(weekly_review)').fetchall())"
기대: 13개 컬럼 (id, draw_no, curator_avg_match, ..., created_at) 출력.
- Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/db.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): weekly_review 테이블 + CRUD 헬퍼"
Task 2 — lotto_briefings.picks 4계층 마이그레이션
Files:
- Modify:
web-backend/lotto/app/db.py(init_db 안에 1회 변환 로직 추가)
기존 picks 컬럼은 JSON 리스트(예: [{numbers,risk_tag,reason}, ...]). 4계층 객체로 변환: {"core": [...], "bonus": [], "extended": [], "pool": []}.
- Step 1: 변환 헬퍼 추가 (db.py
init_db()의 weekly_review 블록 바로 다음)
# ── lotto_briefings.picks 4계층 마이그레이션 (1회 변환) ───────────────
# 기존: picks가 JSON 리스트 [{numbers,risk_tag,reason}]
# 신규: picks가 JSON 객체 {core:[...], bonus:[], extended:[], pool:[]}
rows = conn.execute("SELECT id, picks FROM lotto_briefings").fetchall()
for r in rows:
try:
p = json.loads(r["picks"])
if isinstance(p, list):
new_picks = {"core": p, "bonus": [], "extended": [], "pool": []}
conn.execute(
"UPDATE lotto_briefings SET picks=? WHERE id=?",
(json.dumps(new_picks, ensure_ascii=False), r["id"]),
)
except (json.JSONDecodeError, TypeError):
continue
- Step 2: 추가로
tier_rationale컬럼 신설 (선택적, JSON 저장용)
_ensure_column(conn, "lotto_briefings", "tier_rationale",
"ALTER TABLE lotto_briefings ADD COLUMN tier_rationale TEXT NOT NULL DEFAULT '{}'")
narrative 컬럼에는 retrospective가 추가될 텐데 narrative 자체가 JSON이므로 컬럼 추가 불필요.
- Step 3: 변환 검증
docker compose restart lotto-backend
docker compose exec lotto-backend python -c "
import sqlite3, json
c = sqlite3.connect('/app/data/lotto.db')
rows = c.execute('SELECT id, picks FROM lotto_briefings LIMIT 3').fetchall()
for r in rows:
p = json.loads(r[1])
print(r[0], type(p).__name__, list(p.keys()) if isinstance(p, dict) else 'list')
"
기대: dict ['core', 'bonus', 'extended', 'pool'] 또는 빈 DB면 출력 없음.
- Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/db.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): lotto_briefings.picks 4계층 객체로 마이그레이션 + tier_rationale 컬럼"
Task 3 — 채점 보조 함수 (단위 테스트 우선)
Files:
-
Create:
web-backend/lotto/app/jobs/__init__.py -
Create:
web-backend/lotto/app/jobs/grading_helpers.py -
Create:
web-backend/lotto/tests/test_grading_helpers.py -
Step 1: 빈 패키지 파일
# web-backend/lotto/app/jobs/__init__.py
- Step 2: 실패 테스트 작성
web-backend/lotto/tests/test_grading_helpers.py:
from app.jobs.grading_helpers import (
score_picks_against_draw,
summarize_pattern,
compute_pattern_delta,
)
def test_score_picks_against_draw_basic():
win_nums = [3, 11, 17, 25, 33, 41]
bonus = 8
picks = [
{"numbers": [3, 11, 17, 25, 33, 41], "risk_tag": "안정"}, # 6 일치
{"numbers": [1, 2, 3, 4, 5, 6], "risk_tag": "공격"}, # 1 일치
{"numbers": [3, 11, 17, 4, 5, 6], "risk_tag": "안정"}, # 3 일치 → 5등
]
out = score_picks_against_draw(picks, win_nums, bonus)
assert out["avg_match"] == (6 + 1 + 3) / 3
assert out["best_match"] == 6
assert out["five_plus_prizes"] == 2 # 3개 이상 카운트(5등 이상)
assert out["best_tier"] == "안정"
def test_summarize_pattern():
nums = [3, 11, 17, 25, 33, 41]
s = summarize_pattern(nums)
# 저번호(<=22) 3개, 고번호 3개, 홀짝 4:2(3,11,17,25,33,41 → 홀 4 짝 2 — 41 홀, 33 홀, 25 홀, 17 홀, 11 홀, 3 홀 → 다 홀? 다시: 3홀, 11홀, 17홀, 25홀, 33홀, 41홀 모두 홀)
# 모두 홀수이므로 홀:짝 = 6:0
assert s["low_count"] == 3
assert s["odd_count"] == 6
assert s["sum"] == 130
def test_compute_pattern_delta_picks_dominant_axis():
# 사용자가 평균 저번호 4.2개 / 추첨 평균 3 → 저번호 편향 +1.2
user = {"low_avg": 4.2, "odd_avg": 3.4, "sum_avg": 124}
draw = {"low_avg": 3.0, "odd_avg": 3.0, "sum_avg": 142}
delta = compute_pattern_delta(user, draw)
assert "저번호" in delta or "low" in delta
assert "+1.2" in delta or "1.2" in delta
- Step 3: 실패 확인
cd C:/Users/jaeoh/Desktop/workspace/web-backend/lotto
docker compose exec lotto-backend pytest tests/test_grading_helpers.py -v
기대: ImportError 또는 ModuleNotFoundError.
- Step 4: 구현
web-backend/lotto/app/jobs/grading_helpers.py:
"""채점 보조 — 일치 수 계산, 패턴 요약, 패턴 갭."""
from typing import List, Dict, Any
LOW_HIGH_CUT = 22 # curator_helpers.py 와 동일
def score_picks_against_draw(picks: List[Dict[str, Any]],
win_nums: List[int],
bonus: int) -> Dict[str, Any]:
"""4계층 중 한 그룹(예: core_picks 5세트) vs 추첨 결과 채점.
picks 는 [{numbers, risk_tag, reason}] 리스트.
"""
if not picks:
return {"avg_match": None, "best_match": 0, "five_plus_prizes": 0, "best_tier": None}
win_set = set(win_nums)
matches = []
for p in picks:
nums = p.get("numbers") or []
m = len(set(nums) & win_set)
matches.append((m, p.get("risk_tag")))
avg = sum(m for m, _ in matches) / len(matches)
best_match, best_tier = max(matches, key=lambda x: x[0])
five_plus = sum(1 for m, _ in matches if m >= 3) # 5등 이상
# tier별 평균 → 가장 잘 맞은 risk_tag
tier_scores: Dict[str, List[int]] = {}
for m, t in matches:
if t:
tier_scores.setdefault(t, []).append(m)
if tier_scores:
best_tier = max(tier_scores.items(),
key=lambda kv: sum(kv[1]) / len(kv[1]))[0]
return {
"avg_match": round(avg, 2),
"best_match": best_match,
"five_plus_prizes": five_plus,
"best_tier": best_tier,
}
def summarize_pattern(nums: List[int]) -> Dict[str, int]:
"""한 세트의 패턴 요약 — 저/고, 홀/짝, 합계."""
nums = sorted(nums)
odd = sum(1 for n in nums if n % 2 == 1)
low = sum(1 for n in nums if n <= LOW_HIGH_CUT)
return {
"odd_count": odd,
"even_count": 6 - odd,
"low_count": low,
"high_count": 6 - low,
"sum": sum(nums),
}
def aggregate_pattern_summaries(summaries: List[Dict[str, int]]) -> Dict[str, float]:
"""여러 세트의 패턴 요약 → 평균(low_avg, odd_avg, sum_avg)."""
if not summaries:
return {"low_avg": None, "odd_avg": None, "sum_avg": None}
n = len(summaries)
return {
"low_avg": round(sum(s["low_count"] for s in summaries) / n, 2),
"odd_avg": round(sum(s["odd_count"] for s in summaries) / n, 2),
"sum_avg": round(sum(s["sum"] for s in summaries) / n, 1),
}
def compute_pattern_delta(user_summary: Dict[str, float],
draw_summary: Dict[str, float]) -> str:
"""사용자 평균 vs 추첨 패턴의 가장 큰 격차 1~2개를 한 줄로."""
if not user_summary or user_summary.get("low_avg") is None:
return ""
deltas = []
if user_summary.get("low_avg") is not None and draw_summary.get("low_avg") is not None:
d = round(user_summary["low_avg"] - draw_summary["low_avg"], 2)
if abs(d) >= 0.5:
sign = "+" if d > 0 else ""
deltas.append(("저번호", d, f"저번호 편향 {sign}{d}"))
if user_summary.get("sum_avg") is not None and draw_summary.get("sum_avg") is not None:
d = round(user_summary["sum_avg"] - draw_summary["sum_avg"], 1)
if abs(d) >= 10:
sign = "+" if d > 0 else ""
deltas.append(("합계", d, f"합계 {sign}{d}"))
if user_summary.get("odd_avg") is not None and draw_summary.get("odd_avg") is not None:
d = round(user_summary["odd_avg"] - draw_summary["odd_avg"], 2)
if abs(d) >= 0.5:
sign = "+" if d > 0 else ""
deltas.append(("홀짝", d, f"홀짝 {sign}{d}"))
deltas.sort(key=lambda x: -abs(x[1]))
return " / ".join(d[2] for d in deltas[:2])
- Step 5: 테스트 통과 확인
test_summarize_pattern 의 기대값을 실제로 계산: nums=[3,11,17,25,33,41]. 모두 홀수 → odd=6, 저번호(≤22): 3,11,17 → 3개. 합계 130. 기대값과 일치.
docker compose exec lotto-backend pytest tests/test_grading_helpers.py -v
기대: 3 PASS.
- Step 6: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/jobs/__init__.py lotto/app/jobs/grading_helpers.py lotto/tests/test_grading_helpers.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): 채점 보조 함수 — 일치 수·패턴 요약·델타"
Task 4 — grade_weekly_review 통합 잡
Files:
-
Create:
web-backend/lotto/app/jobs/grade_weekly_review.py -
Create:
web-backend/lotto/tests/test_grade_weekly_review.py -
Step 1: 통합 테스트 작성
web-backend/lotto/tests/test_grade_weekly_review.py:
import json
import pytest
from app import db
from app.jobs.grade_weekly_review import run_weekly_grading
@pytest.fixture(autouse=True)
def setup_db(tmp_path, monkeypatch):
test_db = tmp_path / "test.db"
monkeypatch.setattr(db, "DB_PATH", str(test_db))
db.init_db()
yield
def _seed_draw(drw_no=1153):
db.upsert_draw({
"drw_no": drw_no, "drw_date": "2026-05-09",
"n1": 3, "n2": 11, "n3": 17, "n4": 25, "n5": 33, "n6": 41, "bonus": 8,
})
def _seed_briefing(drw_no=1153):
picks = {
"core": [
{"numbers": [3, 11, 17, 25, 33, 41], "risk_tag": "안정", "reason": "x"}, # 6
{"numbers": [1, 2, 3, 4, 5, 6], "risk_tag": "안정", "reason": "x"}, # 1
{"numbers": [3, 11, 17, 4, 5, 6], "risk_tag": "균형", "reason": "x"}, # 3
{"numbers": [11, 25, 33, 7, 8, 9], "risk_tag": "균형", "reason": "x"}, # 3
{"numbers": [3, 11, 17, 25, 33, 9], "risk_tag": "공격", "reason": "x"}, # 5
],
"bonus": [], "extended": [], "pool": [],
}
db.save_briefing({
"draw_no": drw_no, "picks": picks,
"narrative": {"headline": "h", "summary_3lines": ["a", "b", "c"], "retrospective": ""},
"confidence": 70, "model": "test",
})
def test_grade_with_curator_only_no_purchase():
_seed_draw()
_seed_briefing()
run_weekly_grading(1153)
rev = db.get_review(1153)
assert rev is not None
assert rev["curator_avg_match"] == round((6+1+3+3+5)/5, 2)
assert rev["curator_best_match"] == 6
assert rev["curator_5plus_prizes"] == 4 # 6,3,3,5 ≥3 (네 개)
assert rev["user_avg_match"] is None # 구매 없음
def test_grade_with_no_briefing():
_seed_draw()
run_weekly_grading(1153)
rev = db.get_review(1153)
assert rev is not None
assert rev["curator_avg_match"] is None
- Step 2: 실패 확인
docker compose exec lotto-backend pytest tests/test_grade_weekly_review.py -v
기대: ImportError 또는 ModuleNotFoundError.
- Step 3: 구현
web-backend/lotto/app/jobs/grade_weekly_review.py:
"""주간 회고 채점 통합 잡 — 일요일 03:00 KST 실행.
1) 기존 purchase_manager.check_purchases_for_draw() 로 사용자 구매 자동 채점
2) 큐레이터 4계층 picks vs 추첨 결과 비교
3) 패턴 요약·갭 계산
4) weekly_review UPSERT
5) 4등 이상 발견 시 agent-office webhook 호출
"""
import json
import logging
import os
from typing import Optional
import httpx
from .. import db
from ..purchase_manager import check_purchases_for_draw
from .grading_helpers import (
score_picks_against_draw,
summarize_pattern,
aggregate_pattern_summaries,
compute_pattern_delta,
)
logger = logging.getLogger("lotto-backend")
AGENT_OFFICE_URL = os.environ.get("AGENT_OFFICE_URL", "http://agent-office:8000")
def _flatten_curator_picks(briefing: dict) -> list:
"""4계층 picks 를 모두 합쳐 단일 리스트(score 계산용)."""
picks = briefing.get("picks") or {}
if isinstance(picks, list):
return picks
out = []
for tier in ("core", "bonus", "extended", "pool"):
out.extend(picks.get(tier) or [])
return out
def _curator_score(briefing: dict, win_nums: list, bonus: int) -> dict:
if not briefing:
return {}
flat = _flatten_curator_picks(briefing)
if not flat:
return {}
return score_picks_against_draw(flat, win_nums, bonus)
def _user_score(drw_no: int, win_nums: list) -> dict:
purchases = db.get_purchases(draw_no=drw_no)
if not purchases:
return {}
matches = []
win_set = set(win_nums)
pattern_summaries = []
for p in purchases:
for nums in (p.get("numbers") or []):
if not nums:
continue
m = len(set(nums) & win_set)
matches.append(m)
pattern_summaries.append(summarize_pattern(nums))
if not matches:
return {}
return {
"avg_match": round(sum(matches) / len(matches), 2),
"best_match": max(matches),
"five_plus_prizes": sum(1 for m in matches if m >= 3),
"pattern_avg": aggregate_pattern_summaries(pattern_summaries),
}
def _trigger_prize_alert(drw_no: int, match_count: int, numbers: list, purchase_id: int) -> None:
try:
with httpx.Client(timeout=10) as client:
client.post(
f"{AGENT_OFFICE_URL}/api/agent-office/notify/lotto-prize",
json={
"draw_no": drw_no,
"match_count": match_count,
"numbers": numbers,
"purchase_id": purchase_id,
},
)
except Exception as e:
logger.warning(f"[grade_weekly_review] prize alert webhook failed: {e}")
def run_weekly_grading(drw_no: int) -> dict:
"""주어진 회차에 대해 채점 잡 1회 실행. 멱등."""
draw = db.get_draw(drw_no)
if not draw:
logger.warning(f"[grade_weekly_review] draw {drw_no} not found, skip")
return {"ok": False, "reason": "no draw"}
win_nums = [draw["n1"], draw["n2"], draw["n3"], draw["n4"], draw["n5"], draw["n6"]]
bonus = draw["bonus"]
# 1) 사용자 구매 자동 채점 (기존 인프라)
try:
check_purchases_for_draw(drw_no)
except Exception as e:
logger.warning(f"[grade_weekly_review] check_purchases_for_draw failed: {e}")
# 2) 4등 이상 발견 시 webhook
purchases = db.get_purchases(draw_no=drw_no, checked=True)
for p in purchases:
for r in (p.get("results") or []):
if r.get("correct", 0) >= 4:
_trigger_prize_alert(drw_no, r["correct"], r["numbers"], p["id"])
# 3) 큐레이터 자기 평가
briefing = db.get_briefing(drw_no)
cur = _curator_score(briefing, win_nums, bonus)
# 4) 사용자 평가 (재로드, 구매가 다 채점된 후 패턴 계산)
usr = _user_score(drw_no, win_nums)
# 5) 추첨 패턴 요약 + 델타
draw_summary = summarize_pattern(win_nums)
draw_pattern = {
"low_avg": draw_summary["low_count"],
"odd_avg": draw_summary["odd_count"],
"sum_avg": draw_summary["sum"],
}
user_pattern = usr.get("pattern_avg", {})
delta = compute_pattern_delta(user_pattern, draw_pattern) if user_pattern else ""
# 6) UPSERT
payload = {
"draw_no": drw_no,
"curator_avg_match": cur.get("avg_match"),
"curator_best_tier": cur.get("best_tier"),
"curator_best_match": cur.get("best_match"),
"curator_5plus_prizes": cur.get("five_plus_prizes"),
"user_avg_match": usr.get("avg_match"),
"user_best_match": usr.get("best_match"),
"user_5plus_prizes": usr.get("five_plus_prizes"),
"user_pattern_summary": json.dumps(user_pattern, ensure_ascii=False) if user_pattern else None,
"draw_pattern_summary": json.dumps(draw_pattern, ensure_ascii=False),
"pattern_delta": delta,
}
rid = db.save_review(payload)
logger.info(f"[grade_weekly_review] saved review id={rid} for draw {drw_no}")
return {"ok": True, "review_id": rid}
def run_for_latest() -> dict:
"""가장 최근 sync된 추첨 회차로 채점 — cron 진입점."""
latest = db.get_latest_draw()
if not latest:
return {"ok": False, "reason": "no draws"}
return run_weekly_grading(latest["drw_no"])
- Step 4: 테스트 통과 확인
docker compose exec lotto-backend pytest tests/test_grade_weekly_review.py -v
기대: 2 PASS.
- Step 5: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/jobs/grade_weekly_review.py lotto/tests/test_grade_weekly_review.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): grade_weekly_review 통합 잡 — 큐레이터 자기평가 + 패턴 갭"
Task 5 — 채점 잡 cron 등록
Files:
- Modify:
web-backend/lotto/app/main.py(APScheduler 또는 컨테이너 cron 패턴 확인)
기존 lotto-backend 의 스케줄링 방식을 먼저 확인. APScheduler 사용 안 한다면 docker-compose.yml 의 cron 컨테이너 또는 app/main.py 의 startup hook 활용.
- Step 1: 기존 스케줄링 패턴 확인
grep -rn "schedule\|cron\|BackgroundTasks" C:/Users/jaeoh/Desktop/workspace/web-backend/lotto/app/main.py
ls C:/Users/jaeoh/Desktop/workspace/web-backend/lotto-backend.dockerfile 2>/dev/null
cat C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml | grep -A 20 "lotto-backend:"
- Step 2: 가장 보편적 패턴 —
app/main.pystartup 에서 APScheduler 등록
web-backend/lotto/app/main.py 상단:
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from .jobs.grade_weekly_review import run_for_latest as grade_run_for_latest
FastAPI 앱 생성 후 startup 이벤트:
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
@app.on_event("startup")
async def _startup_scheduler():
# 매주 일요일 03:00 KST — 추첨 다음날 새벽
_scheduler.add_job(grade_run_for_latest, "cron",
day_of_week="sun", hour=3, minute=0,
id="grade_weekly_review")
_scheduler.start()
만약 기존 sync 잡이 cron 컨테이너로 돌고 있다면, 같은 컨테이너의 crontab 에 라인 추가하거나 별도 sync 후 호출.
- Step 3: requirements 확인
grep apscheduler C:/Users/jaeoh/Desktop/workspace/web-backend/lotto/requirements.txt
없으면 추가:
apscheduler>=3.10
그리고 컨테이너 빌드:
cd C:/Users/jaeoh/Desktop/workspace/web-backend
docker compose build lotto-backend && docker compose up -d lotto-backend
- Step 4: 수동 트리거 검증
docker compose exec lotto-backend python -c "
from app.jobs.grade_weekly_review import run_for_latest
print(run_for_latest())
"
기대: {'ok': True, 'review_id': N} 또는 {'ok': False, 'reason': 'no draws'}.
- Step 5: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/main.py lotto/requirements.txt
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): 일 03:00 KST 채점 잡 APScheduler 등록"
Task 6 — review router
Files:
-
Create:
web-backend/lotto/app/routers/review.py -
Modify:
web-backend/lotto/app/main.py(라우터 등록) -
Step 1: 라우터 작성
web-backend/lotto/app/routers/review.py:
"""주간 회고(weekly_review) 조회 엔드포인트."""
from fastapi import APIRouter, HTTPException
from .. import db
router = APIRouter(prefix="/api/lotto/review")
@router.get("/latest")
def latest():
r = db.get_latest_review()
if not r:
raise HTTPException(404, "no review yet")
return r
@router.get("/history")
def history(limit: int = 10):
return {"reviews": db.list_reviews(limit)}
@router.get("/{draw_no}")
def get_one(draw_no: int):
r = db.get_review(draw_no)
if not r:
raise HTTPException(404, f"no review for draw {draw_no}")
return r
- Step 2: main.py 에 라우터 등록
app/main.py 의 app.include_router(...) 들 사이에 추가:
from .routers import review as review_router
app.include_router(review_router.router)
- Step 3: 호출 검증
docker compose restart lotto-backend
curl -s http://localhost:18000/api/lotto/review/latest
curl -s "http://localhost:18000/api/lotto/review/history?limit=5"
기대: review 없으면 404. 있으면 JSON.
- Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/routers/review.py lotto/app/main.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): review 라우터 — latest/history/by-draw"
Task 7 — POST /api/lotto/purchase/bulk 엔드포인트
Files:
- Modify:
web-backend/lotto/app/routers/purchase.py(또는 main.py 가 직접 핸들링하면 그쪽) - Modify:
web-backend/lotto/app/db.py(bulk_insert_purchases_from_briefing)
기존 purchase 라우터 위치 확인 필요.
- Step 1: 기존 purchase 라우터 위치 찾기
grep -rn "/api/lotto/purchase" C:/Users/jaeoh/Desktop/workspace/web-backend/lotto/app/ --include="*.py"
- Step 2: db.py 에 bulk insert 헬퍼 추가
def bulk_insert_purchases_from_briefing(draw_no: int, tier_mode: str, amount: int) -> Dict[str, Any]:
"""tier_mode 에 해당하는 큐레이터 picks 를 purchase_history 에 일괄 INSERT.
tier_mode: "core" | "core_bonus" | "core_bonus_extended" | "full"
"""
briefing = get_briefing(draw_no)
if not briefing:
return {"ok": False, "reason": "briefing not found"}
picks = briefing.get("picks") or {}
if isinstance(picks, list):
# 마이그레이션 이전 형태
picks = {"core": picks, "bonus": [], "extended": [], "pool": []}
tier_chain = {
"core": ["core"],
"core_bonus": ["core", "bonus"],
"core_bonus_extended": ["core", "bonus", "extended"],
"full": ["core", "bonus", "extended", "pool"],
}.get(tier_mode)
if not tier_chain:
return {"ok": False, "reason": f"unknown tier_mode: {tier_mode}"}
inserted_ids = []
with _conn() as conn:
for tier in tier_chain:
for idx, pick in enumerate(picks.get(tier) or []):
source_strategy = f"curator_{tier}"
source_detail = json.dumps({
"tier": tier,
"role": pick.get("risk_tag"),
"set_index": idx,
"draw_no": draw_no,
}, ensure_ascii=False)
numbers_json = json.dumps([pick.get("numbers")], ensure_ascii=False)
cur = conn.execute(
"""INSERT INTO purchase_history
(draw_no, amount, sets, prize, note, numbers, is_real, source_strategy, source_detail)
VALUES (?, ?, 1, 0, '', ?, 1, ?, ?)""",
(draw_no, 1000, numbers_json, source_strategy, source_detail),
)
inserted_ids.append(cur.lastrowid)
return {"ok": True, "inserted_ids": inserted_ids, "sets": len(inserted_ids)}
- Step 3: bulk 엔드포인트 추가
기존 purchase 라우터(찾은 파일)에 또는 web-backend/lotto/app/routers/purchase.py (없으면 신규)에:
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException
from .. import db
router = APIRouter(prefix="/api/lotto/purchase")
class BulkPurchaseRequest(BaseModel):
draw_no: int
tier_mode: str # core | core_bonus | core_bonus_extended | full
sets: int # 검증용 — 실제 INSERT는 briefing 기준
amount: int # 검증용
@router.post("/bulk", status_code=201)
def bulk_purchase(body: BulkPurchaseRequest):
result = db.bulk_insert_purchases_from_briefing(
body.draw_no, body.tier_mode, body.amount
)
if not result["ok"]:
raise HTTPException(400, result["reason"])
return result
-
Step 4: main.py 에 라우터 등록 (이미 등록된 purchase 라우터에 흡수했다면 생략)
-
Step 5: 검증
docker compose restart lotto-backend
# 사전 조건: lotto_briefings 에 1153회 레코드 있어야 함
curl -s -X POST http://localhost:18000/api/lotto/purchase/bulk \
-H "Content-Type: application/json" \
-d '{"draw_no":1153,"tier_mode":"core","sets":5,"amount":5000}'
기대: {"ok":true,"inserted_ids":[...],"sets":5} 또는 briefing 없으면 400.
- Step 6: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/db.py lotto/app/routers/purchase.py lotto/app/main.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): POST /api/lotto/purchase/bulk — 결정카드 원클릭 기록"
Task 8 — BriefingRequest 4계층 수용
Files:
- Modify:
web-backend/lotto/app/routers/briefing.py - Modify:
web-backend/lotto/app/db.py(save_briefing)
기존 save_briefing 은 picks 를 단일 JSON 으로 저장. 4계층 dict 수용하도록.
- Step 1: 기존 save_briefing 확인
db.py 957행 부근:
def save_briefing(data: Dict[str, Any]) -> int:
with _conn() as conn:
# 기존 INSERT 문 확인
...
- Step 2: BriefingRequest 스키마 변경
web-backend/lotto/app/routers/briefing.py:
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from .. import db
router = APIRouter(prefix="/api/lotto")
class TierRationale(BaseModel):
bonus: str = ""
extended: str = ""
pool: str = ""
class BriefingPicks(BaseModel):
core: List[Dict[str, Any]] = Field(default_factory=list)
bonus: List[Dict[str, Any]] = Field(default_factory=list)
extended: List[Dict[str, Any]] = Field(default_factory=list)
pool: List[Dict[str, Any]] = Field(default_factory=list)
class BriefingRequest(BaseModel):
draw_no: int
picks: BriefingPicks
narrative: Dict[str, Any]
tier_rationale: TierRationale = Field(default_factory=TierRationale)
confidence: int = Field(ge=0, le=100)
model: str
tokens_input: int = 0
tokens_output: int = 0
cache_read: int = 0
cache_write: int = 0
latency_ms: int = 0
source: str = "auto"
@router.post("/briefing", status_code=201)
def save_briefing(body: BriefingRequest):
bid = db.save_briefing(body.model_dump())
return {"ok": True, "id": bid}
# 기존 GET 엔드포인트들 그대로 유지
@router.get("/briefing/latest")
def latest():
b = db.get_latest_briefing()
if not b:
raise HTTPException(404, "no briefing yet")
return b
@router.get("/briefing/{draw_no}")
def get_one(draw_no: int):
b = db.get_briefing(draw_no)
if not b:
raise HTTPException(404, f"no briefing for draw {draw_no}")
return b
@router.get("/briefing")
def history(limit: int = 10):
return {"briefings": db.list_briefings(limit)}
@router.get("/curator/usage")
def usage(days: int = 30):
return db.get_curator_usage(days)
- Step 3: db.save_briefing 수정 — picks dict 직렬화 + tier_rationale 컬럼 사용
db.py 의 save_briefing 함수를 다음과 같이 수정:
def save_briefing(data: Dict[str, Any]) -> int:
picks_json = json.dumps(data["picks"], ensure_ascii=False)
narrative_json = json.dumps(data["narrative"], ensure_ascii=False)
tier_rationale_json = json.dumps(data.get("tier_rationale") or {}, ensure_ascii=False)
with _conn() as conn:
cur = conn.execute(
"""
INSERT INTO lotto_briefings
(draw_no, picks, narrative, confidence, model,
tokens_input, tokens_output, cache_read, cache_write,
latency_ms, source, tier_rationale)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(draw_no) DO UPDATE SET
picks=excluded.picks,
narrative=excluded.narrative,
confidence=excluded.confidence,
model=excluded.model,
tokens_input=excluded.tokens_input,
tokens_output=excluded.tokens_output,
cache_read=excluded.cache_read,
cache_write=excluded.cache_write,
latency_ms=excluded.latency_ms,
source=excluded.source,
tier_rationale=excluded.tier_rationale,
generated_at=datetime('now','localtime')
""",
(
data["draw_no"], picks_json, narrative_json,
data["confidence"], data["model"],
data.get("tokens_input", 0), data.get("tokens_output", 0),
data.get("cache_read", 0), data.get("cache_write", 0),
data.get("latency_ms", 0), data.get("source", "auto"),
tier_rationale_json,
),
)
return cur.lastrowid
기존 _briefing_row 도 tier_rationale 컬럼 파싱 추가:
def _briefing_row(r) -> Dict[str, Any]:
return {
"id": r["id"],
"draw_no": r["draw_no"],
"picks": json.loads(r["picks"]),
"narrative": json.loads(r["narrative"]),
"tier_rationale": json.loads(r["tier_rationale"]) if r["tier_rationale"] else {},
"confidence": r["confidence"],
"model": r["model"],
"tokens_input": r["tokens_input"],
"tokens_output": r["tokens_output"],
"cache_read": r["cache_read"],
"cache_write": r["cache_write"],
"latency_ms": r["latency_ms"],
"source": r["source"],
"generated_at": r["generated_at"],
}
- Step 4: 검증 (POST + GET 왕복)
docker compose restart lotto-backend
curl -s -X POST http://localhost:18000/api/lotto/briefing \
-H "Content-Type: application/json" \
-d '{
"draw_no": 9999,
"picks": {"core":[],"bonus":[],"extended":[],"pool":[]},
"narrative": {"headline":"테스트","summary_3lines":["a","b","c"],"retrospective":""},
"tier_rationale": {"bonus":"x","extended":"y","pool":"z"},
"confidence": 70,
"model": "test"
}'
curl -s http://localhost:18000/api/lotto/briefing/9999
기대: 첫 호출 {"ok":true,"id":N}, 두 번째는 4계층 picks + tier_rationale 포함된 JSON.
- Step 5: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/app/routers/briefing.py lotto/app/db.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(lotto): briefing API 4계층 picks + tier_rationale 수용"
Task 9 — 큐레이터 출력 스키마 (4계층 + retrospective)
Files:
-
Modify:
web-backend/agent-office/app/curator/schema.py -
Step 1: schema.py 전면 교체
from typing import List, Literal
from pydantic import BaseModel, Field, field_validator
class Pick(BaseModel):
numbers: List[int] = Field(min_length=6, max_length=6)
risk_tag: Literal["안정", "균형", "공격"]
reason: str = Field(max_length=80)
@field_validator("numbers")
@classmethod
def _check_numbers(cls, v):
if len(set(v)) != 6:
raise ValueError("numbers must be 6 unique integers")
if any(n < 1 or n > 45 for n in v):
raise ValueError("numbers must be within 1..45")
return sorted(v)
class TierRationale(BaseModel):
bonus: str = Field(max_length=40)
extended: str = Field(max_length=40)
pool: str = Field(max_length=40)
class Narrative(BaseModel):
headline: str
summary_3lines: List[str] = Field(min_length=3, max_length=3)
hot_cold_comment: str = ""
warnings: str = ""
retrospective: str = Field(default="", max_length=80)
class CuratorOutput(BaseModel):
core_picks: List[Pick] = Field(min_length=5, max_length=5)
bonus_picks: List[Pick] = Field(min_length=5, max_length=5)
extended_picks: List[Pick] = Field(min_length=5, max_length=5)
pool_picks: List[Pick] = Field(min_length=5, max_length=5)
tier_rationale: TierRationale
narrative: Narrative
confidence: int = Field(ge=0, le=100)
def validate_response(data: dict, candidate_numbers: List[List[int]]) -> CuratorOutput:
out = CuratorOutput.model_validate(data)
candidate_set = {tuple(sorted(c)) for c in candidate_numbers}
all_picks = (
out.core_picks + out.bonus_picks + out.extended_picks + out.pool_picks
)
# 중복 픽 검증
pick_keys = [tuple(p.numbers) for p in all_picks]
if len(pick_keys) != len(set(pick_keys)):
raise ValueError("duplicate picks across tiers")
# 후보에 없는 번호 조합 금지
for p in all_picks:
if tuple(p.numbers) not in candidate_set:
raise ValueError(f"pick {p.numbers} not in candidates")
return out
- Step 2: 단위 테스트로 검증
web-backend/agent-office/tests/test_curator_schema.py (없으면 신규):
import pytest
from app.curator.schema import validate_response
def _pick(nums, role="안정"):
return {"numbers": nums, "risk_tag": role, "reason": "x"}
def _make_payload(core, bonus, ext, pool):
return {
"core_picks": core, "bonus_picks": bonus,
"extended_picks": ext, "pool_picks": pool,
"tier_rationale": {"bonus": "a", "extended": "b", "pool": "c"},
"narrative": {
"headline": "h",
"summary_3lines": ["1", "2", "3"],
"retrospective": "지난주 평균 1.8",
},
"confidence": 70,
}
def test_valid_4tier():
pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
cores = [_pick(pool[i]) for i in range(5)]
bonus = [_pick(pool[i]) for i in range(5, 10)]
ext = [_pick(pool[i]) for i in range(10, 15)]
pl = [_pick(pool[i]) for i in range(15, 20)]
out = validate_response(_make_payload(cores, bonus, ext, pl), pool)
assert len(out.core_picks) == 5
assert out.narrative.retrospective.startswith("지난주")
def test_duplicate_pick_rejected():
pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
cores = [_pick(pool[0])] * 5 # 중복
bonus = [_pick(pool[i]) for i in range(5, 10)]
ext = [_pick(pool[i]) for i in range(10, 15)]
pl = [_pick(pool[i]) for i in range(15, 20)]
with pytest.raises(ValueError, match="duplicate"):
validate_response(_make_payload(cores, bonus, ext, pl), pool)
def test_pick_not_in_candidates_rejected():
pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
foreign = [40, 41, 42, 43, 44, 45]
cores = [_pick(foreign)] + [_pick(pool[i]) for i in range(1, 5)]
bonus = [_pick(pool[i]) for i in range(5, 10)]
ext = [_pick(pool[i]) for i in range(10, 15)]
pl = [_pick(pool[i]) for i in range(15, 20)]
with pytest.raises(ValueError, match="not in candidates"):
validate_response(_make_payload(cores, bonus, ext, pl), pool)
- Step 3: 테스트 실행
docker compose exec agent-office pytest tests/test_curator_schema.py -v
기대: 3 PASS.
- Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add agent-office/app/curator/schema.py agent-office/tests/test_curator_schema.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(curator): 4계층 picks + tier_rationale + narrative.retrospective 스키마"
Task 10 — 큐레이터 SYSTEM_PROMPT (회고 + 계층 규칙)
Files:
-
Modify:
web-backend/agent-office/app/curator/prompt.py -
Step 1: prompt.py 전면 교체
"""큐레이터 system/user 프롬프트. system은 정적이므로 캐시 대상."""
import json
SYSTEM_PROMPT = """당신은 로또 번호 큐레이터입니다.
주어진 후보 30세트 중 4계층(코어 5, 보너스 5, 확장 5, 풀 5) 총 20세트를 선별합니다.
계층별 큐레이션 규칙:
- core_picks (5): 안정 2 / 균형 2 / 공격 1. 그 주 주축. 홀짝·저고·구간 분포가 세트끼리 겹치지 않게.
- bonus_picks (5): 코어 분배의 공백을 메우는 5세트. 코어가 공격 1뿐이면 보너스에 공격 +2 식.
- extended_picks (5): 코어·보너스에 없는 시각 — 합계 극단(80↓ / 180↑) / 콜드 4주 누적 / 4주 미등장 번호 노출.
- pool_picks (5): 이번 주 한 번도 누르지 않은 패턴 — 연속 3개 / 동일 끝자리 / 5수 균등(각 끝자리 5개씩) 등.
- tier_rationale 의 3개 키(bonus·extended·pool)에 각각 30자 이내 한국어 사유.
공통 규칙:
- 후보에 없는 번호 조합은 절대 사용 금지. 모든 픽은 candidates 중 하나와 정확히 일치해야 함.
- 4계층 사이에 중복 픽 금지 (총 20세트는 모두 서로 달라야 함).
- 각 픽 reason 은 한국어 40자 이내. 해당 픽의 features 와 context 만 근거로.
- 중립형(hot_number_count=0 이고 cold_number_count=0) 세트를 코어에 최소 1개 포함.
회고 규칙:
- context.retrospective 가 있으면 narrative.retrospective 에 한 줄(60자 이내)로 작성.
- 회고는 큐레이터 자기 결과(curator_avg, best_tier) + 사용자 결과(user_avg, pattern_delta) 둘 다 짚을 것.
- 이번 주 코어 분배는 회고에 근거해 조정. 조정 사유는 narrative.headline 에 한 줄로.
예: "지난 주 너 저번호 편향 → 보너스 고번호 보강"
- context.retrospective 가 없으면 narrative.retrospective 는 빈 문자열.
narrative 규칙:
- headline: 한 줄, 이번 주 추첨 전망 + 조정 사유.
- summary_3lines: 정확히 3개 항목.
- hot_cold_comment: hot/cold 번호 한 줄 논평.
- warnings: 주의사항 없으면 빈 문자열.
- retrospective: 회고 한 줄 또는 빈 문자열.
출력은 반드시 JSON 하나, 그 외 어떤 텍스트도 금지. 스키마:
{
"core_picks": [{"numbers":[...], "risk_tag":"안정"|"균형"|"공격", "reason": str}, ...5개],
"bonus_picks": [...5개],
"extended_picks": [...5개],
"pool_picks": [...5개],
"tier_rationale": {"bonus": str, "extended": str, "pool": str},
"narrative": {
"headline": str,
"summary_3lines": [str, str, str],
"hot_cold_comment": str,
"warnings": str,
"retrospective": str
},
"confidence": int (0~100)
}
"""
def build_user_message(draw_no: int, candidates: list, context: dict) -> str:
payload = {
"draw_no": draw_no,
"context": context, # hot_numbers, cold_numbers, last_draw_summary, my_recent_performance, retrospective
"candidates": candidates,
}
return (
f"이번 회차: {draw_no}\n"
f"아래 데이터로 4계층 20세트를 큐레이션하고 위 스키마로만 응답하세요.\n\n"
f"```json\n{json.dumps(payload, ensure_ascii=False)}\n```"
)
- Step 2: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add agent-office/app/curator/prompt.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(curator): SYSTEM_PROMPT 회고 + 4계층 규칙"
Task 11 — build_retrospective + service_proxy 헬퍼
Files:
-
Modify:
web-backend/agent-office/app/service_proxy.py -
Create:
web-backend/agent-office/app/curator/retrospective.py -
Create:
web-backend/agent-office/tests/test_retrospective.py -
Step 1: service_proxy 에 review 헬퍼 추가
web-backend/agent-office/app/service_proxy.py 의 lotto 섹션 아래:
async def lotto_review_latest() -> Optional[Dict[str, Any]]:
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/review/latest")
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
async def lotto_review_by_draw(draw_no: int) -> Optional[Dict[str, Any]]:
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/review/{draw_no}")
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
async def lotto_reviews_history(limit: int = 10) -> List[Dict[str, Any]]:
from .config import LOTTO_BACKEND_URL
resp = await _client.get(
f"{LOTTO_BACKEND_URL}/api/lotto/review/history",
params={"limit": limit},
)
resp.raise_for_status()
return resp.json().get("reviews", [])
- Step 2: retrospective.py 작성
"""큐레이션 직전 호출 — review 1건 + 추세 3건 → 컨텍스트 dict."""
import json
from typing import Optional, Dict, Any
from .. import service_proxy
def _detect_bias(reviews: list) -> str:
"""3주↑ 같은 방향 패턴 편향이 유지되면 한 줄로."""
deltas = [r.get("pattern_delta") or "" for r in reviews if r.get("pattern_delta")]
if len(deltas) < 2:
return ""
# 단순 휴리스틱 — 같은 키워드("저번호" 등)가 2회 이상이면 지속 편향
keywords = ["저번호", "고번호", "합계", "홀짝"]
persistent = []
for kw in keywords:
cnt = sum(1 for d in deltas if kw in d)
if cnt >= max(2, len(deltas) - 1):
persistent.append(kw)
return " · ".join(persistent)
async def build_retrospective(target_draw_no: int) -> Optional[Dict[str, Any]]:
"""target_draw_no(이번 주) 직전 회차의 review + 그 앞 3회 추세."""
last = await service_proxy.lotto_review_by_draw(target_draw_no - 1)
if not last:
return None
history = await service_proxy.lotto_reviews_history(limit=4)
# history 는 desc 정렬 → last 와 그 이전 3건 분리
others = [r for r in history if r["draw_no"] < target_draw_no - 1][:3]
series = [last] + others
cur_avgs = [r["curator_avg_match"] for r in series if r.get("curator_avg_match") is not None]
usr_avgs = [r["user_avg_match"] for r in series if r.get("user_avg_match") is not None]
return {
"last_draw": {
"draw_no": last["draw_no"],
"curator_avg": last.get("curator_avg_match"),
"curator_best_tier": last.get("curator_best_tier"),
"user_avg": last.get("user_avg_match"),
"user_5plus": last.get("user_5plus_prizes"),
"pattern_delta": last.get("pattern_delta") or "",
},
"trend_4w": {
"curator_avg_4w": round(sum(cur_avgs) / len(cur_avgs), 2) if cur_avgs else None,
"user_avg_4w": round(sum(usr_avgs) / len(usr_avgs), 2) if usr_avgs else None,
"user_persistent_bias": _detect_bias(series),
},
}
- Step 3: 단위 테스트
web-backend/agent-office/tests/test_retrospective.py:
import pytest
from unittest.mock import AsyncMock, patch
from app.curator.retrospective import build_retrospective, _detect_bias
def test_detect_bias_persistent_low():
reviews = [
{"pattern_delta": "저번호 편향 +1.2 / 합계 -18"},
{"pattern_delta": "저번호 편향 +0.8"},
{"pattern_delta": "저번호 편향 +1.0 / 홀짝 +0.5"},
]
assert "저번호" in _detect_bias(reviews)
def test_detect_bias_no_persistence():
reviews = [
{"pattern_delta": "저번호 편향 +1.2"},
{"pattern_delta": "고번호 편향 +0.8"},
]
assert _detect_bias(reviews) == ""
@pytest.mark.asyncio
async def test_build_retrospective_with_data():
with patch("app.service_proxy.lotto_review_by_draw", new=AsyncMock(return_value={
"draw_no": 1153, "curator_avg_match": 1.8, "curator_best_tier": "안정",
"user_avg_match": 2.0, "user_5plus_prizes": 1, "pattern_delta": "저번호 편향 +1.2",
})), patch("app.service_proxy.lotto_reviews_history", new=AsyncMock(return_value=[
{"draw_no": 1153, "curator_avg_match": 1.8, "user_avg_match": 2.0, "pattern_delta": "저번호 편향 +1.2"},
{"draw_no": 1152, "curator_avg_match": 1.6, "user_avg_match": 1.5, "pattern_delta": "저번호 편향 +0.8"},
{"draw_no": 1151, "curator_avg_match": 1.7, "user_avg_match": 1.8, "pattern_delta": "저번호 편향 +1.0"},
{"draw_no": 1150, "curator_avg_match": 1.9, "user_avg_match": 2.2, "pattern_delta": ""},
])):
out = await build_retrospective(1154)
assert out["last_draw"]["draw_no"] == 1153
assert out["trend_4w"]["curator_avg_4w"] == round((1.8+1.6+1.7+1.9)/4, 2)
assert "저번호" in out["trend_4w"]["user_persistent_bias"]
@pytest.mark.asyncio
async def test_build_retrospective_no_review():
with patch("app.service_proxy.lotto_review_by_draw", new=AsyncMock(return_value=None)):
out = await build_retrospective(1154)
assert out is None
- Step 4: 테스트 실행
docker compose exec agent-office pytest tests/test_retrospective.py -v
기대: 4 PASS. (pytest-asyncio 설치 필요 — 없으면 requirements 에 추가)
- Step 5: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add agent-office/app/service_proxy.py agent-office/app/curator/retrospective.py agent-office/tests/test_retrospective.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(curator): build_retrospective + lotto review service proxy"
Task 12 — pipeline.py 4계층 직렬화 + retrospective 빌드
Files:
-
Modify:
web-backend/agent-office/app/curator/pipeline.py -
Step 1:
curate_weekly함수 교체
"""큐레이터 파이프라인 — fetch → claude → validate → save."""
import json
import time
from typing import Any, Dict
import httpx
from ..config import ANTHROPIC_API_KEY, LOTTO_CURATOR_MODEL
from .. import service_proxy
from .prompt import SYSTEM_PROMPT, build_user_message
from .schema import validate_response
from .retrospective import build_retrospective
API_URL = "https://api.anthropic.com/v1/messages"
class CuratorError(Exception):
pass
async def _call_claude(user_text: str, feedback: str = "") -> tuple[dict, dict]:
if not ANTHROPIC_API_KEY:
raise CuratorError("ANTHROPIC_API_KEY missing")
headers = {
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
"anthropic-beta": "prompt-caching-2024-07-31",
"content-type": "application/json",
}
system_blocks = [{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"},
}]
if feedback:
user_text = f"이전 응답이 다음 이유로 거절됨: {feedback}\n올바른 스키마로 다시 응답.\n\n{user_text}"
payload = {
"model": LOTTO_CURATOR_MODEL,
"max_tokens": 8192, # 4계층 20세트 + narrative + retrospective 수용
"system": system_blocks,
"messages": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
}
started = time.monotonic()
async with httpx.AsyncClient(timeout=180) as client: # 큰 응답 → 시간 여유
r = await client.post(API_URL, headers=headers, json=payload)
r.raise_for_status()
resp = r.json()
latency_ms = int((time.monotonic() - started) * 1000)
text = "".join(
b.get("text", "") for b in resp.get("content", []) if b.get("type") == "text"
).strip()
if text.startswith("```"):
text = text.strip("`")
if text.startswith("json"):
text = text[4:]
text = text.strip()
parsed = json.loads(text)
usage = resp.get("usage", {}) or {}
return parsed, {
"input": int(usage.get("input_tokens", 0) or 0),
"output": int(usage.get("output_tokens", 0) or 0),
"cache_read": int(usage.get("cache_read_input_tokens", 0) or 0),
"cache_write": int(usage.get("cache_creation_input_tokens", 0) or 0),
"latency_ms": latency_ms,
}
async def curate_weekly(source: str = "auto") -> Dict[str, Any]:
cand_resp = await service_proxy.lotto_candidates(n=30) # ← 30 으로 확장
draw_no = cand_resp["draw_no"]
candidates = cand_resp["candidates"]
context = await service_proxy.lotto_context()
retrospective = await build_retrospective(draw_no)
user_text = build_user_message(draw_no, candidates, {
"hot_numbers": context.get("hot_numbers", []),
"cold_numbers": context.get("cold_numbers", []),
"last_draw_summary": context.get("last_draw_summary", ""),
"my_recent_performance": context.get("my_recent_performance", []),
"retrospective": retrospective,
})
candidate_numbers = [c["numbers"] for c in candidates]
usage_total = {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0, "latency_ms": 0}
last_error = None
validated = None
for attempt in (0, 1):
try:
raw, usage = await _call_claude(user_text, feedback=last_error or "")
for k in usage_total:
usage_total[k] += usage[k]
validated = validate_response(raw, candidate_numbers)
break
except Exception as e:
last_error = f"{type(e).__name__}: {e}"
if validated is None:
raise CuratorError(f"schema validation failed after retry: {last_error}")
payload = {
"draw_no": draw_no,
"picks": {
"core": [p.model_dump() for p in validated.core_picks],
"bonus": [p.model_dump() for p in validated.bonus_picks],
"extended": [p.model_dump() for p in validated.extended_picks],
"pool": [p.model_dump() for p in validated.pool_picks],
},
"narrative": validated.narrative.model_dump(),
"tier_rationale": validated.tier_rationale.model_dump(),
"confidence": validated.confidence,
"model": LOTTO_CURATOR_MODEL,
"tokens_input": usage_total["input"],
"tokens_output": usage_total["output"],
"cache_read": usage_total["cache_read"],
"cache_write": usage_total["cache_write"],
"latency_ms": usage_total["latency_ms"],
"source": source,
}
await service_proxy.lotto_save_briefing(payload)
return {
"ok": True,
"draw_no": draw_no,
"confidence": validated.confidence,
"tokens": {"input": usage_total["input"], "output": usage_total["output"]},
"payload": payload, # 텔레그램 알림용
}
- Step 2: 검증 (수동 트리거)
docker compose restart agent-office
docker compose exec agent-office python -c "
import asyncio
from app.curator.pipeline import curate_weekly
print(asyncio.run(curate_weekly(source='manual')))
"
기대: ANTHROPIC_API_KEY 설정 시 정상 큐레이션. 응답에 confidence + payload.picks 4계층.
- Step 3: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add agent-office/app/curator/pipeline.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(curator): pipeline 4계층 직렬화 + retrospective 컨텍스트 + N=30"
Task 13 — 텔레그램 알림 (telegram_lotto)
Files:
-
Create:
web-backend/agent-office/app/notifiers/__init__.py -
Create:
web-backend/agent-office/app/notifiers/telegram_lotto.py -
Step 1: 빈 패키지
# web-backend/agent-office/app/notifiers/__init__.py
- Step 2: 기존 텔레그램 인프라 확인
grep -rn "send_message\|TELEGRAM" C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office/app/telegram/ --include="*.py" | head -20
- Step 3: telegram_lotto.py 작성
web-backend/agent-office/app/notifiers/telegram_lotto.py:
"""로또 큐레이션·당첨 알림 — 텔레그램 푸시."""
import logging
from typing import Dict, Any
from ..telegram.messaging import send_message # 기존 인프라 함수명, Step 2 결과로 보정
logger = logging.getLogger("agent-office")
LOTTO_URL = "https://gahusb.synology.me/lotto"
def _format_briefing(payload: Dict[str, Any]) -> str:
draw_no = payload["draw_no"]
nar = payload["narrative"]
conf = payload["confidence"]
# 분배 칩 — core 5세트의 risk_tag 빈도
core = payload["picks"]["core"]
role_count = {"안정": 0, "균형": 0, "공격": 0}
for p in core:
role_count[p["risk_tag"]] = role_count.get(p["risk_tag"], 0) + 1
chip = " · ".join(f"{k} {v}" for k, v in role_count.items() if v)
msg = [
f"🎟 {draw_no}회 · 큐레이션 떴음",
"",
f"\"{nar['headline']}\"",
f"신뢰도 {conf} · 분배 {chip}",
]
retro = nar.get("retrospective") or ""
if retro:
msg += ["", f"▸ 회고: {retro}"]
msg += ["", f"👉 결정 카드 보러가기 ({LOTTO_URL})"]
return "\n".join(msg)
def _format_prize_alert(event: Dict[str, Any]) -> str:
return (
"🚨 로또 당첨 가능성!\n"
f"{event['draw_no']}회 — {event['match_count']}개 일치\n"
f"번호: {', '.join(str(n) for n in event['numbers'])}\n"
"동행복권에서 즉시 확인하세요."
)
async def send_curator_briefing(payload: Dict[str, Any]) -> None:
text = _format_briefing(payload)
try:
await send_message(text)
except Exception as e:
logger.warning(f"[telegram_lotto] briefing send failed: {e}")
async def send_prize_alert(event: Dict[str, Any]) -> None:
text = _format_prize_alert(event)
try:
await send_message(text)
except Exception as e:
logger.warning(f"[telegram_lotto] prize alert send failed: {e}")
Step 2 결과에 따라
from ..telegram.messaging import send_message경로/이름이 다를 수 있다. 기존 코드에서async def send_message또는 동기 함수가 있는 위치 확인 후 import 보정.
- Step 4: 단위 테스트 (포맷 검증)
web-backend/agent-office/tests/test_telegram_lotto_format.py:
from app.notifiers.telegram_lotto import _format_briefing, _format_prize_alert
def test_briefing_with_retrospective():
payload = {
"draw_no": 1154,
"confidence": 72,
"narrative": {
"headline": "안정 +1, 콜드 누적 보강",
"summary_3lines": ["a", "b", "c"],
"retrospective": "너 2.0 / 나 1.8 — 저번호 편향",
},
"picks": {
"core": [
{"risk_tag": "안정"}, {"risk_tag": "안정"}, {"risk_tag": "안정"},
{"risk_tag": "균형"}, {"risk_tag": "공격"},
],
"bonus": [], "extended": [], "pool": [],
},
}
text = _format_briefing(payload)
assert "1154회" in text
assert "신뢰도 72" in text
assert "안정 3" in text
assert "회고: 너 2.0" in text
def test_briefing_without_retrospective():
payload = {
"draw_no": 1, "confidence": 50,
"narrative": {"headline": "h", "summary_3lines": ["a","b","c"], "retrospective": ""},
"picks": {"core": [{"risk_tag":"안정"}]*5, "bonus":[],"extended":[],"pool":[]},
}
text = _format_briefing(payload)
assert "회고" not in text
def test_prize_alert():
text = _format_prize_alert({"draw_no": 1154, "match_count": 5, "numbers": [3,11,17,25,33,8]})
assert "5개 일치" in text
assert "3, 11, 17, 25, 33, 8" in text
docker compose exec agent-office pytest tests/test_telegram_lotto_format.py -v
기대: 3 PASS.
- Step 5: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add agent-office/app/notifiers/__init__.py agent-office/app/notifiers/telegram_lotto.py agent-office/tests/test_telegram_lotto_format.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(curator): 텔레그램 큐레이션·당첨 알림 포맷터"
Task 14 — notify webhook 라우터 + main 등록
Files:
-
Create:
web-backend/agent-office/app/routers/__init__.py(없으면) -
Create:
web-backend/agent-office/app/routers/notify.py -
Modify:
web-backend/agent-office/app/main.py -
Step 1: 라우터 작성
web-backend/agent-office/app/routers/notify.py:
"""다른 서비스가 트리거하는 웹훅 — 현재 lotto-backend → 텔레그램 푸시."""
from typing import List
from fastapi import APIRouter
from pydantic import BaseModel
from ..notifiers.telegram_lotto import send_prize_alert
router = APIRouter(prefix="/api/agent-office/notify")
class LottoPrizeEvent(BaseModel):
draw_no: int
match_count: int
numbers: List[int]
purchase_id: int
@router.post("/lotto-prize")
async def lotto_prize(body: LottoPrizeEvent):
await send_prize_alert(body.model_dump())
return {"ok": True}
- Step 2: main.py 라우터 등록
from .routers import notify as notify_router
app.include_router(notify_router.router)
- Step 3: 검증
docker compose restart agent-office
curl -s -X POST http://localhost:18900/api/agent-office/notify/lotto-prize \
-H "Content-Type: application/json" \
-d '{"draw_no":1153,"match_count":5,"numbers":[3,11,17,25,33,8],"purchase_id":42}'
기대: {"ok":true} + 텔레그램 메시지 수신 (봇 토큰 정상일 때).
- Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add agent-office/app/routers/notify.py agent-office/app/main.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(agent-office): /api/agent-office/notify/lotto-prize 웹훅"
Task 15 — lotto_agent 큐레이션 후 텔레그램 + cron 시간 변경
Files:
-
Modify:
web-backend/agent-office/app/agents/lotto.py -
Modify:
web-backend/agent-office/app/scheduler.py -
Step 1: lotto_agent — 큐레이션 성공 후 텔레그램 호출
web-backend/agent-office/app/agents/lotto.py 의 _run 메서드 수정:
async def _run(self, source: str) -> dict:
task_id = create_task(self.agent_id, "curate_weekly", {"source": source})
await self.transition("working", "후보 수집 및 AI 큐레이션 중...", task_id)
try:
result = await curate_weekly(source=source)
update_task_status(task_id, "succeeded", result_data={
k: v for k, v in result.items() if k != "payload"
})
await self.transition("reporting", f"#{result['draw_no']} 브리핑 저장 완료")
add_log(self.agent_id, f"큐레이션 완료: #{result['draw_no']} conf={result['confidence']}", task_id=task_id)
# 텔레그램 헤드라인 푸시 (실패해도 큐레이션은 성공으로 마감)
try:
from ..notifiers.telegram_lotto import send_curator_briefing
await send_curator_briefing(result["payload"])
except Exception as e:
add_log(self.agent_id, f"텔레그램 알림 실패: {e}", level="warning", task_id=task_id)
await self.transition("idle", "대기 중")
return {"ok": True, **{k: v for k, v in result.items() if k != "payload"}}
except CuratorError as e:
update_task_status(task_id, "failed", result_data={"error": str(e)})
add_log(self.agent_id, f"큐레이션 실패: {e}", level="error", task_id=task_id)
await self.transition("idle", "오류")
return {"ok": False, "message": str(e)}
except Exception as e:
update_task_status(task_id, "failed", result_data={"error": str(e)})
add_log(self.agent_id, f"큐레이션 예외: {e}", level="error", task_id=task_id)
await self.transition("idle", "오류")
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
- Step 2: scheduler.py — 월 07:00 → 월 09:00
# 기존
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate")
# 신규
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=0, id="lotto_curate")
- Step 3: 수동 트리거로 통합 검증
agent-office API 또는 직접 호출:
docker compose restart agent-office
curl -s -X POST http://localhost:18900/api/agent-office/command \
-H "Content-Type: application/json" \
-d '{"agent_id":"lotto","action":"curate_now","params":{}}'
기대: 큐레이션 진행 + 텔레그램 알림 수신 + 사이트 /api/lotto/briefing/latest 에 4계층 picks 저장됨.
- Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add agent-office/app/agents/lotto.py agent-office/app/scheduler.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(curator): 큐레이션 후 텔레그램 자동 푸시 + cron 09:00 변경"
Task 16 — 프론트 api.js 헬퍼
Files:
-
Modify:
web-ui/src/api.js -
Step 1: 헬퍼 추가 (파일 하단)
// === 주간 회고 (weekly_review) ===
export const getLatestReview = () => apiGet('/api/lotto/review/latest').catch(e => {
if (e?.status === 404) return null;
throw e;
});
export const getReviewHistory = (limit = 4) =>
apiGet(`/api/lotto/review/history?limit=${limit}`).then(d => d.reviews || []);
// === 큐레이터 4계층 원클릭 구매 ===
export const bulkPurchase = ({ draw_no, tier_mode, sets, amount }) =>
apiPost('/api/lotto/purchase/bulk', { draw_no, tier_mode, sets, amount });
- Step 2: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/api.js
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(api): review + bulkPurchase 헬퍼"
Task 17 — useReview 훅 + useBriefing 4계층 수용
Files:
-
Create:
web-ui/src/pages/lotto/hooks/useReview.js -
Modify:
web-ui/src/pages/lotto/hooks/useBriefing.js -
Step 1: useReview
import { useEffect, useState } from 'react';
import { getLatestReview, getReviewHistory } from '../../../api';
export default function useReview() {
const [latest, setLatest] = useState(null);
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancel = false;
Promise.all([getLatestReview(), getReviewHistory(4)])
.then(([l, h]) => {
if (cancel) return;
setLatest(l);
setHistory(h);
})
.catch(() => {})
.finally(() => !cancel && setLoading(false));
return () => { cancel = true; };
}, []);
return { latest, history, loading };
}
- Step 2: useBriefing 변경 — picks 가 dict 인 케이스 수용
기존 useBriefing.js 의 briefing 데이터에서 picks 가 객체(4계층) 또는 리스트(구 데이터) 둘 다 들어올 수 있으므로 정규화:
// useBriefing.js 의 fetch 결과 가공 부분에 추가
const normalizePicks = (picks) => {
if (Array.isArray(picks)) {
return { core: picks, bonus: [], extended: [], pool: [] };
}
return {
core: picks?.core || [],
bonus: picks?.bonus || [],
extended: picks?.extended || [],
pool: picks?.pool || [],
};
};
// fetch 후
setBriefing({ ...data, picks: normalizePicks(data.picks) });
- Step 3: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/lotto/hooks/useReview.js src/pages/lotto/hooks/useBriefing.js
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(lotto): useReview 훅 + useBriefing 4계층 정규화"
Task 18 — DecisionCard 하위 컴포넌트 (Pick/Tier/Toggle/Retro)
Files:
-
Create:
web-ui/src/pages/lotto/components/decision/PickCard.jsx -
Create:
web-ui/src/pages/lotto/components/decision/TierSection.jsx -
Create:
web-ui/src/pages/lotto/components/decision/TierModeToggle.jsx -
Create:
web-ui/src/pages/lotto/components/decision/RetrospectiveBox.jsx -
Create:
web-ui/src/pages/lotto/components/decision/decision.css -
Step 1: PickCard.jsx
const ROLE_COLOR = { '안정': 'stable', '균형': 'balance', '공격': 'aggro' };
export default function PickCard({ pick, index, total }) {
const role = pick.risk_tag;
return (
<div className="lc-set">
<div className="lc-set__head">
<span className={`lc-set__role lc-set__role--${ROLE_COLOR[role]}`}>● {role}</span>
<span className="lc-set__idx">Set {index + 1} / {total}</span>
</div>
<div className="lc-balls">
{pick.numbers.map(n => (
<span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
))}
</div>
<p className="lc-set__reason">{pick.reason}</p>
</div>
);
}
- Step 2: TierSection.jsx
import PickCard from './PickCard';
const TIER_TITLE = {
core: '코어 (필수, 5세트)',
bonus: '보너스 (+5)',
extended: '확장 (+5)',
pool: '풀 (+5)',
};
export default function TierSection({ tier, picks, rationale, indexBase = 0, totalSets }) {
if (!picks?.length) return null;
return (
<section className={`lc-tier lc-tier--${tier}`}>
<header className="lc-tier__head">
<h4>{TIER_TITLE[tier]}</h4>
{rationale && tier !== 'core' && (
<p className="lc-tier__rationale">{rationale}</p>
)}
</header>
{picks.map((p, i) => (
<PickCard key={i} pick={p} index={indexBase + i} total={totalSets} />
))}
</section>
);
}
- Step 3: TierModeToggle.jsx
const MODES = [
{ key: 'core', label: '코어', sets: 5, amount: 5000 },
{ key: 'core_bonus', label: '+ 보너스', sets: 10, amount: 10000 },
{ key: 'core_bonus_extended', label: '+ 확장', sets: 15, amount: 15000 },
{ key: 'full', label: '+ 풀', sets: 20, amount: 20000 },
];
export default function TierModeToggle({ value, onChange }) {
return (
<div className="lc-toggle" role="tablist">
{MODES.map((m, i) => (
<button
key={m.key}
role="tab"
aria-selected={value === m.key}
className={`lc-toggle__chip ${value === m.key ? 'is-active' : ''}`}
onClick={() => onChange(m.key)}
>
<span className="lc-toggle__dots">{'●'.repeat(i + 1) + '○'.repeat(3 - i)}</span>
<span className="lc-toggle__lbl">{m.label}</span>
<span className="lc-toggle__sub">{m.sets}세트 · {m.amount.toLocaleString()}원</span>
</button>
))}
</div>
);
}
export { MODES };
- Step 4: RetrospectiveBox.jsx
export default function RetrospectiveBox({ briefing, review }) {
const retro = briefing?.narrative?.retrospective;
if (!retro) return null;
const drawNo = review?.draw_no ?? (briefing?.draw_no ? briefing.draw_no - 1 : null);
return (
<aside className="lc-retro">
<p className="lc-retro__time">▸ 지난 주 {drawNo ? `${drawNo}회` : ''} 회고</p>
<p className="lc-retro__body">{retro}</p>
</aside>
);
}
- Step 5: decision.css — 결정 카드 스타일
.lc-card { max-width: 720px; margin: 0 auto; background: linear-gradient(180deg, #161220 0%, #1a1426 100%);
border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 24px; color: #ece6f7; }
.lc-head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 14px; }
.lc-eyebrow { font-size: 10px; letter-spacing: 2px; opacity: 0.5; text-transform: uppercase; margin: 0 0 4px; }
.lc-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.lc-conf { display: flex; flex-direction: column; align-items: flex-end; }
.lc-conf__num { font-family: 'Courier New', monospace; font-size: 28px; font-weight: 700; color: #b8a8ff; letter-spacing: -0.04em; }
.lc-conf__lbl { font-size: 9px; letter-spacing: 1.5px; opacity: 0.55; }
.lc-retro { background: rgba(184, 168, 255, 0.06); border-left: 2px solid rgba(184, 168, 255, 0.4);
padding: 10px 14px; margin: 14px 0; border-radius: 4px; }
.lc-retro__time { font-size: 9px; letter-spacing: 1.5px; color: #b8a8ff; opacity: 0.7; margin: 0 0 4px; }
.lc-retro__body { font-size: 13px; line-height: 1.55; opacity: 0.85; margin: 0; }
.lc-headline { font-size: 16px; font-weight: 600; line-height: 1.5; margin: 18px 0 4px; }
.lc-headline-3 { font-size: 12px; opacity: 0.65; line-height: 1.55; margin: 0 0 18px; }
.lc-balance { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px;
background: rgba(255,255,255,0.03); border-radius: 8px; margin-bottom: 16px; font-size: 11px; }
.lc-balance__chips { display: flex; gap: 8px; }
.lc-chip { padding: 3px 8px; border-radius: 100px; font-weight: 600; font-size: 11px; }
.lc-chip--stable { background: rgba(80, 200, 120, 0.15); color: #76e09a; }
.lc-chip--balance { background: rgba(255, 200, 80, 0.15); color: #ffce6e; }
.lc-chip--aggro { background: rgba(255, 100, 130, 0.15); color: #ff8aa0; }
.lc-toggle { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 16px 0; }
.lc-toggle__chip { padding: 10px 8px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px; color: #ece6f7; cursor: pointer; display: flex; flex-direction: column; gap: 4px; align-items: center; }
.lc-toggle__chip.is-active { background: rgba(184, 168, 255, 0.15); border-color: rgba(184, 168, 255, 0.5); }
.lc-toggle__dots { letter-spacing: 2px; font-size: 10px; opacity: 0.7; }
.lc-toggle__lbl { font-size: 12px; font-weight: 600; }
.lc-toggle__sub { font-size: 10px; opacity: 0.55; }
.lc-tier { margin-bottom: 14px; }
.lc-tier__head { padding: 8px 0; border-top: 1px dashed rgba(255,255,255,0.1); margin-bottom: 8px; }
.lc-tier:first-of-type .lc-tier__head { border-top: none; }
.lc-tier__head h4 { font-size: 12px; font-weight: 600; margin: 0 0 4px; opacity: 0.75; letter-spacing: 0.5px; }
.lc-tier__rationale { font-size: 11px; opacity: 0.55; margin: 0; }
.lc-set { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px;
padding: 14px; margin-bottom: 10px; }
.lc-set__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.lc-set__role { font-size: 11px; font-weight: 600; letter-spacing: 0.5px; }
.lc-set__role--stable { color: #76e09a; }
.lc-set__role--balance { color: #ffce6e; }
.lc-set__role--aggro { color: #ff8aa0; }
.lc-set__idx { font-size: 10px; opacity: 0.4; }
.lc-balls { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.lc-set__reason { font-size: 12px; opacity: 0.7; line-height: 1.45; margin: 0; }
.lc-actions { display: flex; gap: 10px; margin-top: 18px; }
@media (max-width: 480px) {
.lc-toggle { grid-template-columns: repeat(2, 1fr); }
}
- Step 6: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/lotto/components/decision/
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(lotto): DecisionCard 하위 컴포넌트(Pick/Tier/Toggle/Retro) + 스타일"
Task 19 — BulkPurchaseButton + DecisionCard 메인 + BriefingTab 교체
Files:
-
Create:
web-ui/src/pages/lotto/components/decision/BulkPurchaseButton.jsx -
Create:
web-ui/src/pages/lotto/components/decision/DecisionCard.jsx -
Modify:
web-ui/src/pages/lotto/tabs/BriefingTab.jsx -
Step 1: BulkPurchaseButton.jsx
import { useState } from 'react';
import { bulkPurchase } from '../../../../api';
import { MODES } from './TierModeToggle';
export default function BulkPurchaseButton({ drawNo, tierMode, onSuccess }) {
const [busy, setBusy] = useState(false);
const mode = MODES.find(m => m.key === tierMode) || MODES[0];
const onClick = async () => {
if (busy) return;
setBusy(true);
try {
await bulkPurchase({
draw_no: drawNo,
tier_mode: tierMode,
sets: mode.sets,
amount: mode.amount,
});
onSuccess?.();
alert(`${mode.sets}세트 구매 기록 완료!`);
} catch (e) {
alert(`구매 기록 실패: ${e?.message || e}`);
} finally {
setBusy(false);
}
};
return (
<button className="lc-btn lc-btn--prim" onClick={onClick} disabled={busy || !drawNo}>
{busy ? '저장 중...' : `이대로 ${mode.sets}세트 구매했음`}
</button>
);
}
- Step 2: DecisionCard.jsx
import { useEffect, useMemo, useState } from 'react';
import RetrospectiveBox from './RetrospectiveBox';
import TierModeToggle, { MODES } from './TierModeToggle';
import TierSection from './TierSection';
import BulkPurchaseButton from './BulkPurchaseButton';
import './decision.css';
const TIER_CHAIN = {
core: ['core'],
core_bonus: ['core', 'bonus'],
core_bonus_extended: ['core', 'bonus', 'extended'],
full: ['core', 'bonus', 'extended', 'pool'],
};
const STORAGE_KEY = 'lotto.tier_mode';
export default function DecisionCard({ briefing, review, onPurchaseSuccess }) {
const [tierMode, setTierMode] = useState(() =>
localStorage.getItem(STORAGE_KEY) || 'core'
);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, tierMode);
}, [tierMode]);
const visibleTiers = TIER_CHAIN[tierMode];
const totalSets = useMemo(
() => visibleTiers.reduce((sum, t) => sum + (briefing?.picks?.[t]?.length || 0), 0),
[briefing, visibleTiers]
);
// 분배 칩 — 보이는 계층의 risk_tag 합산
const balance = useMemo(() => {
const acc = { '안정': 0, '균형': 0, '공격': 0 };
for (const t of visibleTiers) {
for (const p of (briefing?.picks?.[t] || [])) {
if (acc[p.risk_tag] !== undefined) acc[p.risk_tag]++;
}
}
return acc;
}, [briefing, visibleTiers]);
if (!briefing) return null;
let cursor = 0;
return (
<div className="lc-card">
<header className="lc-head">
<div>
<p className="lc-eyebrow">Curator Briefing · {briefing.draw_no}회</p>
<h3 className="lc-title">{briefing.narrative.headline}</h3>
</div>
<div className="lc-conf">
<div className="lc-conf__num">{briefing.confidence}</div>
<div className="lc-conf__lbl">CONFIDENCE</div>
</div>
</header>
<RetrospectiveBox briefing={briefing} review={review} />
<p className="lc-headline-3">
{(briefing.narrative.summary_3lines || []).join(' · ')}
</p>
<div className="lc-balance">
<div className="lc-balance__chips">
{balance['안정'] > 0 && <span className="lc-chip lc-chip--stable">안정 ×{balance['안정']}</span>}
{balance['균형'] > 0 && <span className="lc-chip lc-chip--balance">균형 ×{balance['균형']}</span>}
{balance['공격'] > 0 && <span className="lc-chip lc-chip--aggro">공격 ×{balance['공격']}</span>}
</div>
</div>
<TierModeToggle value={tierMode} onChange={setTierMode} />
{visibleTiers.map(tier => {
const picks = briefing.picks?.[tier] || [];
const idxBase = cursor;
cursor += picks.length;
return (
<TierSection
key={tier}
tier={tier}
picks={picks}
rationale={briefing.tier_rationale?.[tier]}
indexBase={idxBase}
totalSets={totalSets}
/>
);
})}
<div className="lc-actions">
<BulkPurchaseButton
drawNo={briefing.draw_no}
tierMode={tierMode}
onSuccess={onPurchaseSuccess}
/>
</div>
</div>
);
}
- Step 3: BriefingTab.jsx — DecisionCard 단일 화면
import useBriefing from '../hooks/useBriefing';
import useReview from '../hooks/useReview';
import DecisionCard from '../components/decision/DecisionCard';
import BriefingEmpty from '../components/briefing/BriefingEmpty';
export default function BriefingTab() {
const { briefing, loading, error, regenerating, regenerate } = useBriefing();
const { latest: review } = useReview();
if (loading) return <div className="briefing-empty"><p>로딩 중...</p></div>;
if (!briefing) return <BriefingEmpty regenerating={regenerating} onRegenerate={regenerate} error={error} />;
return (
<div className="briefing-tab">
<DecisionCard briefing={briefing} review={review} />
</div>
);
}
- Step 4: 검증 (브라우저)
cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm run dev
브라우저에서 http://localhost:3007/lotto 열기. 브리핑 탭이 결정 카드로 보이는지 + 모드 토글 동작 + 새로고침 후 마지막 모드 기억하는지 확인.
- Step 5: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/lotto/components/decision/BulkPurchaseButton.jsx src/pages/lotto/components/decision/DecisionCard.jsx src/pages/lotto/tabs/BriefingTab.jsx
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(lotto): DecisionCard + BulkPurchaseButton, BriefingTab 단일 화면 재구성"
Task 20 — 분석 탭 → 자료실 라벨 + 첫 진입 접힘
Files:
-
Modify:
web-ui/src/pages/lotto/Functions.jsx -
Modify:
web-ui/src/pages/lotto/tabs/AnalysisTab.jsx -
Step 1: 탭 라벨 변경
Functions.jsx:
const TABS = [
{ id: 'briefing', label: '🗓 이번 주 브리핑' },
{ id: 'analysis', label: '📚 자료실 / Deep Dive' }, // ← 변경
{ id: 'purchase', label: '💰 구매·성과' },
];
- Step 2: AnalysisTab — 모든 패널을
<details>로 감싸 첫 진입 시 접힘
AnalysisTab.jsx 의 각 <section className="lotto-panel"> 또는 CombinedRecommendPanel, ReportPanel, PersonalAnalysisPanel 등을 다음 패턴으로 감싸기:
<details className="lotto-section-fold">
<summary>섹션 제목 (펼치기)</summary>
{/* 기존 내용 */}
</details>
대표 예 — Latest Draw 섹션:
<details className="lotto-section-fold">
<summary>최신 회차</summary>
<section className="lotto-panel">
{/* 기존 panel head + body */}
</section>
</details>
같은 패턴을 시뮬레이션 추천, 통계 분석, 전체 회차 번호 분포, 내 번호 패턴, 수동 추천, 추천 히스토리에 모두 적용.
기존 PerformanceBanner(<PerformanceBanner perf={ld.perfStats} />)는 그대로 노출(자료실의 단축 신뢰도 칭찬). DecisionCard 헤더의 신뢰도와 의미가 다르므로 중복 아님.
- Step 3: CSS 한 줄 추가 (Lotto.css)
.lotto-section-fold { margin-bottom: 14px; }
.lotto-section-fold > summary { cursor: pointer; padding: 12px 16px; background: rgba(255,255,255,0.03);
border-radius: 10px; font-weight: 600; font-size: 14px; opacity: 0.85; }
.lotto-section-fold[open] > summary { margin-bottom: 12px; opacity: 1; }
- Step 4: 브라우저 확인
자료실 탭 클릭 → 모든 패널이 접힌 상태 → 각 클릭으로 펼침.
- Step 5: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/lotto/Functions.jsx src/pages/lotto/tabs/AnalysisTab.jsx src/pages/lotto/Lotto.css
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(lotto): 분석탭 → 자료실 라벨 + 첫 진입 모든 패널 접힘"
Task 21 — PurchasePanel 자동 채점 표시 + 4등 이상 플래그
Files:
- Modify:
web-ui/src/pages/lotto/hooks/usePurchases.js - Modify:
web-ui/src/pages/lotto/components/PurchasePanel.jsx
기존 purchase_history.results JSON 컬럼에는 각 세트의 correct 가 들어있음(자동 채점 결과). 행에 표시.
- Step 1: usePurchases — bulkPurchase 추가
// usePurchases.js 안 적당한 위치에
import { bulkPurchase as apiBulkPurchase } from '../../../api';
// 훅 반환에 추가
const handleBulkPurchase = useCallback(async (params) => {
const result = await apiBulkPurchase(params);
await refresh(); // 목록 갱신
return result;
}, [refresh]);
return {
...,
handleBulkPurchase,
};
- Step 2: PurchasePanel — 행에 results 표시
기존 lotto-purchase-row 안에 추가:
{/* 기존 컬럼들 */}
<span className="lotto-purchase-row__hits">
{(rec.results || []).map((r, i) => (
<span key={i} className={`hit-badge hit-${r.correct}`}>{r.correct}</span>
))}
{(rec.results || []).some(r => r.correct >= 4) && (
<span className="prize-flag">🚨 4등↑ 확인 필요</span>
)}
</span>
CSS:
.hit-badge { display: inline-block; min-width: 16px; padding: 1px 4px; margin-right: 2px;
font-size: 10px; border-radius: 4px; background: rgba(255,255,255,0.06); text-align: center; }
.hit-badge.hit-3 { background: rgba(80, 200, 120, 0.2); color: #76e09a; }
.hit-badge.hit-4 { background: rgba(255, 200, 80, 0.25); color: #ffce6e; font-weight: 700; }
.hit-badge.hit-5, .hit-badge.hit-6 { background: rgba(255, 100, 130, 0.3); color: #ff8aa0; font-weight: 700; }
.prize-flag { font-size: 10px; color: #ff8aa0; margin-left: 6px; }
- Step 3: 검증 — 자동 채점된 회차의 구매 기록이 보이는지
브라우저 → 구매 탭 → 채점된 행에 일치 수 배지 + 4등 이상 시 플래그.
- Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/lotto/hooks/usePurchases.js src/pages/lotto/components/PurchasePanel.jsx src/pages/lotto/Lotto.css
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(lotto): 구매탭에 자동 채점 일치수 배지 + 4등↑ 플래그"
Task 22 — PurchaseTrendChart (4주 추세)
Files:
-
Create:
web-ui/src/pages/lotto/components/PurchaseTrendChart.jsx -
Modify:
web-ui/src/pages/lotto/tabs/PurchaseTab.jsx(마운트) -
Step 1: PurchaseTrendChart
import { useEffect, useState } from 'react';
import { getReviewHistory } from '../../../api';
export default function PurchaseTrendChart() {
const [reviews, setReviews] = useState([]);
useEffect(() => {
getReviewHistory(4).then(rs => setReviews(rs.reverse())); // asc
}, []);
if (reviews.length === 0) return null;
const maxAvg = Math.max(
...reviews.flatMap(r => [r.curator_avg_match || 0, r.user_avg_match || 0]),
2.5
);
const w = 320, h = 80, pad = 16;
const xs = (i) => pad + (i / Math.max(reviews.length - 1, 1)) * (w - 2 * pad);
const ys = (v) => v == null ? null : h - pad - (v / maxAvg) * (h - 2 * pad);
const line = (key) => reviews
.map((r, i) => ({ x: xs(i), y: ys(r[key]) }))
.filter(p => p.y != null)
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`)
.join(' ');
return (
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Trend (last 4 weeks)</p>
<h3>너 vs 큐레이터 평균 일치 수</h3>
</div>
</div>
<svg width={w} height={h} className="trend-chart">
<path d={line('curator_avg_match')} stroke="#b8a8ff" strokeWidth="2" fill="none" />
<path d={line('user_avg_match')} stroke="#76e09a" strokeWidth="2" fill="none" />
</svg>
<div className="trend-legend">
<span><span className="dot dot--curator" /> 큐레이터</span>
<span><span className="dot dot--user" /> 너</span>
</div>
</section>
);
}
CSS:
.trend-chart { display: block; margin: 0 auto; }
.trend-legend { display: flex; gap: 16px; justify-content: center; font-size: 11px; opacity: 0.7; margin-top: 8px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
.dot--curator { background: #b8a8ff; }
.dot--user { background: #76e09a; }
- Step 2: PurchaseTab 에 마운트
import PurchaseTrendChart from '../components/PurchaseTrendChart';
// PurchasePanel 위 또는 아래에
<PurchaseTrendChart />
<PurchasePanel ... />
- Step 3: 검증
구매 탭에서 추세 차트가 보이는지 (review 데이터 1건 이상 있을 때).
- Step 4: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/lotto/components/PurchaseTrendChart.jsx src/pages/lotto/tabs/PurchaseTab.jsx src/pages/lotto/Lotto.css
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(lotto): 구매탭 4주 추세 차트(너 vs 큐레이터 평균 일치)"
Task 23 — 운영 점검 체크리스트 (배포 후 1주차)
이건 새 코드가 아니라 수동 검증. 체크리스트 README 추가.
Files:
-
Create:
web-backend/lotto/docs/operations-week1.md(워크스페이스 사용자 문서, 백엔드 쪽) -
Step 1: 체크리스트 작성
# Lotto Curator Evolution — 1주차 운영 점검
## 일요일 (추첨 다음날)
- [ ] 03:05 KST: lotto-backend 로그에 `[grade_weekly_review] saved review id=N` 출력 확인
- [ ] `curl http://localhost:18000/api/lotto/review/latest` → JSON 정상
- [ ] purchase_history 의 직전 회차 행이 `checked=1`, `total_prize` 채워졌는지
## 월요일
- [ ] 09:05 KST: agent-office 로그에 `큐레이션 완료: #NNNN` + `[telegram_lotto] briefing` 출력
- [ ] 텔레그램 봇 채팅에 헤드라인 알림 도착 (회고 단락 포함/생략 정확)
- [ ] `curl http://localhost:18000/api/lotto/briefing/latest` → 4계층 picks(core/bonus/extended/pool 각 5세트) + tier_rationale + narrative.retrospective
## 사이트 확인
- [ ] http://localhost:3007/lotto 브리핑 탭 결정 카드 정상 렌더
- [ ] 모드 토글 4단계 동작 (5/10/15/20 펼침/접힘)
- [ ] localStorage `lotto.tier_mode` 마지막 선택 기억 (새로고침 후 유지)
- [ ] "이대로 N세트 구매" 클릭 → 토스트 + 구매탭 갱신
- [ ] 자료실 탭 첫 진입 시 모든 패널 접힘
- [ ] 구매탭 추세 차트 1주차에는 점 1개, 2주차부터 라인 형성
## 실패 케이스
- [ ] 큐레이션 실패(Anthropic API 다운): agent-office 로그 + lotto_agent state=idle, 에러 텔레그램
- [ ] 4등 이상 발견: 별도 텔레그램 푸시 도착 (3개 이하만 있으면 미발송)
- [ ] briefing 없는 회차에 bulk purchase 시도: 400 응답, 토스트 표시
## cron 시간 조정 (필요 시)
- 채점 잡: `lotto/app/main.py` `_scheduler.add_job(..., hour=3, minute=0)`
- 큐레이션: `agent-office/app/scheduler.py` `add_job(_run_lotto_schedule, ..., hour=9, minute=0)`
- Step 2: Commit
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add lotto/docs/operations-week1.md
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "docs(lotto): 1주차 운영 점검 체크리스트"
종합 검증 — 전체 흐름 한 번 돌려보기
이 plan 의 모든 task 가 끝난 후 1회 통합 점검.
- lotto-backend + agent-office 재기동
cd C:/Users/jaeoh/Desktop/workspace/web-backend
docker compose restart lotto-backend agent-office
- 수동 채점 잡 실행 (직전 회차에 대해)
docker compose exec lotto-backend python -c "from app.jobs.grade_weekly_review import run_for_latest; print(run_for_latest())"
기대: {'ok': True, 'review_id': N}
- 수동 큐레이션 트리거
curl -s -X POST http://localhost:18900/api/agent-office/command \
-H "Content-Type: application/json" \
-d '{"agent_id":"lotto","action":"curate_now","params":{}}'
기대: 큐레이션 완료 + 텔레그램 메시지 + briefing 저장.
- 프론트 결정 카드 확인
cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm run dev
브라우저 → 결정 카드 + 모드 토글 + 회고 박스 + 자료실 접힘 + 구매탭 추세 모두 확인.
- 배포
cd C:/Users/jaeoh/Desktop/workspace
scripts\deploy.bat
Self-Review 결과
스펙 vs plan 매핑 점검:
| 스펙 섹션 | 매핑되는 Task |
|---|---|
| 4. 결정 카드 (브리핑 탭 메인) | Task 18 (하위 컴포넌트), 19 (DecisionCard 메인 + BriefingTab) |
| 4. 4계층 위계 + localStorage 기억 | Task 18 (TierModeToggle.MODES), 19 (DecisionCard STORAGE_KEY) |
| 4. 분석 탭 자료실 강등 | Task 20 |
| 5. weekly_review 테이블 | Task 1 |
| 5. lotto_briefings.picks 4계층 | Task 2 |
| 6. 큐레이터 출력 스키마 | Task 9 |
| 6. SYSTEM_PROMPT 회고/계층 규칙 | Task 10 |
| 6. build_retrospective + service_proxy | Task 11 |
| 6. 후보 풀 N=30 | Task 12 (lotto_candidates(n=30)) |
| 7. 자동 채점 잡 | Task 3 (보조 함수) + Task 4 (통합 잡) |
| 7. 일치 3개 → prize 5000 | 기존 purchase_manager.RANK_PRIZE 활용 (Task 4) |
| 7. 일치 4+ → webhook | Task 4 (_trigger_prize_alert) |
| 8. 텔레그램 큐레이션 알림 | Task 13 (포맷) + Task 15 (lotto_agent 호출) |
| 8. 4등 이상 별도 알림 webhook 라우터 | Task 14 |
| 9. 프론트 파일 변경 맵 | Task 16~22 |
| 10. 백엔드 파일 변경 맵 | Task 1 |
| 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) 모두 동일.picks4계층 키는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_tierTask 3 → Task 4 일관. purchase_history.resultsJSON 의correct키 Task 21 에서 활용 — 기존purchase_manager.py와 동일.