feat: /api/daily/* 端点(timeline/boss/dates)
新增三个日报 API 端点,从 DailyReport SQLite 读取数据: - GET /api/daily/timeline?date=YYYY-MM-DD - GET /api/daily/boss?date=YYYY-MM-DD - GET /api/daily/dates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,992 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from nmfs_agents.core.queue import TaskQueue, Task
|
||||
|
||||
try:
|
||||
from visual_tester.db import VisualDB as _VisualDB
|
||||
_HAS_VISUAL = True
|
||||
except ImportError:
|
||||
_HAS_VISUAL = False
|
||||
|
||||
# ── Pydantic 模型 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TaskOut(BaseModel):
|
||||
id: int
|
||||
project: str
|
||||
type: str
|
||||
title: str
|
||||
context: str
|
||||
priority: int
|
||||
mode: str
|
||||
agent_role: str
|
||||
status: str
|
||||
created_at: str
|
||||
started_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
result_summary: str
|
||||
retry_count: int
|
||||
parent_task_id: int
|
||||
constraint_type: str
|
||||
awaiting_role: str
|
||||
initiator: str = "system"
|
||||
discussion: str = ""
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
project: str
|
||||
type: str
|
||||
title: str
|
||||
priority: int = 3
|
||||
mode: str = "auto"
|
||||
agent_role: str = "developer"
|
||||
context: str = ""
|
||||
initiator: str = "user"
|
||||
discussion: str = ""
|
||||
queue: str = "project_delivery" # project_delivery | base_opt | insight
|
||||
|
||||
|
||||
class TaskPatch(BaseModel):
|
||||
priority: Optional[int] = None
|
||||
|
||||
|
||||
class RejectBody(BaseModel):
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class ProjectImport(BaseModel):
|
||||
path: str
|
||||
name: str = ""
|
||||
mode: str = "auto"
|
||||
description: str = ""
|
||||
scan_now: bool = True
|
||||
linked_project: str = "" # 关联项目路径(用于生成跨项目集成任务)
|
||||
|
||||
|
||||
class ConfigUpdate(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class StartBody(BaseModel):
|
||||
project: Optional[str] = None # None=全局,非 None=仅执行该项目
|
||||
|
||||
|
||||
class StopBody(BaseModel):
|
||||
project: Optional[str] = None # None=全局停止,非 None=仅停止该项目(暂停)
|
||||
|
||||
|
||||
class VisualApproveBody(BaseModel):
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class MarketTriggerResponse(BaseModel):
|
||||
task_id: int
|
||||
goal: str
|
||||
|
||||
|
||||
# ── 项目特征检测 ────────────────────────────────────────────────────────
|
||||
|
||||
def _detect_project_features(path: Path) -> dict:
|
||||
"""检测项目特征,用于生成适配的任务模板。"""
|
||||
def _has_file(*parts: str) -> bool:
|
||||
return any((path / p).exists() for p in parts)
|
||||
|
||||
def _has_pattern(glob: str) -> bool:
|
||||
return next(path.rglob(glob), None) is not None
|
||||
|
||||
return {
|
||||
"has_tests": _has_file("tests", "test") and _has_pattern("test_*.py"),
|
||||
"has_rknn": (
|
||||
_has_file("requirements-device.txt") or
|
||||
_has_pattern("*.rknn") or
|
||||
any("rknn" in (path / f).read_text(encoding="utf-8", errors="ignore")
|
||||
for f in ["requirements.txt", "requirements-x86.txt",
|
||||
"README.md", "CLAUDE.md"]
|
||||
if (path / f).exists())
|
||||
),
|
||||
"has_training": _has_pattern("train_*.py") or _has_pattern("*train*.py"),
|
||||
"has_docker": _has_file("Dockerfile", "docker-compose.yml"),
|
||||
"has_device_code": (
|
||||
_has_file("requirements-device.txt") or
|
||||
any("rk3588" in (path / f).read_text(encoding="utf-8", errors="ignore")
|
||||
for f in ["README.md", "CLAUDE.md"]
|
||||
if (path / f).exists())
|
||||
),
|
||||
"has_frontend": _has_file("frontend", "web", "dashboard", "package.json"),
|
||||
}
|
||||
|
||||
|
||||
def _generate_template_tasks(
|
||||
name: str, path: Path, mode: str, linked_project: str = ""
|
||||
) -> list[Task]:
|
||||
"""根据项目特征生成适合软硬件协同研发的默认任务集。"""
|
||||
feat = _detect_project_features(path)
|
||||
proj_desc = f"项目路径: {path}\n"
|
||||
tasks: list[Task] = []
|
||||
|
||||
# P1: 全量代码审查(必须)
|
||||
tasks.append(Task(
|
||||
project=name, type="code_review", priority=1, mode=mode,
|
||||
agent_role="developer",
|
||||
title="全量代码审查:架构设计与技术债务识别",
|
||||
context=(
|
||||
proj_desc +
|
||||
"请对整个项目进行全面代码审查:\n"
|
||||
"1) src/ 下各模块的代码质量、接口设计、异常处理完整性、类型注解覆盖率\n"
|
||||
"2) 识别并修复所有技术债务和潜在 Bug\n"
|
||||
"3) 检查编码规范一致性(命名、注释、导入结构)\n"
|
||||
"4) 确保所有公共接口有完整文档字符串"
|
||||
),
|
||||
))
|
||||
|
||||
# P1: 测试覆盖率分析
|
||||
if feat["has_tests"]:
|
||||
tasks.append(Task(
|
||||
project=name, type="test_improvement", priority=1, mode=mode,
|
||||
agent_role="tester",
|
||||
title="测试覆盖率分析:识别盲区并补全关键路径测试",
|
||||
context=(
|
||||
proj_desc +
|
||||
"运行现有测试套件,分析覆盖率报告(pytest --cov):\n"
|
||||
"1) 识别覆盖率低于 80% 的核心模块\n"
|
||||
"2) 为关键业务逻辑补全单元测试(使用 mock 替代外部依赖)\n"
|
||||
"3) 确保边界条件和异常路径有测试覆盖\n"
|
||||
"4) 被 ignore 的测试文件(如依赖模型权重的)改用 mock 使其可在 CI 运行"
|
||||
),
|
||||
))
|
||||
|
||||
# P2: 重构与接口规范化
|
||||
tasks.append(Task(
|
||||
project=name, type="refactor", priority=2, mode=mode,
|
||||
agent_role="developer",
|
||||
title="重构:关键模块解耦与接口规范化",
|
||||
context=(
|
||||
proj_desc +
|
||||
"重点重构以下方面:\n"
|
||||
"1) 识别高耦合模块,提取公共抽象基类或接口\n"
|
||||
"2) 消除重复代码(DRY 原则)\n"
|
||||
"3) 统一错误处理策略(自定义异常类 vs 通用异常)\n"
|
||||
"4) 规范化配置管理(避免硬编码常量散落各处)"
|
||||
),
|
||||
))
|
||||
|
||||
# P2: 性能分析(如有 RKNN/推理相关)
|
||||
if feat["has_rknn"] or feat["has_training"]:
|
||||
tasks.append(Task(
|
||||
project=name, type="performance", priority=2, mode=mode,
|
||||
agent_role="developer",
|
||||
title="性能分析:推理管道瓶颈识别与 numpy 向量化优化",
|
||||
context=(
|
||||
proj_desc +
|
||||
"分析推理管道性能:\n"
|
||||
"1) 使用 timeit 或 cProfile 识别热点函数\n"
|
||||
"2) 检查 numpy 操作是否充分向量化(消除 Python 级别循环)\n"
|
||||
"3) 评估批量推理的可行性(batch inference API)\n"
|
||||
"4) 若有相似度检索,评估大规模数据(1000+)时的查询性能"
|
||||
),
|
||||
))
|
||||
|
||||
# P2: 板端集成测试(如有设备代码)
|
||||
if feat["has_device_code"] or feat["has_rknn"]:
|
||||
tasks.append(Task(
|
||||
project=name, type="device_test", priority=2, mode=mode,
|
||||
agent_role="tester",
|
||||
title="板端集成:RKNN 模型部署验证与 SSH 测试",
|
||||
context=(
|
||||
proj_desc +
|
||||
"验证设备端部署:\n"
|
||||
"1) 检查 RKNN 模型转换脚本的完整性(calibration dataset, quantization)\n"
|
||||
"2) 通过 SSH 到 RK3588 设备运行基准测试\n"
|
||||
"3) 对比 x86 ONNX 与板端 RKNN 的精度差异(允许 < 2% 误差)\n"
|
||||
"4) 记录板端推理延迟(目标 < 50ms/帧)"
|
||||
),
|
||||
))
|
||||
|
||||
# P2: 跨项目数据流集成(如有关联项目)
|
||||
if linked_project:
|
||||
linked_name = Path(linked_project).name
|
||||
tasks.append(Task(
|
||||
project=name, type="integration", priority=2, mode=mode,
|
||||
agent_role="developer",
|
||||
title=f"跨项目集成:与 {linked_name} 数据管道对接",
|
||||
context=(
|
||||
proj_desc +
|
||||
f"关联项目路径: {linked_project}\n\n"
|
||||
f"分析并实现 {name} 与 {linked_name} 的数据流对接:\n"
|
||||
f"1) 评估 {linked_name} 的输出格式与 {name} 训练数据目录的兼容性\n"
|
||||
f"2) 设计或实现自动数据同步脚本(支持增量更新)\n"
|
||||
f"3) 验证数据质量:格式、分辨率、类别标签一致性\n"
|
||||
f"4) 制定持续训练流程(数据更新 → 重新训练 → 评估 → 部署)"
|
||||
),
|
||||
))
|
||||
|
||||
# P3: 架构评估(architect 角色)
|
||||
tasks.append(Task(
|
||||
project=name, type="architecture", priority=3, mode=mode,
|
||||
agent_role="architect",
|
||||
title="架构评估:模块耦合度分析与可扩展性建议",
|
||||
context=(
|
||||
proj_desc +
|
||||
"从架构视角深度分析:\n"
|
||||
"1) 绘制模块依赖图,识别循环依赖和过度耦合\n"
|
||||
"2) 评估当前设计对新需求的扩展成本(如增加新模型类型)\n"
|
||||
"3) 识别与相关项目(yolo/embedding/mediapipe)的代码复用机会\n"
|
||||
"4) 给出下一步架构优化路线图"
|
||||
),
|
||||
))
|
||||
|
||||
# P3: 文档完善
|
||||
tasks.append(Task(
|
||||
project=name, type="documentation", priority=3, mode=mode,
|
||||
agent_role="developer",
|
||||
title="文档完善:README/CLAUDE.md 更新与 API 使用示例",
|
||||
context=(
|
||||
proj_desc +
|
||||
"完善项目文档:\n"
|
||||
"1) 更新 README.md:确保快速开始步骤准确可用\n"
|
||||
"2) 更新 CLAUDE.md:补充最新架构说明和开发约定\n"
|
||||
"3) 为核心 API 添加使用示例代码(注册、查询、管道调用)\n"
|
||||
"4) 补充常见问题(FAQ)和已知限制说明"
|
||||
),
|
||||
))
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
# ── 工厂函数 ───────────────────────────────────────────────────────────
|
||||
|
||||
def make_app(db_path: Path | None = None, config_dir: Path | None = None,
|
||||
global_claude_dir: str | None = None,
|
||||
queue: TaskQueue | None = None,
|
||||
visual_db_path: str | None = None,
|
||||
memory: Any | None = None) -> FastAPI:
|
||||
from nmfs_agents.core.queue import DEFAULT_DB
|
||||
|
||||
_db_path = db_path or DEFAULT_DB
|
||||
_queue = queue if queue is not None else TaskQueue(db_path=_db_path)
|
||||
|
||||
# ── 内嵌执行器状态 ─────────────────────────────────────────────────
|
||||
_exec_state: dict = {
|
||||
"running": False, "thread": None, "started_at": None,
|
||||
"paused_projects": set(), # 项目级暂停集合
|
||||
"scope_project": None, # 非 None 时仅执行该项目的任务
|
||||
}
|
||||
|
||||
def _executor_loop() -> None:
|
||||
"""后台线程:循环执行队列中的 pending 任务。"""
|
||||
import logging as _log
|
||||
_lg = _log.getLogger("dashboard.executor")
|
||||
# 启动时清除所有遗留 running 任务(容器重启后遗留)
|
||||
_queue.reset_stale_running(timeout_minutes=0)
|
||||
while _exec_state["running"]:
|
||||
try:
|
||||
from nmfs_agents.config import load_config
|
||||
from nmfs_agents.core.executor import Executor
|
||||
cfg = load_config(Path(config_dir) if config_dir else None)
|
||||
executor = Executor(cfg, queue=_queue)
|
||||
scope = _exec_state["scope_project"]
|
||||
if scope:
|
||||
# 单项目模式:仅执行该项目,跳过所有其他已知项目
|
||||
all_known = frozenset(cfg.projects.keys())
|
||||
skip = all_known - {scope}
|
||||
else:
|
||||
skip = frozenset(_exec_state["paused_projects"])
|
||||
count = asyncio.run(executor.run_all_pending(skip_projects=skip or None))
|
||||
_lg.info("执行器本轮处理任务数: %d(范围: %s,跳过: %s)",
|
||||
count, scope or "全局", skip or "无")
|
||||
time.sleep(10 if count > 0 else 30)
|
||||
except Exception as e:
|
||||
_lg.error("执行器异常: %s", e)
|
||||
time.sleep(60)
|
||||
|
||||
app = FastAPI(title="NMFS Agents Dashboard")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
def _row_to_dict(row: sqlite3.Row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"project": row["project"],
|
||||
"type": row["type"],
|
||||
"title": row["title"],
|
||||
"context": row["context"] or "",
|
||||
"priority": row["priority"],
|
||||
"mode": row["mode"],
|
||||
"agent_role": row["agent_role"],
|
||||
"status": row["status"],
|
||||
"created_at": row["created_at"] or "",
|
||||
"started_at": row["started_at"],
|
||||
"completed_at": row["completed_at"],
|
||||
"result_summary": row["result_summary"] or "",
|
||||
"retry_count": int(row["retry_count"] or 0),
|
||||
"parent_task_id": int(row["parent_task_id"] or 0),
|
||||
"constraint_type": row["constraint_type"] or "",
|
||||
"awaiting_role": row["awaiting_role"] or "",
|
||||
"initiator": row["initiator"] or "system",
|
||||
"discussion": row["discussion"] or "",
|
||||
}
|
||||
|
||||
def _task_to_out(t: Task) -> dict:
|
||||
"""将 Task dataclass 转换为 API 响应 dict。"""
|
||||
return {
|
||||
"id": t.id,
|
||||
"project": t.project,
|
||||
"type": t.type,
|
||||
"title": t.title,
|
||||
"context": t.context or "",
|
||||
"priority": t.priority,
|
||||
"mode": t.mode,
|
||||
"agent_role": t.agent_role,
|
||||
"status": t.status,
|
||||
"created_at": t.created_at or "",
|
||||
"started_at": t.started_at,
|
||||
"completed_at": t.completed_at,
|
||||
"result_summary": t.result_summary or "",
|
||||
"retry_count": int(t.retry_count or 0),
|
||||
"parent_task_id": int(t.parent_task_id or 0),
|
||||
"constraint_type": t.constraint_type or "",
|
||||
"awaiting_role": t.awaiting_role or "",
|
||||
"initiator": t.initiator or "system",
|
||||
"discussion": t.discussion or "",
|
||||
}
|
||||
|
||||
def _all_tasks() -> list[dict]:
|
||||
with _queue._conn() as c:
|
||||
rows = c.execute(
|
||||
"SELECT * FROM tasks ORDER BY id DESC LIMIT 500"
|
||||
).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
# ── REST 路由 ──────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/tasks")
|
||||
def get_tasks(project: Optional[str] = None) -> list[dict]:
|
||||
tasks = _all_tasks()
|
||||
if project:
|
||||
tasks = [t for t in tasks if t["project"] == project]
|
||||
return tasks
|
||||
|
||||
def _queue_for(name: str) -> TaskQueue:
|
||||
if name in ("base_opt", "insight"):
|
||||
suffix = "base_opt_tasks.db" if name == "base_opt" else "insight_tasks.db"
|
||||
return TaskQueue(db_path=_db_path.parent / suffix)
|
||||
return _queue
|
||||
|
||||
@app.post("/api/tasks")
|
||||
def create_task(body: TaskCreate) -> dict:
|
||||
q = _queue_for(body.queue)
|
||||
task = Task(
|
||||
project=body.project, type=body.type, title=body.title,
|
||||
priority=body.priority, mode=body.mode, agent_role=body.agent_role,
|
||||
context=body.context,
|
||||
initiator=body.initiator,
|
||||
discussion=body.discussion,
|
||||
)
|
||||
tid = q.enqueue(task)
|
||||
if tid == -1:
|
||||
raise HTTPException(400, "同名任务已在 pending/running 中")
|
||||
with q._conn() as c:
|
||||
row = c.execute("SELECT * FROM tasks WHERE id=?", (tid,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
@app.patch("/api/tasks/{task_id}")
|
||||
def patch_task(task_id: int, body: TaskPatch) -> dict:
|
||||
with _queue._conn() as c:
|
||||
row = c.execute("SELECT id FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
if body.priority is not None:
|
||||
c.execute("UPDATE tasks SET priority=? WHERE id=?",
|
||||
(body.priority, task_id))
|
||||
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
@app.post("/api/tasks/{task_id}/retry")
|
||||
def retry_task(task_id: int) -> dict:
|
||||
with _queue._conn() as c:
|
||||
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
if row["status"] not in ("failed", "vetoed"):
|
||||
raise HTTPException(400, f"只能重试 failed/vetoed 任务,当前状态: {row['status']}")
|
||||
c.execute(
|
||||
"UPDATE tasks SET status='pending', retry_count=retry_count+1,"
|
||||
"started_at=NULL, completed_at=NULL WHERE id=?",
|
||||
(task_id,),
|
||||
)
|
||||
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
@app.post("/api/tasks/{task_id}/cancel")
|
||||
def cancel_task(task_id: int) -> dict:
|
||||
with _queue._conn() as c:
|
||||
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
if row["status"] != "running":
|
||||
raise HTTPException(400, "只能取消 running 任务")
|
||||
c.execute(
|
||||
"UPDATE tasks SET status='failed', result_summary='手动取消',"
|
||||
"completed_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), task_id),
|
||||
)
|
||||
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
@app.post("/api/tasks/{task_id}/approve")
|
||||
def approve_task(task_id: int) -> dict:
|
||||
with _queue._conn() as c:
|
||||
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
if row["status"] != "waiting_approval":
|
||||
raise HTTPException(400, "只能审批 waiting_approval 任务")
|
||||
_queue.approve_waiting(task_id)
|
||||
with _queue._conn() as c:
|
||||
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
@app.get("/api/tasks/{task_id}/chain")
|
||||
def get_task_chain(task_id: int) -> dict:
|
||||
with _queue._conn() as c:
|
||||
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
current = _row_to_dict(row)
|
||||
parent = None
|
||||
if current["parent_task_id"]:
|
||||
p_row = c.execute(
|
||||
"SELECT * FROM tasks WHERE id=?", (current["parent_task_id"],)
|
||||
).fetchone()
|
||||
if p_row:
|
||||
parent = _row_to_dict(p_row)
|
||||
children = [
|
||||
_row_to_dict(r) for r in c.execute(
|
||||
"SELECT * FROM tasks WHERE parent_task_id=? ORDER BY id",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
]
|
||||
return {"current": current, "parent": parent, "children": children}
|
||||
|
||||
@app.post("/api/tasks/{task_id}/reject")
|
||||
def reject_task(task_id: int, body: RejectBody) -> dict:
|
||||
with _queue._conn() as c:
|
||||
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
if row["status"] != "waiting_approval":
|
||||
raise HTTPException(400, "只能拒绝 waiting_approval 任务")
|
||||
_queue.mark_vetoed(task_id, reason=body.reason or "审批拒绝")
|
||||
with _queue._conn() as c:
|
||||
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
@app.post("/api/projects/import")
|
||||
def import_project(body: ProjectImport) -> dict:
|
||||
import yaml as _yaml
|
||||
p = Path(body.path)
|
||||
name = body.name.strip() or p.name
|
||||
|
||||
if config_dir:
|
||||
yaml_path = Path(config_dir) / "projects.yaml"
|
||||
else:
|
||||
from nmfs_agents.config import CONFIG_DIR
|
||||
yaml_path = CONFIG_DIR / "projects.yaml"
|
||||
|
||||
with open(yaml_path) as f:
|
||||
proj_data = _yaml.safe_load(f) or {}
|
||||
|
||||
proj_data.setdefault("projects", {})[name] = {
|
||||
"path": str(p),
|
||||
"mode": body.mode,
|
||||
"description": body.description,
|
||||
}
|
||||
|
||||
with open(yaml_path, "w") as f:
|
||||
_yaml.dump(proj_data, f, allow_unicode=True,
|
||||
default_flow_style=False, sort_keys=False)
|
||||
|
||||
if body.scan_now:
|
||||
template_tasks = _generate_template_tasks(
|
||||
name, p, body.mode, body.linked_project
|
||||
)
|
||||
enqueued = []
|
||||
for t in template_tasks:
|
||||
tid = _queue.enqueue(t)
|
||||
if tid > 0:
|
||||
enqueued.append({"title": t.title, "priority": t.priority,
|
||||
"agent_role": t.agent_role})
|
||||
else:
|
||||
enqueued = []
|
||||
|
||||
return {"name": name, "path": body.path, "mode": body.mode,
|
||||
"tasks_created": len(enqueued), "tasks": enqueued}
|
||||
|
||||
# ── 执行器控制 API ─────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/executor/status")
|
||||
def get_executor_status() -> dict:
|
||||
running = (
|
||||
_exec_state["running"] and
|
||||
_exec_state["thread"] is not None and
|
||||
_exec_state["thread"].is_alive()
|
||||
)
|
||||
with _queue._conn() as c:
|
||||
pending = c.execute(
|
||||
"SELECT COUNT(*) FROM tasks WHERE status='pending'"
|
||||
).fetchone()[0]
|
||||
active = c.execute(
|
||||
"SELECT COUNT(*) FROM tasks WHERE status='running'"
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"running": running,
|
||||
"pending": int(pending),
|
||||
"active": int(active),
|
||||
"started_at": _exec_state.get("started_at"),
|
||||
"paused_projects": list(_exec_state["paused_projects"]),
|
||||
"scope_project": _exec_state["scope_project"],
|
||||
}
|
||||
|
||||
@app.post("/api/executor/pause-project/{name}")
|
||||
def pause_project(name: str) -> dict:
|
||||
_exec_state["paused_projects"].add(name)
|
||||
return {"ok": True, "paused_projects": list(_exec_state["paused_projects"])}
|
||||
|
||||
@app.post("/api/executor/resume-project/{name}")
|
||||
def resume_project(name: str) -> dict:
|
||||
_exec_state["paused_projects"].discard(name)
|
||||
return {"ok": True, "paused_projects": list(_exec_state["paused_projects"])}
|
||||
|
||||
@app.post("/api/executor/start")
|
||||
def start_executor(body: StartBody = StartBody()) -> dict:
|
||||
scope = body.project or None
|
||||
already_running = (
|
||||
_exec_state["running"] and
|
||||
_exec_state["thread"] and
|
||||
_exec_state["thread"].is_alive()
|
||||
)
|
||||
# 更新 scope(即使已在运行也可切换范围)
|
||||
_exec_state["scope_project"] = scope
|
||||
if already_running:
|
||||
return {"ok": True, "message": f"范围已切换: {scope or '全局'}"}
|
||||
_exec_state["running"] = True
|
||||
_exec_state["started_at"] = datetime.utcnow().isoformat()
|
||||
t = threading.Thread(target=_executor_loop, daemon=True, name="web-executor")
|
||||
_exec_state["thread"] = t
|
||||
t.start()
|
||||
return {"ok": True, "message": f"执行器已启动(范围: {scope or '全局'})"}
|
||||
|
||||
@app.post("/api/executor/stop")
|
||||
def stop_executor(body: StopBody = StopBody()) -> dict:
|
||||
if body.project:
|
||||
# 单项目停止:只是将该项目加入暂停列表,不停止整个执行器
|
||||
_exec_state["paused_projects"].add(body.project)
|
||||
if _exec_state["scope_project"] == body.project:
|
||||
_exec_state["scope_project"] = None
|
||||
_exec_state["running"] = False
|
||||
return {"ok": True, "message": f"{body.project} 已暂停"}
|
||||
# 全局停止
|
||||
_exec_state["running"] = False
|
||||
_exec_state["scope_project"] = None
|
||||
return {"ok": True, "message": "执行器停止信号已发送(当前任务完成后生效)"}
|
||||
|
||||
# ── 配置文件 API ───────────────────────────────────────────────────
|
||||
|
||||
def _get_project_path(name: str) -> Path | None:
|
||||
try:
|
||||
from nmfs_agents.config import load_config
|
||||
cfg = load_config(Path(config_dir) if config_dir else None)
|
||||
proj = cfg.projects.get(name)
|
||||
if proj:
|
||||
return Path(proj.path)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@app.get("/api/config/global")
|
||||
def get_global_config() -> dict:
|
||||
if not global_claude_dir:
|
||||
raise HTTPException(404, "未配置全局 CLAUDE.md 目录(CLAUDE_GLOBAL_DIR 未设置)")
|
||||
p = Path(global_claude_dir) / "CLAUDE.md"
|
||||
if not p.exists():
|
||||
return {"content": "", "path": str(p), "exists": False}
|
||||
return {"content": p.read_text(encoding="utf-8"), "path": str(p), "exists": True}
|
||||
|
||||
@app.put("/api/config/global")
|
||||
def update_global_config(body: ConfigUpdate) -> dict:
|
||||
if not global_claude_dir:
|
||||
raise HTTPException(404, "未配置全局 CLAUDE.md 目录(CLAUDE_GLOBAL_DIR 未设置)")
|
||||
p = Path(global_claude_dir) / "CLAUDE.md"
|
||||
p.write_text(body.content, encoding="utf-8")
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/projects/{name}/config")
|
||||
def get_project_config(name: str) -> dict:
|
||||
proj_path = _get_project_path(name)
|
||||
if not proj_path:
|
||||
raise HTTPException(404, f"项目 {name} 不存在或路径未配置")
|
||||
result: dict = {"project": name, "path": str(proj_path)}
|
||||
claude_p = proj_path / "CLAUDE.md"
|
||||
result["claude"] = {
|
||||
"content": claude_p.read_text(encoding="utf-8") if claude_p.exists() else "",
|
||||
"exists": claude_p.exists(),
|
||||
"path": str(claude_p),
|
||||
}
|
||||
readme_p = proj_path / "README.md"
|
||||
result["readme"] = {
|
||||
"content": readme_p.read_text(encoding="utf-8") if readme_p.exists() else "",
|
||||
"exists": readme_p.exists(),
|
||||
"path": str(readme_p),
|
||||
}
|
||||
return result
|
||||
|
||||
@app.put("/api/projects/{name}/config/{file_type}")
|
||||
def update_project_config(name: str, file_type: str, body: ConfigUpdate) -> dict:
|
||||
if file_type not in ("claude", "readme"):
|
||||
raise HTTPException(400, "file_type 必须是 claude 或 readme")
|
||||
proj_path = _get_project_path(name)
|
||||
if not proj_path:
|
||||
raise HTTPException(404, f"项目 {name} 不存在或路径未配置")
|
||||
filename = "CLAUDE.md" if file_type == "claude" else "README.md"
|
||||
p = proj_path / filename
|
||||
p.write_text(body.content, encoding="utf-8")
|
||||
return {"ok": True, "path": str(p)}
|
||||
|
||||
@app.get("/api/projects")
|
||||
def get_projects() -> list[dict]:
|
||||
try:
|
||||
from nmfs_agents.config import load_config
|
||||
from pathlib import Path as P
|
||||
cfg = load_config(P(config_dir) if config_dir else None)
|
||||
return [
|
||||
{"name": name, "mode": p.mode, "kind": p.kind,
|
||||
"description": p.description}
|
||||
for name, p in sorted(cfg.projects.items())
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@app.delete("/api/projects/{name}/tasks")
|
||||
def clear_project_tasks(name: str) -> dict:
|
||||
"""清空指定项目的全部任务记录(立即生效,不可恢复)。"""
|
||||
count = _queue.clear_project_tasks(name)
|
||||
return {"ok": True, "name": name, "deleted": count}
|
||||
|
||||
@app.delete("/api/projects/{name}")
|
||||
def delete_project(name: str) -> dict:
|
||||
import yaml as _yaml
|
||||
if config_dir:
|
||||
yaml_path = Path(config_dir) / "projects.yaml"
|
||||
else:
|
||||
from nmfs_agents.config import CONFIG_DIR
|
||||
yaml_path = CONFIG_DIR / "projects.yaml"
|
||||
with open(yaml_path) as f:
|
||||
proj_data = _yaml.safe_load(f) or {}
|
||||
projects = proj_data.get("projects", {})
|
||||
if name not in projects:
|
||||
raise HTTPException(404, f"项目 {name} 不存在")
|
||||
del projects[name]
|
||||
proj_data["projects"] = projects
|
||||
with open(yaml_path, "w") as f:
|
||||
_yaml.dump(proj_data, f, allow_unicode=True,
|
||||
default_flow_style=False, sort_keys=False)
|
||||
# 同步清除该项目所有任务
|
||||
_queue.clear_project_tasks(name)
|
||||
return {"ok": True, "name": name}
|
||||
|
||||
@app.post("/api/projects/{name}/refresh-claude-md")
|
||||
def refresh_claude_md(name: str) -> dict:
|
||||
"""创建 architect 任务,将 ProjectMemory 最新调研与洞察写回项目 CLAUDE.md。"""
|
||||
from nmfs_agents.config import load_config
|
||||
cfg = load_config(Path(config_dir) if config_dir else None)
|
||||
if name not in cfg.projects:
|
||||
raise HTTPException(404, f"项目 {name} 不存在")
|
||||
task = Task(
|
||||
project=name,
|
||||
type="architecture",
|
||||
title="刷新 CLAUDE.md:整合最新调研与洞察结论",
|
||||
context=(
|
||||
"请基于 ProjectMemory 中该项目的全部 facts(包含 __research__.*、__market__.*),"
|
||||
"更新项目 CLAUDE.md,将最新技术决策、调研发现、性能基准写入文件,"
|
||||
"确保后续 AI 任务能直接参考最新信息。"
|
||||
),
|
||||
priority=2,
|
||||
mode=cfg.projects[name].mode,
|
||||
agent_role="architect",
|
||||
initiator="dashboard",
|
||||
)
|
||||
tid = _queue.enqueue(task)
|
||||
if tid == -1:
|
||||
raise HTTPException(400, "刷新任务已在队列中,请等待完成")
|
||||
with _queue._conn() as c:
|
||||
row = c.execute("SELECT * FROM tasks WHERE id=?", (tid,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
@app.get("/api/research/facts")
|
||||
def research_facts():
|
||||
"""返回基础技术调研结论,聚合 rtp/net/kernel 三个专项项目的 __research__.* facts。"""
|
||||
domain_projects = {
|
||||
"rtp": "rtp-research",
|
||||
"net": "net-research",
|
||||
"kernel": "kernel-analysis",
|
||||
}
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
for domain, project in domain_projects.items():
|
||||
facts = _memory.get_facts(project)
|
||||
filtered = {
|
||||
k.split(".", 2)[-1]: v
|
||||
for k, v in facts.items()
|
||||
if k.startswith("__research__.")
|
||||
}
|
||||
if filtered:
|
||||
result[domain] = filtered
|
||||
return {"domains": result}
|
||||
|
||||
# ── 选题审批 API ───────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/tasks/topics")
|
||||
def get_topic_pending() -> list[dict]:
|
||||
"""返回所有待审批的选题任务。"""
|
||||
tasks = _queue.get_topic_pending_tasks()
|
||||
return [_task_to_out(t) for t in tasks]
|
||||
|
||||
@app.post("/api/tasks/{task_id}/approve-topic")
|
||||
def approve_topic(task_id: int) -> dict:
|
||||
"""确认选题:topic_pending → pending,进入正常执行队列。"""
|
||||
_queue.approve_topic(task_id)
|
||||
return {"ok": True, "task_id": task_id, "status": "pending"}
|
||||
|
||||
@app.post("/api/tasks/{task_id}/reject-topic")
|
||||
def reject_topic(task_id: int, body: RejectBody) -> dict:
|
||||
"""否决选题:topic_pending → vetoed。"""
|
||||
_queue.reject_topic(task_id, reason=body.reason)
|
||||
return {"ok": True, "task_id": task_id, "status": "vetoed"}
|
||||
|
||||
# ── 飞书通知设置 API ───────────────────────────────────────────────
|
||||
|
||||
# 内存配置,重启后重置
|
||||
_settings: dict = {"feishu_topic_notify": False}
|
||||
|
||||
class SettingsPatch(BaseModel):
|
||||
feishu_topic_notify: bool | None = None
|
||||
|
||||
@app.get("/api/settings")
|
||||
def get_settings() -> dict:
|
||||
"""获取 Dashboard 配置项。"""
|
||||
return _settings
|
||||
|
||||
@app.patch("/api/settings")
|
||||
def patch_settings(body: SettingsPatch) -> dict:
|
||||
"""更新 Dashboard 配置项(内存中,重启后重置)。"""
|
||||
if body.feishu_topic_notify is not None:
|
||||
_settings["feishu_topic_notify"] = body.feishu_topic_notify
|
||||
return _settings
|
||||
|
||||
# ── 任务日志 API ───────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/tasks/{task_id}/log")
|
||||
def get_task_log(task_id: int, offset: int = 0) -> dict:
|
||||
"""返回任务执行日志,支持增量拉取(offset=已读字节数)。"""
|
||||
from nmfs_agents.agents.developer import _task_log_path
|
||||
log_path = _task_log_path(task_id)
|
||||
if not log_path.exists():
|
||||
return {"lines": [], "size": 0, "exists": False}
|
||||
size = log_path.stat().st_size
|
||||
if offset >= size:
|
||||
return {"lines": [], "size": size, "exists": True}
|
||||
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
f.seek(offset)
|
||||
content = f.read(65536) # 最多 64KB 每次
|
||||
return {"lines": content.splitlines(), "size": log_path.stat().st_size, "exists": True}
|
||||
|
||||
# ── WebSocket ──────────────────────────────────────────────────────
|
||||
|
||||
_ws_connections: set[WebSocket] = set()
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
_ws_connections.add(ws)
|
||||
import json
|
||||
try:
|
||||
snapshot = {"type": "snapshot", "tasks": _all_tasks()}
|
||||
await ws.send_text(json.dumps(snapshot, ensure_ascii=False))
|
||||
while True:
|
||||
await ws.receive_text() # keep-alive (ping)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
_ws_connections.discard(ws)
|
||||
|
||||
# ── Visual Tester 路由 ──────────────────────────────────────────────
|
||||
if _HAS_VISUAL:
|
||||
_vdb_path = visual_db_path or "/data/company/visual-tester/data/visual.db"
|
||||
_visual_db = _VisualDB(_vdb_path)
|
||||
|
||||
@app.get("/api/visual/runs")
|
||||
def visual_runs(project: Optional[str] = None):
|
||||
return _visual_db.get_runs(project=project, limit=20)
|
||||
|
||||
@app.get("/api/visual/runs/{run_id}/cases")
|
||||
def visual_cases(run_id: str):
|
||||
return _visual_db.get_cases(run_id)
|
||||
|
||||
@app.post("/api/visual/cases/{case_id}/approve")
|
||||
def visual_approve(case_id: int, body: VisualApproveBody):
|
||||
case = _visual_db.get_case(case_id)
|
||||
if not case:
|
||||
raise HTTPException(404, "case not found")
|
||||
if case.get("screenshot_path"):
|
||||
_visual_db.set_baseline(
|
||||
case["project"], case["url"], case["name"], case["screenshot_path"]
|
||||
)
|
||||
_visual_db.update_case_status(case_id, "passed")
|
||||
_visual_db.add_approval(case_id, "approve", body.reason)
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/api/visual/cases/{case_id}/reject")
|
||||
def visual_reject(case_id: int, body: VisualApproveBody):
|
||||
case = _visual_db.get_case(case_id)
|
||||
if not case:
|
||||
raise HTTPException(404, "case not found")
|
||||
_visual_db.update_case_status(case_id, "failed")
|
||||
_visual_db.add_approval(case_id, "reject", body.reason)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/visual/baselines/{project}")
|
||||
def visual_baselines(project: str):
|
||||
return _visual_db.list_baselines(project)
|
||||
|
||||
@app.post("/api/visual/runs/{run_id}/resume")
|
||||
def visual_resume(run_id: str):
|
||||
_visual_db.reset_interrupted_run(run_id)
|
||||
return {"ok": True, "run_id": run_id}
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
@app.get("/api/visual/image")
|
||||
def visual_image(path: str):
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
raise HTTPException(404, "image not found")
|
||||
return FileResponse(str(p), media_type="image/png")
|
||||
|
||||
# ── 日报 API ────────────────────────────────────────────────────────
|
||||
import os as _os
|
||||
from nmfs_agents.tools.daily_report import DailyReport as _DailyReport
|
||||
|
||||
def _get_daily_db() -> _DailyReport:
|
||||
db_path_str = _os.environ.get("DAILY_DB_PATH") or _os.environ.get("DB_PATH")
|
||||
if db_path_str:
|
||||
daily_path = Path(db_path_str).parent / "daily_reports.db"
|
||||
else:
|
||||
daily_path = _db_path.parent / "daily_reports.db"
|
||||
return _DailyReport(db_path=daily_path)
|
||||
|
||||
@app.get("/api/daily/timeline")
|
||||
def api_daily_timeline(date: str = ""):
|
||||
if not date:
|
||||
from datetime import date as _date
|
||||
date = _date.today().isoformat()
|
||||
return _get_daily_db().get_timeline(date, limit=200)
|
||||
|
||||
@app.get("/api/daily/boss")
|
||||
def api_daily_boss(date: str = ""):
|
||||
if not date:
|
||||
from datetime import date as _date
|
||||
date = _date.today().isoformat()
|
||||
return _get_daily_db().get_boss_summary(date)
|
||||
|
||||
@app.get("/api/daily/dates")
|
||||
def api_daily_dates():
|
||||
return _get_daily_db().get_available_dates(limit=30)
|
||||
|
||||
# ── Market Insight API ─────────────────────────────────────────────
|
||||
from nmfs_agents.tools.project_memory import ProjectMemory as _PM
|
||||
_memory = memory if memory is not None else _PM()
|
||||
_GOALS = ["运动", "适老", "AI安全"]
|
||||
|
||||
@app.get("/api/market/facts")
|
||||
def market_facts():
|
||||
raw = _memory.get_facts("__market__")
|
||||
grouped: dict[str, dict[str, str]] = {}
|
||||
for k, v in raw.items():
|
||||
parts = k.split(".", 1)
|
||||
if len(parts) == 2:
|
||||
goal, sub = parts
|
||||
grouped.setdefault(goal, {})[sub] = v
|
||||
return {"goals": _GOALS, "facts": grouped}
|
||||
|
||||
@app.post("/api/market/trigger/{goal}")
|
||||
def market_trigger(goal: str):
|
||||
if goal not in _GOALS:
|
||||
raise HTTPException(status_code=400, detail=f"无效目标: {goal}")
|
||||
q = TaskQueue()
|
||||
from nmfs_agents.core.queue import Task as _Task
|
||||
context = (
|
||||
f"目标领域:{goal}。\n"
|
||||
f"请完成以下深度调研并写入 [市场情报] 标签:\n"
|
||||
f"1. 竞品全景:列出主要竞品(≥5个),分析各自优劣势、市场份额、定价策略\n"
|
||||
f"2. 技术趋势:近6个月行业重要技术进展,AI/硬件/算法层面动向\n"
|
||||
f"3. 用户痛点:目标用户群体核心需求、现有方案的不足之处\n"
|
||||
f"4. 市场机会:结合竞品空白和技术趋势,指出差异化切入点\n"
|
||||
f"5. 数据支撑:引用市场规模、增速、用户数量等可量化数据\n"
|
||||
f"最终输出必须包含:\n"
|
||||
f"[市场情报] __market__.{goal}.summary=<综合摘要,100字以上>\n"
|
||||
f"[市场情报] __market__.{goal}.competitors=<竞品列表及关键差异>\n"
|
||||
f"[市场情报] __market__.{goal}.trends=<技术与市场趋势,包含数据>\n"
|
||||
f"[市场情报] __market__.{goal}.opportunities=<差异化机会分析>\n"
|
||||
f"[市场情报] __market__.{goal}.updated_at=<当前日期>\n"
|
||||
)
|
||||
tid = q.enqueue(_Task(
|
||||
project="research",
|
||||
type="market_intel",
|
||||
title=f"市场洞察深度调研:{goal}",
|
||||
context=context,
|
||||
priority=2,
|
||||
mode="auto",
|
||||
agent_role="market-pm",
|
||||
initiator="dashboard",
|
||||
))
|
||||
return MarketTriggerResponse(task_id=tid, goal=goal)
|
||||
|
||||
@app.get("/api/market/history")
|
||||
def market_history():
|
||||
conn = sqlite3.connect(_db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, status, result_summary, completed_at FROM tasks "
|
||||
"WHERE agent_role='market-pm' ORDER BY id DESC LIMIT 10"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── 静态文件(React 构建产物)─────────────────────────────────────
|
||||
# 注意:必须在所有 API 路由注册完成后再挂载,否则 mount("/") 会拦截后续路由
|
||||
|
||||
dist_path = Path(__file__).parent.parent.parent.parent / "dashboard" / "dist"
|
||||
if dist_path.exists():
|
||||
app.mount("/", StaticFiles(directory=dist_path, html=True), name="static")
|
||||
|
||||
# 挂载 WS 连接集合供 watcher 访问
|
||||
app.state.ws_connections = _ws_connections
|
||||
app.state.all_tasks_fn = _all_tasks
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def test_daily_timeline_endpoint(tmp_path):
|
||||
"""GET /api/daily/timeline?date=2026-03-09 返回列表"""
|
||||
from nmfs_agents.dashboard.api import make_app
|
||||
from fastapi.testclient import TestClient
|
||||
import os
|
||||
os.environ["DB_PATH"] = str(tmp_path / "t.db")
|
||||
app = make_app(db_path=tmp_path / "t.db")
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/daily/timeline?date=2026-03-09")
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
|
||||
|
||||
def test_daily_boss_summary_endpoint(tmp_path):
|
||||
"""GET /api/daily/boss?date=2026-03-09 返回 null 或摘要对象"""
|
||||
from nmfs_agents.dashboard.api import make_app
|
||||
from fastapi.testclient import TestClient
|
||||
import os
|
||||
os.environ["DB_PATH"] = str(tmp_path / "t2.db")
|
||||
app = make_app(db_path=tmp_path / "t2.db")
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/daily/boss?date=2026-03-09")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_daily_dates_endpoint(tmp_path):
|
||||
"""GET /api/daily/dates 返回日期列表"""
|
||||
from nmfs_agents.dashboard.api import make_app
|
||||
from fastapi.testclient import TestClient
|
||||
import os
|
||||
os.environ["DB_PATH"] = str(tmp_path / "t3.db")
|
||||
app = make_app(db_path=tmp_path / "t3.db")
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/daily/dates")
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
Reference in New Issue
Block a user