a400130c67
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>
204 lines
6.6 KiB
Python
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()
|