Add dataset bench and validation agents
This commit is contained in:
2
backend/app/agents/__init__.py
Normal file
2
backend/app/agents/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Local deterministic agents for app evaluation and validation."""
|
||||
|
||||
61
backend/app/agents/evaluation_agent.py
Normal file
61
backend/app/agents/evaluation_agent.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ..catalog import get_catalog
|
||||
from ..config import settings
|
||||
|
||||
|
||||
REQUIRED_TASKS = {
|
||||
"dataset.upload": "covered_by_api",
|
||||
"dataset.video_frames": "job",
|
||||
"segmodel.train": "job",
|
||||
"segmodel.predict": "job",
|
||||
"yolo.heatmap": "job",
|
||||
"mmseg.flops_fps": "job",
|
||||
"analysis.all": "job",
|
||||
}
|
||||
|
||||
|
||||
def evaluate_project() -> dict:
|
||||
"""Return product/implementation suggestions for the current web platform."""
|
||||
frontend = settings.project_root / "frontend" / "src" / "main.tsx"
|
||||
backend = settings.project_root / "backend" / "app" / "main.py"
|
||||
readme = settings.project_root / "README.md"
|
||||
catalog = get_catalog()
|
||||
checks = []
|
||||
suggestions = []
|
||||
|
||||
frontend_text = frontend.read_text(encoding="utf-8") if frontend.exists() else ""
|
||||
backend_text = backend.read_text(encoding="utf-8") if backend.exists() else ""
|
||||
readme_text = readme.read_text(encoding="utf-8") if readme.exists() else ""
|
||||
|
||||
expectations = {
|
||||
"left_nav_dataset": "数据集" in frontend_text and "#datasets" in frontend_text,
|
||||
"upload_ui": "uploadDatasetFiles" in frontend_text and "labels" in frontend_text and "masks" in frontend_text,
|
||||
"loss_result_ui": "loss" in frontend_text.lower() and "heatmap" in frontend_text.lower(),
|
||||
"dataset_api": "/api/datasets" in backend_text and "api_upload_dataset_files" in backend_text,
|
||||
"no_weight_to_gitea": "Do not push" in readme_text and "check_no_weight_git" in readme_text,
|
||||
"all_core_tasks": all(task in catalog["task_types"] for task in REQUIRED_TASKS if REQUIRED_TASKS[task] == "job"),
|
||||
}
|
||||
|
||||
for name, passed in expectations.items():
|
||||
checks.append({"name": name, "passed": bool(passed)})
|
||||
if not passed:
|
||||
suggestions.append(f"Improve missing capability: {name}")
|
||||
|
||||
if len(catalog["mmseg_algorithms"]) < 31:
|
||||
suggestions.append("MMSeg algorithm catalog should expose all 31 algorithm generators.")
|
||||
if len(catalog["segmodel_architectures"]) < 12:
|
||||
suggestions.append("SegModel catalog should expose all 12 supported architectures.")
|
||||
if not suggestions:
|
||||
suggestions.append("Current platform covers the requested control-plane features; next focus is real dataset/training acceptance tests.")
|
||||
|
||||
score = sum(1 for item in checks if item["passed"]) / max(len(checks), 1)
|
||||
return {
|
||||
"agent": "evaluation_suggestion_agent",
|
||||
"score": round(score, 3),
|
||||
"checks": checks,
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
|
||||
98
backend/app/agents/validation_agent.py
Normal file
98
backend/app/agents/validation_agent.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from ..catalog import get_catalog
|
||||
from ..config import settings
|
||||
from ..modules.system.service import get_conda_envs, get_gpus
|
||||
from ..modules.weights.service import load_manifest
|
||||
|
||||
|
||||
def _run(command: list[str], cwd: Path | None = None, timeout: int = 60) -> dict:
|
||||
result = subprocess.run(command, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
||||
return {
|
||||
"command": command,
|
||||
"returncode": result.returncode,
|
||||
"stdout": result.stdout[-4000:],
|
||||
"stderr": result.stderr[-4000:],
|
||||
"passed": result.returncode == 0,
|
||||
}
|
||||
|
||||
|
||||
def _fetch(url: str, timeout: int = 5) -> dict:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as response:
|
||||
body = response.read(20000).decode("utf-8", errors="replace")
|
||||
return {"url": url, "status": response.status, "body": body, "passed": 200 <= response.status < 300}
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read(20000).decode("utf-8", errors="replace")
|
||||
return {"url": url, "status": exc.code, "body": body, "passed": False}
|
||||
except Exception as exc:
|
||||
return {"url": url, "error": str(exc), "passed": False}
|
||||
|
||||
|
||||
def validate_project(run_build: bool = False) -> dict:
|
||||
"""Validate current runtime readiness without launching heavy training."""
|
||||
checks = []
|
||||
catalog = get_catalog()
|
||||
manifest = load_manifest()
|
||||
|
||||
checks.append({"name": "catalog_has_yolo_heatmap", "passed": "yolo.heatmap" in catalog["task_types"]})
|
||||
checks.append({"name": "catalog_has_mmseg_31_algs", "passed": len(catalog["mmseg_algorithms"]) >= 31})
|
||||
checks.append({"name": "weights_manifest_present", "passed": manifest.get("count", 0) >= 1})
|
||||
checks.append({"name": "gpus_query", "passed": bool(get_gpus().get("available"))})
|
||||
env_names = [item["name"] for item in get_conda_envs().get("envs", [])]
|
||||
checks.append({"name": "seg_smp_env_exists", "passed": "seg_smp" in env_names})
|
||||
|
||||
smoke = _run(
|
||||
[
|
||||
"conda",
|
||||
"run",
|
||||
"-n",
|
||||
"seg_smp",
|
||||
"python",
|
||||
"-c",
|
||||
"import fastapi, uvicorn, torch, cv2, segmentation_models_pytorch, ultralytics, albumentations, mmengine, mmseg, mmcv; print(torch.__version__, torch.cuda.is_available())",
|
||||
],
|
||||
cwd=settings.project_root,
|
||||
)
|
||||
checks.append({"name": "seg_smp_backend_smoke", "passed": smoke["passed"], "detail": smoke})
|
||||
|
||||
no_weight = _run(["bash", "scripts/check_no_weight_git.sh"], cwd=settings.project_root)
|
||||
checks.append({"name": "no_weight_in_git", "passed": no_weight["passed"], "detail": no_weight})
|
||||
|
||||
if os.getenv("SEG_VALIDATE_LIVE", "1") == "1":
|
||||
backend_url = os.getenv("SEG_VALIDATE_BACKEND_URL", "http://127.0.0.1:8010")
|
||||
frontend_url = os.getenv("SEG_VALIDATE_FRONTEND_URL", "http://127.0.0.1:5173")
|
||||
health = _fetch(f"{backend_url}/api/health")
|
||||
datasets = _fetch(f"{backend_url}/api/datasets")
|
||||
frontend = _fetch(frontend_url)
|
||||
checks.append({"name": "live_backend_health", "passed": health["passed"] and '"ok":true' in health.get("body", "").replace(" ", ""), "detail": health})
|
||||
checks.append({"name": "live_dataset_api", "passed": datasets["passed"] and datasets.get("body", "").lstrip().startswith("["), "detail": datasets})
|
||||
checks.append({"name": "live_frontend_index", "passed": frontend["passed"] and "Seg Data Server" in frontend.get("body", ""), "detail": frontend})
|
||||
|
||||
if run_build:
|
||||
tests = _run(["conda", "run", "-n", settings.backend_conda_env, "python", "-m", "pytest", "-q"], cwd=settings.project_root, timeout=120)
|
||||
checks.append({"name": "backend_tests", "passed": tests["passed"], "detail": tests})
|
||||
build = _run(["npm", "run", "build"], cwd=settings.project_root / "frontend", timeout=120)
|
||||
checks.append({"name": "frontend_build", "passed": build["passed"], "detail": build})
|
||||
|
||||
passed = all(item["passed"] for item in checks)
|
||||
return {
|
||||
"agent": "validation_agent",
|
||||
"passed": passed,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def write_validation_report(path: Path, run_build: bool = False) -> dict:
|
||||
result = validate_project(run_build=run_build)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return result
|
||||
@@ -103,6 +103,11 @@ def discover_datasets() -> list[dict[str, Any]]:
|
||||
if item.name == "All_Data_Record.json" or not data:
|
||||
continue
|
||||
candidates.append({"name": item.stem, "path": rel(item, root), "source": "mmseg_parameter"})
|
||||
uploaded_root = settings.project_root / "var" / "uploads" / "datasets"
|
||||
if uploaded_root.exists():
|
||||
for item in sorted(uploaded_root.iterdir()):
|
||||
if item.is_dir():
|
||||
candidates.append({"name": item.name, "path": rel(item, settings.project_root), "source": "uploaded"})
|
||||
return candidates
|
||||
|
||||
|
||||
@@ -137,4 +142,3 @@ def get_catalog() -> dict[str, Any]:
|
||||
"datasets": discover_datasets(),
|
||||
"weights": discover_weights_summary(),
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ def get_settings() -> Settings:
|
||||
log_dir=log_dir,
|
||||
weights_root=weights_root,
|
||||
task_conda_env=os.getenv("SEG_TASK_CONDA_ENV", "seg_smp"),
|
||||
backend_conda_env=os.getenv("SEG_BACKEND_CONDA_ENV", "seg_server"),
|
||||
backend_conda_env=os.getenv("SEG_BACKEND_CONDA_ENV", "seg_smp"),
|
||||
weight_mode=os.getenv("SEG_WEIGHT_MODE", "copy"),
|
||||
enable_shell_tasks=os.getenv("SEG_ENABLE_SHELL_TASKS", "1") == "1",
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import FastAPI, File, HTTPException, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
@@ -13,9 +13,12 @@ from .catalog import get_catalog
|
||||
from .config import settings
|
||||
from .jobs import cancel_job, create_job
|
||||
from .modules.system.service import disk_usage, get_conda_envs, get_gpus, scan_results
|
||||
from .modules.dataset.service import create_dataset, list_uploaded_datasets, save_upload
|
||||
from .modules.weights.service import load_manifest, sync_weights, verify_weights
|
||||
from .agents.evaluation_agent import evaluate_project
|
||||
from .agents.validation_agent import validate_project
|
||||
from .paths import ensure_inside
|
||||
from .schemas import JobCreate, ProfileCreate, WeightSyncRequest
|
||||
from .schemas import DatasetCreate, JobCreate, ProfileCreate, WeightSyncRequest
|
||||
|
||||
app = FastAPI(title="Seg Data Server Net", version="0.1.0")
|
||||
|
||||
@@ -60,6 +63,24 @@ def api_catalog() -> dict:
|
||||
return get_catalog()
|
||||
|
||||
|
||||
@app.get("/api/datasets")
|
||||
def api_datasets() -> list[dict]:
|
||||
return list_uploaded_datasets()
|
||||
|
||||
|
||||
@app.post("/api/datasets")
|
||||
def api_create_dataset(dataset: DatasetCreate) -> dict:
|
||||
return create_dataset(dataset.name, dataset.description)
|
||||
|
||||
|
||||
@app.post("/api/datasets/{dataset_name}/upload/{kind}")
|
||||
async def api_upload_dataset_files(dataset_name: str, kind: str, files: list[UploadFile] = File(...)) -> dict:
|
||||
try:
|
||||
return await save_upload(dataset_name, kind, files)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/api/profiles")
|
||||
def api_profiles(kind: str | None = None) -> list[dict]:
|
||||
return db.list_profiles(kind)
|
||||
@@ -170,3 +191,12 @@ def api_weight_sync(request: WeightSyncRequest) -> dict:
|
||||
def api_weight_verify() -> dict:
|
||||
return verify_weights()
|
||||
|
||||
|
||||
@app.get("/api/agents/evaluate")
|
||||
def api_agent_evaluate() -> dict:
|
||||
return evaluate_project()
|
||||
|
||||
|
||||
@app.get("/api/agents/validate")
|
||||
def api_agent_validate(run_build: bool = False) -> dict:
|
||||
return validate_project(run_build=run_build)
|
||||
|
||||
132
backend/app/modules/dataset/service.py
Normal file
132
backend/app/modules/dataset/service.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
from ...config import settings
|
||||
|
||||
DATASET_KINDS = ("images", "labels", "masks")
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
|
||||
|
||||
|
||||
def uploads_root() -> Path:
|
||||
root = settings.project_root / "var" / "uploads" / "datasets"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
text = re.sub(r"[^A-Za-z0-9_.\-\u4e00-\u9fff]+", "_", value.strip())
|
||||
return text.strip("._") or "dataset"
|
||||
|
||||
|
||||
def safe_filename(value: str | None) -> str:
|
||||
original = Path(value or "upload.bin").name
|
||||
suffix = Path(original).suffix.lower()
|
||||
stem = slugify(Path(original).stem or "upload")
|
||||
if suffix and re.fullmatch(r"\.[A-Za-z0-9]{1,12}", suffix):
|
||||
return f"{stem}{suffix}"
|
||||
return stem
|
||||
|
||||
|
||||
def dataset_dir(name: str) -> Path:
|
||||
return uploads_root() / slugify(name)
|
||||
|
||||
|
||||
def metadata_path(name: str) -> Path:
|
||||
return dataset_dir(name) / "dataset.json"
|
||||
|
||||
|
||||
def create_dataset(name: str, description: str = "") -> dict:
|
||||
safe_name = slugify(name)
|
||||
root = dataset_dir(safe_name)
|
||||
for kind in DATASET_KINDS:
|
||||
(root / kind).mkdir(parents=True, exist_ok=True)
|
||||
meta = {
|
||||
"name": safe_name,
|
||||
"description": description,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"root": str(root.relative_to(settings.project_root)),
|
||||
"layout": {
|
||||
"images": str((root / "images").relative_to(settings.project_root)),
|
||||
"labels": str((root / "labels").relative_to(settings.project_root)),
|
||||
"masks": str((root / "masks").relative_to(settings.project_root)),
|
||||
},
|
||||
}
|
||||
metadata_path(safe_name).write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return describe_dataset(safe_name)
|
||||
|
||||
|
||||
def _load_meta(name: str) -> dict:
|
||||
path = metadata_path(name)
|
||||
if path.exists():
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
return {"name": slugify(name), "description": "", "root": str(dataset_dir(name).relative_to(settings.project_root))}
|
||||
|
||||
|
||||
def _iter_files(root: Path) -> Iterable[Path]:
|
||||
if not root.exists():
|
||||
return []
|
||||
return sorted(path for path in root.rglob("*") if path.is_file())
|
||||
|
||||
|
||||
def describe_dataset(name: str) -> dict:
|
||||
safe_name = slugify(name)
|
||||
root = dataset_dir(safe_name)
|
||||
meta = _load_meta(safe_name)
|
||||
counts = {}
|
||||
samples = {}
|
||||
for kind in sorted(DATASET_KINDS):
|
||||
files = list(_iter_files(root / kind))
|
||||
counts[kind] = len(files)
|
||||
samples[kind] = [
|
||||
{
|
||||
"name": path.name,
|
||||
"path": str(path.resolve()),
|
||||
"relative_path": str(path.resolve().relative_to(settings.project_root)),
|
||||
"size": path.stat().st_size,
|
||||
"previewable": path.suffix.lower() in IMAGE_EXTS,
|
||||
}
|
||||
for path in files[:80]
|
||||
]
|
||||
return {**meta, "counts": counts, "samples": samples}
|
||||
|
||||
|
||||
def list_uploaded_datasets() -> list[dict]:
|
||||
root = uploads_root()
|
||||
datasets = []
|
||||
for item in sorted(root.iterdir()):
|
||||
if item.is_dir():
|
||||
datasets.append(describe_dataset(item.name))
|
||||
return datasets
|
||||
|
||||
|
||||
async def save_upload(dataset: str, kind: str, files: list[UploadFile]) -> dict:
|
||||
if kind not in DATASET_KINDS:
|
||||
raise ValueError(f"unsupported dataset file kind: {kind}")
|
||||
safe_name = slugify(dataset)
|
||||
if not metadata_path(safe_name).exists():
|
||||
create_dataset(safe_name)
|
||||
target = dataset_dir(safe_name) / kind
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
saved = []
|
||||
for upload in files:
|
||||
filename = safe_filename(upload.filename)
|
||||
dst = target / filename
|
||||
if dst.exists():
|
||||
stem = dst.stem
|
||||
suffix = dst.suffix
|
||||
counter = 1
|
||||
while dst.exists():
|
||||
dst = target / f"{stem}_{counter}{suffix}"
|
||||
counter += 1
|
||||
with dst.open("wb") as handle:
|
||||
shutil.copyfileobj(upload.file, handle)
|
||||
saved.append({"name": dst.name, "relative_path": str(dst.relative_to(settings.project_root)), "size": dst.stat().st_size})
|
||||
return {"dataset": describe_dataset(safe_name), "saved": saved}
|
||||
@@ -43,3 +43,7 @@ class WeightSyncRequest(BaseModel):
|
||||
hash_files: bool = True
|
||||
skip_existing: bool = True
|
||||
|
||||
|
||||
class DatasetCreate(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
|
||||
14
backend/tests/test_agents.py
Normal file
14
backend/tests/test_agents.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from app.agents.evaluation_agent import evaluate_project
|
||||
from app.agents.validation_agent import validate_project
|
||||
|
||||
|
||||
def test_evaluation_agent_returns_checks():
|
||||
result = evaluate_project()
|
||||
assert result["agent"] == "evaluation_suggestion_agent"
|
||||
assert result["checks"]
|
||||
|
||||
|
||||
def test_validation_agent_lightweight():
|
||||
result = validate_project(run_build=False)
|
||||
assert result["agent"] == "validation_agent"
|
||||
assert any(item["name"] == "catalog_has_yolo_heatmap" for item in result["checks"])
|
||||
13
backend/tests/test_dataset_service.py
Normal file
13
backend/tests/test_dataset_service.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from app.modules.dataset.service import create_dataset, describe_dataset
|
||||
|
||||
|
||||
def test_create_dataset_layout(tmp_path, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
from app.modules.dataset import service
|
||||
|
||||
monkeypatch.setattr(service, "settings", SimpleNamespace(project_root=tmp_path))
|
||||
created = create_dataset("case 01", "demo")
|
||||
assert created["name"] == "case_01"
|
||||
assert created["counts"] == {"images": 0, "labels": 0, "masks": 0}
|
||||
described = describe_dataset("case_01")
|
||||
assert described["layout"]["images"].endswith("images")
|
||||
Reference in New Issue
Block a user