Sprint 6: report review workflow, CI/K8s, and client tabs.
Add coach review APIs, pose calibration thresholds, Gitea CI, Kubernetes skeleton, H5 practice page, and mini program tab bar. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""Configurable thresholds for pose metric calibration."""
|
||||
|
||||
CALIBRATION = {
|
||||
"head_forward_scale": 180.0,
|
||||
"shoulder_asymmetry_scale": 220.0,
|
||||
"pelvic_tilt_scale": 4.5,
|
||||
"normal_max": 35,
|
||||
"low_max": 55,
|
||||
"medium_max": 75,
|
||||
"review_required_min": 75,
|
||||
}
|
||||
|
||||
METRIC_NAMES = ("头前伸", "高低肩", "骨盆倾斜")
|
||||
@@ -15,7 +15,7 @@ RIGHT_HIP = 24
|
||||
Landmark = dict[str, float]
|
||||
FrameLandmarks = dict[int, Landmark]
|
||||
|
||||
METRIC_NAMES = ("头前伸", "高低肩", "骨盆倾斜")
|
||||
from app.ai.calibration import CALIBRATION, METRIC_NAMES
|
||||
|
||||
|
||||
def _point(frame: FrameLandmarks, idx: int) -> Landmark | None:
|
||||
@@ -43,7 +43,7 @@ def score_head_forward(frame: FrameLandmarks) -> float | None:
|
||||
return None
|
||||
mid_x = (left["x"] + right["x"]) / 2
|
||||
offset = abs(nose["x"] - mid_x) / width
|
||||
return min(100.0, max(0.0, offset * 180))
|
||||
return min(100.0, max(0.0, offset * CALIBRATION["head_forward_scale"]))
|
||||
|
||||
|
||||
def score_shoulder_asymmetry(frame: FrameLandmarks) -> float | None:
|
||||
@@ -53,7 +53,7 @@ def score_shoulder_asymmetry(frame: FrameLandmarks) -> float | None:
|
||||
if not left or not right or not width:
|
||||
return None
|
||||
diff = abs(left["y"] - right["y"]) / width
|
||||
return min(100.0, max(0.0, diff * 220))
|
||||
return min(100.0, max(0.0, diff * CALIBRATION["shoulder_asymmetry_scale"]))
|
||||
|
||||
|
||||
def score_pelvic_tilt(frame: FrameLandmarks) -> float | None:
|
||||
@@ -63,15 +63,15 @@ def score_pelvic_tilt(frame: FrameLandmarks) -> float | None:
|
||||
return None
|
||||
angle = abs(math.degrees(math.atan2(right["y"] - left["y"], right["x"] - left["x"])))
|
||||
tilt = min(angle, 180 - angle)
|
||||
return min(100.0, max(0.0, tilt * 4.5))
|
||||
return min(100.0, max(0.0, tilt * CALIBRATION["pelvic_tilt_scale"]))
|
||||
|
||||
|
||||
def value_to_level(value: float) -> str:
|
||||
if value < 35:
|
||||
if value < CALIBRATION["normal_max"]:
|
||||
return "normal"
|
||||
if value < 55:
|
||||
if value < CALIBRATION["low_max"]:
|
||||
return "low"
|
||||
if value < 75:
|
||||
if value < CALIBRATION["medium_max"]:
|
||||
return "medium"
|
||||
return "high"
|
||||
|
||||
@@ -111,12 +111,16 @@ def build_screening_report(frames: list[FrameLandmarks]) -> dict:
|
||||
|
||||
worst = max((m["value"] for m in metrics), default=0)
|
||||
risk_level = value_to_level(worst)
|
||||
if worst >= CALIBRATION["review_required_min"]:
|
||||
risk_level = "review_required"
|
||||
if risk_level == "normal":
|
||||
summary = "体态指标整体正常,建议保持日常活动与姿势习惯"
|
||||
elif risk_level == "low":
|
||||
summary = "存在轻度体态偏差,建议开始基础纠正训练"
|
||||
elif risk_level == "medium":
|
||||
summary = "建议关注头前伸与高低肩,开始针对性训练"
|
||||
elif risk_level == "review_required":
|
||||
summary = "指标偏高,已提交机构教练复核"
|
||||
else:
|
||||
summary = "多项指标偏高,建议尽快安排专业评估与干预"
|
||||
|
||||
|
||||
+18
-1
@@ -35,6 +35,20 @@ def seed() -> None:
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
coach_hash = hash_phone("18600000001")
|
||||
coach = db.scalar(select(User).where(User.phone_hash == coach_hash))
|
||||
if not coach:
|
||||
coach = User(
|
||||
tenant_id=tenant.id,
|
||||
phone="18600000001",
|
||||
phone_hash=coach_hash,
|
||||
role="coach",
|
||||
status="active",
|
||||
consent_signed=True,
|
||||
)
|
||||
db.add(coach)
|
||||
db.flush()
|
||||
|
||||
child = db.scalar(
|
||||
select(Child).where(Child.parent_user_id == user.id, Child.name == "小明")
|
||||
)
|
||||
@@ -80,7 +94,10 @@ def seed() -> None:
|
||||
child_row = db.scalar(
|
||||
select(Child).where(Child.parent_user_id == user.id, Child.name == "小明")
|
||||
)
|
||||
print(f"Seed OK · tenant={tenant.id} user={user.id} child={child_row.id if child_row else '-'}")
|
||||
print(
|
||||
f"Seed OK · tenant={tenant.id} user={user.id} coach={coach.id if coach else '-'} "
|
||||
f"child={child_row.id if child_row else '-'}"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ CONTRACTS_OPENAPI = Path(__file__).resolve().parents[3] / "contracts" / "openapi
|
||||
|
||||
app = FastAPI(
|
||||
title="Kids AI Posture Platform API",
|
||||
version="0.6.0",
|
||||
version="0.7.0",
|
||||
description=(
|
||||
"儿童 AI 体态管理平台 API · Sprint 5。"
|
||||
"MediaPipe 帧级评分 + PDF 导出 + OSS 生产配置 + H5/小程序对接。"
|
||||
"儿童 AI 体态管理平台 API · Sprint 6。"
|
||||
"报告复核工作流 + 模型标定 + CI/K8s 部署骨架。"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "env": settings.app_env, "stage": "sprint5"}
|
||||
return {"status": "ok", "env": settings.app_env, "stage": "sprint6"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@@ -56,5 +56,5 @@ def root():
|
||||
"name": settings.app_name,
|
||||
"docs": "/docs",
|
||||
"contract": str(CONTRACTS_OPENAPI),
|
||||
"stage": "sprint5-mediapipe-pdf-oss",
|
||||
"stage": "sprint6-review-ci-k8s",
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.response import ok
|
||||
from app.core.response import error, ok
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db
|
||||
from app.deps import require_staff
|
||||
from app.schemas.models import ReportReviewRequest
|
||||
from app.services import admin as admin_service
|
||||
from app.services import report_review as review_service
|
||||
from app.services.reports import report_to_dict
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["Admin"])
|
||||
|
||||
@@ -18,3 +22,47 @@ def admin_dashboard(
|
||||
):
|
||||
metrics = admin_service.get_dashboard_metrics(db, tenant_id=current_user.tenant_id)
|
||||
return ok(metrics, request_id=request.state.request_id)
|
||||
|
||||
|
||||
@router.get("/reports/pending")
|
||||
def pending_reports(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_staff),
|
||||
):
|
||||
items = review_service.list_pending_reports(db, tenant_id=current_user.tenant_id)
|
||||
return ok({"list": items}, request_id=request.state.request_id)
|
||||
|
||||
|
||||
@router.post("/reports/{report_id}/review")
|
||||
def review_report(
|
||||
report_id: int,
|
||||
body: ReportReviewRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_staff),
|
||||
):
|
||||
report, err = review_service.review_report(
|
||||
db,
|
||||
current_user.id,
|
||||
report_id,
|
||||
body.action,
|
||||
tenant_id=current_user.tenant_id,
|
||||
note=body.note,
|
||||
)
|
||||
if err == "report_not_found":
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10008, "report_not_found", request.state.request_id),
|
||||
)
|
||||
if err == "invalid_status":
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content=error(10012, "invalid_report_status", request.state.request_id),
|
||||
)
|
||||
if err == "invalid_action":
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=error(10013, "invalid_review_action", request.state.request_id),
|
||||
)
|
||||
return ok(report_to_dict(report), message="reviewed", request_id=request.state.request_id)
|
||||
|
||||
@@ -163,3 +163,8 @@ class ReportMetric(BaseModel):
|
||||
value: float
|
||||
level: str
|
||||
confidence: float
|
||||
|
||||
|
||||
class ReportReviewRequest(BaseModel):
|
||||
action: str = Field(description="approve or reject")
|
||||
note: str | None = None
|
||||
|
||||
@@ -56,16 +56,18 @@ def create_report_for_task(db: Session, task: AnalysisTask, analysis: dict) -> R
|
||||
)
|
||||
else:
|
||||
payload = {**MOCK_REPORT, **report_data, "childId": task.child_id, "taskId": task.id}
|
||||
risk_level = payload.get("riskLevel", "medium")
|
||||
report_status = "pending_review" if risk_level in ("high", "review_required") else "published"
|
||||
report = Report(
|
||||
child_id=task.child_id,
|
||||
task_id=task.id,
|
||||
report_type="posture_screening",
|
||||
risk_level=payload.get("riskLevel", "medium"),
|
||||
risk_level=risk_level,
|
||||
summary=payload.get("summary", MOCK_REPORT["summary"]),
|
||||
metrics=payload.get("metrics", MOCK_REPORT["metrics"]),
|
||||
recommendations=payload.get("recommendations", MOCK_REPORT["recommendations"]),
|
||||
disclaimer=payload.get("disclaimer", DISCLAIMER_SCREENING),
|
||||
status="published",
|
||||
status=report_status,
|
||||
)
|
||||
db.add(report)
|
||||
db.flush()
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Report review workflow for staff."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Child, Report
|
||||
|
||||
|
||||
def report_admin_dict(report: Report, child: Child | None = None) -> dict:
|
||||
return {
|
||||
"id": report.id,
|
||||
"childId": report.child_id,
|
||||
"childName": child.name if child else None,
|
||||
"taskId": report.task_id,
|
||||
"reportType": report.report_type,
|
||||
"riskLevel": report.risk_level,
|
||||
"summary": report.summary,
|
||||
"status": report.status,
|
||||
"reviewedBy": report.reviewed_by,
|
||||
"createdAt": report.created_at.isoformat() if report.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def list_pending_reports(db: Session, tenant_id: int | None = None) -> list[dict]:
|
||||
query = (
|
||||
select(Report, Child)
|
||||
.join(Child, Child.id == Report.child_id)
|
||||
.where(Report.status == "pending_review")
|
||||
.order_by(Report.id.desc())
|
||||
)
|
||||
if tenant_id:
|
||||
query = query.where(Child.tenant_id == tenant_id)
|
||||
|
||||
rows = db.execute(query).all()
|
||||
return [report_admin_dict(report, child) for report, child in rows]
|
||||
|
||||
|
||||
def get_report_for_review(db: Session, report_id: int, tenant_id: int | None = None) -> Report | None:
|
||||
report = db.get(Report, report_id)
|
||||
if not report:
|
||||
return None
|
||||
child = db.get(Child, report.child_id)
|
||||
if not child:
|
||||
return None
|
||||
if tenant_id and child.tenant_id != tenant_id:
|
||||
return None
|
||||
return report
|
||||
|
||||
|
||||
def review_report(
|
||||
db: Session,
|
||||
reviewer_id: int,
|
||||
report_id: int,
|
||||
action: str,
|
||||
tenant_id: int | None = None,
|
||||
note: str | None = None,
|
||||
) -> tuple[Report | None, str | None]:
|
||||
report = get_report_for_review(db, report_id, tenant_id)
|
||||
if not report:
|
||||
return None, "report_not_found"
|
||||
if report.status != "pending_review":
|
||||
return None, "invalid_status"
|
||||
|
||||
if action == "approve":
|
||||
report.status = "published"
|
||||
elif action == "reject":
|
||||
report.status = "rejected"
|
||||
else:
|
||||
return None, "invalid_action"
|
||||
|
||||
report.reviewed_by = reviewer_id
|
||||
report.updated_at = datetime.now(timezone.utc)
|
||||
if note:
|
||||
recommendations = list(report.recommendations or [])
|
||||
recommendations.append(f"复核备注:{note}")
|
||||
report.recommendations = recommendations
|
||||
|
||||
db.commit()
|
||||
db.refresh(report)
|
||||
return report, None
|
||||
@@ -37,6 +37,7 @@ def report_to_dict(report: Report) -> dict:
|
||||
"metrics": report.metrics,
|
||||
"recommendations": report.recommendations,
|
||||
"disclaimer": report.disclaimer,
|
||||
"status": report.status,
|
||||
"reviewedBy": report.reviewed_by,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user