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>
59 KiB
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 함수를 다음으로 교체:
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 파일 끝에 추가:
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
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
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
# 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 메서드 본체를 다음으로 교체 (메서드 시그니처 그대로):
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
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
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 메서드를 다음으로 교체:
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에 추가:
@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
cd agent-office && pytest tests/test_lotto_task_wrap.py -v
Expected: 3 tests pass.
- Step 4: Commit
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
기존 메서드를 다음으로 교체:
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에 추가:
@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
cd agent-office && pytest tests/test_lotto_task_wrap.py -v
Expected: 4 tests pass.
- Step 4: Commit
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
# 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 클래스 끝에 추가:
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에 추가:
async def _run_lotto_sync_evolver_activity():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.sync_evolver_activity()
기존 lotto cron 등록 근처에:
scheduler.add_job(
_run_lotto_sync_evolver_activity,
"cron", hour=9, minute=30,
id="lotto_evolver_activity_sync",
)
- Step 4: Run tests pass
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
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를 다음으로 교체:
@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
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
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 함수들 패턴 따름):
// --- 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)
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
// 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
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
// 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
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
// 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
// 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
// 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
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
// 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
// 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 페이지
// 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
/* 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')); 다음 줄에 추가:
const Evolver = lazy(() => import('./pages/lotto/Evolver'));
appRoutes 배열의 { path: 'lotto', element: <Lotto /> } 다음에 추가:
{
path: 'lotto/evolver',
element: <Evolver />,
},
- Step 6: Local dev verify
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
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 (자동 배포)
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git push
agent-office 컨테이너 재시작 → 새 cron lotto_evolver_activity_sync 등록 + task_id wrap 작동 시작.
- Step 2: web-ui 빌드 + 배포
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으로 충분)