diff --git a/agent-office/Dockerfile b/agent-office/Dockerfile new file mode 100644 index 0000000..c05ee7c --- /dev/null +++ b/agent-office/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-alpine +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/agent-office/app/__init__.py b/agent-office/app/__init__.py new file mode 100644 index 0000000..35502e0 --- /dev/null +++ b/agent-office/app/__init__.py @@ -0,0 +1 @@ +# agent-office/app/__init__.py diff --git a/agent-office/app/config.py b/agent-office/app/config.py new file mode 100644 index 0000000..66caf61 --- /dev/null +++ b/agent-office/app/config.py @@ -0,0 +1,23 @@ +import os + +# Service URLs (Docker internal network) +STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", "http://localhost:18500") +MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://localhost:18600") + +# Telegram +TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") +TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") +TELEGRAM_WEBHOOK_URL = os.getenv("TELEGRAM_WEBHOOK_URL", "") + +# Database +DB_PATH = os.getenv("AGENT_OFFICE_DB_PATH", "/app/data/agent_office.db") + +# CORS +CORS_ALLOW_ORIGINS = os.getenv( + "CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080" +) + +# Idle break threshold (seconds) +IDLE_BREAK_THRESHOLD = int(os.getenv("IDLE_BREAK_THRESHOLD", "300")) # 5 min +BREAK_DURATION_MIN = int(os.getenv("BREAK_DURATION_MIN", "60")) # 1 min +BREAK_DURATION_MAX = int(os.getenv("BREAK_DURATION_MAX", "180")) # 3 min diff --git a/agent-office/app/db.py b/agent-office/app/db.py new file mode 100644 index 0000000..c22c1c4 --- /dev/null +++ b/agent-office/app/db.py @@ -0,0 +1,261 @@ +import os +import json +import sqlite3 +import uuid +from typing import Any, Dict, List, Optional + +from .config import DB_PATH + + +def _conn() -> sqlite3.Connection: + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def init_db() -> None: + with _conn() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS agent_config ( + agent_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + schedule_config TEXT NOT NULL DEFAULT '{}', + custom_config TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS agent_tasks ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + task_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + input_data TEXT NOT NULL DEFAULT '{}', + result_data TEXT, + requires_approval INTEGER NOT NULL DEFAULT 0, + approved_at TEXT, + approved_via TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + completed_at TEXT + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_tasks_agent + ON agent_tasks(agent_id, created_at DESC) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS agent_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT NOT NULL, + task_id TEXT, + level TEXT NOT NULL DEFAULT 'info', + message TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS telegram_state ( + callback_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + action TEXT, + responded INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ) + """) + # Seed default agent configs + for agent_id, name in [("stock", "주식 트레이더"), ("music", "음악 프로듀서")]: + conn.execute( + "INSERT OR IGNORE INTO agent_config(agent_id, display_name) VALUES(?,?)", + (agent_id, name), + ) + + +# --- agent_config CRUD --- + +def get_all_agents() -> List[Dict[str, Any]]: + with _conn() as conn: + rows = conn.execute("SELECT * FROM agent_config ORDER BY agent_id").fetchall() + return [_config_to_dict(r) for r in rows] + + +def get_agent_config(agent_id: str) -> Optional[Dict[str, Any]]: + with _conn() as conn: + r = conn.execute("SELECT * FROM agent_config WHERE agent_id=?", (agent_id,)).fetchone() + return _config_to_dict(r) if r else None + + +def update_agent_config(agent_id: str, **kwargs) -> None: + sets, vals = [], [] + for k in ("enabled", "schedule_config", "custom_config"): + if k in kwargs and kwargs[k] is not None: + if k in ("schedule_config", "custom_config"): + sets.append(f"{k}=?") + vals.append(json.dumps(kwargs[k])) + else: + sets.append(f"{k}=?") + vals.append(kwargs[k]) + if not sets: + return + sets.append("updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')") + vals.append(agent_id) + with _conn() as conn: + conn.execute(f"UPDATE agent_config SET {','.join(sets)} WHERE agent_id=?", vals) + + +def _config_to_dict(r) -> Dict[str, Any]: + return { + "agent_id": r["agent_id"], + "display_name": r["display_name"], + "enabled": bool(r["enabled"]), + "schedule_config": json.loads(r["schedule_config"]), + "custom_config": json.loads(r["custom_config"]), + "created_at": r["created_at"], + "updated_at": r["updated_at"], + } + + +# --- agent_tasks CRUD --- + +def create_task(agent_id: str, task_type: str, input_data: dict, requires_approval: bool = False) -> str: + task_id = str(uuid.uuid4()) + status = "pending" if requires_approval else "working" + with _conn() as conn: + conn.execute( + "INSERT INTO agent_tasks(id,agent_id,task_type,status,input_data,requires_approval) VALUES(?,?,?,?,?,?)", + (task_id, agent_id, task_type, status, json.dumps(input_data), int(requires_approval)), + ) + return task_id + + +def update_task_status(task_id: str, status: str, result_data: dict = None) -> None: + with _conn() as conn: + if result_data is not None: + conn.execute( + "UPDATE agent_tasks SET status=?, result_data=?, completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?", + (status, json.dumps(result_data), task_id), + ) + else: + conn.execute("UPDATE agent_tasks SET status=? WHERE id=?", (status, task_id)) + + +def approve_task(task_id: str, via: str = "web") -> None: + with _conn() as conn: + conn.execute( + "UPDATE agent_tasks SET status='approved', approved_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), approved_via=? WHERE id=?", + (via, task_id), + ) + + +def reject_task(task_id: str) -> None: + with _conn() as conn: + conn.execute( + "UPDATE agent_tasks SET status='failed', completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?", + (task_id,), + ) + + +def get_task(task_id: str) -> Optional[Dict[str, Any]]: + with _conn() as conn: + r = conn.execute("SELECT * FROM agent_tasks WHERE id=?", (task_id,)).fetchone() + return _task_to_dict(r) if r else None + + +def get_agent_tasks(agent_id: str, limit: int = 20) -> List[Dict[str, Any]]: + with _conn() as conn: + rows = conn.execute( + "SELECT * FROM agent_tasks WHERE agent_id=? ORDER BY created_at DESC LIMIT ?", + (agent_id, limit), + ).fetchall() + return [_task_to_dict(r) for r in rows] + + +def get_pending_approvals() -> List[Dict[str, Any]]: + with _conn() as conn: + rows = conn.execute( + "SELECT * FROM agent_tasks WHERE status='pending' AND requires_approval=1 ORDER BY created_at DESC" + ).fetchall() + return [_task_to_dict(r) for r in rows] + + +def _task_to_dict(r) -> Dict[str, Any]: + return { + "id": r["id"], + "agent_id": r["agent_id"], + "task_type": r["task_type"], + "status": r["status"], + "input_data": json.loads(r["input_data"]) if r["input_data"] else {}, + "result_data": json.loads(r["result_data"]) if r["result_data"] else None, + "requires_approval": bool(r["requires_approval"]), + "approved_at": r["approved_at"], + "approved_via": r["approved_via"], + "created_at": r["created_at"], + "completed_at": r["completed_at"], + } + + +# --- agent_logs --- + +def add_log(agent_id: str, message: str, level: str = "info", task_id: str = None) -> None: + with _conn() as conn: + conn.execute( + "INSERT INTO agent_logs(agent_id,task_id,level,message) VALUES(?,?,?,?)", + (agent_id, task_id, level, message), + ) + + +def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]: + with _conn() as conn: + rows = conn.execute( + "SELECT * FROM agent_logs WHERE agent_id=? ORDER BY created_at DESC LIMIT ?", + (agent_id, limit), + ).fetchall() + return [ + { + "id": r["id"], + "agent_id": r["agent_id"], + "task_id": r["task_id"], + "level": r["level"], + "message": r["message"], + "created_at": r["created_at"], + } + for r in rows + ] + + +# --- telegram_state --- + +def save_telegram_callback(callback_id: str, task_id: str, agent_id: str) -> None: + with _conn() as conn: + conn.execute( + "INSERT OR REPLACE INTO telegram_state(callback_id,task_id,agent_id) VALUES(?,?,?)", + (callback_id, task_id, agent_id), + ) + + +def get_telegram_callback(callback_id: str) -> Optional[Dict[str, Any]]: + with _conn() as conn: + r = conn.execute( + "SELECT * FROM telegram_state WHERE callback_id=? AND responded=0", + (callback_id,), + ).fetchone() + if not r: + return None + return { + "callback_id": r["callback_id"], + "task_id": r["task_id"], + "agent_id": r["agent_id"], + "responded": bool(r["responded"]), + } + + +def mark_telegram_responded(callback_id: str, action: str) -> None: + with _conn() as conn: + conn.execute( + "UPDATE telegram_state SET responded=1, action=? WHERE callback_id=?", + (action, callback_id), + ) diff --git a/agent-office/app/models.py b/agent-office/app/models.py new file mode 100644 index 0000000..891c19f --- /dev/null +++ b/agent-office/app/models.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel +from typing import Optional + + +class CommandRequest(BaseModel): + agent: str + action: str + params: Optional[dict] = None + + +class ApprovalRequest(BaseModel): + agent: str + task_id: str + approved: bool + feedback: Optional[str] = None + + +class AgentConfigUpdate(BaseModel): + enabled: Optional[bool] = None + schedule_config: Optional[dict] = None + custom_config: Optional[dict] = None + + +class PriceAlertConfig(BaseModel): + symbol: str + name: str + target_price: float + direction: str # "above" or "below" + + +class ComposeCommand(BaseModel): + prompt: str + style: Optional[str] = None + model: Optional[str] = "V4" + instrumental: Optional[bool] = False diff --git a/agent-office/app/test_db.py b/agent-office/app/test_db.py new file mode 100644 index 0000000..267763b --- /dev/null +++ b/agent-office/app/test_db.py @@ -0,0 +1,110 @@ +import os +import sys +import tempfile + +# Override DB_PATH before importing db +_tmp = tempfile.mktemp(suffix=".db") +os.environ["AGENT_OFFICE_DB_PATH"] = _tmp + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from app.db import ( + init_db, get_all_agents, get_agent_config, update_agent_config, + create_task, update_task_status, approve_task, get_task, get_agent_tasks, + get_pending_approvals, add_log, get_logs, + save_telegram_callback, get_telegram_callback, mark_telegram_responded, +) + + +def test_init_and_seed(): + init_db() + agents = get_all_agents() + assert len(agents) == 2, f"Expected 2 agents, got {len(agents)}" + ids = {a["agent_id"] for a in agents} + assert ids == {"stock", "music"}, f"Unexpected agent ids: {ids}" + print(" [PASS] test_init_and_seed") + + +def test_agent_config_update(): + init_db() + update_agent_config("stock", custom_config={"watch": ["AAPL"]}) + cfg = get_agent_config("stock") + assert cfg["custom_config"] == {"watch": ["AAPL"]}, f"Unexpected config: {cfg['custom_config']}" + print(" [PASS] test_agent_config_update") + + +def test_task_lifecycle(): + init_db() + # Create task with approval + tid = create_task("music", "compose", {"prompt": "test"}, requires_approval=True) + task = get_task(tid) + assert task["status"] == "pending", f"Expected pending, got {task['status']}" + assert task["requires_approval"] is True + + # Approve + approve_task(tid, via="telegram") + task = get_task(tid) + assert task["status"] == "approved", f"Expected approved, got {task['status']}" + assert task["approved_via"] == "telegram" + + # Complete + update_task_status(tid, "succeeded", {"url": "/media/music/test.mp3"}) + task = get_task(tid) + assert task["status"] == "succeeded", f"Expected succeeded, got {task['status']}" + assert task["result_data"]["url"] == "/media/music/test.mp3" + print(" [PASS] test_task_lifecycle") + + +def test_task_no_approval(): + init_db() + tid = create_task("stock", "news_summary", {"limit": 10}) + task = get_task(tid) + assert task["status"] == "working", f"Expected working, got {task['status']}" + print(" [PASS] test_task_no_approval") + + +def test_pending_approvals(): + init_db() + create_task("music", "compose", {"prompt": "a"}, requires_approval=True) + create_task("music", "compose", {"prompt": "b"}, requires_approval=True) + create_task("stock", "news_summary", {}) + pending = get_pending_approvals() + assert len(pending) == 2, f"Expected 2 pending, got {len(pending)}" + print(" [PASS] test_pending_approvals") + + +def test_logs(): + init_db() + add_log("stock", "News fetched", "info", "task-1") + add_log("stock", "API error", "error") + logs = get_logs("stock") + assert len(logs) == 2, f"Expected 2 logs, got {len(logs)}" + assert logs[0]["level"] == "error", f"Expected error first (DESC), got {logs[0]['level']}" + print(" [PASS] test_logs") + + +def test_telegram_state(): + init_db() + save_telegram_callback("cb-1", "task-1", "music") + cb = get_telegram_callback("cb-1") + assert cb["task_id"] == "task-1" + mark_telegram_responded("cb-1", "approve") + cb = get_telegram_callback("cb-1") + assert cb is None, f"Expected None after responded=1, got {cb}" + print(" [PASS] test_telegram_state") + + +if __name__ == "__main__": + test_init_and_seed() + test_agent_config_update() + test_task_lifecycle() + test_task_no_approval() + test_pending_approvals() + test_logs() + test_telegram_state() + print("All DB tests passed!") + # Cleanup temp DB (best-effort; WAL mode may keep files open on Windows) + for ext in ("", "-wal", "-shm"): + try: + os.unlink(_tmp + ext) + except OSError: + pass diff --git a/agent-office/requirements.txt b/agent-office/requirements.txt new file mode 100644 index 0000000..accb774 --- /dev/null +++ b/agent-office/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.115.6 +uvicorn[standard]==0.30.6 +requests==2.32.3 +apscheduler==3.10.4 +python-telegram-bot==21.5 +websockets>=12.0 +httpx>=0.27