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:
63
platform/as_platform/audit/log_utils.py
Normal file
63
platform/as_platform/audit/log_utils.py
Normal 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 # 日志写入失败不影响业务
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
286
platform/as_platform/audit/review.py
Normal file
286
platform/as_platform/audit/review.py
Normal 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
|
||||
Reference in New Issue
Block a user