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,
}
+33
View File
@@ -44,3 +44,36 @@ def auth_headers(client):
assert resp.status_code == 200, resp.text
token = resp.json()["data"]["token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture()
def staff_headers(client, db_session):
from sqlalchemy import select
from app.core.security import hash_phone
from app.db.models import User
resp = client.post(
"/api/auth/login",
json={"loginType": "phone_code", "credential": "18600000001", "code": "682139"},
)
assert resp.status_code == 200, resp.text
token = resp.json()["data"]["token"]
user = db_session.scalar(select(User).where(User.phone_hash == hash_phone("18600000001")))
if user:
user.role = "coach"
db_session.commit()
return {"Authorization": f"Bearer {token}"}
@pytest.fixture()
def db_session(client):
gen = app.dependency_overrides[get_db]()
db = next(gen)
try:
yield db
finally:
try:
next(gen)
except StopIteration:
pass
+1 -1
View File
@@ -4,7 +4,7 @@ from fastapi.testclient import TestClient
def test_health(client):
resp = client.get("/health")
assert resp.status_code == 200
assert resp.json()["stage"] == "sprint5"
assert resp.json()["stage"] == "sprint6"
def test_login_invalid_code(client):
+63
View File
@@ -0,0 +1,63 @@
def test_review_required_risk_level():
from app.ai.pose_metrics import LEFT_HIP, LEFT_SHOULDER, NOSE, RIGHT_HIP, RIGHT_SHOULDER, build_screening_report
bad_frame = {
NOSE: {"x": 0.85, "y": 0.28, "visibility": 0.95},
LEFT_SHOULDER: {"x": 0.42, "y": 0.35, "visibility": 0.95},
RIGHT_SHOULDER: {"x": 0.58, "y": 0.48, "visibility": 0.95},
LEFT_HIP: {"x": 0.44, "y": 0.62, "visibility": 0.95},
RIGHT_HIP: {"x": 0.56, "y": 0.50, "visibility": 0.95},
}
report = build_screening_report([bad_frame] * 6)
assert report["riskLevel"] == "review_required"
def test_admin_report_review_flow(client, auth_headers, staff_headers, db_session):
from app.db.models import Report
child_id = client.post(
"/api/children",
headers=auth_headers,
json={"name": "复核测试", "birthday": "2016-06-01", "gender": "female"},
).json()["data"]["id"]
object_key = f"videos/{child_id}/review.mp4"
video_id = client.post(
"/api/videos",
headers=auth_headers,
json={"childId": child_id, "objectKey": object_key, "scene": "front_posture"},
).json()["data"]["id"]
task = client.post(
"/api/analysis/tasks",
headers={**auth_headers, "Idempotency-Key": "sprint6-review"},
json={"childId": child_id, "videoId": video_id, "taskType": "posture_screening"},
).json()["data"]
report_id = task["reportId"]
assert report_id
report = db_session.get(Report, report_id)
report.status = "pending_review"
report.risk_level = "high"
db_session.commit()
pending = client.get("/api/admin/reports/pending", headers=staff_headers)
assert pending.status_code == 200
assert any(item["id"] == report_id for item in pending.json()["data"]["list"])
approved = client.post(
f"/api/admin/reports/{report_id}/review",
headers=staff_headers,
json={"action": "approve", "note": "指标可接受,发布报告"},
)
assert approved.status_code == 200
assert approved.json()["data"]["status"] == "published"
assert approved.json()["data"]["reviewedBy"]
detail = client.get(f"/api/reports/{report_id}", headers=auth_headers)
assert detail.json()["data"]["status"] == "published"
def test_health_sprint6(client):
resp = client.get("/health")
assert resp.json()["stage"] == "sprint6"