From ff3134b838f113799726ff33e2a0703812594387 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 11 May 2026 08:49:58 +0900 Subject: [PATCH] feat(curator): build_retrospective + lotto review service proxy --- agent-office/app/curator/retrospective.py | 50 +++++++++++++++++++++++ agent-office/app/service_proxy.py | 28 +++++++++++++ agent-office/tests/test_retrospective.py | 47 +++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 agent-office/app/curator/retrospective.py create mode 100644 agent-office/tests/test_retrospective.py diff --git a/agent-office/app/curator/retrospective.py b/agent-office/app/curator/retrospective.py new file mode 100644 index 0000000..b88b8df --- /dev/null +++ b/agent-office/app/curator/retrospective.py @@ -0,0 +1,50 @@ +"""큐레이션 직전 호출 — review 1건 + 추세 3건 → 컨텍스트 dict.""" +import json +from typing import Optional, Dict, Any +from .. import service_proxy + + +def _detect_bias(reviews: list) -> str: + """3주↑ 같은 방향 패턴 편향이 유지되면 한 줄로.""" + deltas = [r.get("pattern_delta") or "" for r in reviews if r.get("pattern_delta")] + if len(deltas) < 2: + return "" + # 단순 휴리스틱 — 같은 키워드("저번호" 등)가 2회 이상이면 지속 편향 + keywords = ["저번호", "고번호", "합계", "홀짝"] + persistent = [] + for kw in keywords: + cnt = sum(1 for d in deltas if kw in d) + if cnt >= max(2, len(deltas) - 1): + persistent.append(kw) + return " · ".join(persistent) + + +async def build_retrospective(target_draw_no: int) -> Optional[Dict[str, Any]]: + """target_draw_no(이번 주) 직전 회차의 review + 그 앞 3회 추세.""" + last = await service_proxy.lotto_review_by_draw(target_draw_no - 1) + if not last: + return None + + history = await service_proxy.lotto_reviews_history(limit=4) + # history 는 desc 정렬 → last 와 그 이전 3건 분리 + others = [r for r in history if r["draw_no"] < target_draw_no - 1][:3] + series = [last] + others + + cur_avgs = [r["curator_avg_match"] for r in series if r.get("curator_avg_match") is not None] + usr_avgs = [r["user_avg_match"] for r in series if r.get("user_avg_match") is not None] + + return { + "last_draw": { + "draw_no": last["draw_no"], + "curator_avg": last.get("curator_avg_match"), + "curator_best_tier": last.get("curator_best_tier"), + "user_avg": last.get("user_avg_match"), + "user_5plus": last.get("user_5plus_prizes"), + "pattern_delta": last.get("pattern_delta") or "", + }, + "trend_4w": { + "curator_avg_4w": round(sum(cur_avgs) / len(cur_avgs), 2) if cur_avgs else None, + "user_avg_4w": round(sum(usr_avgs) / len(usr_avgs), 2) if usr_avgs else None, + "user_persistent_bias": _detect_bias(series), + }, + } diff --git a/agent-office/app/service_proxy.py b/agent-office/app/service_proxy.py index 4353c10..4ff6dc0 100644 --- a/agent-office/app/service_proxy.py +++ b/agent-office/app/service_proxy.py @@ -180,6 +180,34 @@ async def lotto_save_briefing(payload: dict) -> Dict[str, Any]: return resp.json() +async def lotto_review_latest() -> Optional[Dict[str, Any]]: + from .config import LOTTO_BACKEND_URL + resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/review/latest") + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json() + + +async def lotto_review_by_draw(draw_no: int) -> Optional[Dict[str, Any]]: + from .config import LOTTO_BACKEND_URL + resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/review/{draw_no}") + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json() + + +async def lotto_reviews_history(limit: int = 10) -> List[Dict[str, Any]]: + from .config import LOTTO_BACKEND_URL + resp = await _client.get( + f"{LOTTO_BACKEND_URL}/api/lotto/review/history", + params={"limit": limit}, + ) + resp.raise_for_status() + return resp.json().get("reviews", []) + + # --- music-lab pipeline (YouTube publisher orchestration) --- async def list_active_pipelines() -> list[dict]: diff --git a/agent-office/tests/test_retrospective.py b/agent-office/tests/test_retrospective.py new file mode 100644 index 0000000..042025c --- /dev/null +++ b/agent-office/tests/test_retrospective.py @@ -0,0 +1,47 @@ +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +import pytest +from unittest.mock import AsyncMock, patch +from app.curator.retrospective import build_retrospective, _detect_bias + + +def test_detect_bias_persistent_low(): + reviews = [ + {"pattern_delta": "저번호 편향 +1.2 / 합계 -18"}, + {"pattern_delta": "저번호 편향 +0.8"}, + {"pattern_delta": "저번호 편향 +1.0 / 홀짝 +0.5"}, + ] + assert "저번호" in _detect_bias(reviews) + + +def test_detect_bias_no_persistence(): + reviews = [ + {"pattern_delta": "저번호 편향 +1.2"}, + {"pattern_delta": "고번호 편향 +0.8"}, + ] + assert _detect_bias(reviews) == "" + + +@pytest.mark.asyncio +async def test_build_retrospective_with_data(): + with patch("app.service_proxy.lotto_review_by_draw", new=AsyncMock(return_value={ + "draw_no": 1153, "curator_avg_match": 1.8, "curator_best_tier": "안정", + "user_avg_match": 2.0, "user_5plus_prizes": 1, "pattern_delta": "저번호 편향 +1.2", + })), patch("app.service_proxy.lotto_reviews_history", new=AsyncMock(return_value=[ + {"draw_no": 1153, "curator_avg_match": 1.8, "user_avg_match": 2.0, "pattern_delta": "저번호 편향 +1.2"}, + {"draw_no": 1152, "curator_avg_match": 1.6, "user_avg_match": 1.5, "pattern_delta": "저번호 편향 +0.8"}, + {"draw_no": 1151, "curator_avg_match": 1.7, "user_avg_match": 1.8, "pattern_delta": "저번호 편향 +1.0"}, + {"draw_no": 1150, "curator_avg_match": 1.9, "user_avg_match": 2.2, "pattern_delta": ""}, + ])): + out = await build_retrospective(1154) + assert out["last_draw"]["draw_no"] == 1153 + assert out["trend_4w"]["curator_avg_4w"] == round((1.8+1.6+1.7+1.9)/4, 2) + assert "저번호" in out["trend_4w"]["user_persistent_bias"] + + +@pytest.mark.asyncio +async def test_build_retrospective_no_review(): + with patch("app.service_proxy.lotto_review_by_draw", new=AsyncMock(return_value=None)): + out = await build_retrospective(1154) + assert out is None