/
/
/
1"""Free-text matching helpers for the Music Quiz provider."""
2
3from __future__ import annotations
4
5from music_assistant_models.helpers import create_safe_string
6
7from music_assistant.helpers.compare import compare_strings
8from music_assistant.helpers.util import parse_title_and_version
9
10
11def compare_free_text_answer(submitted: str, expected: str) -> bool:
12 """
13 Return whether a free-text answer conservatively matches the expected value.
14
15 :param submitted: Player-provided answer.
16 :param expected: Correct answer value.
17 """
18 submitted_title, _ = parse_title_and_version(submitted, strip_for_search=True)
19 expected_title, _ = parse_title_and_version(expected, strip_for_search=True)
20 if compare_strings(submitted_title, expected_title):
21 return True
22
23 submitted_safe = create_safe_string(
24 submitted_title.replace(" & ", " and "),
25 replace_space=True,
26 )
27 expected_safe = create_safe_string(
28 expected_title.replace(" & ", " and "),
29 replace_space=True,
30 )
31 if not submitted_safe or not expected_safe:
32 return False
33 shortest_length = min(len(submitted_safe), len(expected_safe))
34 max_distance = 0 if shortest_length < 5 else 1 if shortest_length < 13 else 2
35 return _within_edit_distance(submitted_safe, expected_safe, max_distance)
36
37
38def _within_edit_distance(first: str, second: str, max_distance: int) -> bool:
39 """Return whether two strings are within a bounded Levenshtein distance."""
40 if abs(len(first) - len(second)) > max_distance:
41 return False
42 previous_row = list(range(len(second) + 1))
43 for first_index, first_char in enumerate(first, start=1):
44 current_row = [first_index]
45 for second_index, second_char in enumerate(second, start=1):
46 current_row.append(
47 min(
48 current_row[-1] + 1,
49 previous_row[second_index] + 1,
50 previous_row[second_index - 1] + (first_char != second_char),
51 )
52 )
53 if min(current_row) > max_distance:
54 return False
55 previous_row = current_row
56 return previous_row[-1] <= max_distance
57