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

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