#!/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()