Files
web-page-backend/docs/superpowers/plans/2026-05-23-lotto-evolver-ui.md
gahusb 0f65aa53e4 docs(plan): Lotto Evolver UI + 활동 가시화 구현 plan (12 tasks)
Why: spec (2026-05-23-lotto-evolver-ui-design.md)을 12개 atomic task로
분해. Phase 1-2 web-backend (task_id wrap + sync cron + API 확장),
Phase 3 web-ui (Evolver 페이지 + 5 컴포넌트 + 라우터), Phase 4 배포 검증.
TDD red→green→commit + 멱등 guard 패턴.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 01:45:47 +09:00

1619 lines
59 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (
<div className="evolver-card winner-card empty">
<h2>🏆 Winner</h2>
<p className="muted">아직 회고 결과가 없습니다.</p>
</div>
);
}
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 (
<div className="evolver-card winner-card">
<header>
<h2>🏆 Winner: {dayName}요일</h2>
<span className="badge">{updateReason}</span>
</header>
<div className="winner-meta">
<span>최고 적중 <strong>{winner.max_correct}</strong></span>
<span>평균 점수 <strong>{(winner.avg_score || 0).toFixed(2)}</strong></span>
<span>{winner.n_picks}/5 picks</span>
{drawNo && <span>{drawNo}회차</span>}
</div>
<div className="winner-chart">
<ResponsiveContainer width="100%" height={300}>
<RadarChart data={data}>
<PolarGrid />
<PolarAngleAxis dataKey="metric" />
<PolarRadiusAxis angle={90} domain={[0, 0.5]} />
<Radar name="이번주 winner" dataKey="winner" stroke="#34d399" fill="#34d399" fillOpacity={0.4} />
<Radar name="이전 base" dataKey="previous" stroke="#999" fill="#999" fillOpacity={0.1} />
<Legend />
</RadarChart>
</ResponsiveContainer>
</div>
</div>
);
}
```
- [ ] **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 (
<div className="evolver-card trials-grid">
<h2>이번주 6 Trials</h2>
<div className="grid">
{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 (
<button
key={dow}
className={`trial-cell ${isWinner ? 'winner' : ''} ${expanded === dow ? 'expanded' : ''}`}
onClick={() => setExpanded(expanded === dow ? null : dow)}
>
<div className="bar" style={{ height: `${heightPct}%` }} />
<span className="label">{name}{isWinner && '⭐'}</span>
{day && <span className="max-correct">max={day.max_correct}</span>}
</button>
);
})}
</div>
{expanded !== null && byDow[expanded] && (
<div className="trial-detail">
<h3>{DAY_NAMES[expanded]}요일 상세</h3>
<p>W = [{(byDow[expanded].weight || []).map(w => w.toFixed(2)).join(', ')}]</p>
<ul>
{(byDow[expanded].picks || []).map(p => (
<li key={p.id}>
{(p.numbers || []).join(', ')}
score {(p.meta_score || 0).toFixed(3)}
{p.correct != null && ` · 적중 ${p.correct}`}
</li>
))}
</ul>
</div>
)}
</div>
);
}
```
- [ ] **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 (
<div className="evolver-card base-diff empty">
<h2>다음주 base 변경</h2>
<p className="muted">아직 base 변경 이력 없음.</p>
</div>
);
}
return (
<div className="evolver-card base-diff">
<h2>다음주 base 변경 <span className="badge">{updateReason}</span></h2>
<div className="diff-grid">
{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 (
<div key={name} className={`metric-card ${cls}`}>
<div className="metric-name">{name}</div>
<div className="metric-values">
{prev.toFixed(2)} <strong>{next.toFixed(2)}</strong>
</div>
<div className="metric-diff">
{mark} {diff >= 0 ? '+' : ''}{(diff * 100).toFixed(0)}%p
</div>
</div>
);
})}
</div>
</div>
);
}
```
- [ ] **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 (
<div className="evolver-card base-history empty">
<h2>12 Base 변화</h2>
<p className="muted">학습 이력이 부족합니다.</p>
</div>
);
}
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 (
<div className="evolver-card base-history">
<h2>Base 변화 (최근 {history.length})</h2>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis domain={[0, 0.5]} />
<Tooltip />
<Legend />
{METRIC_NAMES.map((name, i) => (
<Line key={name} type="monotone" dataKey={name} stroke={COLORS[i]} dot />
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
```
- [ ] **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 (
<li key={`task-${t.id}`} className={`activity-item task ${cls}`}>
<span className="icon">{icon}</span>
<div className="body">
<div className="line"><strong>{t.task_type}</strong> · <span className={`status ${cls}`}>{t.status}</span></div>
{detail && <div className="detail">{detail}</div>}
</div>
<span className="ts">{ts}</span>
</li>
);
}
if (item.kind === 'log') {
const l = item.payload;
return (
<li key={`log-${l.id}`} className={`activity-item log level-${l.level}`}>
<span className="icon">{l.level === 'error' ? '❌' : l.level === 'warning' ? '⚠️' : '·'}</span>
<div className="body"><div className="line">{l.message}</div></div>
<span className="ts">{ts}</span>
</li>
);
}
if (item.kind === 'evolver') {
const e = item.payload;
return (
<li key={`evolver-${e.id}`} className="activity-item evolver">
<span className="icon"></span>
<div className="body">
<div className="line"><strong>weight_evolver_eval</strong> (lotto-lab)</div>
<div className="detail">reason={e.update_reason} winner_max={e.winner_max_correct}</div>
</div>
<span className="ts">{ts}</span>
</li>
);
}
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 (
<div className="evolver-card activity-card empty">
<h2>최근 활동</h2>
<p className="muted">지난 {days} 활동 없음.</p>
</div>
);
}
return (
<div className="evolver-card activity-card">
<h2>최근 {days} 에이전트 활동 ({activity.length})</h2>
<ul className="activity-list">{activity.map(renderItem)}</ul>
</div>
);
}
```
- [ ] **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 (
<div className="evolver-card actions-card">
<h2>수동 트리거 (dev)</h2>
<div className="action-buttons">
<button disabled={!!busy} onClick={() => run('generate')}>
{busy === 'generate' ? '생성 중...' : 'generate-now (월요일 후보 생성)'}
</button>
<button disabled={!!busy} onClick={() => run('evaluate')}>
{busy === 'evaluate' ? '평가 중...' : 'evaluate-now (회고 + base 갱신)'}
</button>
</div>
{out && <pre className="action-output">{JSON.stringify(out, null, 2)}</pre>}
</div>
);
}
```
- [ ] **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 <div className="evolver"><p>로딩 ...</p></div>;
if (error) return <div className="evolver"><p>에러: {String(error)}</p></div>;
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 (
<div className="evolver">
<header className="evolver-header">
<div>
<p className="evolver-kicker">Lotto · Weight Evolver</p>
<h1>자율 학습 루프</h1>
<p className="evolver-sub">
매주 6가지 가중치를 시도해서 best 조합을 다음주 base로 학습합니다.
{status?.latest_draw && ` 마지막 회차: ${status.latest_draw}회.`}
</p>
</div>
<button className="refresh-btn" onClick={refetch}> 새로고침</button>
</header>
{!hasBase ? (
<div className="evolver-card empty-state">
<h2>아직 학습 시작 </h2>
<p>다음 월요일 09:00 자동 시작 또는 수동 트리거 사용.</p>
<EvolverActions onChange={refetch} />
</div>
) : (
<>
<WinnerCard
winner={winnerInfo}
previousBase={previousBase}
updateReason={latestBase?.update_reason}
drawNo={status?.latest_draw}
/>
<TrialsGrid trials={trials} perDay={perDay} winnerTrialId={winnerTrialId} />
<BaseDiff
previousBase={previousBase}
newBase={newBase}
updateReason={latestBase?.update_reason}
/>
<BaseHistory history={history.items || []} />
<LottoActivityTimeline activity={activity} days={7} />
<EvolverActions onChange={refetch} />
</>
)}
</div>
);
}
```
- [ ] **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: <Lotto /> }` 다음에 추가:
```jsx
{
path: 'lotto/evolver',
element: <Evolver />,
},
```
- [ ] **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으로 충분)