feat: HSAP platform v2 — modular navigation, quality review, audit log, world model simulation

Major changes:
- New frontend (platform/web/): Vite + React 18 + TypeScript + Tailwind
- 4-module navigation: 数据送标 / 模型管理 / 车队管理 / 系统管理
- Data catalog with charts (DMS/ADAS/Lane 3-tab view)
- Quality review workflow (标注质检): Good/Fine/Bad scoring with auto-advance
- Audit enhancements: batch operations, rejection categories, Feishu notifications
- Operation audit log (操作日志)
- World model simulation studio (仿真工坊)
- Dataset version management with snapshots and diff
- ADAS 7-class dataset integration (138K images organized + compressed)
- User management with Feishu integration and pagination
- CRUD/search/filter on all pages, card layout redesign
- PIL-optimized image overlay rendering
- Auto-snapshot on build, in_review workflow stage
- Removed embedded algorithm code (now in workspace)
This commit is contained in:
2026-06-03 11:40:21 +08:00
parent 7c43b44c57
commit e72bc061c5
5487 changed files with 979207 additions and 6197 deletions

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Annotated, Any
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -12,8 +12,8 @@ from sqlalchemy.orm import Session
from as_platform.auth.deps import get_current_user, require_permission
from as_platform.auth.feishu import build_authorize_url, exchange_code, is_feishu_configured, verify_state
from as_platform.auth.jwt import create_access_token
from as_platform.auth.users import get_or_create_dev_user, list_users, set_user_roles, upsert_feishu_user
from as_platform.config import DEV_AUTH_ENABLED, FRONTEND_URL
from as_platform.auth.users import get_or_create_dev_user, list_users, list_users_paginated, set_user_roles, upsert_feishu_user
from as_platform.config import DEV_AUTH_ENABLED, FORCE_DEV_AUTH, FRONTEND_URL
from as_platform.db.engine import get_db
from as_platform.db.init_db import user_to_dict
from as_platform.db.models import User
@@ -33,7 +33,7 @@ class SetRolesBody(BaseModel):
def auth_config() -> dict[str, Any]:
return {
"feishu_enabled": is_feishu_configured(),
"dev_auth_enabled": DEV_AUTH_ENABLED and not is_feishu_configured(),
"dev_auth_enabled": DEV_AUTH_ENABLED and (FORCE_DEV_AUTH or not is_feishu_configured()),
}
@@ -56,6 +56,10 @@ def feishu_callback(code: str, state: str, db: Annotated[Session, Depends(get_db
user = upsert_feishu_user(db, info)
db.commit()
token = create_access_token(user.id)
# 审计日志
from as_platform.audit.log_utils import log_op
log_op(user_id=user.id, user_name=user.name, category="auth", action="login",
target_type="user", target_id=str(user.id), summary=f"飞书登录: {user.name}")
# 避免 BrowserRouter 直达路径 404统一回根路径并附带 token
qs = urlencode({"token": token})
return RedirectResponse(f"{FRONTEND_URL}/?{qs}")
@@ -63,11 +67,15 @@ def feishu_callback(code: str, state: str, db: Annotated[Session, Depends(get_db
@router.post("/dev/login")
def dev_login(body: DevLoginBody, db: Annotated[Session, Depends(get_db)]):
if not DEV_AUTH_ENABLED or is_feishu_configured():
if not DEV_AUTH_ENABLED or (not FORCE_DEV_AUTH and is_feishu_configured()):
raise HTTPException(403, "开发登录未启用")
user = get_or_create_dev_user(db, body.name)
db.commit()
token = create_access_token(user.id)
# 审计日志
from as_platform.audit.log_utils import log_op
log_op(user_id=user.id, user_name=user.name, category="auth", action="login",
target_type="user", target_id=str(user.id), summary=f"开发登录: {user.name}")
return {"access_token": token, "user": user_to_dict(user)}
@@ -80,8 +88,13 @@ def auth_me(user: Annotated[User, Depends(get_current_user)]):
def auth_list_users(
_user: Annotated[User, Depends(require_permission("admin:users"))],
db: Annotated[Session, Depends(get_db)],
search: str = Query(""),
role: str = Query(""),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
):
return {"items": [user_to_dict(u) for u in list_users(db)]}
users, total = list_users_paginated(db, search=search, role_code=role, offset=offset, limit=limit)
return {"items": [user_to_dict(u) for u in users], "total": total}
@router.put("/users/{user_id}/roles")
@@ -95,4 +108,8 @@ def auth_set_roles(
if not user:
raise HTTPException(404, "用户不存在")
db.commit()
from as_platform.audit.log_utils import log_op
log_op(user_id=_user.id, user_name=_user.name, category="system", action="set_roles",
target_type="user", target_id=str(user_id), summary=f"修改角色: {user.name}{body.role_codes}",
detail={"user_id": user_id, "roles": body.role_codes})
return user_to_dict(user)

View File

@@ -0,0 +1,133 @@
"""平台批次送标申请 API。"""
from __future__ import annotations
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from as_platform.auth.deps import get_current_user, require_any_permission
from as_platform.db.models import User
from as_platform.deliveries import service as delivery_svc
router = APIRouter(prefix="/api/v1/deliveries", tags=["deliveries"])
class DeliveryBody(BaseModel):
project: str = "dms"
task: str | None = None
mode: str | None = None
batch_name: str = ""
source_type: str | None = None
vehicle_scene: str | None = None
collection_start: str | None = None
collection_end: str | None = None
data_path: str = ""
estimated_count: int | None = None
remark: str | None = None
owner_user_id: int | None = None
owner_name: str | None = None
class DeliveryPatchBody(BaseModel):
project: str | None = None
task: str | None = None
mode: str | None = None
batch_name: str | None = None
source_type: str | None = None
vehicle_scene: str | None = None
collection_start: str | None = None
collection_end: str | None = None
data_path: str | None = None
estimated_count: int | None = None
remark: str | None = None
owner_user_id: int | None = None
owner_name: str | None = None
@router.get("")
def api_list_deliveries(
_user: Annotated[User, Depends(require_any_permission("read:deliveries", "read:pending", "*"))],
status: str | None = Query(None),
mine: bool = Query(False),
mine_editable: bool = Query(False, description="待我处理:仅草稿/驳回"),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
mine_id = _user.id if mine or mine_editable else None
return delivery_svc.list_deliveries(
status=status,
mine_user_id=mine_id,
mine_editable_only=mine_editable,
offset=offset,
limit=limit,
)
@router.get("/{delivery_id}")
def api_get_delivery(
delivery_id: str,
_user: Annotated[User, Depends(require_any_permission("read:deliveries", "read:pending", "*"))],
) -> dict[str, Any]:
row = delivery_svc.get_delivery(delivery_id)
if not row:
raise HTTPException(404, "送标申请不存在")
return row
@router.post("")
def api_create_delivery(
body: DeliveryBody,
user: Annotated[User, Depends(require_any_permission("write:delivery_submit", "*"))],
) -> dict[str, Any]:
try:
return delivery_svc.create_delivery(body.model_dump(), user)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.patch("/{delivery_id}")
def api_patch_delivery(
delivery_id: str,
body: DeliveryPatchBody,
user: Annotated[User, Depends(require_any_permission("write:delivery_submit", "*"))],
) -> dict[str, Any]:
data = body.model_dump(exclude_unset=True)
try:
return delivery_svc.update_delivery(delivery_id, data, user)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.post("/{delivery_id}/submit")
def api_submit_delivery(
delivery_id: str,
user: Annotated[User, Depends(require_any_permission("write:delivery_submit", "*"))],
) -> dict[str, Any]:
try:
return delivery_svc.submit_delivery_for_review(delivery_id, user)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.post("/{delivery_id}/retry-ingest")
def api_retry_delivery_ingest(
delivery_id: str,
user: Annotated[User, Depends(require_any_permission("write:delivery_submit", "*"))],
) -> dict[str, Any]:
try:
return delivery_svc.retry_delivery_ingest(delivery_id, user)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.delete("/{delivery_id}")
def api_delete_delivery(
delivery_id: str,
user: Annotated[User, Depends(require_any_permission("write:delivery_submit", "*"))],
) -> dict[str, Any]:
try:
delivery_svc.delete_delivery(delivery_id, user)
return {"ok": True}
except ValueError as e:
raise HTTPException(400, str(e)) from e

View File

@@ -0,0 +1,75 @@
"""飞书集成 API。"""
from __future__ import annotations
from typing import Annotated, Any
from fastapi import APIRouter, Depends
from as_platform.auth.deps import require_any_permission
from as_platform.db.models import User
from as_platform.config import FEISHU_BITABLE_AUTO_INGEST, FEISHU_BITABLE_WEBHOOK_ENABLED
from as_platform.integrations.feishu_bitable import connectivity_check, is_bitable_configured, list_tables
from as_platform.integrations.feishu_bitable_ingest import process_pending_ingest
from as_platform.integrations.feishu_bitable_sync import backfill_hints
from as_platform.integrations.feishu_notify import is_notify_configured
from as_platform.jobs.feishu_bitable_sync import run_sync_cycle
router = APIRouter(prefix="/api/v1/integrations/feishu", tags=["feishu"])
@router.get("/bitable/status")
def api_bitable_status(
_user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "*"))],
) -> dict[str, Any]:
return connectivity_check()
@router.get("/bitable/tables")
def api_bitable_tables(
_user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "*"))],
) -> dict[str, Any]:
if not is_bitable_configured():
return {"items": [], "message": "未配置 BITABLE_APP_TOKEN / TABLE_ID"}
return {"items": list_tables()}
@router.post("/bitable/sync")
def api_bitable_sync(
_user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "*"))],
) -> dict[str, Any]:
return run_sync_cycle()
@router.post("/bitable/ingest")
def api_bitable_ingest(
_user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "*"))],
) -> dict[str, Any]:
"""手动触发「待落盘」入湖(须 FEISHU_BITABLE_AUTO_INGEST=1 或本接口始终可用)。"""
return process_pending_ingest()
@router.get("/bitable/config")
def api_bitable_config(
_user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "*"))],
) -> dict[str, Any]:
return {
"bitable_configured": is_bitable_configured(),
"notify_configured": is_notify_configured(),
"auto_ingest": FEISHU_BITABLE_AUTO_INGEST,
"webhook_enabled": FEISHU_BITABLE_WEBHOOK_ENABLED,
}
@router.post("/bitable/webhook")
def api_bitable_webhook_disabled() -> dict[str, Any]:
"""内网默认关闭;有公网穿透后再实现验签入站。"""
if not FEISHU_BITABLE_WEBHOOK_ENABLED:
return {"ok": False, "message": "FEISHU_BITABLE_WEBHOOK_ENABLED 未开启"}
return {"ok": False, "message": "Webhook 处理器尚未实现"}
@router.get("/bitable/backfill-hints")
def api_bitable_backfill_hints(
_user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "*"))],
) -> dict[str, Any]:
return backfill_hints()

View File

@@ -0,0 +1,344 @@
"""Fleet map API routes."""
from __future__ import annotations
import json
from typing import Annotated, Any
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from as_platform.auth.deps import get_current_user, require_permission
from as_platform.config import AMAP_KEY, FLEET_MAP_ENABLED, MAP_TILE_PROVIDER, TBOX_INGEST_TOKEN
from as_platform.db.engine import get_db
from as_platform.db.models import User
from as_platform.fleet import service as fleet_svc
from as_platform.fleet.mock_seed import reseed_demo_fleet, seed_demo_fleet
router = APIRouter(tags=["fleet"])
class VehicleBody(BaseModel):
plate_no: str
tbox_device_id: str
name: str | None = None
team: str | None = None
status: str | None = "active"
class VehiclePatchBody(BaseModel):
plate_no: str | None = None
tbox_device_id: str | None = None
name: str | None = None
team: str | None = None
status: str | None = None
class TboxGpsBody(BaseModel):
device_id: str
lat: float
lng: float
ts: str | None = None
speed_kmh: float | None = None
heading: float | None = None
plate_no: str | None = None
run_signal: str | None = "active"
engineer: str | None = None
project: str | None = None
batch: str | None = None
class TboxGpsBatchBody(BaseModel):
points: list[TboxGpsBody] = Field(..., min_length=1, max_length=100)
def _check_fleet_enabled() -> None:
if not FLEET_MAP_ENABLED:
raise HTTPException(503, "车队地图功能未启用 (AS_FLEET_MAP_ENABLED)")
def _verify_tbox_token(x_tbox_token: str | None = None, authorization: str | None = None) -> None:
token = (x_tbox_token or "").strip()
if not token and authorization:
parts = authorization.split(None, 1)
if len(parts) == 2 and parts[0].lower() == "bearer":
token = parts[1].strip()
if not TBOX_INGEST_TOKEN:
return
if token != TBOX_INGEST_TOKEN:
raise HTTPException(401, "T-Box Token 无效")
@router.get("/api/v1/fleet/map-config")
def fleet_map_config(_user: Annotated[User, Depends(require_permission("read:fleet"))]) -> dict[str, Any]:
_check_fleet_enabled()
tile_provider = MAP_TILE_PROVIDER if MAP_TILE_PROVIDER in ("gaode", "osm") else "gaode"
return {
"enabled": True,
"provider": tile_provider,
"tileProvider": tile_provider,
"amapKey": AMAP_KEY or "",
"pollIntervalSec": 5,
"note": "GPS 为 WGS84高德底图为 GCJ-02演示轨迹可能有轻微偏移",
}
@router.get("/api/v1/fleet/live")
def fleet_live(_user: Annotated[User, Depends(require_permission("read:fleet"))], db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
_check_fleet_enabled()
return fleet_svc.get_live_fleet(db)
@router.get("/api/v1/fleet/vehicles")
def fleet_vehicles(_user: Annotated[User, Depends(require_permission("read:fleet"))], db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
_check_fleet_enabled()
return {"items": fleet_svc.list_vehicles(db)}
@router.get("/api/v1/fleet/vehicles/{vehicle_id}")
def fleet_vehicle_get(
vehicle_id: int,
_user: Annotated[User, Depends(require_permission("read:fleet"))],
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
_check_fleet_enabled()
item = fleet_svc.get_vehicle(db, vehicle_id)
if not item:
raise HTTPException(404, "车辆不存在")
return item
@router.post("/api/v1/fleet/vehicles")
def fleet_vehicle_create(
body: VehicleBody,
_user: Annotated[User, Depends(require_permission("write:fleet"))],
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
_check_fleet_enabled()
try:
item = fleet_svc.create_vehicle(db, body.model_dump())
db.commit()
return {"ok": True, "vehicle": item}
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.patch("/api/v1/fleet/vehicles/{vehicle_id}")
def fleet_vehicle_update(
vehicle_id: int,
body: VehiclePatchBody,
_user: Annotated[User, Depends(require_permission("write:fleet"))],
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
_check_fleet_enabled()
try:
item = fleet_svc.update_vehicle(db, vehicle_id, body.model_dump(exclude_unset=True))
db.commit()
return {"ok": True, "vehicle": item}
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.delete("/api/v1/fleet/vehicles/{vehicle_id}")
def fleet_vehicle_delete(
vehicle_id: int,
_user: Annotated[User, Depends(require_permission("write:fleet"))],
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
_check_fleet_enabled()
try:
fleet_svc.delete_vehicle(db, vehicle_id)
db.commit()
return {"ok": True}
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.get("/api/v1/fleet/runs")
def fleet_runs(
_user: Annotated[User, Depends(require_permission("read:fleet"))],
db: Annotated[Session, Depends(get_db)],
vehicle_id: int | None = None,
status: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
_check_fleet_enabled()
return fleet_svc.list_runs(db, vehicle_id=vehicle_id, status=status, offset=offset, limit=limit)
@router.get("/api/v1/fleet/runs/{run_id}")
def fleet_run_detail(run_id: int, _user: Annotated[User, Depends(require_permission("read:fleet"))], db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
_check_fleet_enabled()
detail = fleet_svc.get_run_detail(db, run_id)
if not detail:
raise HTTPException(404, "趟次不存在")
return detail
@router.get("/api/v1/fleet/runs/{run_id}/track")
def fleet_run_track(run_id: int, _user: Annotated[User, Depends(require_permission("read:fleet"))], db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
_check_fleet_enabled()
return fleet_svc.get_run_track_geojson(db, run_id)
@router.get("/api/v1/fleet/summary")
def fleet_summary(_user: Annotated[User, Depends(require_permission("read:fleet"))], db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
_check_fleet_enabled()
return fleet_svc.fleet_summary(db)
@router.post("/api/v1/fleet/mock/seed")
def fleet_mock_seed(
_user: Annotated[User, Depends(require_permission("write:fleet"))],
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
_check_fleet_enabled()
created = seed_demo_fleet(db)
db.commit()
return {"ok": True, "created": created}
@router.post("/api/v1/fleet/mock/reseed")
def fleet_mock_reseed(
_user: Annotated[User, Depends(require_permission("write:fleet"))],
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
"""清空车队演示数据并重新注入(切换区域后使用)。"""
_check_fleet_enabled()
created = reseed_demo_fleet(db)
db.commit()
return {"ok": True, "created": created, "region": "changsha"}
@router.post("/api/v1/fleet/mock/tick")
def fleet_mock_tick(
_user: Annotated[User, Depends(require_permission("write:fleet"))],
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
_check_fleet_enabled()
n = fleet_svc.simulate_tick(db)
db.commit()
return {"ok": True, "advanced": n}
@router.post("/api/v1/fleet/runs/import-gpx")
async def fleet_import_gpx(
_user: Annotated[User, Depends(require_permission("write:fleet"))],
db: Annotated[Session, Depends(get_db)],
vehicle_id: int = Form(...),
file: UploadFile = File(...),
note: str | None = Form(None),
) -> dict[str, Any]:
_check_fleet_enabled()
raw = await file.read()
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
text = raw.decode("utf-8", errors="ignore")
try:
result = fleet_svc.import_gpx_run(db, vehicle_id=vehicle_id, gpx_content=text, note=note)
db.commit()
return result
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.get("/api/v1/fleet/stream")
def fleet_stream(
_user: Annotated[User, Depends(require_permission("read:fleet"))],
) -> StreamingResponse:
"""SSE推送 fleet.gps 事件(需 Redis"""
_check_fleet_enabled()
from as_platform.redis.bus import get_redis
def event_generator():
r = get_redis()
if not r:
yield f"data: {json.dumps({'event': 'error', 'message': 'redis unavailable'}, ensure_ascii=False)}\n\n"
return
pubsub = r.pubsub()
pubsub.subscribe("as:events")
yield f"data: {json.dumps({'event': 'fleet.connected'}, ensure_ascii=False)}\n\n"
while True:
msg = pubsub.get_message(timeout=25)
if msg is None:
yield ": ping\n\n"
continue
if msg.get("type") != "message":
continue
raw = msg.get("data")
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="ignore")
if isinstance(raw, str) and "fleet." in raw:
yield f"data: {raw}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.post("/api/v1/tbox/gps")
def tbox_gps(
body: TboxGpsBody,
db: Annotated[Session, Depends(get_db)],
x_tbox_token: Annotated[str | None, Header(alias="X-Tbox-Token")] = None,
authorization: Annotated[str | None, Header()] = None,
) -> dict[str, Any]:
_check_fleet_enabled()
_verify_tbox_token(x_tbox_token, authorization)
try:
fleet_svc.close_idle_runs(db)
result = fleet_svc.ingest_tbox_gps(db, body.model_dump())
db.commit()
return result
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.post("/api/v1/tbox/gps/batch")
def tbox_gps_batch(
body: TboxGpsBatchBody,
db: Annotated[Session, Depends(get_db)],
x_tbox_token: Annotated[str | None, Header(alias="X-Tbox-Token")] = None,
authorization: Annotated[str | None, Header()] = None,
) -> dict[str, Any]:
_check_fleet_enabled()
_verify_tbox_token(x_tbox_token, authorization)
try:
fleet_svc.close_idle_runs(db)
result = fleet_svc.ingest_tbox_gps_batch(db, [p.model_dump() for p in body.points])
db.commit()
return result
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.post("/api/v1/fleet/runs/import-csv")
async def fleet_import_csv(
_user: Annotated[User, Depends(require_permission("write:fleet"))],
db: Annotated[Session, Depends(get_db)],
vehicle_id: int = Form(...),
file: UploadFile = File(...),
note: str | None = Form(None),
project: str | None = Form(None),
batch: str | None = Form(None),
) -> dict[str, Any]:
_check_fleet_enabled()
raw = await file.read()
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
text = raw.decode("utf-8", errors="ignore")
try:
result = fleet_svc.import_csv_run(
db,
vehicle_id=vehicle_id,
csv_content=text,
note=note,
project=project,
batch=batch,
)
db.commit()
return result
except ValueError as e:
raise HTTPException(400, str(e)) from e

View File

@@ -0,0 +1,445 @@
from __future__ import annotations
import zipfile
from typing import Annotated, Any
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from as_platform.auth.deps import require_any_permission, require_permission
from as_platform.db.models import User
from as_platform.labeling.annotate import (
campaign_bootstrap,
campaign_tasks,
get_annotation,
resolve_media_file,
save_annotation,
)
from as_platform.labeling.lock import acquire_lock, release_lock, renew_lock
from as_platform.labeling.progress import (
assign_tasks_even,
assign_tasks_explicit,
campaign_progress,
release_task_assignment,
reassign_task,
user_is_coordinator,
)
from as_platform.labeling.service import (
assign_campaign,
get_campaign,
list_campaign_export_jobs,
list_labeling_assignees,
list_labeling_batches,
open_campaign,
submit_campaign,
trigger_labeling_export,
)
from as_platform.labeling.vendor_import import import_vendor_zip, list_registry_profiles
router = APIRouter(tags=["labeling"])
class OpenCampaignBody(BaseModel):
project: str = Field(..., pattern="^(dms|lane)$")
task: str
batch: str
mode: str | None = None
pack: str | None = None
location: str = "inbox"
class AssignCampaignBody(BaseModel):
user_id: int | None = None
class AnnotationBody(BaseModel):
result: list[dict[str, Any]] | dict[str, Any] | None = None
annotations: list[dict[str, Any]] | None = None
class AssignTasksExplicitItem(BaseModel):
user_id: int
task_ids: list[str] = Field(default_factory=list)
class AssignTasksBody(BaseModel):
mode: str = "even"
user_ids: list[int] | None = None
items: list[AssignTasksExplicitItem] | None = None
class ReassignTaskBody(BaseModel):
user_id: int
@router.get("/api/v1/labeling/assignees")
def api_labeling_assignees(
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
return list_labeling_assignees()
@router.patch("/api/v1/labeling/campaigns/{campaign_id}/assign")
def api_assign_campaign(
campaign_id: str,
body: AssignCampaignBody,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return assign_campaign(campaign_id, body.user_id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.get("/api/v1/labeling/batches")
def api_labeling_batches(
_user: Annotated[User, Depends(require_permission("read:pending"))],
stage: str | None = Query(None),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
return list_labeling_batches(stage=stage, offset=offset, limit=limit)
@router.post("/api/v1/labeling/campaigns/open")
def api_open_campaign(
body: OpenCampaignBody,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
return open_campaign(
project=body.project,
task=body.task,
batch=body.batch,
mode=body.mode,
pack=body.pack,
location=body.location,
)
@router.get("/api/v1/labeling/campaigns/{campaign_id}")
def api_get_campaign(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
row = get_campaign(campaign_id)
if not row:
raise HTTPException(404, "campaign not found")
return row
@router.get("/api/v1/labeling/campaigns/{campaign_id}/bootstrap")
def api_campaign_bootstrap(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return campaign_bootstrap(campaign_id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
except Exception as e:
raise HTTPException(400, str(e)) from e
@router.get("/api/v1/labeling/campaigns/{campaign_id}/progress")
def api_campaign_progress(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return campaign_progress(campaign_id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
@router.post("/api/v1/labeling/campaigns/{campaign_id}/assign-tasks")
def api_assign_tasks(
campaign_id: str,
body: AssignTasksBody,
user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "read:pending"))],
) -> dict[str, Any]:
if not user_is_coordinator(user):
raise HTTPException(403, "仅协调员可分配任务")
try:
if body.mode == "explicit":
items = [{"user_id": i.user_id, "task_ids": i.task_ids} for i in (body.items or [])]
return assign_tasks_explicit(campaign_id, items, assigned_by_user_id=user.id)
if not body.user_ids:
raise ValueError("even 模式需要 user_ids")
return assign_tasks_even(campaign_id, body.user_ids, assigned_by_user_id=user.id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.patch("/api/v1/labeling/campaigns/{campaign_id}/assignments/{task_id}")
def api_reassign_task(
campaign_id: str,
task_id: str,
body: ReassignTaskBody,
user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "read:pending"))],
) -> dict[str, Any]:
if not user_is_coordinator(user):
raise HTTPException(403, "仅协调员可改派任务")
try:
return reassign_task(campaign_id, task_id, body.user_id)
except FileNotFoundError:
raise HTTPException(404, "assignment not found") from None
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.delete("/api/v1/labeling/campaigns/{campaign_id}/assignments/{task_id}")
def api_release_assignment(
campaign_id: str,
task_id: str,
user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "read:pending"))],
) -> dict[str, Any]:
if not user_is_coordinator(user):
raise HTTPException(403, "仅协调员可释放任务")
try:
return release_task_assignment(campaign_id, task_id)
except FileNotFoundError:
raise HTTPException(404, "assignment not found") from None
@router.get("/api/v1/labeling/campaigns/{campaign_id}/tasks")
def api_campaign_tasks(
campaign_id: str,
user: Annotated[User, Depends(require_permission("read:pending"))],
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
assignee: str | None = Query(None),
) -> dict[str, Any]:
try:
eff = assignee
if eff is None and not user_is_coordinator(user):
eff = "me"
return campaign_tasks(campaign_id, offset=offset, limit=limit, user=user, assignee=eff)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
except PermissionError as e:
raise HTTPException(403, str(e)) from e
except Exception as e:
raise HTTPException(400, str(e)) from e
@router.get("/api/v1/labeling/media/{campaign_id}/{file_path:path}")
def api_labeling_media(
campaign_id: str,
file_path: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
):
try:
target = resolve_media_file(campaign_id, file_path)
except FileNotFoundError:
raise HTTPException(404, "not found") from None
except PermissionError:
raise HTTPException(403, "forbidden") from None
except Exception as e:
raise HTTPException(400, str(e)) from e
return FileResponse(target)
@router.get("/api/v1/labeling/campaigns/{campaign_id}/annotations/{task_id}")
def api_get_annotation(
campaign_id: str,
task_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return get_annotation(campaign_id, task_id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
@router.put("/api/v1/labeling/campaigns/{campaign_id}/annotations/{task_id}")
def api_put_annotation(
campaign_id: str,
task_id: str,
body: AnnotationBody,
user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return save_annotation(campaign_id, task_id, body.model_dump(exclude_none=True), user=user)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
except PermissionError as e:
raise HTTPException(403, str(e)) from e
except Exception as e:
raise HTTPException(400, str(e)) from e
@router.post("/api/v1/labeling/campaigns/{campaign_id}/export")
def api_labeling_export(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return trigger_labeling_export(campaign_id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
@router.get("/api/v1/labeling/campaigns/{campaign_id}/export-jobs")
def api_campaign_export_jobs(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
limit: int = Query(30, ge=1, le=100),
) -> dict[str, Any]:
if not get_campaign(campaign_id):
raise HTTPException(404, "campaign not found")
return list_campaign_export_jobs(campaign_id, limit=limit)
@router.post("/api/v1/labeling/campaigns/{campaign_id}/submit")
def api_campaign_submit(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return submit_campaign(campaign_id)
except ValueError as e:
raise HTTPException(400, str(e)) from None
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
@router.get("/api/v1/labeling/registry-profiles")
def api_labeling_registry_profiles(
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
return list_registry_profiles()
@router.post("/api/v1/labeling/campaigns/{campaign_id}/import-vendor")
async def api_import_vendor(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("write:labeling_vendor"))],
file: UploadFile = File(...),
) -> dict[str, Any]:
raw = await file.read()
try:
return import_vendor_zip(campaign_id, raw)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
except (zipfile.BadZipFile, ValueError) as e:
raise HTTPException(400, str(e)) from e
@router.post("/api/v1/labeling/campaigns/{campaign_id}/tasks/{task_id}/lock")
def api_labeling_lock_acquire(
campaign_id: str,
task_id: str,
user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
if not get_campaign(campaign_id):
raise HTTPException(404, "campaign not found")
result = acquire_lock(campaign_id, task_id, user_id=user.id, user_name=user.name)
if not result.get("ok"):
raise HTTPException(409, detail=result)
return result
@router.delete("/api/v1/labeling/campaigns/{campaign_id}/tasks/{task_id}/lock")
def api_labeling_lock_release(
campaign_id: str,
task_id: str,
user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
result = release_lock(campaign_id, task_id, user_id=user.id)
if not result.get("ok"):
raise HTTPException(409, detail=result)
return result
@router.post("/api/v1/labeling/campaigns/{campaign_id}/tasks/{task_id}/lock/renew")
def api_labeling_lock_renew(
campaign_id: str,
task_id: str,
user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
result = renew_lock(campaign_id, task_id, user_id=user.id)
if not result.get("ok"):
raise HTTPException(409, detail=result)
return result
# ── 标注质检 (Quality Review) ──
class ReviewScoreBody(BaseModel):
image_path: str
score: str # good / fine / bad
comment: str | None = None
class ReviewBatchBody(BaseModel):
scores: list[ReviewScoreBody]
@router.get("/api/v1/labeling/campaigns/{campaign_id}/review-queue")
def api_review_queue(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("write:approval_review"))],
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
from as_platform.audit.review import get_review_queue
return get_review_queue(campaign_id, offset=offset, limit=limit)
@router.get("/api/v1/labeling/campaigns/{campaign_id}/review-image")
def api_review_image(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("write:approval_review"))],
path: str = Query(...),
) -> FileResponse:
from as_platform.audit.review import get_review_image
import tempfile
try:
# Get class names from registry
import yaml
from as_platform.data.core import load_wf, proj_root
wf = load_wf()
root = proj_root(wf, "dms")
reg = yaml.safe_load((root / wf["projects"]["dms"]["registry"]).read_text())
# Build class_names dict from campaign scope
class_names: dict[int, str] = {}
# Try to get from the specific task
tasks = reg.get("tasks", {})
for task_cfg in tasks.values():
names = task_cfg.get("names")
if isinstance(names, list):
for i, n in enumerate(names):
class_names[i] = n
data = get_review_image(campaign_id, path, class_names)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.write(data)
tmp.close()
return FileResponse(tmp.name, media_type="image/jpeg")
except FileNotFoundError as e:
raise HTTPException(404, str(e)) from e
@router.post("/api/v1/labeling/campaigns/{campaign_id}/review-submit")
def api_review_submit(
campaign_id: str,
body: ReviewBatchBody,
user: Annotated[User, Depends(require_permission("write:approval_review"))],
) -> dict[str, Any]:
from as_platform.audit.review import submit_review_scores
items = [s.model_dump() for s in body.scores]
return submit_review_scores(campaign_id, items, reviewer_user_id=user.id, reviewer_name=user.name)
@router.get("/api/v1/labeling/campaigns/{campaign_id}/review-progress")
def api_review_progress(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, int]:
from as_platform.audit.review import review_progress
return review_progress(campaign_id)

View File

@@ -0,0 +1,155 @@
"""模型管理 API 路由 — 模块化前缀 /api/v1/models
本路由将 training/* 功能以模块化路径重新暴露,同时保留旧路径兼容。
"""
from __future__ import annotations
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from as_platform.auth.deps import can_submit_action, get_current_user, require_permission
from as_platform.db.models import User
from as_platform.data.versions import create_snapshot, diff_versions, get_version, list_versions
from as_platform.training.service import (
TRAINING_ACTIONS,
create_training_submission,
get_model_registry,
get_training_record,
list_training_records,
)
router = APIRouter(prefix="/api/v1/models", tags=["models"])
class CreateTrainingBody(BaseModel):
action: str
params: dict[str, Any] = Field(default_factory=dict)
note: str | None = None
@router.get("/actions")
def api_models_actions(_user: Annotated[User, Depends(require_permission("read:jobs"))]) -> dict[str, Any]:
"""可用的训练/评估/晋级操作列表"""
from as_platform.audit.queue import ACTION_LABELS
return {
"actions": [
{"id": action, "label": ACTION_LABELS.get(action, action)}
for action in sorted(TRAINING_ACTIONS)
]
}
@router.get("/registry")
def api_models_registry(
_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)
@router.get("/records")
def api_models_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,
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
"""训练/评估记录列表"""
return list_training_records(
project=project, kind=kind, status=status, task=task, offset=offset, limit=limit
)
@router.get("/records/{record_id}")
def api_models_record(
record_id: str,
_user: Annotated[User, Depends(require_permission("read:jobs"))],
) -> dict[str, Any]:
"""单条训练记录详情"""
rec = get_training_record(record_id)
if not rec:
raise HTTPException(404, "训练记录不存在")
return rec
@router.post("/records")
def api_models_create_record(
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
# ── 数据集版本管理 ──
class CreateSnapshotBody(BaseModel):
project: str = "dms"
description: str = ""
@router.get("/datasets")
def api_list_dataset_versions(
_user: Annotated[User, Depends(require_permission("read:jobs"))],
project: str = Query("dms"),
) -> dict[str, Any]:
return {"items": list_versions(project)}
@router.post("/datasets/snapshot")
def api_create_snapshot(
body: CreateSnapshotBody,
user: Annotated[User, Depends(require_permission("write:approval_submit"))],
) -> dict[str, Any]:
try:
result = create_snapshot(body.project, body.description, author=user.name)
from as_platform.audit.log_utils import log_op
log_op(user_id=user.id, user_name=user.name, category="data", action="create_snapshot",
target_type="snapshot", target_id=result.get("version_id"), summary=f"创建快照: {result.get('version_id')} ({body.project})")
return result
except Exception as e:
raise HTTPException(500, str(e)) from e
@router.get("/datasets/{version_id}")
def api_get_version(
version_id: str,
_user: Annotated[User, Depends(require_permission("read:jobs"))],
project: str = Query("dms"),
) -> dict[str, Any]:
v = get_version(project, version_id)
if not v:
raise HTTPException(404, f"版本 {version_id} 不存在")
return v
@router.get("/datasets/{version_id}/diff")
def api_diff_versions(
version_id: str,
_user: Annotated[User, Depends(require_permission("read:jobs"))],
compare: str = Query(...),
project: str = Query("dms"),
) -> dict[str, Any]:
result = diff_versions(project, compare, version_id)
if "error" in result:
raise HTTPException(400, result["error"])
return result

View File

@@ -23,6 +23,12 @@ 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.api.fleet_routes import router as fleet_router
from as_platform.api.delivery_routes import router as delivery_router
from as_platform.api.feishu_routes import router as feishu_router
from as_platform.api.labeling_routes import router as labeling_router
from as_platform.api.models_routes import router as models_router
from as_platform.api.system_routes import router as system_router
from as_platform.audit.queue import (
ACTION_LABELS,
ACTIONS_REQUIRING_APPROVAL,
@@ -34,7 +40,16 @@ from as_platform.audit.queue import (
)
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.config import (
FEISHU_BITABLE_SYNC_ENABLED,
FEISHU_BITABLE_SYNC_INTERVAL_SEC,
FLEET_MOCK_SIMULATE,
FLEET_SIM_INTERVAL_SEC,
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 (
@@ -42,10 +57,11 @@ from as_platform.data.lake import (
get_candidate,
link_candidate_analysis_job,
list_candidates as list_data_candidates,
promote_candidate_to_inbox,
write_candidate_upload,
)
from as_platform.data.organize import organize_batch
from as_platform.db.engine import check_connection
from as_platform.db.engine import check_connection, session_scope
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
@@ -59,16 +75,51 @@ from as_platform.training.service import (
)
def _feishu_bitable_sync_loop() -> None:
import time
from as_platform.jobs.feishu_bitable_sync import run_sync_cycle
while True:
time.sleep(FEISHU_BITABLE_SYNC_INTERVAL_SEC)
try:
run_sync_cycle()
except Exception:
pass
def _fleet_sim_loop() -> None:
import time
from as_platform.fleet import service as fleet_svc
while True:
time.sleep(max(3, FLEET_SIM_INTERVAL_SEC))
try:
with session_scope() as db:
fleet_svc.simulate_tick(db)
except Exception:
pass
@asynccontextmanager
async def lifespan(_app: FastAPI):
init_database()
threading.Thread(target=warmup_catalog_cache, daemon=True, name="catalog-warmup").start()
if FLEET_MOCK_SIMULATE:
threading.Thread(target=_fleet_sim_loop, daemon=True, name="fleet-sim",).start()
if FEISHU_BITABLE_SYNC_ENABLED:
threading.Thread(target=_feishu_bitable_sync_loop, daemon=True, name="feishu-bitable-sync").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)
app.include_router(fleet_router)
app.include_router(labeling_router)
app.include_router(feishu_router)
app.include_router(delivery_router)
app.include_router(models_router) # 模型管理: /api/v1/models/*
app.include_router(system_router) # 系统管理: /api/v1/system/*
_UI_DIR: Path | None = None
@@ -152,6 +203,19 @@ def api_pending(_user: Annotated[User, Depends(require_permission("read:pending"
return get_pending_report()
@app.get("/api/v1/pending/gates")
def api_pending_gates(_user: Annotated[User, Depends(require_permission("read:pending"))]) -> dict[str, Any]:
"""ML 自动化 P0build 门禁与 manifest 对齐说明。"""
return {
"build_validate": (
"python as.py build dms <task> <pack> 入库后默认执行 scripts/validate_dms_tasks.py"
"仅调试可用 --skip-validate"
),
"manifest_smoke": "bash HSAP/scripts/smoke_manifest_alignment.sh",
"pending_cli": "python as.py pending",
}
@app.get("/api/v1/catalog")
def api_catalog(
_user: Annotated[User, Depends(require_permission("read:catalog"))],
@@ -193,9 +257,10 @@ def api_actions(_user: Annotated[User, Depends(require_permission("read:audit"))
def api_jobs(
_user: Annotated[User, Depends(require_permission("read:jobs"))],
status: str | None = None,
limit: int = Query(100, le=500),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
return {"items": list_jobs(status=status, limit=limit)}
return list_jobs(status=status, offset=offset, limit=limit)
@app.get("/api/v1/jobs/{job_id}")
@@ -223,9 +288,12 @@ def api_training_records(
kind: str | None = None,
status: str | None = None,
task: str | None = None,
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
return list_training_records(project=project, kind=kind, status=status, task=task, limit=limit)
return list_training_records(
project=project, kind=kind, status=status, task=task, offset=offset, limit=limit
)
@app.get("/api/v1/training/records/{job_id}")
@@ -310,9 +378,10 @@ def api_agent_invoke(body: AgentInvokeBody, _user: Annotated[User, Depends(get_c
def api_list_approvals(
_user: Annotated[User, Depends(require_permission("read:audit"))],
status: str | None = None,
limit: int = Query(100, le=500),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
return {"items": list_approvals(status=status, limit=limit)}
return list_approvals(status=status, offset=offset, limit=limit)
@app.get("/api/v1/approvals/{record_id}")
@@ -346,13 +415,29 @@ def api_approval_preview(
"exists": batch_dir.is_dir(),
}
)
params = rec.get("params") or {}
title = str(rec.get("action_label") or rec.get("action") or record_id)
summary = ""
if rec.get("action") == "delivery_ingest":
title = f"数据送标入湖 · {params.get('batch_name') or ''}"
parts = [
f"项目 {params.get('project') or 'dms'}",
f"任务 {params.get('task') or ''}",
f"路径 {params.get('data_path') or ''}",
]
if params.get("estimated_count"):
parts.append(f"{params['estimated_count']}")
summary = " · ".join(parts)
return {
"approval": rec,
"title": title,
"summary": summary,
"scope_label": scope.get("scope_label"),
"task": scope.get("task"),
"pack": scope.get("pack"),
"class_names": scope.get("class_names"),
"batches": batch_summaries,
"delivery": params if rec.get("action") == "delivery_ingest" else None,
}
@@ -456,22 +541,29 @@ def api_reject(record_id: str, body: ReviewBody, user: Annotated[User, Depends(r
@app.post("/api/v1/register-batch")
def api_register_batch(body: RegisterBatchBody, user: Annotated[User, Depends(get_current_user)]) -> dict[str, Any]:
result = None
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)
result = 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",
)
else:
if not can_submit_action(user, "register_batch"):
raise HTTPException(403, "无权提交登记审核")
result = 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",
)
# 审计日志
from as_platform.audit.log_utils import log_op
log_op(user_id=user.id, user_name=user.name, category="data", action="register_batch",
target_type="batch", target_id=f"{body.project}/{body.task}/{body.batch}", summary=f"登记批次: {body.project}/{body.task}/{body.batch}{body.stage}")
return result
@app.post("/api/v1/data/organize")
@@ -498,6 +590,7 @@ 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,
mode: Annotated[str | None, Form()] = None,
file: UploadFile = File(...),
) -> dict[str, Any]:
if not file.filename:
@@ -506,6 +599,7 @@ async def api_upload_file(
candidate = create_uploaded_candidate(
project=project,
task=task,
mode=mode,
original_name=file.filename,
upload_size_bytes=0,
submitted_by_name=user.name if user else None,
@@ -525,9 +619,10 @@ async def api_upload_file(
@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),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
return {"items": list_data_candidates(limit=limit)}
return list_data_candidates(offset=offset, limit=limit)
@app.get("/api/v1/data/candidates/{candidate_id}")
@@ -538,18 +633,272 @@ def api_data_candidate(candidate_id: str, _user: Annotated[User, Depends(require
return item
class PromoteInboxBody(BaseModel):
batch: str | None = None
mode: str | None = None
@app.post("/api/v1/data/candidates/{candidate_id}/promote-inbox")
def api_promote_candidate_inbox(
candidate_id: str,
body: PromoteInboxBody,
_user: Annotated[User, Depends(require_any_permission("write:approval_submit", "write:approval_submit:register"))],
) -> dict[str, Any]:
try:
return promote_candidate_to_inbox(candidate_id, batch=body.batch, mode=body.mode)
except ValueError as e:
raise HTTPException(400, str(e)) from e
except FileNotFoundError as e:
raise HTTPException(404, str(e)) from e
@app.get("/api/v1/data/registry-tasks")
def api_registry_tasks(
_user: Annotated[User, Depends(require_permission("read:catalog"))],
project: str = Query("dms"),
) -> dict[str, Any]:
import yaml
from as_platform.data.core import load_wf, proj_root
wf = load_wf()
if project != "dms":
return {"project": project, "tasks": {}}
root = proj_root(wf, "dms")
reg_path = root / wf["projects"]["dms"]["registry"]
if not reg_path.is_file():
return {"project": project, "tasks": {}}
reg = yaml.safe_load(reg_path.read_text(encoding="utf-8"))
import sys
scripts = WORKSPACE / "datasets" / "dms" / "scripts"
if str(scripts) not in sys.path:
sys.path.insert(0, str(scripts))
from task_registry import task_defs_for_pending
return {"project": project, "tasks": task_defs_for_pending(reg)}
@app.get("/api/v1/data/scan-inbox")
def api_scan_inbox(
_user: Annotated[User, Depends(require_permission("read:catalog"))],
project: str = Query("dms"),
) -> dict[str, Any]:
"""扫描 inbox 目录,返回未登记的新批次。"""
from as_platform.data.core import get_pending_report, load_wf, proj_root
wf = load_wf()
root = proj_root(wf, project)
inbox = root / "inbox"
if not inbox.is_dir():
return {"project": project, "items": [], "inbox_path": str(inbox)}
report = get_pending_report()
registered = {b.get("batch", "") for b in report.get("batches", [])}
items: list[dict[str, Any]] = []
for task_dir in sorted(inbox.iterdir()):
if not task_dir.is_dir():
continue
for batch_dir in sorted(task_dir.iterdir()):
if not batch_dir.is_dir():
continue
batch_name = batch_dir.name
task_name = task_dir.name
if batch_name in registered:
continue # 已登记
# Count images
img_count = 0
lbl_count = 0
has_labels = False
for ext in ["*.jpg", "*.jpeg", "*.png", "*.bmp"]:
for _ in batch_dir.glob(ext):
img_count += 1
if (batch_dir / "labels").is_dir():
has_labels = True
for ext in ["*.txt", "*.json"]:
for _ in (batch_dir / "labels").glob(ext):
lbl_count += 1
items.append({
"project": project,
"task": task_name,
"batch": batch_name,
"path": str(batch_dir.relative_to(root)),
"images": img_count,
"labels": lbl_count,
"has_labels": has_labels,
"stage_hint": "returned" if has_labels and lbl_count > 0 else "raw_pool",
})
return {"project": project, "items": items, "inbox_path": str(inbox)}
@app.get("/api/v1/dashboard")
def api_dashboard(_user: Annotated[User, Depends(get_current_user)]) -> dict[str, Any]:
"""首页仪表盘聚合数据。"""
from as_platform.data.core import get_pending_report, get_catalog
from as_platform.jobs.queue import list_jobs
# 批次统计
report = get_pending_report()
batches = report.get("batches", []) or []
stage_counts: dict[str, int] = {"raw_pool": 0, "out_for_labeling": 0, "labeling_submitted": 0, "returned": 0, "ingested": 0}
for b in batches:
s = b.get("stage", "raw_pool") if isinstance(b, dict) else "raw_pool"
stage_counts[s] = stage_counts.get(s, 0) + 1
# 审核统计
approvals_pending = list_approvals(status="pending", limit=200)
pending_approvals = approvals_pending.get("total", 0)
# Job 统计
jobs_data = list_jobs(limit=5)
jobs = jobs_data.get("items", []) or []
running_jobs = len([j for j in jobs if isinstance(j, dict) and j.get("status") == "running"])
# 模型统计
try:
from as_platform.training.service import get_model_registry
models = get_model_registry(project="dms")
model_count = len((models.get("models") or []) if isinstance(models, dict) else [])
except Exception:
model_count = 0
# 训练记录
try:
from as_platform.training.service import list_training_records
records = list_training_records(limit=5)
recent_records = (records.get("items") or [])[:5] if isinstance(records, dict) else []
except Exception:
recent_records = []
# 车队
try:
from as_platform.fleet import service as fleet_svc
from as_platform.db.engine import session_scope
with session_scope() as db:
summary = fleet_svc.get_summary(db) if hasattr(fleet_svc, "get_summary") else {}
except Exception:
summary = {}
# 最近活动
activity: list[dict[str, Any]] = []
for j in jobs[:5]:
if isinstance(j, dict):
activity.append({"type": "job", "id": j.get("id"), "action": j.get("action"), "status": j.get("status"), "time": j.get("created_at")})
for a in (approvals_pending.get("items") or [])[:3]:
if isinstance(a, dict):
activity.append({"type": "approval", "id": a.get("id"), "action": a.get("action_label") or a.get("action"), "status": a.get("status"), "time": a.get("submitted_at")})
activity.sort(key=lambda x: str(x.get("time") or ""), reverse=True)
return {
"stages": stage_counts,
"total_batches": len(batches),
"pending_approvals": pending_approvals,
"running_jobs": running_jobs,
"model_count": model_count,
"fleet": summary,
"activity": activity[:8],
"recent_training": [{"id": r.get("id"), "action": r.get("action"), "status": r.get("status"), "created_at": r.get("created_at")} for r in recent_records if isinstance(r, dict)],
}
# ── 世界模型仿真 ──
class SimulateBody(BaseModel):
scene: str = "urban_highway"
camera: str = "truck_front"
weather: str = "clear"
objects: list[str] = Field(default_factory=lambda: ["Pedestrain", "Car", "Truck", "Bus"])
density: str = "medium"
count: int = 100
fov_variant: bool = False
note: str = ""
@app.get("/api/v1/simulate/jobs")
def api_simulate_jobs(
_user: Annotated[User, Depends(get_current_user)],
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
from as_platform.data.simulate import list_jobs
return list_jobs(offset=offset, limit=limit)
@app.post("/api/v1/simulate/generate")
def api_simulate_generate(
body: SimulateBody,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
from as_platform.data.simulate import submit_job
return submit_job(body.model_dump(), user_name=user.name)
@app.get("/api/v1/simulate/jobs/{job_id}")
def api_simulate_job(
job_id: str,
_user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
from as_platform.data.simulate import get_job
job = get_job(job_id)
if not job:
raise HTTPException(404, "Job 不存在")
return job
@app.get("/api/v1/simulate/jobs/{job_id}/images")
def api_simulate_job_images(
job_id: str,
_user: Annotated[User, Depends(get_current_user)],
offset: int = Query(0, ge=0),
limit: int = Query(60, ge=1, le=200),
) -> dict[str, Any]:
from as_platform.data.simulate import get_job_images
return get_job_images(job_id, offset=offset, limit=limit)
@app.post("/api/v1/simulate/jobs/{job_id}/ingest")
def api_simulate_ingest(
job_id: str,
user: Annotated[User, Depends(get_current_user)],
task: str = Query("adas"),
) -> dict[str, Any]:
from as_platform.data.simulate import ingest_job_to_batch
return ingest_job_to_batch(job_id, task=task, user_name=user.name)
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
ui = PLATFORM_WEB
if (ui / "index.html").is_file():
_UI_DIR = ui
assets = ui / "assets"
if assets.is_dir():
app.mount("/assets", StaticFiles(directory=str(assets)), name="ui-assets")
annotate = ui / "annotate"
if annotate.is_dir():
app.mount("/annotate", StaticFiles(directory=str(annotate), html=True), name="ui-annotate")
return
_mount_ui()
@app.get("/labeling/campaigns/{campaign_id}/annotate", include_in_schema=False)
def serve_annotate_app(campaign_id: str):
"""标注编辑器页面 — 返回旧 Label Studio 构建产物"""
if not _UI_DIR:
raise HTTPException(404, "UI not built")
annotate_index = _UI_DIR / "annotate" / "index.html"
if annotate_index.is_file():
return FileResponse(annotate_index)
raise HTTPException(404, "标注编辑器未构建")
@app.get("/{full_path:path}", include_in_schema=False)
def spa_fallback(full_path: str):
if full_path.startswith("api/"):

View File

@@ -0,0 +1,381 @@
"""系统管理 API 路由 — 模块化前缀 /api/v1/system
本路由将 audit/jobs/traces/agents 功能以模块化路径重新暴露。
"""
from __future__ import annotations
from typing import Annotated, Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from as_platform.agents.tools import TOOL_REGISTRY, invoke_tool
from as_platform.agents.trace import get_trace, list_traces
from as_platform.audit.queue import (
ACTION_LABELS,
ACTIONS_REQUIRING_APPROVAL,
REJECTION_CATEGORIES,
approve_and_execute,
batch_approve,
batch_reject,
get_approval,
list_approvals,
reject_approval,
submit_approval,
)
from as_platform.audit.preview import find_image_ref, list_scope_images, resolve_approval_scope
from as_platform.auth.deps import can_submit_action, get_current_user, require_any_permission, require_permission
from as_platform.auth.feishu import sync_feishu_users_to_db
from as_platform.auth.users import list_users, list_users_paginated, set_user_roles
from as_platform.db.engine import get_db
from as_platform.db.init_db import user_to_dict
from as_platform.db.models import User
from as_platform.jobs.queue import enqueue_job, get_job, list_jobs
router = APIRouter(prefix="/api/v1/system", tags=["system"])
# ── Audit / Approvals ──
@router.get("/audit")
def api_system_approvals(
_user: Annotated[User, Depends(require_permission("read:audit"))],
status: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
return list_approvals(status=status, offset=offset, limit=limit)
@router.get("/audit/actions")
def api_system_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)]}
class SubmitApprovalBody(BaseModel):
action: str
params: dict[str, Any] = Field(default_factory=dict)
note: str | None = None
class ReviewBody(BaseModel):
comment: str | None = None
class RejectBody(BaseModel):
comment: str | None = None
rejection_category: str = ""
class BatchReviewBody(BaseModel):
ids: list[str] = Field(default_factory=list)
comment: str | None = None
rejection_category: str = ""
@router.post("/audit/submit")
def api_system_submit_approval(
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
@router.get("/audit/{record_id}")
def api_system_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
@router.get("/audit/{record_id}/preview")
def api_system_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
return {
"approval": rec,
"scope_label": scope.get("scope_label"),
"task": scope.get("task"),
"pack": scope.get("pack"),
"class_names": scope.get("class_names"),
}
@router.get("/audit/{record_id}/images")
def api_system_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
@router.post("/audit/{record_id}/approve")
def api_system_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
@router.get("/audit/rejection-categories")
def api_rejection_categories(
_user: Annotated[User, Depends(require_permission("read:audit"))],
) -> dict[str, Any]:
return {"categories": [{"key": k, "label": v} for k, v in REJECTION_CATEGORIES.items()]}
@router.post("/audit/batch-approve")
def api_system_batch_approve(
body: BatchReviewBody,
user: Annotated[User, Depends(require_permission("write:approval_review"))],
) -> dict[str, Any]:
try:
return batch_approve(body.ids, reviewed_by=user.name, reviewed_by_user_id=user.id)
except Exception as e:
raise HTTPException(400, str(e)) from e
@router.post("/audit/batch-reject")
def api_system_batch_reject(
body: BatchReviewBody,
user: Annotated[User, Depends(require_permission("write:approval_review"))],
) -> dict[str, Any]:
try:
return batch_reject(body.ids, reviewed_by=user.name, reviewed_by_user_id=user.id,
comment=body.comment, rejection_category=body.rejection_category)
except Exception as e:
raise HTTPException(400, str(e)) from e
@router.post("/audit/{record_id}/reject")
def api_system_reject(
record_id: str,
body: RejectBody,
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,
rejection_category=body.rejection_category,
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
# ── Jobs ──
@router.get("/jobs")
def api_system_jobs(
_user: Annotated[User, Depends(require_permission("read:jobs"))],
status: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
return list_jobs(status=status, offset=offset, limit=limit)
@router.get("/jobs/{job_id}")
def api_system_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
# ── Traces / Execution Logs ──
@router.get("/traces")
def api_system_traces(
_user: Annotated[User, Depends(get_current_user)],
limit: int = 50,
) -> dict[str, Any]:
return {"trace_ids": list_traces(limit=limit)}
@router.get("/traces/{trace_id}")
def api_system_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}
# ── Agent Tools ──
@router.get("/agents/tools")
def api_system_tools(
_user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
return {"tools": list(TOOL_REGISTRY.keys())}
class ToolInvokeBody(BaseModel):
tool: str
params: dict[str, Any] = Field(default_factory=dict)
@router.post("/agents/tools/invoke")
def api_system_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
# ── User Management ──
class SetRolesBody(BaseModel):
roles: list[str] = Field(default_factory=list)
@router.get("/users")
def api_system_users(
_user: Annotated[User, Depends(require_permission("admin:users"))],
db: Annotated[Session, Depends(get_db)],
search: str = Query(""),
role: str = Query(""),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
users, total = list_users_paginated(db, search=search, role_code=role, offset=offset, limit=limit)
return {"items": [user_to_dict(u) for u in users], "total": total}
@router.put("/users/{user_id}/roles")
def api_system_set_roles(
user_id: int,
body: SetRolesBody,
_user: Annotated[User, Depends(require_permission("admin:users"))],
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
user = set_user_roles(db, user_id, body.roles)
if not user:
raise HTTPException(404, "用户不存在")
return {"ok": True, "user": user_to_dict(user)}
@router.post("/feishu/sync-users")
def api_sync_feishu_users(
_user: Annotated[User, Depends(require_permission("admin:users"))],
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
try:
result = sync_feishu_users_to_db(db)
db.commit()
# 审计日志
from as_platform.audit.log_utils import log_op
log_op(user_id=_user.id, user_name=_user.name, category="system", action="sync_feishu_users",
target_type="user", summary=f"飞书用户同步: 新增{result.get('created',0)} 更新{result.get('updated',0)}")
return {"ok": True, **result}
except Exception as e:
raise HTTPException(500, f"飞书用户同步失败: {e}") from e
# ── Audit Log ──
@router.get("/audit-log")
def api_audit_log(
_user: Annotated[User, Depends(require_permission("admin:users"))],
db: Annotated[Session, Depends(get_db)],
user_id: int | None = None,
category: str | None = None,
action: str | None = None,
search: str = Query(""),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=200),
) -> dict[str, Any]:
from as_platform.db.models import OperationLog
q = db.query(OperationLog)
if user_id:
q = q.filter(OperationLog.user_id == user_id)
if category:
q = q.filter(OperationLog.category == category)
if action:
q = q.filter(OperationLog.action == action)
if search:
like = f"%{search}%"
from sqlalchemy import or_
q = q.filter(or_(OperationLog.summary.ilike(like), OperationLog.user_name.ilike(like)))
total = q.count()
items = q.order_by(OperationLog.timestamp.desc()).offset(offset).limit(limit).all()
return {"items": [log.to_dict() for log in items], "total": total}
@router.get("/audit-log/stats")
def api_audit_log_stats(
_user: Annotated[User, Depends(require_permission("admin:users"))],
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
from as_platform.db.models import OperationLog
from sqlalchemy import func
from datetime import datetime, timezone, timedelta
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
today_count = db.query(OperationLog).filter(OperationLog.timestamp >= today).count()
top_users = (
db.query(OperationLog.user_name, func.count().label("cnt"))
.filter(OperationLog.timestamp >= today)
.group_by(OperationLog.user_name).order_by(func.count().desc()).limit(5).all()
)
by_category = (
db.query(OperationLog.category, func.count().label("cnt"))
.filter(OperationLog.timestamp >= today)
.group_by(OperationLog.category).all()
)
return {
"today_count": today_count,
"top_users": [{"name": name, "count": cnt} for name, cnt in top_users],
"by_category": [{"category": cat, "count": cnt} for cat, cnt in by_category],
}