184 lines
5.1 KiB
Python
184 lines
5.1 KiB
Python
from datetime import datetime
|
|
from typing import Dict, List, Literal, Optional
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
|
|
|
class SentenceBoundary(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
index: int = Field(ge=0)
|
|
start_ms: int = Field(ge=0)
|
|
end_ms: int = Field(gt=0)
|
|
text: Optional[str] = None
|
|
language: Optional[str] = None
|
|
reference_speech_duration_ms: Optional[int] = Field(default=None, gt=0)
|
|
|
|
@model_validator(mode="after")
|
|
def validate_range(self):
|
|
if self.end_ms <= self.start_ms:
|
|
raise ValueError("end_ms must be greater than start_ms")
|
|
return self
|
|
|
|
|
|
class SentenceBoundaryDocument(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
video_hash: str
|
|
duration_ms: int = Field(gt=0)
|
|
algorithm_version: str = Field(min_length=1)
|
|
sentences: List[SentenceBoundary]
|
|
|
|
@field_validator("video_hash")
|
|
@classmethod
|
|
def validate_video_hash(cls, value: str) -> str:
|
|
normalized = value.strip().lower()
|
|
if len(normalized) != 64 or any(char not in "0123456789abcdef" for char in normalized):
|
|
raise ValueError("video_hash must be a SHA-256 hex digest")
|
|
return normalized
|
|
|
|
@model_validator(mode="after")
|
|
def validate_sentences(self):
|
|
previous_end = 0
|
|
for expected_index, sentence in enumerate(self.sentences):
|
|
if sentence.index != expected_index:
|
|
raise ValueError("sentence indexes must be contiguous and zero-based")
|
|
if sentence.start_ms < previous_end:
|
|
raise ValueError("sentences must be sorted and non-overlapping")
|
|
if sentence.end_ms > self.duration_ms:
|
|
raise ValueError("sentence end_ms cannot exceed duration_ms")
|
|
previous_end = sentence.end_ms
|
|
return self
|
|
|
|
|
|
VideoStatus = Literal["uploaded", "processing", "ready", "failed"]
|
|
|
|
|
|
class VideoSummary(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
video_hash: str
|
|
title: str
|
|
filename: str
|
|
content_type: str
|
|
size_bytes: int = Field(ge=0)
|
|
duration_ms: Optional[int] = Field(default=None, gt=0)
|
|
language: Optional[str] = None
|
|
status: VideoStatus
|
|
error_message: Optional[str] = None
|
|
sentence_count: int = Field(ge=0)
|
|
stream_url: str
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class VideoListResponse(BaseModel):
|
|
videos: List[VideoSummary]
|
|
|
|
|
|
class VideoUploadResponse(BaseModel):
|
|
video_hash: str
|
|
status: VideoStatus
|
|
detail_url: str
|
|
|
|
|
|
class VideoDetailResponse(BaseModel):
|
|
video: VideoSummary
|
|
boundaries: Optional[SentenceBoundaryDocument] = None
|
|
|
|
|
|
class SentenceTextUpdate(BaseModel):
|
|
text: str = Field(min_length=1, max_length=4000)
|
|
language: Optional[str] = Field(default=None, max_length=32)
|
|
|
|
|
|
class SentenceBoundaryAdjust(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
delta_ms: int = Field(ge=-10_000, le=10_000)
|
|
|
|
|
|
class TextSubstitution(BaseModel):
|
|
expected: str
|
|
actual: str
|
|
|
|
|
|
class AssessmentResult(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
attempt_id: str
|
|
scoring_version: str
|
|
overall_score: float = Field(ge=0, le=100)
|
|
passed: bool
|
|
pass_score: float = Field(ge=0, le=100)
|
|
content_score: float = Field(ge=0, le=100)
|
|
completeness_score: float = Field(ge=0, le=100)
|
|
fluency_score: float = Field(ge=0, le=100)
|
|
pronunciation_score: Optional[float] = Field(default=None, ge=0, le=100)
|
|
prosody_score: Optional[float] = Field(default=None, ge=0, le=100)
|
|
duration_score: float = Field(ge=0, le=100)
|
|
pause_score: float = Field(ge=0, le=100)
|
|
speech_rate_score: float = Field(ge=0, le=100)
|
|
reference_text: str
|
|
recognized_text: str
|
|
reference_duration_ms: int = Field(gt=0)
|
|
reference_speech_duration_ms: int = Field(gt=0)
|
|
student_recording_duration_ms: int = Field(gt=0)
|
|
student_speech_duration_ms: int = Field(gt=0)
|
|
duration_ratio: float = Field(gt=0)
|
|
missing_tokens: List[str]
|
|
extra_tokens: List[str]
|
|
substitutions: List[TextSubstitution]
|
|
feedback: str
|
|
details: Dict[str, str] = Field(default_factory=dict)
|
|
|
|
|
|
class AuthRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
username: str = Field(min_length=3, max_length=50, pattern=r"^[A-Za-z0-9_.-]+$")
|
|
password: str = Field(min_length=8, max_length=128)
|
|
|
|
|
|
class UserPublic(BaseModel):
|
|
id: str
|
|
username: str
|
|
nickname: str
|
|
created_at: datetime
|
|
|
|
|
|
class AuthResponse(BaseModel):
|
|
token: str
|
|
user: UserPublic
|
|
|
|
|
|
class CourseSummary(BaseModel):
|
|
video_hash: str
|
|
title: str
|
|
duration_ms: Optional[int] = None
|
|
language: Optional[str] = None
|
|
sentence_count: int = Field(ge=0)
|
|
stream_url: str
|
|
enrolled: bool
|
|
|
|
|
|
class UserResultSummary(BaseModel):
|
|
attempt_id: str
|
|
video_hash: str
|
|
course_title: str
|
|
sentence_index: int
|
|
sentence_text: str
|
|
overall_score: float = Field(ge=0, le=100)
|
|
passed: bool
|
|
created_at: datetime
|
|
|
|
|
|
class UserDubShareSummary(BaseModel):
|
|
share_id: str
|
|
video_hash: str
|
|
course_title: str
|
|
title: str
|
|
segment_count: int = Field(ge=0)
|
|
average_score: float = Field(ge=0, le=100)
|
|
created_at: datetime
|