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