diff --git a/CLAUDE.md b/CLAUDE.md index d3eb02f..76efe13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -360,6 +360,7 @@ AI 에이전트 가상 오피스 — 기존 서비스 API를 프록시로 호출 | POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook (realestate_bookmark_* 콜백 포함) | | POST | `/api/agent-office/realestate/notify` | realestate-lab 전용 push 수신 → 텔레그램 | | GET | `/api/agent-office/states` | 전체 에이전트 상태 | +| GET | `/api/agent-office/activity` | 전 에이전트 통합 활동 피드 (tasks+logs UNION). 필터 `agent_id`/`type`(task\|log)/`status`/`days` + `limit`/`offset` | | GET | `/api/agent-office/conversation/stats` | 텔레그램 대화 토큰·캐시 통계 (`days`) | | POST/GET | `/api/agent-office/youtube/research` (+ `/status`) | YouTube 트렌드 수집 트리거/상태 | | GET | `/api/agent-office/lotto/signals`, `/lotto/baselines` | 로또 시그널 이력·baseline | diff --git a/agent-office/app/db.py b/agent-office/app/db.py index e9212e0..cb31815 100644 --- a/agent-office/app/db.py +++ b/agent-office/app/db.py @@ -534,33 +534,58 @@ def get_conversation_stats(days: int = 7) -> Dict[str, Any]: } -def get_activity_feed(limit: int = 50, offset: int = 0) -> dict: - with _conn() as conn: - total_row = conn.execute(""" - SELECT (SELECT COUNT(*) FROM agent_tasks) + (SELECT COUNT(*) FROM agent_logs) AS total - """).fetchone() - total = total_row["total"] if total_row else 0 +def get_activity_feed(limit: int = 50, offset: int = 0, agent_id: str = None, + type: str = None, status: str = None, days: int = None) -> dict: + # 브랜치별 WHERE (값은 ? 바인딩, type은 브랜치 선택용). status는 task 전용 → 주면 log 제외. + task_where, task_params = [], [] + log_where, log_params = [], [] + if agent_id: + task_where.append("agent_id=?"); task_params.append(agent_id) + log_where.append("agent_id=?"); log_params.append(agent_id) + if status: + task_where.append("status=?"); task_params.append(status) + if days and days > 0: + task_where.append("created_at >= datetime('now', ?)"); task_params.append(f"-{int(days)} days") + log_where.append("created_at >= datetime('now', ?)"); log_params.append(f"-{int(days)} days") + include_tasks = type in (None, "task") + include_logs = type in (None, "log") and not status - rows = conn.execute(""" + task_clause = (" WHERE " + " AND ".join(task_where)) if task_where else "" + log_clause = (" WHERE " + " AND ".join(log_where)) if log_where else "" + + branches, branch_params = [], [] + if include_tasks: + branches.append(f""" SELECT 'task' AS type, agent_id, id AS task_id, task_type, status, NULL AS level, - COALESCE( - json_extract(result_data, '$.summary'), - task_type - ) AS message, - created_at, completed_at, - result_data - FROM agent_tasks - UNION ALL + COALESCE(json_extract(result_data, '$.summary'), task_type) AS message, + created_at, completed_at, result_data + FROM agent_tasks{task_clause}""") + branch_params += task_params + if include_logs: + branches.append(f""" SELECT 'log' AS type, agent_id, task_id, NULL AS task_type, - NULL AS status, level, - message, - created_at, NULL AS completed_at, - NULL AS result_data - FROM agent_logs - ORDER BY created_at DESC - LIMIT ? OFFSET ? - """, (limit, offset)).fetchall() + NULL AS status, level, message, + created_at, NULL AS completed_at, NULL AS result_data + FROM agent_logs{log_clause}""") + branch_params += log_params + + if not branches: + return {"items": [], "total": 0} + + union_sql = " UNION ALL ".join(branches) + " ORDER BY created_at DESC LIMIT ? OFFSET ?" + + with _conn() as conn: + total = 0 + if include_tasks: + total += conn.execute( + f"SELECT COUNT(*) AS c FROM agent_tasks{task_clause}", task_params + ).fetchone()["c"] + if include_logs: + total += conn.execute( + f"SELECT COUNT(*) AS c FROM agent_logs{log_clause}", log_params + ).fetchone()["c"] + rows = conn.execute(union_sql, branch_params + [limit, offset]).fetchall() items = [] for r in rows: diff --git a/agent-office/app/main.py b/agent-office/app/main.py index 0b39bcc..d275e14 100644 --- a/agent-office/app/main.py +++ b/agent-office/app/main.py @@ -198,8 +198,9 @@ def conversation_stats(days: int = 7): return get_conversation_stats(days) @app.get("/api/agent-office/activity") -def activity_feed(limit: int = 50, offset: int = 0): - return get_activity_feed(limit, offset) +def activity_feed(limit: int = 50, offset: int = 0, agent_id: str | None = None, + type: str | None = None, status: str | None = None, days: int | None = None): + return get_activity_feed(limit, offset, agent_id=agent_id, type=type, status=status, days=days) # --- Realestate Agent Push Endpoint --- diff --git a/agent-office/tests/test_activity_feed_filters.py b/agent-office/tests/test_activity_feed_filters.py new file mode 100644 index 0000000..caf635b --- /dev/null +++ b/agent-office/tests/test_activity_feed_filters.py @@ -0,0 +1,76 @@ +# agent-office/tests/test_activity_feed_filters.py +import os +import sys +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(): + db.DB_PATH = _TMP + 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 + + +def test_filter_by_agent_id(): + db.create_task("lotto", "curate", {}) + db.create_task("stock", "brief", {}) + db.add_log("stock", "stock 로그") + feed = db.get_activity_feed(limit=50, offset=0, agent_id="lotto") + assert feed["total"] == 1 + assert all(i["agent_id"] == "lotto" for i in feed["items"]) + + +def test_filter_type_task_excludes_logs(): + db.create_task("lotto", "curate", {}) + db.add_log("lotto", "로그 한 줄") + feed = db.get_activity_feed(limit=50, offset=0, type="task") + assert feed["total"] == 1 + assert all(i["type"] == "task" for i in feed["items"]) + + +def test_filter_type_log_excludes_tasks(): + db.create_task("lotto", "curate", {}) + db.add_log("lotto", "로그 한 줄") + feed = db.get_activity_feed(limit=50, offset=0, type="log") + assert feed["total"] == 1 + assert all(i["type"] == "log" for i in feed["items"]) + + +def test_filter_status_tasks_only(): + t1 = db.create_task("lotto", "curate", {}) + t2 = db.create_task("lotto", "curate", {}) + db.update_task_status(t1, "succeeded", {}) + db.update_task_status(t2, "failed", {}) + db.add_log("lotto", "로그 한 줄") # status 필터 시 log는 제외돼야 함 + feed = db.get_activity_feed(limit=50, offset=0, status="succeeded") + assert feed["total"] == 1 + assert all(i["type"] == "task" and i["status"] == "succeeded" for i in feed["items"]) + + +def test_no_filters_returns_all(): + db.create_task("lotto", "curate", {}) + db.add_log("stock", "로그") + feed = db.get_activity_feed(limit=50, offset=0) + assert feed["total"] == 2