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

@@ -0,0 +1,63 @@
"""操作审计日志工具。异步写入,不阻塞主流程。"""
from __future__ import annotations
import json
import threading
from datetime import datetime, timezone
from typing import Any
from as_platform.db.models import OperationLog
def log_op(
*,
user_id: int | None = None,
user_name: str | None = None,
category: str,
action: str,
target_type: str | None = None,
target_id: str | None = None,
summary: str = "",
detail: dict[str, Any] | None = None,
ip_address: str | None = None,
) -> None:
"""异步记录操作日志。"""
threading.Thread(
target=_write_log,
args=(user_id, user_name, category, action, target_type, target_id, summary, detail, ip_address),
daemon=True,
name=f"audit-log-{action}",
).start()
def _write_log(
user_id: int | None,
user_name: str | None,
category: str,
action: str,
target_type: str | None,
target_id: str | None,
summary: str,
detail: dict[str, Any] | None,
ip_address: str | None,
) -> None:
try:
from as_platform.db.engine import session_scope
with session_scope() as db:
log = OperationLog(
timestamp=datetime.now(timezone.utc),
user_id=user_id,
user_name=user_name,
category=category,
action=action,
target_type=target_type,
target_id=str(target_id)[:128] if target_id else None,
summary=summary[:512] if summary else None,
detail_json=json.dumps(detail, ensure_ascii=False) if detail else None,
ip_address=ip_address,
)
db.add(log)
db.commit()
except Exception:
pass # 日志写入失败不影响业务

View File

@@ -264,6 +264,33 @@ def resolve_approval_scope(action: str, params: dict[str, Any]) -> dict[str, Any
"batches": batches,
}
if action == "delivery_ingest":
data_path = (p.get("data_path") or "").strip()
if not data_path:
raise ValueError("缺少 data_path 参数")
src = Path(data_path)
project = p.get("project") or "dms"
task = p.get("task") or ""
batch_name = p.get("batch_name") or src.name
scope_label = f"数据送标入湖 · {project}"
if task:
scope_label += f" · {task}"
scope_label += f" · {batch_name}"
return {
"project": project,
"task": task or None,
"pack": None,
"scope_label": scope_label,
"class_names": {},
"batches": [
{
"path": src,
"batch": batch_name,
"location": "delivery",
}
],
}
if action in ("train_dms", "promote_dms", "eval_dms"):
task = p.get("task")
if not task:

View File

@@ -8,13 +8,27 @@ from typing import Any
from as_platform.db.engine import session_scope
from as_platform.db.models import Approval, User
from as_platform.config import LANE_DATA_VIZ_ENABLED
from as_platform.integrations.feishu_notify import send_chat_async
ACTIONS_REQUIRING_APPROVAL = {
"build_dms", "build_lane", "enable_pack", "disable_pack",
"train_dms", "train_lane", "eval_dms", "promote_dms",
"pipeline_dms", "register_batch", "eval_lane", "visualize_dms", "visualize_lane",
"delivery_ingest",
}
REJECTION_CATEGORIES = {
"data_quality": "数据质量问题",
"wrong_params": "参数配置有误",
"duplicate": "重复提交",
"not_needed": "无需此操作",
"permission": "权限不足",
"other": "其他原因",
}
REJECTION_CATEGORY_LABEL = {k: v for k, v in REJECTION_CATEGORIES.items()}
ACTION_LABELS = {
"build_dms": "DMS 入库 (build)",
"build_lane": "车道线合并列表 (build lane)",
@@ -29,6 +43,7 @@ ACTION_LABELS = {
"promote_dms": "DMS 模型晋级",
"pipeline_dms": "DMS 半自动流水线",
"register_batch": "登记批次元数据",
"delivery_ingest": "数据送标入湖",
}
@@ -77,15 +92,36 @@ def submit_approval(
if auto_execute:
return approve_and_execute(out["id"], reviewed_by="system", comment="auto_execute")
# 飞书通知 + 审计日志
label = ACTION_LABELS.get(action, action)
send_chat_async(f"📋 新审核提交\n{label}\n提单人: {submitted_by or ''}\n备注: {note or ''}")
from as_platform.audit.log_utils import log_op
log_op(user_id=submitted_by_user_id, user_name=submitted_by or "", category="audit", action="submit_approval",
target_type="approval", target_id=out["id"], summary=f"提交审核: {label}",
detail={"action": action, "params": params, "note": note})
return out
def list_approvals(status: str | None = None, limit: int = 100) -> list[dict[str, Any]]:
def list_approvals(
status: str | None = None,
*,
offset: int = 0,
limit: int = 20,
) -> dict[str, Any]:
with session_scope() as db:
q = db.query(Approval).order_by(Approval.submitted_at.desc())
if status:
q = q.filter(Approval.status == status)
return [a.to_dict() for a in q.limit(limit).all()]
total = q.count()
rows = q.offset(max(0, offset)).limit(max(1, limit)).all()
return {
"items": [a.to_dict() for a in rows],
"total": total,
"offset": offset,
"limit": limit,
}
def get_approval(record_id: str) -> dict[str, Any] | None:
@@ -137,6 +173,16 @@ def approve_and_execute(
job = enqueue_job(rec["action"], rec.get("params") or {}, approval_id=record_id, async_run=True)
_update(record_id, job_id=job.get("id"), status="running")
# 飞书通知 + 审计日志
label = ACTION_LABELS.get(rec["action"], rec["action"])
submitter = rec.get("submitted_by") or rec.get("submitted_by_name") or ""
send_chat_async(f"✅ 审核通过\n{label}\n审核人: {reviewed_by or ''}\n提单人: {submitter}")
from as_platform.audit.log_utils import log_op
log_op(user_id=reviewed_by_user_id, user_name=reviewed_by or "", category="audit", action="approve",
target_type="approval", target_id=record_id, summary=f"审核通过: {label}",
detail={"comment": comment})
return get_approval(record_id) or {}
@@ -146,17 +192,75 @@ def reject_approval(
reviewed_by: str | None = None,
reviewed_by_user_id: int | None = None,
comment: str | None = None,
rejection_category: str = "",
) -> dict[str, Any]:
rec = get_approval(record_id)
if not rec:
raise ValueError(f"审核单不存在: {record_id}")
if rec.get("status") != "pending":
raise ValueError(f"当前状态不可驳回: {rec.get('status')}")
return _update(
out = _update(
record_id,
status="rejected",
reviewed_by_name=reviewed_by,
reviewed_by_user_id=reviewed_by_user_id,
reviewed_at=_now(),
review_comment=comment,
rejection_category=rejection_category or "",
) or {}
# 飞书通知:审核驳回
label = ACTION_LABELS.get(rec["action"], rec["action"])
cat = REJECTION_CATEGORIES.get(rejection_category, rejection_category) if rejection_category else ""
submitter = rec.get("submitted_by") or rec.get("submitted_by_name") or ""
reason_text = f"\n原因: {cat}" if cat else ""
send_chat_async(f"❌ 审核驳回\n{label}\n审核人: {reviewed_by or ''}\n提单人: {submitter}{reason_text}\n意见: {comment or ''}")
from as_platform.audit.log_utils import log_op
log_op(user_id=reviewed_by_user_id, user_name=reviewed_by or "", category="audit", action="reject",
target_type="approval", target_id=record_id, summary=f"审核驳回: {label}",
detail={"comment": comment, "rejection_category": rejection_category})
try:
from as_platform.deliveries.service import mark_delivery_rejected_by_approval
mark_delivery_rejected_by_approval(record_id)
except Exception:
pass
return out
def batch_approve(
record_ids: list[str],
*,
reviewed_by: str | None = None,
reviewed_by_user_id: int | None = None,
) -> dict[str, Any]:
"""批量通过审核。返回 {approved, failed, errors}。"""
approved: list[str] = []
errors: list[dict[str, str]] = []
for rid in record_ids:
try:
approve_and_execute(rid, reviewed_by=reviewed_by, reviewed_by_user_id=reviewed_by_user_id)
approved.append(rid)
except Exception as e:
errors.append({"id": rid, "error": str(e)})
return {"approved": len(approved), "failed": len(errors), "errors": errors}
def batch_reject(
record_ids: list[str],
*,
reviewed_by: str | None = None,
reviewed_by_user_id: int | None = None,
comment: str | None = None,
rejection_category: str = "",
) -> dict[str, Any]:
"""批量驳回审核。"""
rejected: list[str] = []
errors: list[dict[str, str]] = []
for rid in record_ids:
try:
reject_approval(rid, reviewed_by=reviewed_by, reviewed_by_user_id=reviewed_by_user_id,
comment=comment, rejection_category=rejection_category)
rejected.append(rid)
except Exception as e:
errors.append({"id": rid, "error": str(e)})
return {"rejected": len(rejected), "failed": len(errors), "errors": errors}

View File

@@ -0,0 +1,286 @@
"""标注质检 — 逐张审核标注质量Good/Fine/Bad 评分 + PIL 优化渲染)。"""
from __future__ import annotations
import io
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from PIL import Image, ImageDraw, ImageFont
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
from as_platform.data.batch import IMG_EXTS
from as_platform.db.engine import session_scope
from as_platform.db.models import Base
IMAGE_EXTS = tuple(ext.lower() for ext in IMG_EXTS)
# ── PIL font cache ──
_font_cache: dict[int, ImageFont.FreeTypeFont | ImageFont.ImageFont] = {}
def _get_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
if size not in _font_cache:
try:
_font_cache[size] = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", size)
except Exception:
try:
_font_cache[size] = ImageFont.truetype("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", size)
except Exception:
_font_cache[size] = ImageFont.load_default()
return _font_cache[size]
# ── YOLO bbox utils ──
def _parse_yolo_line(line: str) -> dict[str, Any] | None:
parts = line.strip().split()
if len(parts) < 5:
return None
try:
return {"class_id": int(float(parts[0])), "bbox": tuple(map(float, parts[1:5]))}
except Exception:
return None
def _bbox_to_xyxy(bbox: tuple[float, ...], w: int, h: int) -> tuple[int, int, int, int]:
cx, cy, bw, bh = bbox[:4]
x1 = int((cx - bw / 2) * w)
y1 = int((cy - bh / 2) * h)
x2 = int((cx + bw / 2) * w)
y2 = int((cy + bh / 2) * h)
return max(0, x1), max(0, y1), min(w, x2), min(h, y2)
def _parse_labels(label_path: Path) -> list[dict[str, Any]]:
if not label_path or not label_path.is_file():
return []
results = []
for line in label_path.read_text().strip().splitlines():
ann = _parse_yolo_line(line)
if ann:
results.append(ann)
return results
# ── Optimized overlay render ──
PALETTE = [(220, 20, 60), (30, 144, 255), (50, 205, 50), (255, 165, 0), (186, 85, 211), (0, 206, 209)]
def render_review_overlay(
image_path: Path,
label_path: Path | None,
class_names: dict[int, str],
*,
max_size: int = 800,
quality: int = 85,
) -> bytes:
"""PIL optimized: single pass resize + draw, no copy. Returns JPEG bytes."""
with Image.open(image_path) as im:
if im.mode != "RGB":
im = im.convert("RGB")
# Resize first for faster drawing
if max_size and max(im.size) > max_size:
im.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
w, h = im.size
draw = ImageDraw.Draw(im)
font = _get_font(max(12, min(16, w // 50)))
line_w = max(1, w // 400)
anns = _parse_labels(label_path) if label_path else []
for ann in anns:
cid = ann["class_id"]
color = PALETTE[cid % len(PALETTE)]
x1, y1, x2, y2 = _bbox_to_xyxy(ann["bbox"], w, h)
draw.rectangle((x1, y1, x2, y2), outline=color, width=line_w)
label = class_names.get(cid, f"cls_{cid}")
draw.text((x1 + 2, max(0, y1 - 16)), label, fill=color, font=font)
buf = io.BytesIO()
im.save(buf, format="JPEG", quality=quality)
return buf.getvalue()
# ── Quality Review Model ──
class LabelingReview(Base):
__tablename__ = "labeling_reviews"
id = Column(Integer, primary_key=True, autoincrement=True)
campaign_id = Column(String(64), nullable=False, index=True)
image_path = Column(String(512), nullable=False)
score = Column(String(16), nullable=False, default="pending") # good / fine / bad
reviewer_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
reviewer_name = Column(String(128), nullable=True)
comment = Column(Text, nullable=True)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
def to_dict(self) -> dict:
return {
"id": self.id,
"campaign_id": self.campaign_id,
"image_path": self.image_path,
"score": self.score,
"reviewer_user_id": self.reviewer_user_id,
"reviewer_name": self.reviewer_name,
"comment": self.comment,
"reviewed_at": self.reviewed_at.isoformat() if self.reviewed_at else None,
}
# ── Review operations ──
def get_review_queue(campaign_id: str, offset: int = 0, limit: int = 20) -> dict[str, Any]:
from as_platform.labeling.annotate import resolve_campaign_batch_dir
from as_platform.db.engine import session_scope
from as_platform.db.models import LabelingCampaign
with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id)
if not camp:
return {"items": [], "total": 0, "hint": "Campaign 不存在"}
batch_dir = resolve_campaign_batch_dir(camp)
if not batch_dir or not batch_dir.is_dir():
return {"items": [], "total": 0, "hint": "批次目录不存在"}
img_dir = batch_dir / "images"
if not img_dir.is_dir():
return {"items": [], "total": 0, "hint": "无 images 目录"}
all_images: list[Path] = []
for ext in IMAGE_EXTS:
all_images.extend(sorted(img_dir.rglob(f"*{ext}")))
# Get existing reviews
with session_scope() as db:
reviewed = {
r.image_path: r.score
for r in db.query(LabelingReview).filter(LabelingReview.campaign_id == campaign_id).all()
}
total = len(all_images)
page = all_images[offset:offset + limit]
score_counts = {"good": 0, "fine": 0, "bad": 0, "pending": 0}
items = []
for img in page:
rel = str(img.relative_to(batch_dir))
score = reviewed.get(rel, "pending")
score_counts[score] += 1
label_path = batch_dir / "labels" / (img.stem + ".txt")
items.append({
"id": rel, "image_path": rel,
"fileName": img.name,
"score": score,
"has_label": label_path.is_file(),
})
# Fill remaining counts
for img in all_images:
rel = str(img.relative_to(batch_dir))
s = reviewed.get(rel, "pending")
if s not in score_counts:
score_counts[s] = 0
return {
"items": items, "total": total,
"offset": offset, "limit": limit,
"scores": score_counts,
}
def get_review_image(campaign_id: str, image_rel_path: str, class_names: dict[int, str]) -> bytes:
from as_platform.labeling.annotate import resolve_campaign_batch_dir
from as_platform.db.engine import session_scope
from as_platform.db.models import LabelingCampaign
with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id)
if not camp:
raise FileNotFoundError("Campaign 不存在")
batch_dir = resolve_campaign_batch_dir(camp)
if not batch_dir:
raise FileNotFoundError("批次不存在")
img_path = batch_dir / image_rel_path
lbl_path = batch_dir / "labels" / (img_path.stem + ".txt")
return render_review_overlay(img_path, lbl_path if lbl_path.is_file() else None, class_names)
def submit_review_scores(
campaign_id: str,
scores: list[dict[str, str]],
reviewer_user_id: int | None = None,
reviewer_name: str | None = None,
) -> dict[str, Any]:
now = datetime.now(timezone.utc)
updated = 0
with session_scope() as db:
for item in scores:
img_path = item["image_path"]
score = item["score"]
rec = db.query(LabelingReview).filter(
LabelingReview.campaign_id == campaign_id,
LabelingReview.image_path == img_path,
).first()
if rec:
rec.score = score
rec.reviewer_user_id = reviewer_user_id
rec.reviewer_name = reviewer_name
rec.reviewed_at = now
rec.comment = item.get("comment")
else:
db.add(LabelingReview(
campaign_id=campaign_id, image_path=img_path, score=score,
reviewer_user_id=reviewer_user_id, reviewer_name=reviewer_name,
reviewed_at=now, comment=item.get("comment"),
))
updated += 1
db.commit()
# Check if all images are reviewed and auto-advance stage
counts = _review_db_counts(db, campaign_id)
from as_platform.labeling.annotate import resolve_campaign_batch_dir
from as_platform.data.batch import IMG_EXTS
from as_platform.db.engine import session_scope as _scope
from as_platform.db.models import LabelingCampaign as _LC
with _scope() as _db:
_camp = _db.get(_LC, campaign_id)
batch_dir = resolve_campaign_batch_dir(_camp) if _camp else None
total_images = 0
if batch_dir and (batch_dir / "images").is_dir():
for ext in IMG_EXTS:
total_images += len(list((batch_dir / "images").rglob(f"*{ext}")))
reviewed = sum(counts.values())
if reviewed >= total_images and total_images > 0:
pass_rate = counts.get("good", 0) / max(total_images, 1)
new_stage = "review_approved" if pass_rate >= 0.8 else "review_rejected"
_update_campaign_stage(db, campaign_id, new_stage)
return {"ok": True, "updated": updated, "auto_advanced": reviewed >= total_images if total_images > 0 else False}
def _review_db_counts(db, campaign_id: str) -> dict[str, int]:
from sqlalchemy import func
from collections import Counter
rows = db.query(LabelingReview.score, func.count()).filter(
LabelingReview.campaign_id == campaign_id
).group_by(LabelingReview.score).all()
return {score: cnt for score, cnt in rows}
def _update_campaign_stage(db, campaign_id: str, new_stage: str) -> None:
from as_platform.db.models import LabelingCampaign
from as_platform.labeling.batch_stage import update_campaign_batch_meta_stage
camp = db.get(LabelingCampaign, campaign_id)
if camp:
camp.status = new_stage
db.flush()
update_campaign_batch_meta_stage(camp, new_stage)
def review_progress(campaign_id: str) -> dict[str, int]:
with session_scope() as db:
rows = db.query(LabelingReview).filter(LabelingReview.campaign_id == campaign_id).all()
counts = {"good": 0, "fine": 0, "bad": 0, "pending": 0}
for r in rows:
counts[r.score] = counts.get(r.score, 0) + 1
return counts