# 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에 자동 시작 또는 수동 트리거 사용.