# 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 (

🏆 Winner

아직 회고 결과가 없습니다.

); } const dayName = DAY_NAMES[winner.day_of_week] || '?'; const W = winner.weight || []; const prev = previousBase || [0.2, 0.2, 0.2, 0.2, 0.2]; const data = METRIC_NAMES.map((name, i) => ({ metric: name, winner: W[i] || 0, previous: prev[i] || 0, })); return (

🏆 Winner: {dayName}요일

{updateReason}
최고 적중 {winner.max_correct}개 평균 점수 {(winner.avg_score || 0).toFixed(2)} {winner.n_picks}/5 picks {drawNo && {drawNo}회차}
); } ``` - [ ] **Step 2: Commit** ```bash cd C:/Users/jaeoh/Desktop/workspace/web-ui git add src/pages/lotto/evolver/WinnerCard.jsx git commit -m "feat(evolver): WinnerCard — Radar 차트 + 이전 base overlay" ``` --- ## Task 10: TrialsGrid.jsx + BaseDiff.jsx + BaseHistory.jsx **Files:** - Create: 3 컴포넌트 - [ ] **Step 1: Create TrialsGrid** ```jsx // web-ui/src/pages/lotto/evolver/TrialsGrid.jsx import React, { useState } from 'react'; const DAY_NAMES = ['월', '화', '수', '목', '금', '토']; export default function TrialsGrid({ trials, perDay, winnerTrialId }) { const [expanded, setExpanded] = useState(null); const byDow = {}; for (const t of trials || []) byDow[t.day_of_week] = t; const perDayByDow = {}; for (const d of perDay || []) perDayByDow[d.day_of_week] = d; const maxScore = Math.max(...(perDay || []).map(d => d.avg_score || 0), 0.001); return (

이번주 6일 Trials

{DAY_NAMES.map((name, dow) => { const trial = byDow[dow]; const day = perDayByDow[dow]; const isWinner = trial && trial.id === winnerTrialId; const heightPct = day ? (day.avg_score / maxScore) * 100 : 0; return ( ); })}
{expanded !== null && byDow[expanded] && (

{DAY_NAMES[expanded]}요일 상세

W = [{(byDow[expanded].weight || []).map(w => w.toFixed(2)).join(', ')}]

)}
); } ``` - [ ] **Step 2: Create BaseDiff** ```jsx // web-ui/src/pages/lotto/evolver/BaseDiff.jsx import React from 'react'; const METRIC_NAMES = ['freq', 'finger', 'gap', 'cooccur', 'divers']; function diffMarker(diff) { if (Math.abs(diff) < 0.005) return { mark: '=', cls: 'eq' }; if (diff > 0) return diff < 0.05 ? { mark: '↑', cls: 'up' } : { mark: '↑↑', cls: 'up-big' }; return diff > -0.05 ? { mark: '↓', cls: 'down' } : { mark: '↓↓', cls: 'down-big' }; } export default function BaseDiff({ previousBase, newBase, updateReason }) { if (!previousBase || !newBase) { return (

다음주 base 변경

아직 base 변경 이력 없음.

); } return (

다음주 base 변경 {updateReason}

{METRIC_NAMES.map((name, i) => { const prev = previousBase[i] || 0; const next = newBase[i] || 0; const diff = next - prev; const { mark, cls } = diffMarker(diff); return (
{name}
{prev.toFixed(2)} → {next.toFixed(2)}
{mark} {diff >= 0 ? '+' : ''}{(diff * 100).toFixed(0)}%p
); })}
); } ``` - [ ] **Step 3: Create BaseHistory** ```jsx // web-ui/src/pages/lotto/evolver/BaseHistory.jsx import React from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, } from 'recharts'; const METRIC_NAMES = ['freq', 'finger', 'gap', 'cooccur', 'divers']; const COLORS = ['#34d399', '#60a5fa', '#fbbf24', '#f43f5e', '#c084fc']; export default function BaseHistory({ history }) { if (!history || history.length === 0) { return (

12주 Base 변화

학습 이력이 부족합니다.

); } const data = history .slice() .reverse() .map(h => { const w = h.weight || [0, 0, 0, 0, 0]; return { date: (h.effective_from || '').slice(5), // 'MM-DD' freq: w[0], finger: w[1], gap: w[2], cooccur: w[3], divers: w[4], reason: h.update_reason, }; }); return (

Base 변화 (최근 {history.length}주)

{METRIC_NAMES.map((name, i) => ( ))}
); } ``` - [ ] **Step 4: Commit** ```bash cd C:/Users/jaeoh/Desktop/workspace/web-ui git add src/pages/lotto/evolver/TrialsGrid.jsx src/pages/lotto/evolver/BaseDiff.jsx src/pages/lotto/evolver/BaseHistory.jsx git commit -m "feat(evolver): TrialsGrid + BaseDiff + BaseHistory 컴포넌트" ``` --- ## Task 11: LottoActivityTimeline + EvolverActions + Evolver page + 라우터 **Files:** - Create: LottoActivityTimeline.jsx, EvolverActions.jsx, Evolver.jsx, Evolver.css - Modify: routes.jsx - [ ] **Step 1: LottoActivityTimeline** ```jsx // web-ui/src/pages/lotto/evolver/LottoActivityTimeline.jsx import React from 'react'; const ICONS = { curate_weekly: '📋', signal_check: '🔍', daily_digest: '📊', weekly_evolution_report: '🧬', evolver_generate: '🌱', evolver_apply: '🎲', }; const STATUS_CLS = { succeeded: 'ok', failed: 'err', working: 'pending', pending: 'pending', }; function renderItem(item) { const ts = (item.ts || '').replace('T', ' ').slice(0, 19); if (item.kind === 'task') { const t = item.payload; const icon = ICONS[t.task_type] || '⚙️'; const cls = STATUS_CLS[t.status] || ''; const detail = formatTaskDetail(t); return (
  • {icon}
    {t.task_type} · {t.status}
    {detail &&
    {detail}
    }
    {ts}
  • ); } if (item.kind === 'log') { const l = item.payload; return (
  • {l.level === 'error' ? '❌' : l.level === 'warning' ? '⚠️' : '·'}
    {l.message}
    {ts}
  • ); } if (item.kind === 'evolver') { const e = item.payload; return (
  • ⚖️
    weight_evolver_eval (lotto-lab)
    reason={e.update_reason} winner_max={e.winner_max_correct}
    {ts}
  • ); } return null; } function formatTaskDetail(t) { const r = t.result_data || {}; switch (t.task_type) { case 'signal_check': return `${r.source} → ${r.overall_fire} (${r.n_results} results)`; case 'daily_digest': return `평가 ${r.evaluated} / 발화 ${r.fired}`; case 'weekly_evolution_report': return `draw=${r.draw_no} reason=${r.update_reason}`; case 'evolver_apply': return `${r.n_picks}세트 추출`; case 'evolver_generate': return `${r.trials_count} trials 생성`; case 'curate_weekly': return `draw=${r.draw_no || '?'} conf=${r.confidence || '?'}`; default: return ''; } } export default function LottoActivityTimeline({ activity = [], days = 7 }) { if (!activity || activity.length === 0) { return (

    최근 활동

    지난 {days}일 활동 없음.

    ); } return (

    최근 {days}일 에이전트 활동 ({activity.length})

    ); } ``` - [ ] **Step 2: EvolverActions** ```jsx // web-ui/src/pages/lotto/evolver/EvolverActions.jsx import React, { useState } from 'react'; import { triggerEvolverGenerate, triggerEvolverEvaluate } from '../../../api'; export default function EvolverActions({ onChange }) { const [busy, setBusy] = useState(null); const [out, setOut] = useState(null); async function run(kind) { setBusy(kind); setOut(null); try { const fn = kind === 'generate' ? triggerEvolverGenerate : triggerEvolverEvaluate; const res = await fn(); setOut(res); onChange && onChange(); } catch (e) { setOut({ error: String(e) }); } finally { setBusy(null); } } return (

    수동 트리거 (dev)

    {out &&
    {JSON.stringify(out, null, 2)}
    }
    ); } ``` - [ ] **Step 3: Evolver.jsx 페이지** ```jsx // web-ui/src/pages/lotto/Evolver.jsx import React from 'react'; import './Evolver.css'; import { useEvolverApi } from './evolver/useEvolverApi'; import WinnerCard from './evolver/WinnerCard'; import TrialsGrid from './evolver/TrialsGrid'; import BaseDiff from './evolver/BaseDiff'; import BaseHistory from './evolver/BaseHistory'; import LottoActivityTimeline from './evolver/LottoActivityTimeline'; import EvolverActions from './evolver/EvolverActions'; export default function Evolver() { const { status, history, activity, loading, error, refetch } = useEvolverApi({ days: 7, weeks: 12 }); if (loading) return

    로딩 중...

    ; if (error) return

    에러: {String(error)}

    ; const latestBase = (history.items || [])[0]; const previousBase = (history.items || [])[1]?.weight || status?.current_base || [0.2, 0.2, 0.2, 0.2, 0.2]; const newBase = latestBase?.weight || status?.current_base; // winner/perDay 정보는 latest base_history에 trial_id 있음 → trials에서 찾기 const trials = status?.trials || []; const winnerTrialId = latestBase?.source_trial_id; const winnerTrial = trials.find(t => t.id === winnerTrialId); const winnerInfo = winnerTrial ? { day_of_week: winnerTrial.day_of_week, weight: winnerTrial.weight, avg_score: latestBase?.winner_score, max_correct: latestBase?.winner_max_correct, n_picks: (winnerTrial.picks || []).length, } : null; const perDay = trials.map(t => ({ day_of_week: t.day_of_week, trial_id: t.id, avg_score: (t.picks || []).reduce((s, p) => s + (p.meta_score || 0), 0) / Math.max(1, (t.picks || []).length), max_correct: Math.max(0, ...(t.picks || []).map(p => p.correct || 0)), })); const hasBase = (history.items || []).length > 0; return (

    Lotto · Weight Evolver

    자율 학습 루프

    매주 6가지 가중치를 시도해서 best 조합을 다음주 base로 학습합니다. {status?.latest_draw && ` 마지막 회차: ${status.latest_draw}회.`}

    {!hasBase ? (

    아직 학습 시작 전

    다음 월요일 09:00에 자동 시작 또는 수동 트리거 사용.

    ) : ( <> )}
    ); } ``` - [ ] **Step 4: Evolver.css** ```css /* web-ui/src/pages/lotto/Evolver.css */ .evolver { max-width: 1100px; margin: 0 auto; padding: 24px 16px; } .evolver-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 24px; gap: 16px; } .evolver-kicker { letter-spacing: 0.12em; color: #6b7280; font-size: 0.75rem; margin: 0 0 4px; } .evolver-header h1 { margin: 0 0 8px; font-size: 2rem; } .evolver-sub { color: #6b7280; margin: 0; } .refresh-btn { padding: 8px 14px; background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 6px; cursor: pointer; } .evolver-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px; margin-bottom: 16px; } .evolver-card.empty .muted { color: #9ca3af; } .evolver-card h2 { margin: 0 0 12px; font-size: 1.1rem; display: flex; justify-content: space-between; align-items: center; } .evolver-card .badge { background: #ecfdf5; color: #065f46; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: normal; } .winner-card .winner-meta { display: flex; gap: 16px; flex-wrap: wrap; color: #6b7280; font-size: 0.9rem; margin-bottom: 12px; } .winner-card .winner-meta strong { color: #111827; } .trials-grid .grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; height: 140px; align-items: end; } .trial-cell { border: none; background: #f9fafb; border-radius: 6px; padding: 8px 4px; display: flex; flex-direction: column; align-items: center; justify-content: end; cursor: pointer; height: 100%; } .trial-cell.winner { background: #ecfdf5; } .trial-cell .bar { width: 80%; background: #34d399; border-radius: 3px 3px 0 0; min-height: 4px; } .trial-cell.winner .bar { background: #059669; } .trial-cell .label { font-size: 0.85rem; margin-top: 6px; } .trial-cell .max-correct { font-size: 0.7rem; color: #6b7280; } .trial-detail { margin-top: 16px; padding: 12px; background: #f9fafb; border-radius: 6px; } .trial-detail ul { margin: 8px 0 0; padding-left: 18px; } .base-diff .diff-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; } .metric-card { padding: 12px; background: #f9fafb; border-radius: 8px; text-align: center; } .metric-card .metric-name { color: #6b7280; font-size: 0.75rem; text-transform: uppercase; } .metric-card .metric-values { margin: 6px 0; font-size: 0.85rem; } .metric-card .metric-diff { font-weight: bold; } .metric-card.up .metric-diff, .metric-card.up-big .metric-diff { color: #059669; } .metric-card.down .metric-diff, .metric-card.down-big .metric-diff { color: #dc2626; } .metric-card.eq .metric-diff { color: #9ca3af; } .activity-card .activity-list { list-style: none; padding: 0; margin: 0; } .activity-item { display: grid; grid-template-columns: 24px 1fr auto; gap: 8px; padding: 8px 0; border-bottom: 1px solid #f3f4f6; } .activity-item .ts { color: #9ca3af; font-size: 0.75rem; white-space: nowrap; } .activity-item .status.ok { color: #059669; } .activity-item .status.err { color: #dc2626; } .activity-item .detail { color: #6b7280; font-size: 0.85rem; } .actions-card .action-buttons { display: flex; gap: 8px; flex-wrap: wrap; } .actions-card button { padding: 8px 14px; background: #1f2937; color: #fff; border: none; border-radius: 6px; cursor: pointer; } .actions-card button:disabled { opacity: 0.5; cursor: wait; } .action-output { background: #1f2937; color: #d1d5db; padding: 12px; border-radius: 6px; margin-top: 12px; max-height: 200px; overflow: auto; font-size: 0.8rem; } @media (max-width: 640px) { .trials-grid .grid { grid-template-columns: repeat(3, 1fr); height: auto; } .base-diff .diff-grid { grid-template-columns: repeat(2, 1fr); } .evolver-header { flex-direction: column; } } ``` - [ ] **Step 5: Register route in routes.jsx** `web-ui/src/routes.jsx` 변경: 기존 `const Lotto = lazy(() => import('./pages/lotto/Lotto'));` 다음 줄에 추가: ```jsx const Evolver = lazy(() => import('./pages/lotto/Evolver')); ``` `appRoutes` 배열의 `{ path: 'lotto', element: }` 다음에 추가: ```jsx { path: 'lotto/evolver', element: , }, ``` - [ ] **Step 6: Local dev verify** ```bash cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm run dev ``` Open http://localhost:3007/lotto/evolver — 페이지 로드 확인. NAS API에 접근해야 하니 vite proxy 또는 직접 NAS URL 호출. 로컬 dev에선 cold start로 보일 가능성. - [ ] **Step 7: Commit** ```bash cd C:/Users/jaeoh/Desktop/workspace/web-ui git add src/pages/lotto/Evolver.jsx src/pages/lotto/Evolver.css src/pages/lotto/evolver/LottoActivityTimeline.jsx src/pages/lotto/evolver/EvolverActions.jsx src/routes.jsx git commit -m "feat(evolver): Evolver 페이지 + LottoActivityTimeline + EvolverActions + 라우터 등록" ``` --- # Phase 4 — 배포 + 검증 ## Task 12: 배포 + 검증 (사용자 수동) - [ ] **Step 1: web-backend push (자동 배포)** ```bash cd C:/Users/jaeoh/Desktop/workspace/web-backend git push ``` agent-office 컨테이너 재시작 → 새 cron `lotto_evolver_activity_sync` 등록 + task_id wrap 작동 시작. - [ ] **Step 2: web-ui 빌드 + 배포** ```bash cd C:/Users/jaeoh/Desktop/workspace/web-ui npm run release:nas ``` robocopy로 Z:\docker\webpage\frontend\로 빌드 산출물 업로드. - [ ] **Step 3: 텔레그램 링크 클릭 검증** 이전 토요일에 받은 텔레그램 메시지의 "[웹에서 차트 보기]" 링크 클릭 → `/lotto/evolver` 페이지 정상 로드. cold start면 안내 카드 + 수동 트리거 버튼만 보임. - [ ] **Step 4: 수동 트리거 후 페이지 확인** `[수동 generate-now]` 클릭 → 6 trials 생성. 페이지 새로고침 → TrialsGrid에 6 cells, BaseHistory에 cold_start row 1개 표시. - [ ] **Step 5: agent-office 페이지에서 LottoAgent task 확인** `/agent-office` 접속 → LottoAgent 카드 클릭 → Tasks 탭에서 새 task_type들 (`signal_check`, `daily_digest`, `evolver_apply` 등) 확인. - [ ] **Step 6: 다음 토요일 22:15 자연 검증** 자동 cron으로 `weekly_evolution_report` task가 생성되고 텔레그램 도착 + 링크 클릭 시 새 winner 정보가 차트에 반영됨. --- # Self-Review (수행 완료) **1. Spec coverage check** | Spec 섹션 | 구현 task | |---|---| | 3.1 컴포넌트 | T9-T11 (5 컴포넌트) + T11 라우터 | | 4 페이지 layout | T11 Evolver.jsx 조립 + Evolver.css | | 4.1 모바일 반응형 | T11 Evolver.css @media query | | 5.1 useEvolverApi | T8 | | 5.2 Recharts | T9 (Radar), T11 (Line), T10 (Bar는 trial cell에 inline) | | 5.3 LottoActivityTimeline | T11 (Evolver 전용으로 한정 — spec adjustment 명시) | | 5.4 cold start | T11 Evolver.jsx 분기 | | 6.1 task_id wrap | T2-T4 | | 6.2 sync_evolver_activity | T5 | | 6.3 db helper | T1 | | 6.4 cron | T5 | | 6.5 API 확장 | T6 | | 6.6 task_type 명세 | T2-T5 result_data, T11 icon mapping | | 7 라우터 등록 | T11 | | 9 비기능 요구 | T2-T5 단위 테스트 | 모두 매핑됨. **2. Placeholder scan**: 없음. 모든 step에 실제 코드 또는 명령. **3. Type consistency**: - `create_task(agent_id, task_type, input_data)` → returns UUID string. T2-T5 호출 일치 - `update_task_status(task_id, status, result_data)` 시그니처 일관 - `get_agent_tasks(agent_id, limit, task_type, days)` T1 정의 + T6 API consumes + T8 fetchLottoTasks 호출 일치 - `useEvolverApi` 반환 `{status, history, activity, loading, error, refetch}` — T8 정의, T11 Evolver.jsx 사용 일치 - `mergeActivityStream({logs, tasks, evolverEvents})` shape — T8 정의, T11 LottoActivityTimeline 파싱 일치 이슈 없음. --- # 비목표 (Out of scope, v3 후속) - 과거 주 deep dive (`/trials/{week_start}` 사용한 별도 페이지) - 차트 export / CSV - 가중치 수동 편집 UI - 다른 에이전트 활동 통합 timeline (현재 lotto only) - WebSocket 실시간 푸시 (polling으로 충분)