Sprint 6: report review workflow, CI/K8s, and client tabs.
CI / api-test (push) Has been cancelled
CI / web-build (push) Has been cancelled

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:
john
2026-07-23 11:44:11 +08:00
parent 1aaef71f52
commit a400130c67
27 changed files with 636 additions and 27 deletions
+13
View File
@@ -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 = ("头前伸", "高低肩", "骨盆倾斜")
+11 -7
View File
@@ -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
View File
@@ -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()
+5 -5
View File
@@ -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",
}
+49 -1
View File
@@ -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)
+5
View File
@@ -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
+4 -2
View File
@@ -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()
+82
View File
@@ -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
+1
View File
@@ -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,
}