diff --git a/docs/superpowers/plans/2026-05-23-lotto-evolver-ui.md b/docs/superpowers/plans/2026-05-23-lotto-evolver-ui.md new file mode 100644 index 0000000..6a2c48f --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-lotto-evolver-ui.md @@ -0,0 +1,1618 @@ +# Lotto Evolver UI + 활동 가시화 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:** /lotto/evolver 페이지 신설 + LottoAgent 모든 작업을 agent_tasks에 기록 (텔레그램 404 해결 + 활동 가시화) + +**Architecture:** web-backend agent-office에서 LottoAgent 3개 메서드에 task_id wrap + lotto-lab activity sync cron 추가. web-ui에 /lotto/evolver 라우트 + 5 카드 단일 스크롤 페이지 + LottoActivityTimeline 컴포넌트. Recharts 활용. agent-office UI는 기존 TaskTab/LogTab이 새 task_type들을 자동 표시. + +**Tech Stack:** Python 3.12, FastAPI, APScheduler, SQLite (agent-office); React 18, Vite, React Router v6, Recharts, @testing-library/react (web-ui) + +**Spec:** `docs/superpowers/specs/2026-05-23-lotto-evolver-ui-design.md` + +--- + +## Important Spec Adjustment Noted in Plan + +Spec section 2 / 5.3에서 `LottoActivityTimeline`을 `/agent-office`에서도 재사용한다고 명시했으나, **확인 결과 agent-office UI에는 이미 `TaskTab.jsx` + `LogTab.jsx`가 LottoAgent 카드 안에 task/log를 표시**. 백엔드 보강(task_id wrap + sync cron)만 하면 agent-office UI는 추가 변경 없이 새 task_type들이 자동 노출됨. 따라서 `LottoActivityTimeline`은 **`/lotto/evolver` 전용**으로 한정. spec 정신(한 곳에서 추적 가능)은 유지되고 작업량 감소. + +--- + +## File Structure + +### web-backend (agent-office) + +| 경로 | 작업 | 책임 | +|---|---|---| +| `agent-office/app/db.py` | Modify | `get_tasks_by_agent_date_kind` helper 추가, `get_agent_tasks` task_type/days 필터 | +| `agent-office/app/agents/lotto.py` | Modify | run_signal_check / run_daily_digest / run_weekly_evolution_report에 task_id wrap + 신규 sync_evolver_activity | +| `agent-office/app/scheduler.py` | Modify | cron 1종 추가 (lotto_evolver_activity_sync 매일 09:30) | +| `agent-office/app/main.py` | Modify | GET /agents/{id}/tasks에 task_type query param 추가 | +| `agent-office/tests/test_lotto_task_wrap.py` | Create | task_id wrap 패턴 단위 테스트 | +| `agent-office/tests/test_sync_evolver_activity.py` | Create | sync 멱등 + 데이터 매핑 테스트 | + +### web-ui + +| 경로 | 작업 | 책임 | +|---|---|---| +| `web-ui/src/api.js` | Modify | evolver fetch helpers (3종) + lotto activity fetch | +| `web-ui/src/pages/lotto/Evolver.jsx` | Create | 페이지 진입점, useEvolverApi 호출 + 5 카드 조립 | +| `web-ui/src/pages/lotto/Evolver.css` | Create | 페이지 스타일 | +| `web-ui/src/pages/lotto/evolver/WinnerCard.jsx` | Create | Radar + 메타 정보 | +| `web-ui/src/pages/lotto/evolver/TrialsGrid.jsx` | Create | 6일 Bar + 펼치기 | +| `web-ui/src/pages/lotto/evolver/BaseDiff.jsx` | Create | 5개 metric-card (텍스트 diff) | +| `web-ui/src/pages/lotto/evolver/BaseHistory.jsx` | Create | LineChart 12주 시계열 | +| `web-ui/src/pages/lotto/evolver/EvolverActions.jsx` | Create | 수동 트리거 버튼 | +| `web-ui/src/pages/lotto/evolver/LottoActivityTimeline.jsx` | Create | 활동 timeline 컴포넌트 | +| `web-ui/src/pages/lotto/evolver/useEvolverApi.js` | Create | 4 fetch + activity merge hook | +| `web-ui/src/routes.jsx` | Modify | Evolver lazy import + route 추가 | +| `web-ui/src/pages/lotto/evolver/Evolver.test.jsx` | Create | 핵심 컴포넌트 smoke test | + +--- + +# Phase 1 — agent-office 백엔드 보강 + +## Task 1: db.py에 멱등 helper + task_type 필터 + +**Files:** +- Modify: `agent-office/app/db.py:239` (`get_agent_tasks`), append at end (`get_tasks_by_agent_date_kind`) +- Test: `agent-office/tests/test_lotto_task_wrap.py` (다음 task에서 작성) + +- [ ] **Step 1: Add task_type + days filter to get_agent_tasks** + +`agent-office/app/db.py` line 239 함수를 다음으로 교체: + +```python +def get_agent_tasks( + agent_id: str, + limit: int = 20, + task_type: Optional[str] = None, + days: Optional[int] = None, +) -> List[Dict[str, Any]]: + sql = "SELECT * FROM agent_tasks WHERE agent_id=?" + params: List[Any] = [agent_id] + if task_type is not None: + sql += " AND task_type=?" + params.append(task_type) + if days is not None and days > 0: + sql += " AND created_at >= datetime('now', ?)" + params.append(f"-{int(days)} days") + sql += " ORDER BY created_at DESC LIMIT ?" + params.append(limit) + with _conn() as conn: + rows = conn.execute(sql, params).fetchall() + return [_task_to_dict(r) for r in rows] +``` + +- [ ] **Step 2: Add idempotent guard helper at end of db.py** + +`agent-office/app/db.py` 파일 끝에 추가: + +```python + + +def get_tasks_by_agent_date_kind(agent_id: str, date_iso: str, task_type: str) -> List[Dict[str, Any]]: + """같은 (agent, date, task_type)으로 이미 생성된 task 조회. 멱등 guard.""" + with _conn() as conn: + rows = conn.execute( + """ + SELECT * FROM agent_tasks + WHERE agent_id = ? AND task_type = ? + AND substr(created_at, 1, 10) = ? + ORDER BY created_at DESC + """, + (agent_id, task_type, date_iso), + ).fetchall() + return [_task_to_dict(r) for r in rows] +``` + +- [ ] **Step 3: Smoke verify** + +```bash +cd agent-office && python -c " +from app.db import get_agent_tasks, get_tasks_by_agent_date_kind +print(get_agent_tasks('lotto', task_type='curate_weekly', days=7)[:1]) +print(get_tasks_by_agent_date_kind('lotto', '2026-05-23', 'evolver_apply')) +" +``` +Expected: 빈 리스트 (또는 기존 lotto curate task 1개) + +- [ ] **Step 4: Commit** + +```bash +git add agent-office/app/db.py +git commit -m "feat(lotto-agent): get_agent_tasks 필터 + get_tasks_by_agent_date_kind 멱등 guard" +``` + +--- + +## Task 2: LottoAgent.run_signal_check task_id wrap + 테스트 + +**Files:** +- Modify: `agent-office/app/agents/lotto.py` (run_signal_check 메서드 전체) +- Create: `agent-office/tests/test_lotto_task_wrap.py` + +- [ ] **Step 1: Write failing test** + +```python +# agent-office/tests/test_lotto_task_wrap.py +import os +import sys +import asyncio +import tempfile +import gc + +_fd, _TMP = tempfile.mkstemp(suffix=".db") +os.close(_fd) +os.unlink(_TMP) +os.environ["AGENT_OFFICE_DB_PATH"] = _TMP + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest +from app import db +db.DB_PATH = _TMP + + +@pytest.fixture(autouse=True) +def fresh_db(): + gc.collect() + if os.path.exists(_TMP): + os.remove(_TMP) + db.init_db() + yield + gc.collect() + if os.path.exists(_TMP): + try: + os.remove(_TMP) + except PermissionError: + pass + + +@pytest.mark.asyncio +async def test_run_signal_check_creates_task_row(monkeypatch): + """run_signal_check이 agent_tasks에 row를 만들고 result_data를 저장.""" + from app.agents.lotto import LottoAgent + from app.curator import signal_runner + + async def fake_run_signal_check(**kwargs): + return { + "overall_fire": "normal", + "results": [ + {"signal_id": 1, "metric": "sim_signal", + "value": 0.6, "z_score": 1.7, "fire_level": "normal", + "baseline_mu": 0.5, "baseline_sigma": 0.05, "payload": {}}, + ], + } + monkeypatch.setattr(signal_runner, "run_signal_check", fake_run_signal_check) + + # 텔레그램 발송 stub + from app.notifiers import telegram_lotto + async def fake_send(_event): pass + monkeypatch.setattr(telegram_lotto, "send_urgent_signal", fake_send) + + agent = LottoAgent() + result = await agent.run_signal_check(source="light") + assert result["ok"] is True + + tasks = db.get_agent_tasks("lotto", task_type="signal_check", days=1) + assert len(tasks) == 1 + t = tasks[0] + assert t["status"] == "succeeded" + assert t["result_data"]["source"] == "light" + assert t["result_data"]["overall_fire"] == "normal" + assert "sim_signal" in t["result_data"]["fired_metrics"] + + +@pytest.mark.asyncio +async def test_run_signal_check_failure_marks_task_failed(monkeypatch): + from app.agents.lotto import LottoAgent + from app.curator import signal_runner + + async def boom(**kwargs): + raise RuntimeError("boom") + monkeypatch.setattr(signal_runner, "run_signal_check", boom) + + agent = LottoAgent() + result = await agent.run_signal_check(source="sim") + assert result["ok"] is False + + tasks = db.get_agent_tasks("lotto", task_type="signal_check", days=1) + assert len(tasks) == 1 + assert tasks[0]["status"] == "failed" + assert "boom" in tasks[0]["result_data"]["error"] +``` + +Run: `cd agent-office && pytest tests/test_lotto_task_wrap.py -v` +Expected: FAIL — current `run_signal_check` 은 task row를 만들지 않음. + +- [ ] **Step 2: Modify run_signal_check in lotto.py** + +`agent-office/app/agents/lotto.py`의 `run_signal_check` 메서드 본체를 다음으로 교체 (메서드 시그니처 그대로): + +```python + async def run_signal_check(self, source: str = "light") -> dict: + """비-LLM 시그널 평가. task_id wrap 적용.""" + from ..curator.signal_runner import run_signal_check + from ..config import ( + LOTTO_Z_NORMAL, LOTTO_Z_URGENT, + LOTTO_THROTTLE_HOURS, LOTTO_URGENT_DAILY_MAX, + ) + from ..db import ( + create_task, update_task_status, add_log, + get_last_signal_notification, get_recent_urgent_count, + mark_signal_notified, + ) + from ..notifiers.telegram_lotto import send_urgent_signal + from ..service_proxy import lotto_latest_draw + + if self.state not in ("idle", "reporting"): + return {"ok": False, "message": f"busy ({self.state})"} + + task_id = create_task("lotto", "signal_check", {"source": source}) + try: + curate_result = None + current_draw_no = await lotto_latest_draw() + + if source == "deep": + from ..curator.pipeline import curate_weekly + cw = await curate_weekly(source="signal_deep") + curate_result = {"confidence": cw.get("confidence")} + if cw.get("draw_no"): + current_draw_no = cw.get("draw_no") + + outcome = await run_signal_check( + source=source, + z_normal=LOTTO_Z_NORMAL, + z_urgent=LOTTO_Z_URGENT, + curate_result=curate_result, + current_draw_no=current_draw_no, + ) + + # --- urgent 텔레그램 발송 + throttle (기존 로직) --- + if outcome["overall_fire"] == "urgent": + if get_recent_urgent_count(hours=24) >= LOTTO_URGENT_DAILY_MAX: + add_log("lotto", "urgent daily cap 도달 → normal로 강등", level="warning", task_id=task_id) + else: + blocked = False + for r in outcome["results"]: + if r["fire_level"] in ("normal", "urgent"): + if get_last_signal_notification( + metric=r["metric"], fire_level=r["fire_level"], + hours=LOTTO_THROTTLE_HOURS, + ): + blocked = True + break + if not blocked: + from datetime import datetime, timezone + event = { + "fire_level": "urgent", + "triggered_at": datetime.now(timezone.utc).isoformat(), + "results": outcome["results"], + } + await send_urgent_signal(event) + for r in outcome["results"]: + if r["fire_level"] in ("normal", "urgent"): + mark_signal_notified(r["signal_id"]) + add_log("lotto", f"urgent 텔레그램 발송 ({len(outcome['results'])}개 시그널)", task_id=task_id) + + # --- task 성공 마킹 --- + fired_metrics = [r["metric"] for r in outcome["results"] if r["fire_level"] != "noop" and r["fire_level"] != "warmup"] + update_task_status(task_id, "succeeded", result_data={ + "source": source, + "overall_fire": outcome["overall_fire"], + "n_results": len(outcome["results"]), + "fired_metrics": fired_metrics, + }) + add_log("lotto", f"signal_check({source}) → {outcome['overall_fire']} results={len(outcome['results'])}", task_id=task_id) + return {"ok": True, **outcome} + except Exception as e: + update_task_status(task_id, "failed", result_data={"error": str(e)}) + add_log("lotto", f"signal_check 예외: {e}", level="error", task_id=task_id) + return {"ok": False, "message": f"{type(e).__name__}: {e}"} +``` + +- [ ] **Step 3: Run tests pass** + +```bash +cd agent-office && pytest tests/test_lotto_task_wrap.py -v +cd agent-office && pytest tests/ -v 2>&1 | tail -5 +``` +Expected: 2 new tests pass + no regression. + +- [ ] **Step 4: Commit** + +```bash +git add agent-office/app/agents/lotto.py agent-office/tests/test_lotto_task_wrap.py +git commit -m "feat(lotto-agent): run_signal_check task_id wrap + 테스트" +``` + +--- + +## Task 3: run_daily_digest task_id wrap + +**Files:** +- Modify: `agent-office/app/agents/lotto.py` (run_daily_digest 메서드) + +- [ ] **Step 1: Modify run_daily_digest** + +기존 `run_daily_digest` 메서드를 다음으로 교체: + +```python + async def run_daily_digest(self) -> dict: + """일일 요약 — 지난 24h normal/urgent 발화 텔레그램 1통. task_id wrap.""" + from ..db import ( + create_task, update_task_status, add_log, + get_recent_lotto_signals, get_signals_history, get_baseline, + ) + from ..notifiers.telegram_lotto import send_signal_summary + + task_id = create_task("lotto", "daily_digest", {}) + try: + sigs = get_recent_lotto_signals(hours=24, min_fire="normal") + total_24h = get_signals_history(days=1) + evaluated = len(total_24h) + + trend = {} + try: + cache = get_baseline("drift_weights_cache") + if cache and isinstance(cache["window_values"], list) and len(cache["window_values"]) >= 2: + prev_w = cache["window_values"][-2] + curr_w = cache["window_values"][-1] + trend = { + k: curr_w.get(k, 0.0) - prev_w.get(k, 0.0) + for k in (set(prev_w) | set(curr_w)) + } + except Exception as e: + add_log("lotto", f"weights_trend 계산 실패: {e}", level="warning", task_id=task_id) + + digest = { + "evaluated": evaluated, + "fired": len(sigs), + "signals": sigs, + "weights_trend": trend, + } + await send_signal_summary(digest) + update_task_status(task_id, "succeeded", result_data={ + "evaluated": evaluated, + "fired": len(sigs), + "signals_count": len(sigs), + }) + add_log("lotto", f"daily_digest 발송: 평가 {evaluated} / 발화 {len(sigs)}", task_id=task_id) + return {"ok": True, **digest} + except Exception as e: + update_task_status(task_id, "failed", result_data={"error": str(e)}) + add_log("lotto", f"daily_digest 예외: {e}", level="error", task_id=task_id) + return {"ok": False, "message": f"{type(e).__name__}: {e}"} +``` + +- [ ] **Step 2: Append test for daily_digest task wrap** + +`agent-office/tests/test_lotto_task_wrap.py`에 추가: + +```python + + +@pytest.mark.asyncio +async def test_run_daily_digest_creates_task(monkeypatch): + from app.agents.lotto import LottoAgent + from app.notifiers import telegram_lotto + async def fake_send(_d): pass + monkeypatch.setattr(telegram_lotto, "send_signal_summary", fake_send) + + agent = LottoAgent() + result = await agent.run_daily_digest() + assert result["ok"] is True + + tasks = db.get_agent_tasks("lotto", task_type="daily_digest", days=1) + assert len(tasks) == 1 + assert tasks[0]["status"] == "succeeded" + assert "fired" in tasks[0]["result_data"] +``` + +- [ ] **Step 3: Run tests pass** + +```bash +cd agent-office && pytest tests/test_lotto_task_wrap.py -v +``` +Expected: 3 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add agent-office/app/agents/lotto.py agent-office/tests/test_lotto_task_wrap.py +git commit -m "feat(lotto-agent): run_daily_digest task_id wrap" +``` + +--- + +## Task 4: run_weekly_evolution_report task_id wrap + +**Files:** +- Modify: `agent-office/app/agents/lotto.py` (run_weekly_evolution_report) + +- [ ] **Step 1: Modify run_weekly_evolution_report** + +기존 메서드를 다음으로 교체: + +```python + async def run_weekly_evolution_report(self) -> dict: + """토 22:15 — lotto-lab evaluate-now 트리거 후 텔레그램 리포트. task_id wrap.""" + from ..service_proxy import lotto_evolver_evaluate, lotto_evolver_status + from ..notifiers.telegram_lotto import send_evolution_report + from ..db import create_task, update_task_status, add_log + + task_id = create_task("lotto", "weekly_evolution_report", {}) + try: + eval_result = await lotto_evolver_evaluate() + status = await lotto_evolver_status() + current_base = status.get("current_base") or [0.2] * 5 + await send_evolution_report(eval_result, current_base) + + winner = eval_result.get("winner") or {} + update_task_status(task_id, "succeeded", result_data={ + "draw_no": eval_result.get("draw_no"), + "update_reason": eval_result.get("update_reason"), + "winner_day_of_week": winner.get("day_of_week"), + "winner_max_correct": winner.get("max_correct"), + }) + add_log("lotto", f"weekly_evolution_report 발송: draw={eval_result.get('draw_no')} reason={eval_result.get('update_reason')}", task_id=task_id) + return {"ok": True, **eval_result} + except Exception as e: + update_task_status(task_id, "failed", result_data={"error": str(e)}) + add_log("lotto", f"weekly_evolution_report 예외: {e}", level="error", task_id=task_id) + return {"ok": False, "message": f"{type(e).__name__}: {e}"} +``` + +- [ ] **Step 2: Append test** + +`agent-office/tests/test_lotto_task_wrap.py`에 추가: + +```python + + +@pytest.mark.asyncio +async def test_run_weekly_evolution_report_creates_task(monkeypatch): + from app.agents.lotto import LottoAgent + from app import service_proxy + from app.notifiers import telegram_lotto + + async def fake_eval(): + return { + "ok": True, "draw_no": 1225, + "winner": {"day_of_week": 3, "weight": [0.18,0.32,0.20,0.22,0.08], + "avg_score": 0.42, "max_correct": 4, "n_picks": 5}, + "new_base": [0.18,0.32,0.20,0.22,0.08], + "previous_base": [0.2]*5, + "update_reason": "winner_4plus", + } + async def fake_status(): + return {"current_base": [0.2]*5} + async def fake_send(_e, _b): pass + + monkeypatch.setattr(service_proxy, "lotto_evolver_evaluate", fake_eval) + monkeypatch.setattr(service_proxy, "lotto_evolver_status", fake_status) + monkeypatch.setattr(telegram_lotto, "send_evolution_report", fake_send) + + agent = LottoAgent() + result = await agent.run_weekly_evolution_report() + assert result["ok"] is True + + tasks = db.get_agent_tasks("lotto", task_type="weekly_evolution_report", days=1) + assert len(tasks) == 1 + assert tasks[0]["status"] == "succeeded" + assert tasks[0]["result_data"]["draw_no"] == 1225 + assert tasks[0]["result_data"]["update_reason"] == "winner_4plus" + assert tasks[0]["result_data"]["winner_day_of_week"] == 3 +``` + +- [ ] **Step 3: Run tests pass** + +```bash +cd agent-office && pytest tests/test_lotto_task_wrap.py -v +``` +Expected: 4 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add agent-office/app/agents/lotto.py agent-office/tests/test_lotto_task_wrap.py +git commit -m "feat(lotto-agent): run_weekly_evolution_report task_id wrap" +``` + +--- + +## Task 5: sync_evolver_activity 신규 메서드 + 멱등 테스트 + cron + +**Files:** +- Modify: `agent-office/app/agents/lotto.py` (append method) +- Modify: `agent-office/app/scheduler.py` (cron 추가) +- Create: `agent-office/tests/test_sync_evolver_activity.py` + +- [ ] **Step 1: Write failing test** + +```python +# agent-office/tests/test_sync_evolver_activity.py +import os +import sys +import asyncio +import tempfile +import gc + +_fd, _TMP = tempfile.mkstemp(suffix=".db") +os.close(_fd) +os.unlink(_TMP) +os.environ["AGENT_OFFICE_DB_PATH"] = _TMP + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest +from app import db +db.DB_PATH = _TMP + + +@pytest.fixture(autouse=True) +def fresh_db(): + gc.collect() + if os.path.exists(_TMP): + os.remove(_TMP) + db.init_db() + yield + gc.collect() + if os.path.exists(_TMP): + try: + os.remove(_TMP) + except PermissionError: + pass + + +@pytest.mark.asyncio +async def test_sync_evolver_activity_creates_apply_task(monkeypatch): + """오늘 trial에 picks가 있으면 evolver_apply task 1개 생성.""" + from app.agents.lotto import LottoAgent + from app import service_proxy + + # 오늘이 화요일(dow=1)이라 가정 — fixture로 강제 못 하니 실제 dow에 맞춰 status mock + from datetime import datetime, timezone, timedelta + KST = timezone(timedelta(hours=9)) + dow = datetime.now(KST).weekday() + if dow == 6: + dow = 5 + + async def fake_status(): + return { + "week_start": "2026-05-18", + "current_base": [0.2]*5, + "trials": [ + {"id": 100 + i, "day_of_week": i, + "weight": [0.2]*5, "source": "perturb", + "picks": [{"id": j, "numbers": [1,2,3,4,5,6], "meta_score": 0.5} for j in range(5)] if i == dow else []} + for i in range(6) + ], + } + monkeypatch.setattr(service_proxy, "lotto_evolver_status", fake_status) + + agent = LottoAgent() + await agent.sync_evolver_activity() + + apply_tasks = db.get_agent_tasks("lotto", task_type="evolver_apply", days=1) + assert len(apply_tasks) == 1 + assert apply_tasks[0]["result_data"]["n_picks"] == 5 + assert apply_tasks[0]["input_data"]["day_of_week"] == dow + + +@pytest.mark.asyncio +async def test_sync_evolver_activity_idempotent(monkeypatch): + """같은 날 두 번 호출해도 task는 1개만.""" + from app.agents.lotto import LottoAgent + from app import service_proxy + + from datetime import datetime, timezone, timedelta + KST = timezone(timedelta(hours=9)) + dow = datetime.now(KST).weekday() + if dow == 6: + dow = 5 + + async def fake_status(): + return { + "week_start": "2026-05-18", + "current_base": [0.2]*5, + "trials": [ + {"id": 100 + i, "day_of_week": i, + "weight": [0.2]*5, "source": "perturb", + "picks": [{"id": j, "numbers": [1,2,3,4,5,6], "meta_score": 0.5} for j in range(5)] if i == dow else []} + for i in range(6) + ], + } + monkeypatch.setattr(service_proxy, "lotto_evolver_status", fake_status) + + agent = LottoAgent() + await agent.sync_evolver_activity() + await agent.sync_evolver_activity() # 두 번째 호출 — guard로 skip + + apply_tasks = db.get_agent_tasks("lotto", task_type="evolver_apply", days=1) + assert len(apply_tasks) == 1 # 멱등 + + +@pytest.mark.asyncio +async def test_sync_evolver_activity_no_picks_no_task(monkeypatch): + """오늘 trial에 picks가 없으면 task 생성하지 않음.""" + from app.agents.lotto import LottoAgent + from app import service_proxy + + async def fake_status(): + return { + "week_start": "2026-05-18", + "current_base": [0.2]*5, + "trials": [ + {"id": 100 + i, "day_of_week": i, + "weight": [0.2]*5, "source": "perturb", "picks": []} + for i in range(6) + ], + } + monkeypatch.setattr(service_proxy, "lotto_evolver_status", fake_status) + + agent = LottoAgent() + await agent.sync_evolver_activity() + + apply_tasks = db.get_agent_tasks("lotto", task_type="evolver_apply", days=1) + assert len(apply_tasks) == 0 +``` + +- [ ] **Step 2: Implement sync_evolver_activity** + +`agent-office/app/agents/lotto.py` 클래스 끝에 추가: + +```python + async def sync_evolver_activity(self) -> dict: + """매일 09:30 — lotto-lab evolver 상태 polling → agent_office.db에 task+log 거울. 멱등.""" + from datetime import datetime, timezone, timedelta + from ..service_proxy import lotto_evolver_status + from ..db import ( + create_task, update_task_status, add_log, + get_tasks_by_agent_date_kind, + ) + + KST = timezone(timedelta(hours=9)) + today = datetime.now(KST).date() + today_iso = today.isoformat() + dow = today.weekday() + if dow == 6: + dow = 5 # 일요일은 토요일과 같이 + + try: + status = await lotto_evolver_status() + except Exception as e: + add_log("lotto", f"sync_evolver_activity: lotto-lab status fetch 실패: {e}", level="warning") + return {"ok": False, "reason": "status_fetch_failed", "error": str(e)} + + results = {"created": []} + + # ① 오늘 trial에 picks가 있으면 evolver_apply task 기록 (멱등) + today_trial = next((t for t in status.get("trials", []) if t.get("day_of_week") == dow), None) + if today_trial and today_trial.get("picks"): + if not get_tasks_by_agent_date_kind("lotto", today_iso, "evolver_apply"): + tid = create_task("lotto", "evolver_apply", { + "date": today_iso, + "trial_id": today_trial["id"], + "day_of_week": dow, + "weight": today_trial["weight"], + }) + update_task_status(tid, "succeeded", result_data={ + "n_picks": len(today_trial["picks"]), + "meta_scores": [p.get("meta_score") for p in today_trial["picks"]], + }) + add_log("lotto", f"evolver_apply: 오늘({dow}) W로 {len(today_trial['picks'])}세트 추출", task_id=tid) + results["created"].append("evolver_apply") + + # ② 월요일 + 6 trials 완성 → evolver_generate task (멱등) + if today.weekday() == 0 and len(status.get("trials", [])) == 6: + if not get_tasks_by_agent_date_kind("lotto", today_iso, "evolver_generate"): + tid = create_task("lotto", "evolver_generate", {"week_start": status.get("week_start")}) + update_task_status(tid, "succeeded", result_data={ + "trials_count": 6, + "candidates_per_source": {"perturb": 4, "dirichlet": 2}, + }) + add_log("lotto", f"evolver_generate: {status.get('week_start')} 주의 6 trials 생성", task_id=tid) + results["created"].append("evolver_generate") + + return {"ok": True, **results} +``` + +- [ ] **Step 3: Add cron to scheduler.py** + +`agent-office/app/scheduler.py`에 추가: + +```python +async def _run_lotto_sync_evolver_activity(): + agent = AGENT_REGISTRY.get("lotto") + if agent: + await agent.sync_evolver_activity() +``` + +기존 lotto cron 등록 근처에: + +```python +scheduler.add_job( + _run_lotto_sync_evolver_activity, + "cron", hour=9, minute=30, + id="lotto_evolver_activity_sync", +) +``` + +- [ ] **Step 4: Run tests pass** + +```bash +cd agent-office && pytest tests/test_sync_evolver_activity.py -v +cd agent-office && pytest tests/ -v 2>&1 | tail -10 +``` +Expected: 3 new tests pass + no regression. + +- [ ] **Step 5: Commit** + +```bash +git add agent-office/app/agents/lotto.py agent-office/app/scheduler.py agent-office/tests/test_sync_evolver_activity.py +git commit -m "feat(lotto-agent): sync_evolver_activity 매일 09:30 cron + 멱등 가드" +``` + +--- + +# Phase 2 — agent-office API 확장 + +## Task 6: GET /agents/{id}/tasks에 task_type 필터 + +**Files:** +- Modify: `agent-office/app/main.py` + +- [ ] **Step 1: Update endpoint signature** + +`agent-office/app/main.py`에서 `/agents/{agent_id}/tasks` endpoint를 다음으로 교체: + +```python +@app.get("/api/agent-office/agents/{agent_id}/tasks") +async def list_agent_tasks( + agent_id: str, + limit: int = 20, + task_type: Optional[str] = None, + days: Optional[int] = None, +): + return {"items": get_agent_tasks(agent_id, limit=limit, task_type=task_type, days=days)} +``` + +`Optional` 이 main.py 상단 typing import에 없으면 추가. + +- [ ] **Step 2: Verify** + +```bash +cd agent-office && python -c " +from app.main import app +for r in app.routes: + if 'agents' in r.path and 'tasks' in r.path: + print(r.path, r.methods) +" +cd agent-office && pytest tests/ -v 2>&1 | tail -5 +``` +Expected: route 표시 + no regression. + +- [ ] **Step 3: Commit** + +```bash +git add agent-office/app/main.py +git commit -m "feat(agent-office): GET /agents/{id}/tasks에 task_type/days 필터 추가" +``` + +--- + +# Phase 3 — web-ui Evolver 페이지 + +## Task 7: api.js에 evolver fetch helpers + +**Files:** +- Modify: `web-ui/src/api.js` + +- [ ] **Step 1: Append 4 fetch helpers** + +`web-ui/src/api.js` 파일 끝에 추가 (다른 fetch 함수들 패턴 따름): + +```javascript +// --- Lotto Weight Evolver --- + +export async function fetchEvolverStatus() { + const r = await fetch('/api/lotto/evolver/status'); + if (!r.ok) throw new Error(`evolver/status ${r.status}`); + return r.json(); +} + +export async function fetchEvolverHistory(weeks = 12) { + const r = await fetch(`/api/lotto/evolver/history?weeks=${weeks}`); + if (!r.ok) throw new Error(`evolver/history ${r.status}`); + return r.json(); +} + +export async function fetchLottoTasks({ days = 7, taskType = null } = {}) { + const params = new URLSearchParams({ days: String(days), limit: '100' }); + if (taskType) params.set('task_type', taskType); + const r = await fetch(`/api/agent-office/agents/lotto/tasks?${params}`); + if (!r.ok) throw new Error(`agent-office tasks ${r.status}`); + return r.json(); +} + +export async function fetchLottoLogs({ days = 7 } = {}) { + const r = await fetch(`/api/agent-office/agents/lotto/logs?limit=200`); + if (!r.ok) throw new Error(`agent-office logs ${r.status}`); + const data = await r.json(); + // 클라이언트에서 days 필터링 (기존 endpoint가 limit만 받음) + if (!days) return data; + const cutoff = new Date(Date.now() - days * 24 * 3600 * 1000).toISOString(); + return { items: (data.items || []).filter(l => (l.created_at || '') >= cutoff) }; +} + +export async function triggerEvolverGenerate() { + const r = await fetch('/api/lotto/evolver/generate-now', { method: 'POST' }); + if (!r.ok) throw new Error(`generate-now ${r.status}`); + return r.json(); +} + +export async function triggerEvolverEvaluate() { + const r = await fetch('/api/lotto/evolver/evaluate-now', { method: 'POST' }); + if (!r.ok) throw new Error(`evaluate-now ${r.status}`); + return r.json(); +} +``` + +- [ ] **Step 2: Commit (web-ui repo)** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-ui +git add src/api.js +git commit -m "feat(evolver): api.js에 evolver + lotto activity fetch helpers" +``` + +--- + +## Task 8: useEvolverApi.js hook + +**Files:** +- Create: `web-ui/src/pages/lotto/evolver/useEvolverApi.js` + +- [ ] **Step 1: Create hook** + +```javascript +// web-ui/src/pages/lotto/evolver/useEvolverApi.js +import { useEffect, useState, useCallback } from 'react'; +import { + fetchEvolverStatus, + fetchEvolverHistory, + fetchLottoTasks, + fetchLottoLogs, +} from '../../../api'; + + +function mergeActivityStream({ logs, tasks, evolverEvents }) { + const stream = []; + for (const l of logs.items || []) { + stream.push({ ts: l.created_at, kind: 'log', payload: l }); + } + for (const t of tasks.items || []) { + stream.push({ ts: t.created_at, kind: 'task', payload: t }); + } + for (const e of evolverEvents) { + stream.push({ ts: e.created_at, kind: 'evolver', payload: e }); + } + stream.sort((a, b) => (b.ts || '').localeCompare(a.ts || '')); + return stream; +} + + +export function useEvolverApi({ days = 7, weeks = 12 } = {}) { + const [status, setStatus] = useState(null); + const [history, setHistory] = useState({ items: [] }); + const [activity, setActivity] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refetch = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [s, h, t, l] = await Promise.all([ + fetchEvolverStatus(), + fetchEvolverHistory(weeks), + fetchLottoTasks({ days }), + fetchLottoLogs({ days }), + ]); + setStatus(s); + setHistory(h); + setActivity(mergeActivityStream({ + logs: l, + tasks: t, + evolverEvents: h.items || [], + })); + } catch (e) { + setError(e); + } finally { + setLoading(false); + } + }, [days, weeks]); + + useEffect(() => { refetch(); }, [refetch]); + + return { status, history, activity, loading, error, refetch }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-ui +git add src/pages/lotto/evolver/useEvolverApi.js +git commit -m "feat(evolver): useEvolverApi hook (status+history+activity)" +``` + +--- + +## Task 9: WinnerCard.jsx + +**Files:** +- Create: `web-ui/src/pages/lotto/evolver/WinnerCard.jsx` + +- [ ] **Step 1: Create WinnerCard** + +```jsx +// web-ui/src/pages/lotto/evolver/WinnerCard.jsx +import React from 'react'; +import { + RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, + Radar, ResponsiveContainer, Legend, +} from 'recharts'; + +const DAY_NAMES = ['월', '화', '수', '목', '금', '토']; +const METRIC_NAMES = ['freq', 'finger', 'gap', 'cooccur', 'divers']; + +export default function WinnerCard({ winner, previousBase, updateReason, drawNo }) { + if (!winner) { + return ( +
아직 회고 결과가 없습니다.
+W = [{(byDow[expanded].weight || []).map(w => w.toFixed(2)).join(', ')}]
+아직 base 변경 이력 없음.
+학습 이력이 부족합니다.
+지난 {days}일 활동 없음.
+{JSON.stringify(out, null, 2)}}
+ 로딩 중...
에러: {String(error)}
Lotto · Weight Evolver
++ 매주 6가지 가중치를 시도해서 best 조합을 다음주 base로 학습합니다. + {status?.latest_draw && ` 마지막 회차: ${status.latest_draw}회.`} +
+다음 월요일 09:00에 자동 시작 또는 수동 트리거 사용.
+