Files
john a400130c67
CI / api-test (push) Has been cancelled
CI / web-build (push) Has been cancelled
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>
2026-07-23 11:44:11 +08:00

204 lines
6.6 KiB
Python

from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.ai.pose_analyzer import analyze_task
from app.config import settings
from app.db.models import AnalysisTask, Report, Video
from app.mock_data import MOCK_MOVEMENT_REPORT, MOCK_REPORT
from app.queue.analysis_queue import get_analysis_queue
from app.schemas.models import AnalysisTaskCreateRequest, TaskStatus, TaskType
from app.services import oss as oss_service
from app.services.videos import verify_child_owner
DISCLAIMER_SCREENING = "本报告用于健康管理建议,不构成医疗诊断。"
DISCLAIMER_MOVEMENT = "本报告用于运动训练反馈,不构成医疗诊断。"
def task_to_dict(task: AnalysisTask) -> dict:
report_id = None
if task.result and isinstance(task.result, dict):
report_id = task.result.get("reportId")
return {
"id": task.id,
"status": task.status,
"progress": task.progress,
"errorCode": task.error_code,
"reportId": report_id,
"retryCount": task.retry_count,
}
def build_screening_report_payload(child_id: int, task_id: int) -> dict:
data = {**MOCK_REPORT, "childId": child_id, "taskId": task_id}
return data
def build_movement_report_payload(child_id: int, task_id: int) -> dict:
return {**MOCK_MOVEMENT_REPORT, "childId": child_id, "taskId": task_id}
def create_report_for_task(db: Session, task: AnalysisTask, analysis: dict) -> Report:
report_data = analysis.get("report", {})
if task.task_type == TaskType.movement_scoring.value:
payload = {**MOCK_MOVEMENT_REPORT, **report_data}
report = Report(
child_id=task.child_id,
task_id=task.id,
report_type="movement_scoring",
risk_level="medium",
summary="跟练打卡报告已生成",
metrics=[payload],
recommendations=["继续保持训练节奏", "28 天后安排复测"],
disclaimer=DISCLAIMER_MOVEMENT,
status="published",
)
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=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=report_status,
)
db.add(report)
db.flush()
return report
def process_analysis_task(db: Session, task_id: int) -> AnalysisTask | None:
task = db.get(AnalysisTask, task_id)
if not task or task.status in (TaskStatus.SUCCEEDED.value, TaskStatus.CANCELLED.value):
return task
task.status = TaskStatus.PROCESSING.value
task.progress = 35
task.started_at = datetime.now(timezone.utc)
db.commit()
video = db.get(Video, task.video_id)
video_path = None
if video:
video_path = oss_service.resolve_local_path(video.object_key)
analysis = analyze_task(task.task_type, video_path)
task.model_version = analysis.get("modelVersion", "mock-v1")
task.progress = 80
db.commit()
report = create_report_for_task(db, task, analysis)
task.status = TaskStatus.SUCCEEDED.value
task.progress = 100
task.finished_at = datetime.now(timezone.utc)
task.result = {
"reportId": report.id,
"engine": analysis.get("engine"),
"confidence": analysis.get("confidence"),
}
db.commit()
db.refresh(task)
return task
def enqueue_task(task_id: int, db: Session | None = None) -> None:
queue = get_analysis_queue()
queue.push(task_id)
if not settings.analysis_inline_process:
return
if db is not None:
process_analysis_task(db, task_id)
return
from app.db.session import SessionLocal
local_db = SessionLocal()
try:
process_analysis_task(local_db, task_id)
finally:
local_db.close()
def create_analysis_task(
db: Session,
user_id: int,
body: AnalysisTaskCreateRequest,
idempotency_key: str | None,
) -> tuple[AnalysisTask | None, str | None, AnalysisTask | None]:
child = verify_child_owner(db, user_id, body.child_id)
if not child:
return None, "child_not_found", None
video = db.get(Video, body.video_id)
if not video or video.child_id != body.child_id:
return None, "video_not_found", None
if idempotency_key:
existing = db.scalar(
select(AnalysisTask).where(
AnalysisTask.child_id == body.child_id,
AnalysisTask.video_id == body.video_id,
AnalysisTask.task_type == body.task_type.value,
AnalysisTask.idempotency_key == idempotency_key,
)
)
if existing:
return existing, "duplicate", existing
task = AnalysisTask(
child_id=body.child_id,
video_id=body.video_id,
task_type=body.task_type.value,
status=TaskStatus.QUEUED.value,
progress=0,
idempotency_key=idempotency_key,
)
db.add(task)
db.commit()
db.refresh(task)
enqueue_task(task.id, db=db)
db.refresh(task)
return task, None, None
def get_task_for_user(db: Session, user_id: int, task_id: int) -> AnalysisTask | None:
task = db.get(AnalysisTask, task_id)
if not task:
return None
child = verify_child_owner(db, user_id, task.child_id)
if not child:
return None
return task
def cancel_task(db: Session, user_id: int, task_id: int) -> AnalysisTask | None:
task = get_task_for_user(db, user_id, task_id)
if not task:
return None
if task.status in (TaskStatus.SUCCEEDED.value, TaskStatus.CANCELLED.value):
return task
task.status = TaskStatus.CANCELLED.value
task.finished_at = datetime.now(timezone.utc)
db.commit()
db.refresh(task)
return task
def handle_webhook(db: Session, task_id: int, status: str) -> None:
task = db.get(AnalysisTask, task_id)
if not task:
return
if status == TaskStatus.SUCCEEDED.value:
process_analysis_task(db, task_id)
elif status == TaskStatus.FAILED.value:
task.status = TaskStatus.FAILED.value
task.error_code = "worker_failed"
task.finished_at = datetime.now(timezone.utc)
db.commit()