From 6358da627681b7f9db054f742a7d14879cfe2812 Mon Sep 17 00:00:00 2001 From: aflyqi <1439811959@qq.com> Date: Fri, 27 Jun 2025 11:10:06 +0800 Subject: [PATCH] Add API services that support task queues. Add streamlit applications that can handle multiple optimization tasks. --- dump.rdb | Bin 0 -> 34004 bytes metagpt/ext/spo_api_backend/Readme.md | 28 ++ metagpt/ext/spo_api_backend/__init__.py | 0 metagpt/ext/spo_api_backend/celery_app.py | 16 + .../frontend_sample/spo_gui.py | 460 ++++++++++++++++++ metagpt/ext/spo_api_backend/schemas.py | 32 ++ metagpt/ext/spo_api_backend/spo_api.py | 361 ++++++++++++++ metagpt/ext/spo_api_backend/tasks.py | 50 ++ 8 files changed, 947 insertions(+) create mode 100644 dump.rdb create mode 100644 metagpt/ext/spo_api_backend/Readme.md create mode 100644 metagpt/ext/spo_api_backend/__init__.py create mode 100644 metagpt/ext/spo_api_backend/celery_app.py create mode 100644 metagpt/ext/spo_api_backend/frontend_sample/spo_gui.py create mode 100644 metagpt/ext/spo_api_backend/schemas.py create mode 100644 metagpt/ext/spo_api_backend/spo_api.py create mode 100644 metagpt/ext/spo_api_backend/tasks.py diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000000000000000000000000000000000000..d08001ae89402879a6e2da6b6d2123cfde0106b5 GIT binary patch literal 34004 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 ft*5 None: + """Call `st.rerun()` if available; fall back to `st.experimental_rerun()` if not.""" + if hasattr(st, "rerun"): + st.rerun() + elif hasattr(st, "experimental_rerun"): + st.experimental_rerun() + +############################################################################### +# ---------------------------- HISTORY PERSISTENCE ------------------------ # +############################################################################### + +def load_history() -> Dict[str, Any]: + """Load historical tasks from local files""" + if HISTORY_FILE.exists(): + try: + with HISTORY_FILE.open("r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + return {} + +def save_history(tasks: Dict[str, Any]) -> None: + """Save task history to local file""" + try: + with HISTORY_FILE.open("w", encoding="utf-8") as f: + json.dump(tasks, f, ensure_ascii=False, indent=2) + except Exception as e: + st.error(f"Failed to save history: {e}") + +############################################################################### +# ---------------------------- API HELPER CLASS --------------------------- # +############################################################################### + +class SPOClient: + """Lightweight client for the SPO FastAPI backend.""" + + def __init__(self, base_url: str = "http://localhost:8000") -> None: + self.base_url = base_url.rstrip("/") + + def start_optimization( + self, + optimization_model: str, + optimization_temp: float, + evaluation_model: str, + evaluation_temp: float, + execution_model: str, + execution_temp: float, + template_path: str, + initial_round: int = 1, + max_rounds: int = 10, + task_name: str | None = None, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "optimization_model": { + "model": optimization_model, + "temperature": optimization_temp, + }, + "evaluation_model": { + "model": evaluation_model, + "temperature": evaluation_temp, + }, + "execution_model": { + "model": execution_model, + "temperature": execution_temp, + }, + "template_path": template_path, + "initial_round": int(initial_round), + "max_rounds": int(max_rounds), + } + if task_name: + payload["task_name"] = task_name + + res = requests.post(f"{self.base_url}/optimize", json=payload, timeout=30) + res.raise_for_status() + return res.json() + + def safe_get_status(self, task_id: str) -> Dict[str, Any]: + try: + res = requests.get(f"{self.base_url}/status/{task_id}", timeout=10) + res.raise_for_status() + return res.json() + except Exception as exc: # noqa: BLE001 + return {"task_id": task_id, "status": "error", "error_message": str(exc)} + +############################################################################### +# ------------------------------ TEMPLATE UTILS ---------------------------- # +############################################################################### + +def generate_template_yaml( + prompt: str, + requirements: str, + qa_list: List[Dict[str, str]], + output_dir: str | Path = r"settings", +) -> str: + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + fname = output_dir / f"template_{uuid.uuid4().hex}.yaml" + + yaml_obj = { + "prompt": prompt, + "requirements": requirements, + "count": None, + "qa": qa_list, + } + + with fname.open("w", encoding="utf-8") as fp: + yaml.safe_dump(yaml_obj, fp, allow_unicode=True, sort_keys=False) + + return str(fname.resolve()) + +############################################################################### +# ---------------------------- STREAMLIT STATE ---------------------------- # +############################################################################### + +def init_session_state() -> None: + # 从历史文件加载任务 + history = load_history() + st.session_state.setdefault("tasks", history) + st.session_state.setdefault("selected_task", None) + st.session_state.setdefault("qa_buffer", []) + st.session_state.setdefault("current_view", "active") # active 或 history + +############################################################################### +# ---------------------------- SIDEBAR LAYOUT ----------------------------- # +############################################################################### + +def sidebar() -> None: + st.sidebar.title("🗂️ task management") + + # 视图切换 + view_choice = st.sidebar.radio("view mode", ["🔄 active Tasks", "📚 historical tasks"]) + st.session_state.current_view = "active" if "active" in view_choice else "history" + + tasks = st.session_state.tasks + + if st.session_state.current_view == "active": + # 显示活跃任务(运行中或待刷新的任务) + active_tasks = {tid: info for tid, info in tasks.items() + if info.get("status") not in {"completed", "failed", "error"}} + task_labels = ["➕ new task"] + [ + f"{tid[:8]} | {info.get('task_name', 'Unnamed')}" + for tid, info in active_tasks.items() + ] + choice = st.sidebar.radio("select task:", task_labels, index=0) + + if choice == "➕ new task": + st.session_state.selected_task = None + else: + sel_prefix = choice.split(" | ")[0] + for tid in active_tasks: + if tid.startswith(sel_prefix): + st.session_state.selected_task = tid + break + + with st.sidebar.expander("🔄 Refresh active tasks", expanded=False): + if st.button("🔃 Refresh all active task statuses", use_container_width=True): + client = SPOClient() + for tid, info in active_tasks.items(): + updated_info = client.safe_get_status(tid) + tasks[tid].update(updated_info) + save_history(tasks) + safe_rerun() + + else: # 历史视图 + # 显示所有任务,按创建时间排序 + sorted_tasks = sorted(tasks.items(), + key=lambda x: x[1].get('created_at', ''), reverse=True) + + if not sorted_tasks: + st.sidebar.info("There are currently no historical tasks available") + st.session_state.selected_task = None + else: + task_options = {} + for tid, info in sorted_tasks: + status_emoji = {"completed": "✅", "failed": "❌", "error": "⚠️", "running": "🔄"}.get(info.get("status"), "❓") + label = f"{status_emoji} {tid[:8]} | {info.get('task_name', 'Unnamed')}" + task_options[label] = tid + + choice = st.sidebar.selectbox("Select historical tasks:", list(task_options.keys())) + if choice: + st.session_state.selected_task = task_options[choice] + + with st.sidebar.expander("🗑️ Manage historical tasks", expanded=False): + if st.button("🔃 Refresh all task statuses", use_container_width=True): + client = SPOClient() + for tid, info in tasks.items(): + if info.get("status") not in {"completed", "failed", "error"}: + updated_info = client.safe_get_status(tid) + tasks[tid].update(updated_info) + save_history(tasks) + safe_rerun() + + st.markdown("**Delete task:**") + deletable = [tid for tid, info in tasks.items() + if info.get("status") in {"completed", "failed", "error"}] + + if deletable: + selected_for_deletion = st.multiselect( + "Select the task to delete:", + options=deletable, + format_func=lambda x: f"{x[:8]} | {tasks[x].get('task_name', 'Unnamed')}" + ) + + if selected_for_deletion and st.button("🗑️ Delete selected task", use_container_width=True): + for tid in selected_for_deletion: + tasks.pop(tid, None) + if st.session_state.selected_task in selected_for_deletion: + st.session_state.selected_task = None + save_history(tasks) + safe_rerun() + else: + st.info("There are no tasks that can be deleted") + +############################################################################### +# --------------------------- QA EDITOR HELPER ----------------------------- # +############################################################################### + +def show_qa_editor(in_form: bool) -> None: + qa_list: List[Dict[str, str]] = st.session_state.qa_buffer + + for idx, pair in enumerate(qa_list): + q, a = st.columns(2) + pair["question"] = q.text_input(f"Question {idx+1}", value=pair.get("question", ""), key=f"q_{idx}") + pair["answer"] = a.text_input(f"Answer {idx+1}", value=pair.get("answer", ""), key=f"a_{idx}") + + ctrl = st.columns(3) + if in_form: + add = ctrl[0].form_submit_button("➕ Add QA") + rm = ctrl[1].form_submit_button("➖ Remove the last item") + clr = ctrl[2].form_submit_button("🧹 Clear") + else: + add = ctrl[0].button("➕ Add QA") + rm = ctrl[1].button("➖ Remove the last item") + clr = ctrl[2].button("🧹 Clear") + + if add: + qa_list.append({"question": "", "answer": ""}) + if rm and qa_list: + qa_list.pop() + if clr and qa_list: + qa_list.clear() + +############################################################################### +# --------------------------- NEW TASK LAYOUT ----------------------------- # +############################################################################### + +def render_new_task_ui() -> None: + st.header("🆕 New optimization task") + + with st.form("task_form", clear_on_submit=False): + c1, c2 = st.columns(2) + task_name = c1.text_input("Task Name", value="my_optimization_task") + base_url = c2.text_input("API Base URL", value="http://localhost:8000") + + st.subheader("✍️ model selection") + models = [ + "Qwen1.5-72B-Chat-AWQ", + "Qwen3-32B-AWQ", + "gpt-4o", + "claude-3-5-sonnet-20240620", + "deepseek-chat", + ] + mcol = st.columns(3) + opt_model = mcol[0].selectbox("Optimization", models, index=0) + eval_model = mcol[1].selectbox("Evaluation", models, index=3) + exe_model = mcol[2].selectbox("Execution", models, index=2) + + tcol = st.columns(3) + opt_temp = tcol[0].slider("Opt Temp", 0.0, 1.0, 0.7, 0.05) + eval_temp = tcol[1].slider("Eval Temp", 0.0, 1.0, 0.3, 0.05) + exe_temp = tcol[2].slider("Exec Temp", 0.0, 1.0, 0.0, 0.05) + + st.divider() + st.subheader("📑 template") + template_path = st.text_input("template_path") + + with st.expander("🛠️ Create/Edit Template", expanded=False): + ptxt = st.text_area("Prompt", key="tmp_prompt") + rtxt = st.text_area("Requirements", key="tmp_req") + st.markdown("#### QA List") + show_qa_editor(in_form=True) + if st.form_submit_button("⏺️ generation template", use_container_width=True): + if not ptxt or not rtxt: + st.warning("Prompt and Requirements cannot be empty!") + else: + BASE_DIR = Path(__file__).resolve().parent + output_dir = BASE_DIR.parent.parent / "spo" / "settings" + tpath = generate_template_yaml(ptxt, rtxt, st.session_state.qa_buffer,output_dir) + st.success(f"Template has been generated: {tpath}") + st.session_state.template_path_value = tpath + + # autofill + if "template_path_value" in st.session_state and not template_path: + template_path = st.session_state.template_path_value + + st.subheader("🔢 Round number setting") + rcol = st.columns(2) + init_round = int(rcol[0].number_input("Initial Round", 1, 100, 1)) + max_round = int(rcol[1].number_input("Max Rounds", 1, 100, 10)) + + start_clicked = st.form_submit_button("🚀 Start optimization", use_container_width=True) + + # ---- 表单外逻辑 ---- # + if start_clicked: + if not template_path: + st.error("Please provide a template or create a template!") + st.stop() + + client = SPOClient(base_url) + with st.spinner("Starting task..."): + try: + info = client.start_optimization( + opt_model, + opt_temp, + eval_model, + eval_temp, + exe_model, + exe_temp, + template_path, + init_round, + max_round, + task_name=task_name, + ) + except Exception as e: # noqa: BLE001 + st.error(f"FAIL TO START: {e}") + st.stop() + + tid = info["task_id"] + + # 保存完整的任务信息 + task_info = { + **info, + "task_name": task_name, # 确保保存任务名称 + "base_url": base_url, + "created_at": datetime.now().isoformat(), + "config": { + "optimization_model": opt_model, + "evaluation_model": eval_model, + "execution_model": exe_model, + "optimization_temp": opt_temp, + "evaluation_temp": eval_temp, + "execution_temp": exe_temp, + "template_path": template_path, + "initial_round": init_round, + "max_rounds": max_round, + } + } + + st.session_state.tasks[tid] = task_info + save_history(st.session_state.tasks) # 保存到历史文件 + st.session_state.selected_task = tid + st.success(f"✅ Task created!Task ID: {tid}") + safe_rerun() + +############################################################################### +# ------------------------- TASK DISPLAY LAYOUT --------------------------- # +############################################################################### + +def render_task_view(tid: str) -> None: + task = st.session_state.tasks[tid] + + # 任务标题和复制按钮 + col1, col2 = st.columns([3, 1]) + with col1: + st.header(f"📊 Task: {task.get('task_name', 'Unnamed')}") + with col2: + if st.button("📋 Copy Task ID", key=f"copy_{tid}"): + st.code(tid, language=None) + st.success("Task ID has been displayed, please manually copy it") + + # 基本信息 + status = task.get("status", "unknown") + status_emoji = {"completed": "✅", "failed": "❌", "error": "⚠️", "running": "🔄"}.get(status, "❓") + st.markdown(f"**state**: {status_emoji} `{status}`") + + # 显示配置信息 + if "config" in task: + config = task["config"] + with st.expander("⚙️ Task Configuration", expanded=False): + col1, col2 = st.columns(2) + with col1: + st.markdown("**Model configuration:**") + st.markdown(f"- optimization model: `{config.get('optimization_model')}`") + st.markdown(f"- evaluation model: `{config.get('evaluation_model')}`") + st.markdown(f"- execution model: `{config.get('execution_model')}`") + with col2: + st.markdown("**parameter configuration:**") + st.markdown(f"- Initial number of rounds: `{config.get('initial_round')}`") + st.markdown(f"- Maximum number of rounds: `{config.get('max_rounds')}`") + st.markdown(f"- template file: `{Path(config.get('template_path', '')).name}`") + + # 刷新按钮 + if st.button("🔃 Refresh the status of this task", key=f"refresh_{tid}"): + client = SPOClient(task.get("base_url", "http://localhost:8000")) + updated_info = client.safe_get_status(tid) + task.update(updated_info) + save_history(st.session_state.tasks) # 保存更新后的状态 + safe_rerun() + + # 任务统计 + if status in {"completed", "failed"}: + st.markdown( + f"**用时**: {task.get('elapsed_time', 0):.2f}s | **Number of successful rounds**: {task.get('successful_rounds', 0)} / {task.get('total_rounds', 0)}" + ) + + # 结果显示 + if "results" in task and task["results"]: + st.subheader("📈 Round Details") + for res in task["results"]: + emoji = "✅" if res.get("succeed") else "❌" + with st.expander(f"Round {res['round']} {emoji}"): + st.markdown("**Prompt**") + st.code(res.get("prompt", ""), language="text") + if res.get("answers"): + st.markdown("**Q&A Result**") + for qa in res.get("answers", []): + st.markdown(f"- **{qa['question']}**: {qa['answer']}") + elif status == "running": + st.info("The task is running, refresh later to see the results...") + elif status == "error": + st.error(f"task exception: {task.get('error_message')}") + +############################################################################### +# --------------------------------- MAIN ----------------------------------- # +############################################################################### + +def main() -> None: + st.set_page_config("SPO Prompt Optimizer", layout="wide") + init_session_state() + sidebar() + + sel = st.session_state.selected_task + if sel is None: + if st.session_state.current_view == "active": + render_new_task_ui() + else: + st.info("Please select a historical task from the left to view details") + else: + render_task_view(sel) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/metagpt/ext/spo_api_backend/schemas.py b/metagpt/ext/spo_api_backend/schemas.py new file mode 100644 index 0000000000..ead5e4bee3 --- /dev/null +++ b/metagpt/ext/spo_api_backend/schemas.py @@ -0,0 +1,32 @@ +# metagpt/ext/spo_api_backend/schemas.py +from pydantic import BaseModel, Field +from typing import List, Dict, Optional +from enum import Enum + + +class RoundResult(BaseModel): + round: int + prompt: str + succeed: bool + tokens: Optional[int] = None + answers: Optional[List[Dict]] = None + + +class TaskStatus(str, Enum): + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +class OptimizationResponse(BaseModel): + task_id: str + status: str + results: List[RoundResult] = [] + last_successful_prompt: Optional[str] = None + last_successful_round: Optional[int] = None + total_rounds: int = 0 + successful_rounds: int = 0 + error_message: Optional[str] = None + start_time: Optional[float] = None + end_time: Optional[float] = None + elapsed_time: Optional[float] = None diff --git a/metagpt/ext/spo_api_backend/spo_api.py b/metagpt/ext/spo_api_backend/spo_api.py new file mode 100644 index 0000000000..49ed86d58b --- /dev/null +++ b/metagpt/ext/spo_api_backend/spo_api.py @@ -0,0 +1,361 @@ +import asyncio +import uuid +import time +import os +import sys +from pathlib import Path +from typing import Dict, List, Optional +from enum import Enum + +from fastapi import FastAPI, HTTPException, BackgroundTasks +from pydantic import BaseModel, Field +import yaml +from loguru import logger +import nest_asyncio +nest_asyncio.apply() +from fastapi.background import BackgroundTasks +import redis +#from .tasks import run_optimization_celery +import json +from metagpt.ext.spo_api_backend.tasks import run_optimization_celery +from metagpt.ext.spo_api_backend.schemas import OptimizationResponse, TaskStatus, RoundResult + +r = redis.Redis(decode_responses=True) + + + + +def save_task_to_redis(task: OptimizationResponse): + r.set(f"task:{task.task_id}", task.json()) + + +def load_task_from_redis(task_id: str) -> Optional[OptimizationResponse]: + raw = r.get(f"task:{task_id}") + if not raw: + return None + return OptimizationResponse.parse_raw(raw) + + + +# Startup script for solving path problems +def setup_metagpt_environment(): + """Set up MetaGPT environment""" + current_file = Path(__file__).absolute() + + # Try multiple possible MetaGPT root directory locations + possible_roots = [ + current_file.parent.parent.parent, # if in metagpt/ext/spo/ + current_file.parent.parent.parent.parent, # If deeper + Path.cwd(), # 当Previous Work Catalog + Path.cwd().parent, # parent directory + ] + + metagpt_root = None + for root in possible_roots: + if (root / "metagpt" / "__init__.py").exists(): + metagpt_root = root + break + + if metagpt_root is None: + # If not found, use the parent directory of the current directory + metagpt_root = current_file.parent.parent.parent + print(f"Warning: Could not auto-detect MetaGPT root, using: {metagpt_root}") + + # 添加到 Python 路径 + if str(metagpt_root) not in sys.path: + sys.path.insert(0, str(metagpt_root)) + + # 设置环境变量 + os.environ['METAGPT_ROOT'] = str(metagpt_root) + + return metagpt_root + +# 设置环境 +METAGPT_ROOT = setup_metagpt_environment() + +# 尝试导入 MetaGPT 模块 +try: + from metagpt.ext.spo.components.optimizer import PromptOptimizer + from metagpt.ext.spo.utils.llm_client import SPO_LLM + print("✓ Successfully imported MetaGPT modules") +except ImportError as e: + print(f"❌ Failed to import MetaGPT modules: {e}") + print(f"Current directory: {Path.cwd()}") + print(f"Script directory: {Path(__file__).parent}") + print(f"MetaGPT root: {METAGPT_ROOT}") + print(f"Python path: {sys.path[:3]}...") # 只显示前几个路径 + + # 提供详细的错误信息和解决方案 + print("\n🔧 Troubleshooting steps:") + print("1. Make sure you're running this script from the correct directory") + print("2. Check if MetaGPT is properly installed") + print("3. Try running: pip install -e . (from MetaGPT root directory)") + print("4. Or set PYTHONPATH manually: export PYTHONPATH=/path/to/MetaGPT:$PYTHONPATH") + + raise SystemExit(1) + + +# Pydantic Models +class ModelConfig(BaseModel): + model: str + temperature: float = Field(ge=0.0, le=1.0) + + +class OptimizationRequest(BaseModel): + optimization_model: ModelConfig + evaluation_model: ModelConfig + execution_model: ModelConfig + template_path: str + initial_round: int = Field(ge=1, le=100, default=1) + max_rounds: int = Field(ge=1, le=100, default=10) + task_name: Optional[str] = None + + +class RoundResult(BaseModel): + round: int + prompt: str + succeed: bool + tokens: Optional[int] = None + answers: Optional[List[Dict]] = None + + +class OptimizationResponse(BaseModel): + task_id: str + status: str # "running", "completed", "failed" + results: List[RoundResult] = [] + last_successful_prompt: Optional[str] = None + last_successful_round: Optional[int] = None + total_rounds: int = 0 + successful_rounds: int = 0 + error_message: Optional[str] = None + start_time: Optional[float] = None + end_time: Optional[float] = None + elapsed_time: Optional[float] = None + + +class TaskStatus(str, Enum): + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +# Global storage for tasks (in production, use Redis or database) +task_storage: Dict[str, OptimizationResponse] = {} + + +# FastAPI app +app = FastAPI( + title="SPO API - Self-Supervised Prompt Optimization", + description="API for running prompt optimization tasks concurrently", + version="1.0.0" +) + + +def load_yaml_template(template_path: Path) -> Dict: + """Load YAML template from file path""" + if template_path.exists(): + with open(template_path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) + raise FileNotFoundError(f"Template file not found: {template_path}") + + +async def run_optimization_task(task_id: str, request: OptimizationRequest): + """Run the optimization task asynchronously""" + try: + # Update task status to running + task = load_task_from_redis(task_id) + if task: + task.status = TaskStatus.RUNNING + task.start_time = time.time() + save_task_to_redis(task) + + logger.info(f"Starting optimization task {task_id}") + + # Validate template path + template_path = Path(request.template_path) + if not template_path.exists(): + raise FileNotFoundError(f"Template file not found: {template_path}") + + # Initialize LLM + SPO_LLM.initialize( + optimize_kwargs={ + "model": request.optimization_model.model, + "temperature": request.optimization_model.temperature + }, + evaluate_kwargs={ + "model": request.evaluation_model.model, + "temperature": request.evaluation_model.temperature + }, + execute_kwargs={ + "model": request.execution_model.model, + "temperature": request.execution_model.temperature + }, + ) + + # Extract template name from path + template_name = template_path.stem + + # Create workspace directory + workspace_path = f"workspace_{task_id}" + workspace_dir = Path(workspace_path) + workspace_dir.mkdir(exist_ok=True) + + # Create optimizer instance + optimizer = PromptOptimizer( + optimized_path=workspace_path, + initial_round=request.initial_round, + max_rounds=request.max_rounds, + template=template_path.name, + name=request.task_name or template_name, + ) + + # Copy template to optimizer's settings directory if needed + optimizer_template_path = optimizer.root_path / "settings" / template_path.name + optimizer_template_path.parent.mkdir(parents=True, exist_ok=True) + + if not optimizer_template_path.exists(): + import shutil + shutil.copy2(template_path, optimizer_template_path) + + # Run optimization + if hasattr(optimizer, 'aoptimize'): + await optimizer.aoptimize() # 使用异步版本 + else: + # 如果异步版本不存在,在后台线程中运行同步版本 + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, optimizer.optimize) + + # Load results + prompt_path = optimizer.root_path / "prompts" + result_data = optimizer.data_utils.load_results(prompt_path) + + # Process results + results = [] + last_successful_prompt = None + last_successful_round = None + successful_rounds = 0 + + for result in result_data: + round_result = RoundResult( + round=result["round"], + prompt=result["prompt"], + succeed=result["succeed"], + tokens=result.get("tokens"), + answers=result.get("answers", []) + ) + results.append(round_result) + + if result["succeed"]: + successful_rounds += 1 + last_successful_prompt = result["prompt"] + last_successful_round = result["round"] + + # Update task with results + end_time = time.time() + task = load_task_from_redis(task_id) + if task: + task.status = TaskStatus.COMPLETED + task.results = results + task.last_successful_prompt = last_successful_prompt + task.last_successful_round = last_successful_round + task.total_rounds = len(results) + task.successful_rounds = successful_rounds + task.end_time = end_time + task.elapsed_time = end_time - task.start_time + save_task_to_redis(task) + + logger.info(f"Optimization task {task_id} completed successfully") + + except Exception as e: + logger.error(f"Optimization task {task_id} failed: {str(e)}") + task = load_task_from_redis(task_id) + if task: + task.status = TaskStatus.FAILED + task.error_message = str(e) + task.end_time = time.time() + if task.start_time: + task.elapsed_time = task.end_time - task.start_time + save_task_to_redis(task) + + +@app.post("/optimize", response_model=OptimizationResponse) +async def start_optimization(request: OptimizationRequest): + task_id = str(uuid.uuid4()) + + task = OptimizationResponse( + task_id=task_id, + status=TaskStatus.RUNNING + ) + + save_task_to_redis(task) + + run_optimization_celery.delay(task_id, request.dict()) + + return task + + + +@app.get("/status/{task_id}", response_model=OptimizationResponse) +async def get_task_status(task_id: str): + """Get the status of an optimization task""" + + task = load_task_from_redis(task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return task + + + +@app.get("/tasks", response_model=List[OptimizationResponse]) +async def list_all_tasks(): + """List all optimization tasks""" + keys = r.keys("task:*") + tasks = [OptimizationResponse.parse_raw(r.get(k)) for k in keys] + return tasks + + +@app.delete("/tasks/{task_id}") +async def delete_task(task_id: str): + if not r.exists(f"task:{task_id}"): + raise HTTPException(status_code=404, detail="Task not found") + r.delete(f"task:{task_id}") + return {"message": f"Task {task_id} deleted successfully"} + +@app.get("/health") +async def health_check(): + """Health check endpoint""" + return { + "status": "healthy", + "metagpt_root": str(METAGPT_ROOT), + "current_dir": str(Path.cwd()) + } + + +@app.get("/debug") +async def debug_info(): + """Debug information endpoint""" + return { + "current_directory": str(Path.cwd()), + "script_directory": str(Path(__file__).parent), + "metagpt_root": str(METAGPT_ROOT), + "python_path": sys.path[:5], # 只显示前5个路径 + "environment_vars": { + "METAGPT_ROOT": os.environ.get("METAGPT_ROOT"), + "PYTHONPATH": os.environ.get("PYTHONPATH", "Not set") + } + } + + +if __name__ == "__main__": + import uvicorn + + print(f"🚀 Starting SPO API server...") + print(f"📁 MetaGPT root: {METAGPT_ROOT}") + print(f"📍 Current directory: {Path.cwd()}") + print(f"🌐 API docs will be available at: http://localhost:8000/docs") + + # 创建日志目录 + log_dir = METAGPT_ROOT / "logs" + log_dir.mkdir(exist_ok=True) + + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/metagpt/ext/spo_api_backend/tasks.py b/metagpt/ext/spo_api_backend/tasks.py new file mode 100644 index 0000000000..50aa383628 --- /dev/null +++ b/metagpt/ext/spo_api_backend/tasks.py @@ -0,0 +1,50 @@ +from .celery_app import celery_app +import asyncio +import logging +import redis +import json +from metagpt.ext.spo_api_backend.schemas import OptimizationResponse + +r = redis.Redis() + +def redis_key(task_id: str) -> str: + return f"task:{task_id}" + +def save_task_to_redis(task: OptimizationResponse): + r.set(redis_key(task.task_id), task.json()) + +def load_task_from_redis(task_id: str) -> OptimizationResponse | None: + raw = r.get(redis_key(task_id)) + if raw: + return OptimizationResponse.parse_raw(raw) + return None + + +@celery_app.task(bind=True) +def run_optimization_celery(self, task_id: str, request_dict: dict): + try: + from metagpt.ext.spo_api_backend.spo_api import run_optimization_task, OptimizationRequest + + request = OptimizationRequest(**request_dict) + asyncio.run(run_optimization_task(task_id, request)) + + return {"status": "success", "task_id": task_id} + + except Exception as e: + logging.exception(f"[Celery Task Failed] Task {task_id}: {e}") + + try: + task = load_task_from_redis(task_id) + if task: + task.status = "failed" + task.error_message = str(e) + save_task_to_redis(task) + except Exception: + logging.warning(f"Couldn't update task in Redis for {task_id}") + + return {"status": "failed", "task_id": task_id, "error": str(e)} + + +@celery_app.task +def ping(): + return "pong"