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

241
sentence_api/scoring.py Normal file
View File

@@ -0,0 +1,241 @@
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)