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:
2026-03-09 23:06:27 +08:00
co-authored by Claude Sonnet 4.6
parent c869f709dc
commit 34cd02ca56
2 changed files with 1033 additions and 0 deletions
+992
View File
@@ -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
+41
View File
@@ -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)