Files
mediaplayer/sentence_api/scoring.py
2026-08-16 15:39:52 +08:00

242 lines
7.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import math
import unicodedata
from dataclasses import dataclass
from typing import List, Sequence, Tuple
from .audio_metrics import AudioMetrics
SCORING_VERSION = "asr-fluency-v1"
@dataclass(frozen=True)
class TextAlignment:
reference_tokens: List[str]
recognized_tokens: List[str]
matches: int
missing_tokens: List[str]
extra_tokens: List[str]
substitutions: List[Tuple[str, str]]
content_score: float
completeness_score: float
@dataclass(frozen=True)
class ScoreBreakdown:
overall_score: float
content_score: float
completeness_score: float
fluency_score: float
duration_score: float
pause_score: float
speech_rate_score: float
duration_ratio: float
missing_tokens: List[str]
extra_tokens: List[str]
substitutions: List[Tuple[str, str]]
feedback: str
def tokenize(text: str) -> List[str]:
normalized = unicodedata.normalize("NFKC", text).lower().strip()
tokens: List[str] = []
word: List[str] = []
def flush_word() -> None:
if word:
token = "".join(word).strip("'")
if token:
tokens.append(token)
word.clear()
for char in normalized:
if _is_cjk(char):
flush_word()
tokens.append(char)
elif char.isalnum() or (char == "'" and word):
word.append(char)
else:
flush_word()
flush_word()
return tokens
def align_text(reference_text: str, recognized_text: str) -> TextAlignment:
reference = tokenize(reference_text)
recognized = tokenize(recognized_text)
if not reference:
raise ValueError("Reference text does not contain any scoreable tokens.")
costs = [[0] * (len(recognized) + 1) for _ in range(len(reference) + 1)]
for row in range(1, len(reference) + 1):
costs[row][0] = row
for column in range(1, len(recognized) + 1):
costs[0][column] = column
for row in range(1, len(reference) + 1):
for column in range(1, len(recognized) + 1):
substitution_cost = 0 if reference[row - 1] == recognized[column - 1] else 1
costs[row][column] = min(
costs[row - 1][column] + 1,
costs[row][column - 1] + 1,
costs[row - 1][column - 1] + substitution_cost,
)
row = len(reference)
column = len(recognized)
matches = 0
missing: List[str] = []
extra: List[str] = []
substitutions: List[Tuple[str, str]] = []
while row > 0 or column > 0:
if row > 0 and column > 0:
same = reference[row - 1] == recognized[column - 1]
diagonal_cost = costs[row - 1][column - 1] + (0 if same else 1)
if costs[row][column] == diagonal_cost:
if same:
matches += 1
else:
substitutions.append((reference[row - 1], recognized[column - 1]))
row -= 1
column -= 1
continue
if row > 0 and costs[row][column] == costs[row - 1][column] + 1:
missing.append(reference[row - 1])
row -= 1
else:
extra.append(recognized[column - 1])
column -= 1
missing.reverse()
extra.reverse()
substitutions.reverse()
distance = len(missing) + len(extra) + len(substitutions)
accuracy = max(0.0, 1.0 - distance / len(reference)) * 100
attempted_reference_tokens = matches + len(substitutions)
completeness = attempted_reference_tokens / len(reference) * 100
content = accuracy * 0.7 + completeness * 0.3
return TextAlignment(
reference_tokens=reference,
recognized_tokens=recognized,
matches=matches,
missing_tokens=missing,
extra_tokens=extra,
substitutions=substitutions,
content_score=_round_score(content),
completeness_score=_round_score(completeness),
)
def score_attempt(
*,
reference_text: str,
recognized_text: str,
reference_speech_duration_ms: int,
student_metrics: AudioMetrics,
) -> ScoreBreakdown:
if reference_speech_duration_ms <= 0:
raise ValueError("reference_speech_duration_ms must be positive")
alignment = align_text(reference_text, recognized_text)
ratio = student_metrics.speech_duration_ms / reference_speech_duration_ms
duration = duration_similarity_score(ratio)
pause = pause_score(student_metrics.internal_pause_ratio)
reference_rate = len(alignment.reference_tokens) / (reference_speech_duration_ms / 1000)
recognized_units = max(1, len(alignment.recognized_tokens))
student_rate = recognized_units / (student_metrics.speech_duration_ms / 1000)
rate_ratio = student_rate / reference_rate if reference_rate > 0 else 1.0
rate = symmetric_rate_score(rate_ratio)
fluency = duration * 0.35 + pause * 0.40 + rate * 0.25
overall = alignment.content_score * 0.80 + fluency * 0.20
feedback = build_feedback(alignment, ratio, student_metrics.internal_pause_ratio)
return ScoreBreakdown(
overall_score=_round_score(overall),
content_score=alignment.content_score,
completeness_score=alignment.completeness_score,
fluency_score=_round_score(fluency),
duration_score=_round_score(duration),
pause_score=_round_score(pause),
speech_rate_score=_round_score(rate),
duration_ratio=round(ratio, 4),
missing_tokens=alignment.missing_tokens,
extra_tokens=alignment.extra_tokens,
substitutions=alignment.substitutions,
feedback=feedback,
)
def duration_similarity_score(ratio: float) -> float:
if ratio <= 0:
return 0.0
if 0.80 <= ratio <= 1.30:
return 100.0
if 0.65 <= ratio < 0.80:
return _interpolate(ratio, 0.65, 0.80, 60, 100)
if 1.30 < ratio <= 1.50:
return _interpolate(ratio, 1.30, 1.50, 100, 60)
if 0.50 <= ratio < 0.65:
return _interpolate(ratio, 0.50, 0.65, 20, 60)
if 1.50 < ratio <= 1.80:
return _interpolate(ratio, 1.50, 1.80, 60, 20)
if ratio < 0.50:
return max(0.0, ratio / 0.50 * 20)
return max(0.0, 20 - (ratio - 1.80) / 0.40 * 20)
def pause_score(internal_pause_ratio: float) -> float:
if internal_pause_ratio <= 0.15:
return 100.0
if internal_pause_ratio <= 0.30:
return _interpolate(internal_pause_ratio, 0.15, 0.30, 100, 70)
if internal_pause_ratio <= 0.50:
return _interpolate(internal_pause_ratio, 0.30, 0.50, 70, 20)
return max(0.0, 20 - (internal_pause_ratio - 0.50) / 0.30 * 20)
def symmetric_rate_score(ratio: float) -> float:
if ratio <= 0:
return 0.0
deviation = abs(math.log(ratio))
free_tolerance = math.log(1.20)
if deviation <= free_tolerance:
return 100.0
return max(0.0, 100 - (deviation - free_tolerance) / math.log(2.5) * 100)
def build_feedback(
alignment: TextAlignment,
duration_ratio: float,
internal_pause_ratio: float,
) -> str:
messages = []
if alignment.missing_tokens:
messages.append("存在漏读")
if alignment.substitutions:
messages.append("存在错读")
if alignment.extra_tokens:
messages.append("存在多读")
if duration_ratio < 0.65:
messages.append("朗读明显偏快或内容不完整")
elif duration_ratio > 1.50:
messages.append("朗读速度偏慢")
if internal_pause_ratio > 0.30:
messages.append("句内停顿偏多")
return "".join(messages) if messages else "内容和朗读节奏匹配良好"
def _is_cjk(char: str) -> bool:
codepoint = ord(char)
return (
0x3400 <= codepoint <= 0x4DBF
or 0x4E00 <= codepoint <= 0x9FFF
or 0xF900 <= codepoint <= 0xFAFF
)
def _interpolate(value: float, start: float, end: float, start_score: float, end_score: float) -> float:
return start_score + (value - start) / (end - start) * (end_score - start_score)
def _round_score(value: float) -> float:
return round(min(100.0, max(0.0, value)), 1)