add test module

This commit is contained in:
2026-08-16 15:39:52 +08:00
parent d0310620fc
commit 6e4d93cea6
46 changed files with 3880 additions and 206 deletions

107
sentence_api/assessment.py Normal file
View File

@@ -0,0 +1,107 @@
import uuid
from pathlib import Path
from typing import Optional
from .audio_metrics import analyze_audio
from .models import AssessmentResult, SentenceBoundaryDocument, TextSubstitution
from .repository import VideoRepository
from .scoring import SCORING_VERSION, score_attempt
from .transcription import Transcriber
class AssessmentService:
def __init__(
self,
repository: VideoRepository,
transcriber: Transcriber,
pass_score: float,
):
self.repository = repository
self.transcriber = transcriber
self.pass_score = pass_score
def assess(
self,
*,
document: SentenceBoundaryDocument,
sentence_index: int,
audio_path: Path,
language: Optional[str] = None,
retained_audio_filename: Optional[str] = None,
) -> AssessmentResult:
if not self.transcriber.available:
raise RuntimeError("MOSS transcription is not configured on this server.")
sentence = next(
(item for item in document.sentences if item.index == sentence_index),
None,
)
if sentence is None:
raise LookupError("Sentence index was not found.")
if not sentence.text or not sentence.text.strip():
raise ValueError("The sentence has no reviewed reference text yet.")
student_metrics = analyze_audio(audio_path)
transcript = self.transcriber.transcribe(
audio_path,
language or sentence.language,
)
recognized_text = transcript.text.strip()
if not recognized_text:
raise ValueError("MOSS did not recognize any speech in the recording.")
reference_duration_ms = sentence.end_ms - sentence.start_ms
reference_speech_duration_ms = (
sentence.reference_speech_duration_ms or reference_duration_ms
)
score = score_attempt(
reference_text=sentence.text,
recognized_text=recognized_text,
reference_speech_duration_ms=reference_speech_duration_ms,
student_metrics=student_metrics,
)
attempt_id = uuid.uuid4().hex
result = AssessmentResult(
attempt_id=attempt_id,
scoring_version=SCORING_VERSION,
overall_score=score.overall_score,
passed=score.overall_score >= self.pass_score,
pass_score=self.pass_score,
content_score=score.content_score,
completeness_score=score.completeness_score,
fluency_score=score.fluency_score,
pronunciation_score=None,
prosody_score=None,
duration_score=score.duration_score,
pause_score=score.pause_score,
speech_rate_score=score.speech_rate_score,
reference_text=sentence.text,
recognized_text=recognized_text,
reference_duration_ms=reference_duration_ms,
reference_speech_duration_ms=reference_speech_duration_ms,
student_recording_duration_ms=student_metrics.recording_duration_ms,
student_speech_duration_ms=student_metrics.speech_duration_ms,
duration_ratio=score.duration_ratio,
missing_tokens=score.missing_tokens,
extra_tokens=score.extra_tokens,
substitutions=[
TextSubstitution(expected=expected, actual=actual)
for expected, actual in score.substitutions
],
feedback=score.feedback,
details={
"score_kind": "朗读内容与流畅度匹配分",
"content_weight": "0.80",
"fluency_weight": "0.20",
"duration_weight_within_fluency": "0.35",
"phoneme_scoring": "not_enabled",
},
)
if self.repository.get_video(document.video_hash) is not None:
self.repository.record_attempt(
attempt_id=attempt_id,
video_hash=document.video_hash,
sentence_index=sentence_index,
result=result.model_dump(mode="json"),
audio_filename=retained_audio_filename,
)
return result