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

🏆 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으로 충분)