feat: initial HSAP platform

Huaxu Sentinel Active Safety Platform with embedded algorithm code,
Docker Compose setup, and vendored dataset scaffolds for clone-and-run.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-25 16:59:59 +08:00
commit 7c43b44c57
1619 changed files with 373355 additions and 0 deletions

View File

@@ -0,0 +1,584 @@
#!/usr/bin/env python3
"""统一 API + React Web。cd HSAP && PYTHONPATH=platform python -m as_platform.api.server"""
from __future__ import annotations
import argparse
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Annotated, Any
import threading
try:
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
except ImportError as e:
raise SystemExit("需要安装: pip install fastapi uvicorn pydantic sqlalchemy python-jose httpx") from e
from as_platform.agents.graphs.ingest_flow import run_ingest_flow
from as_platform.agents.graphs.labeling_flow import run_labeling_flow
from as_platform.agents.graphs.train_promote_flow import run_train_promote_flow
from as_platform.agents.tools import TOOL_REGISTRY, invoke_tool
from as_platform.agents.trace import get_trace, list_traces
from as_platform.api.auth_routes import router as auth_router
from as_platform.audit.queue import (
ACTION_LABELS,
ACTIONS_REQUIRING_APPROVAL,
approve_and_execute,
get_approval,
list_approvals,
reject_approval,
submit_approval,
)
from as_platform.audit.preview import find_image_ref, list_scope_images, render_overlay, resolve_approval_scope
from as_platform.auth.deps import can_submit_action, get_current_user, require_any_permission, require_permission
from as_platform.config import IS_POSTGRES, PLATFORM_DIR, PLATFORM_WEB, WORKSPACE
from as_platform.data.core import get_catalog, get_pending_report, register_batch, warmup_catalog_cache
from as_platform.data.ingest import UnknownFormatError, inspect_uploaded_dataset
from as_platform.data.lake import (
create_uploaded_candidate,
get_candidate,
link_candidate_analysis_job,
list_candidates as list_data_candidates,
write_candidate_upload,
)
from as_platform.data.organize import organize_batch
from as_platform.db.engine import check_connection
from as_platform.db.init_db import init_database
from as_platform.db.models import User
from as_platform.jobs.queue import enqueue_job, get_job, list_jobs
from as_platform.redis.bus import ping_redis
from as_platform.training.service import (
TRAINING_ACTIONS,
create_training_submission,
get_model_registry,
get_training_record,
list_training_records,
)
@asynccontextmanager
async def lifespan(_app: FastAPI):
init_database()
threading.Thread(target=warmup_catalog_cache, daemon=True, name="catalog-warmup").start()
yield
app = FastAPI(title="华胥智能主动安全平台", version="1.1.0", lifespan=lifespan)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
app.include_router(auth_router)
_UI_DIR: Path | None = None
class SubmitApprovalBody(BaseModel):
action: str
params: dict[str, Any] = Field(default_factory=dict)
submitted_by: str | None = None
note: str | None = None
class ReviewBody(BaseModel):
reviewed_by: str | None = None
comment: str | None = None
class RegisterBatchBody(BaseModel):
project: str
task: str | None = None
batch: str
pack: str | None = None
stage: str = "returned"
engineer: str | None = None
location: str = "inbox"
submitted_by: str | None = None
skip_audit: bool = False
class BuildFromBatchBody(BaseModel):
project: str = "dms"
task: str
batch: str
pack: str = "dms_v2"
location: str = "inbox"
submitted_by: str | None = None
note: str | None = None
class OrganizeBody(BaseModel):
batch_path: str
task: str | None = None
class AgentInvokeBody(BaseModel):
graph: str = "ingest_flow"
params: dict[str, Any] = Field(default_factory=dict)
class ToolInvokeBody(BaseModel):
tool: str
params: dict[str, Any] = Field(default_factory=dict)
class CreateTrainingBody(BaseModel):
action: str
params: dict[str, Any] = Field(default_factory=dict)
note: str | None = None
class InspectUploadBody(BaseModel):
project: str
task: str | None = None
source_path: str
@app.get("/api/v1/health")
def health() -> dict[str, str]:
db_ok = check_connection()
redis_ok = ping_redis()
ok = db_ok and redis_ok
return {
"status": "ok" if ok else "degraded",
"workspace": str(WORKSPACE),
"database": "postgresql" if IS_POSTGRES else "sqlite",
"db_connected": str(db_ok).lower(),
"redis_connected": str(redis_ok).lower(),
}
@app.get("/api/v1/pending")
def api_pending(_user: Annotated[User, Depends(require_permission("read:pending"))]) -> dict[str, Any]:
return get_pending_report()
@app.get("/api/v1/catalog")
def api_catalog(
_user: Annotated[User, Depends(require_permission("read:catalog"))],
refresh: bool = Query(False),
) -> dict[str, Any]:
return get_catalog(refresh=refresh)
@app.get("/api/v1/catalog/dms/{task}")
def api_catalog_dms(
task: str,
_user: Annotated[User, Depends(require_permission("read:catalog"))],
refresh: bool = Query(False),
) -> dict[str, Any]:
full = get_catalog(refresh=refresh)
if task not in (full.get("dms") or {}):
raise HTTPException(404, f"未知 DMS 任务: {task}")
return {"task": task, **full["dms"][task]}
@app.get("/api/v1/catalog/lane/{pack}")
def api_catalog_lane(
pack: str,
_user: Annotated[User, Depends(require_permission("read:catalog"))],
refresh: bool = Query(False),
) -> dict[str, Any]:
full = get_catalog(refresh=refresh)
if pack not in (full.get("lane") or {}):
raise HTTPException(404, f"未知 Lane 包: {pack}")
return {"pack": pack, **full["lane"][pack]}
@app.get("/api/v1/actions")
def api_actions(_user: Annotated[User, Depends(require_permission("read:audit"))]) -> dict[str, Any]:
return {"actions": [{"id": k, "label": ACTION_LABELS.get(k, k)} for k in sorted(ACTIONS_REQUIRING_APPROVAL)]}
@app.get("/api/v1/jobs")
def api_jobs(
_user: Annotated[User, Depends(require_permission("read:jobs"))],
status: str | None = None,
limit: int = Query(100, le=500),
) -> dict[str, Any]:
return {"items": list_jobs(status=status, limit=limit)}
@app.get("/api/v1/jobs/{job_id}")
def api_job(job_id: str, _user: Annotated[User, Depends(require_permission("read:jobs"))]) -> dict[str, Any]:
job = get_job(job_id)
if not job:
raise HTTPException(404, "Job 不存在")
return job
@app.get("/api/v1/training/actions")
def api_training_actions(_user: Annotated[User, Depends(require_permission("read:jobs"))]) -> dict[str, Any]:
return {
"actions": [
{"id": action, "label": ACTION_LABELS.get(action, action)}
for action in sorted(TRAINING_ACTIONS)
]
}
@app.get("/api/v1/training/records")
def api_training_records(
_user: Annotated[User, Depends(require_permission("read:jobs"))],
project: str | None = None,
kind: str | None = None,
status: str | None = None,
task: str | None = None,
limit: int = Query(100, ge=1, le=500),
) -> dict[str, Any]:
return list_training_records(project=project, kind=kind, status=status, task=task, limit=limit)
@app.get("/api/v1/training/records/{job_id}")
def api_training_record(
job_id: str,
_user: Annotated[User, Depends(require_permission("read:jobs"))],
) -> dict[str, Any]:
rec = get_training_record(job_id)
if not rec:
raise HTTPException(404, "训练记录不存在")
return rec
@app.get("/api/v1/training/models")
def api_training_models(
_user: Annotated[User, Depends(require_permission("read:jobs"))],
project: str = Query("dms"),
task: str | None = None,
) -> dict[str, Any]:
return get_model_registry(project=project, task=task)
@app.post("/api/v1/training/records")
def api_create_training(
body: CreateTrainingBody,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
if not can_submit_action(user, body.action):
raise HTTPException(403, f"无权提交: {body.action}")
try:
return create_training_submission(
body.action,
body.params,
submitted_by=user.name,
submitted_by_user_id=user.id,
note=body.note,
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@app.get("/api/v1/traces")
def api_traces(_user: Annotated[User, Depends(get_current_user)], limit: int = 50) -> dict[str, Any]:
return {"trace_ids": list_traces(limit=limit)}
@app.get("/api/v1/traces/{trace_id}")
def api_trace(trace_id: str, _user: Annotated[User, Depends(get_current_user)]) -> dict[str, Any]:
spans = get_trace(trace_id)
if not spans:
raise HTTPException(404, "Trace 不存在")
return {"trace_id": trace_id, "spans": spans}
@app.get("/api/v1/agents/tools")
def api_tools(_user: Annotated[User, Depends(get_current_user)]) -> dict[str, Any]:
return {"tools": list(TOOL_REGISTRY.keys())}
@app.post("/api/v1/agents/tools/invoke")
def api_tool_invoke(body: ToolInvokeBody, _user: Annotated[User, Depends(get_current_user)]) -> dict[str, Any]:
try:
return {"result": invoke_tool(body.tool, **body.params)}
except ValueError as e:
raise HTTPException(400, str(e)) from e
@app.post("/api/v1/agents/invoke")
def api_agent_invoke(body: AgentInvokeBody, _user: Annotated[User, Depends(get_current_user)]) -> dict[str, Any]:
graphs = {
"ingest_flow": run_ingest_flow,
"labeling_flow": run_labeling_flow,
"train_promote_flow": run_train_promote_flow,
}
fn = graphs.get(body.graph)
if not fn:
raise HTTPException(400, f"未知 graph: {body.graph}")
return fn(**body.params)
@app.get("/api/v1/approvals")
def api_list_approvals(
_user: Annotated[User, Depends(require_permission("read:audit"))],
status: str | None = None,
limit: int = Query(100, le=500),
) -> dict[str, Any]:
return {"items": list_approvals(status=status, limit=limit)}
@app.get("/api/v1/approvals/{record_id}")
def api_get_approval(record_id: str, _user: Annotated[User, Depends(require_permission("read:audit"))]) -> dict[str, Any]:
rec = get_approval(record_id)
if not rec:
raise HTTPException(404, "审核单不存在")
return rec
@app.get("/api/v1/approvals/{record_id}/preview")
def api_approval_preview(
record_id: str,
_user: Annotated[User, Depends(require_permission("read:audit"))],
) -> dict[str, Any]:
rec = get_approval(record_id)
if not rec:
raise HTTPException(404, "审核单不存在")
try:
scope = resolve_approval_scope(rec["action"], rec.get("params") or {})
except ValueError as e:
raise HTTPException(400, str(e)) from e
batch_summaries = []
for b in scope.get("batches") or []:
batch_dir = Path(b["path"])
batch_summaries.append(
{
"batch": b.get("batch"),
"location": b.get("location"),
"path": str(batch_dir) if batch_dir.is_dir() else None,
"exists": batch_dir.is_dir(),
}
)
return {
"approval": rec,
"scope_label": scope.get("scope_label"),
"task": scope.get("task"),
"pack": scope.get("pack"),
"class_names": scope.get("class_names"),
"batches": batch_summaries,
}
@app.get("/api/v1/approvals/{record_id}/images")
def api_approval_images(
record_id: str,
_user: Annotated[User, Depends(require_permission("read:audit"))],
offset: int = Query(0, ge=0),
limit: int = Query(60, ge=1, le=200),
) -> dict[str, Any]:
rec = get_approval(record_id)
if not rec:
raise HTTPException(404, "审核单不存在")
try:
scope = resolve_approval_scope(rec["action"], rec.get("params") or {})
return list_scope_images(scope, offset=offset, limit=limit)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@app.get("/api/v1/approvals/{record_id}/images/{image_id}")
def api_approval_image(
record_id: str,
image_id: str,
_user: Annotated[User, Depends(require_permission("read:audit"))],
thumb: bool = Query(True),
) -> Response:
rec = get_approval(record_id)
if not rec:
raise HTTPException(404, "审核单不存在")
try:
scope = resolve_approval_scope(rec["action"], rec.get("params") or {})
ref = find_image_ref(scope, image_id)
if not ref or not ref.image_path.is_file():
raise HTTPException(404, "图像不存在")
class_names = scope.get("class_names") or {}
max_size = 480 if thumb else 1920
data = render_overlay(ref.image_path, ref.label_path, class_names, max_size=max_size)
return Response(content=data, media_type="image/jpeg")
except ValueError as e:
raise HTTPException(400, str(e)) from e
@app.post("/api/v1/approvals/submit")
def api_submit(body: SubmitApprovalBody, user: Annotated[User, Depends(get_current_user)]) -> dict[str, Any]:
if not can_submit_action(user, body.action):
raise HTTPException(403, f"无权提交: {body.action}")
try:
return submit_approval(
body.action, body.params,
submitted_by=user.name,
submitted_by_user_id=user.id,
note=body.note,
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@app.post("/api/v1/approvals/submit-build-batch")
def api_submit_build_batch(body: BuildFromBatchBody, user: Annotated[User, Depends(get_current_user)]) -> dict[str, Any]:
if not can_submit_action(user, "build_dms"):
raise HTTPException(403, "无权提交 build")
params: dict[str, Any] = {"task": body.task, "pack": body.pack}
if body.location == "inbox":
params["batch"] = body.batch
else:
params["all_sources"] = True
return submit_approval(
"build_dms", params,
submitted_by=user.name,
submitted_by_user_id=user.id,
note=body.note or f"入库 {body.batch}",
)
@app.post("/api/v1/approvals/{record_id}/approve")
def api_approve(record_id: str, body: ReviewBody, user: Annotated[User, Depends(require_permission("write:approval_review"))]) -> dict[str, Any]:
try:
return approve_and_execute(
record_id,
reviewed_by=user.name,
reviewed_by_user_id=user.id,
comment=body.comment,
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@app.post("/api/v1/approvals/{record_id}/reject")
def api_reject(record_id: str, body: ReviewBody, user: Annotated[User, Depends(require_permission("write:approval_review"))]) -> dict[str, Any]:
try:
return reject_approval(
record_id,
reviewed_by=user.name,
reviewed_by_user_id=user.id,
comment=body.comment,
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@app.post("/api/v1/register-batch")
def api_register_batch(body: RegisterBatchBody, user: Annotated[User, Depends(get_current_user)]) -> dict[str, Any]:
if body.skip_audit:
if not can_submit_action(user, "register_batch"):
raise HTTPException(403, "无权登记批次")
try:
return register_batch(None, body.project, body.task, body.batch, pack=body.pack, stage=body.stage, engineer=body.engineer, location=body.location)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(400, str(e)) from e
if not can_submit_action(user, "register_batch"):
raise HTTPException(403, "无权提交登记审核")
return submit_approval(
"register_batch",
body.model_dump(exclude={"submitted_by", "skip_audit"}),
submitted_by=user.name,
submitted_by_user_id=user.id,
note="登记 batch.meta",
)
@app.post("/api/v1/data/organize")
def api_organize(body: OrganizeBody, _user: Annotated[User, Depends(get_current_user)]) -> dict[str, Any]:
try:
return organize_batch(Path(body.batch_path), task=body.task)
except FileNotFoundError as e:
raise HTTPException(404, str(e)) from e
@app.post("/api/v1/data/inspect-upload")
def api_inspect_upload(body: InspectUploadBody, _user: Annotated[User, Depends(require_permission("read:catalog"))]) -> dict[str, Any]:
try:
result = inspect_uploaded_dataset(body.project, body.task, body.source_path)
return {"ok": True, "normalized": result.to_dict()}
except FileNotFoundError as e:
raise HTTPException(404, str(e)) from e
except UnknownFormatError as e:
raise HTTPException(400, str(e)) from e
@app.post("/api/v1/data/upload/file")
async def api_upload_file(
project: Annotated[str, Form()],
user: Annotated[User, Depends(require_any_permission("write:approval_submit", "write:approval_submit:register"))],
task: Annotated[str | None, Form()] = None,
file: UploadFile = File(...),
) -> dict[str, Any]:
if not file.filename:
raise HTTPException(400, "上传文件名不能为空")
try:
candidate = create_uploaded_candidate(
project=project,
task=task,
original_name=file.filename,
upload_size_bytes=0,
submitted_by_name=user.name if user else None,
submitted_by_user_id=user.id if user else None,
)
write_candidate_upload(candidate["id"], file.file)
job = enqueue_job("analyze_uploaded_dataset", {"candidate_id": candidate["id"]}, async_run=True)
link_candidate_analysis_job(candidate["id"], job["id"])
updated = get_candidate(candidate["id"]) or candidate
return {"ok": True, "candidate": updated, "job": job}
except ValueError as e:
raise HTTPException(400, str(e)) from e
finally:
await file.close()
@app.get("/api/v1/data/candidates")
def api_data_candidates(
_user: Annotated[User, Depends(require_permission("read:catalog"))],
limit: int = Query(50, ge=1, le=500),
) -> dict[str, Any]:
return {"items": list_data_candidates(limit=limit)}
@app.get("/api/v1/data/candidates/{candidate_id}")
def api_data_candidate(candidate_id: str, _user: Annotated[User, Depends(require_permission("read:catalog"))]) -> dict[str, Any]:
item = get_candidate(candidate_id)
if not item:
raise HTTPException(404, "candidate 不存在")
return item
def _mount_ui() -> None:
global _UI_DIR
for ui in (PLATFORM_WEB, PLATFORM_DIR / "web" / "dist"):
if (ui / "index.html").is_file():
_UI_DIR = ui
app.mount("/assets", StaticFiles(directory=str(ui / "assets")), name="ui-assets")
return
_mount_ui()
@app.get("/{full_path:path}", include_in_schema=False)
def spa_fallback(full_path: str):
if full_path.startswith("api/"):
raise HTTPException(404, "Not Found")
if not _UI_DIR:
raise HTTPException(404, "UI not built")
safe = Path(full_path).as_posix().lstrip("/")
target = (_UI_DIR / safe).resolve()
ui_root = _UI_DIR.resolve()
if safe and target.is_file() and target.is_relative_to(ui_root):
return FileResponse(target)
return FileResponse(_UI_DIR / "index.html")
@app.head("/{full_path:path}", include_in_schema=False)
def spa_fallback_head(full_path: str):
return spa_fallback(full_path)
def main() -> None:
import uvicorn
ap = argparse.ArgumentParser(description="华胥智能主动安全平台")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=8787)
args = ap.parse_args()
uvicorn.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()