From 0364fac65602e9bfcb563a9d1fec59d80f2b5392 Mon Sep 17 00:00:00 2001 From: Mustafa J Date: Tue, 3 Mar 2026 11:25:16 +0300 Subject: [PATCH 1/2] error_explainer: normalize fallback speech type and lengths --- .../phonetics/error_explainer.py | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/quran_transcript/phonetics/error_explainer.py b/src/quran_transcript/phonetics/error_explainer.py index dde5b1d..dcaa7dd 100644 --- a/src/quran_transcript/phonetics/error_explainer.py +++ b/src/quran_transcript/phonetics/error_explainer.py @@ -115,6 +115,36 @@ def get_ref_phonetic_groups_tajweed_rules( return ref_tajweed_rules +def infer_speech_error_type(expected_ph: str, predicted_ph: str, fallback: str) -> str: + if not expected_ph and predicted_ph: + return "insert" + if expected_ph and not predicted_ph: + return "delete" + if len(predicted_ph) > len(expected_ph): + return "insert" + if len(predicted_ph) < len(expected_ph): + return "delete" + if expected_ph != predicted_ph: + return "replace" + return fallback + + +def normalize_error_details(errors: list[ReciterError]) -> list[ReciterError]: + for err in errors: + expected_ph = err.expected_ph or "" + predicted_ph = err.preditected_ph or "" + err.speech_error_type = infer_speech_error_type( + expected_ph, + predicted_ph, + err.speech_error_type, + ) + if err.expected_len is None: + err.expected_len = len(expected_ph) + if err.predicted_len is None: + err.predicted_len = len(predicted_ph) + return errors + + def explain_error( uthmani_text, ref_ph_text, predicted_ph_text, mappings: list[MappingPos | None] ) -> list[ReciterError]: @@ -282,7 +312,7 @@ def explain_error( pred_ph_start = pred_ph_end ref_ph_start = ref_ph_end - return errors + return normalize_error_details(errors) if __name__ == "__main__": From cbfd59c18e1c78445afa30c3133c8b66ad0e2f7b Mon Sep 17 00:00:00 2001 From: Mustafa J Date: Tue, 3 Mar 2026 12:33:03 +0300 Subject: [PATCH 2/2] error_explainer, tests: add error_code and symbol metadata --- .../phonetics/error_explainer.py | 87 +++++++++++++++++++ tests/test_error_explainer_metadata_pytest.py | 45 ++++++++++ 2 files changed, 132 insertions(+) create mode 100644 tests/test_error_explainer_metadata_pytest.py diff --git a/src/quran_transcript/phonetics/error_explainer.py b/src/quran_transcript/phonetics/error_explainer.py index dcaa7dd..021653d 100644 --- a/src/quran_transcript/phonetics/error_explainer.py +++ b/src/quran_transcript/phonetics/error_explainer.py @@ -22,6 +22,8 @@ class ReciterError: preditected_ph: str expected_len: Optional[int] | None = None predicted_len: Optional[int] | None = None + error_code: str = "" + symbol_metadata: Optional[dict[str, dict[str, str]]] | None = None tajweed_rules: Optional[list[TajweedRule]] | None = None predicted_tajweed_rules: Optional[list[TajweedRule]] | None = None @@ -129,6 +131,89 @@ def infer_speech_error_type(expected_ph: str, predicted_ph: str, fallback: str) return fallback +PHONEME_META_BY_ATTR = { + "fatha": {"label_en": "Fatha (a)", "symbol_class": "vowel"}, + "dama": {"label_en": "Damma (u)", "symbol_class": "vowel"}, + "kasra": {"label_en": "Kasra (i)", "symbol_class": "vowel"}, + "alif": {"label_en": "Alif", "symbol_class": "letter"}, + "waw_madd": {"label_en": "Waw madd", "symbol_class": "madd"}, + "yaa_madd": {"label_en": "Yaa madd", "symbol_class": "madd"}, + "noon_mokhfah": {"label_en": "Noon ghunnah", "symbol_class": "nasal"}, + "meem_mokhfah": {"label_en": "Meem ikhfa", "symbol_class": "nasal"}, + "qlqla": {"label_en": "Qalqalah marker", "symbol_class": "marker"}, + "sakt": {"label_en": "Sakt marker", "symbol_class": "marker"}, + "fatha_momala": {"label_en": "Imala marker", "symbol_class": "marker"}, + "hamza_mosahala": {"label_en": "Hamza musahala", "symbol_class": "marker"}, + "dama_mokhtalasa": {"label_en": "Damma mukhtalasa", "symbol_class": "vowel"}, +} + + +def build_symbol_metadata(expected_ph: str, predicted_ph: str) -> dict[str, dict[str, str]]: + attr_by_symbol = {} + for attr, value in vars(alph.phonetics).items(): + if not attr.startswith("_") and isinstance(value, str) and len(value) == 1: + attr_by_symbol[value] = attr + + out = {} + for symbol in set(expected_ph + predicted_ph): + attr = attr_by_symbol.get(symbol) + if attr is None: + out[symbol] = { + "attr": "unknown", + "label_en": "Unknown phoneme symbol", + "symbol_class": "unknown", + } + continue + + meta = PHONEME_META_BY_ATTR.get(attr) + if meta is None: + if symbol in alph.phonetic_groups.harakat: + symbol_class = "vowel" + elif symbol in alph.phonetic_groups.residuals: + symbol_class = "marker" + else: + symbol_class = "letter" + label_en = attr.replace("_", " ") + else: + symbol_class = meta["symbol_class"] + label_en = meta["label_en"] + + out[symbol] = { + "attr": attr, + "label_en": label_en, + "symbol_class": symbol_class, + } + return out + + +def infer_error_code(err: ReciterError) -> str: + if err.error_type == "tajweed": + if err.expected_len is not None and err.predicted_len is not None: + if err.predicted_len < err.expected_len: + return "TAJWEED_LENGTH_SHORT" + if err.predicted_len > err.expected_len: + return "TAJWEED_LENGTH_LONG" + return "TAJWEED_RULE_REPLACE" + if err.speech_error_type == "insert": + return "TAJWEED_EXTRA_SOUND" + if err.speech_error_type == "delete": + return "TAJWEED_MISSING_SOUND" + return "TAJWEED_RULE_REPLACE" + + if err.error_type == "tashkeel": + if err.speech_error_type == "insert": + return "TASHKEEL_EXTRA" + if err.speech_error_type == "delete": + return "TASHKEEL_MISSING" + return "TASHKEEL_REPLACE" + + if err.speech_error_type == "insert": + return "PHONEME_EXTRA" + if err.speech_error_type == "delete": + return "PHONEME_MISSING" + return "PHONEME_REPLACE" + + def normalize_error_details(errors: list[ReciterError]) -> list[ReciterError]: for err in errors: expected_ph = err.expected_ph or "" @@ -142,6 +227,8 @@ def normalize_error_details(errors: list[ReciterError]) -> list[ReciterError]: err.expected_len = len(expected_ph) if err.predicted_len is None: err.predicted_len = len(predicted_ph) + err.error_code = infer_error_code(err) + err.symbol_metadata = build_symbol_metadata(expected_ph, predicted_ph) return errors diff --git a/tests/test_error_explainer_metadata_pytest.py b/tests/test_error_explainer_metadata_pytest.py new file mode 100644 index 0000000..5395cb3 --- /dev/null +++ b/tests/test_error_explainer_metadata_pytest.py @@ -0,0 +1,45 @@ +from quran_transcript.phonetics.error_explainer import ReciterError, normalize_error_details + + +def test_tajweed_length_short_sets_error_code_and_symbol_metadata(): + err = ReciterError( + uthmani_pos=(36, 37), + ph_pos=(27, 31), + error_type='tajweed', + speech_error_type='replace', + expected_ph='ۦۦۦۦ', + preditected_ph='ۦۦ', + expected_len=4, + predicted_len=2, + ) + + out = normalize_error_details([err])[0] + + assert out.error_code == 'TAJWEED_LENGTH_SHORT' + assert out.symbol_metadata == { + 'ۦ': { + 'attr': 'yaa_madd', + 'label_en': 'Yaa madd', + 'symbol_class': 'madd', + } + } + + +def test_tashkeel_replace_sets_lengths_and_error_code(): + err = ReciterError( + uthmani_pos=(4, 6), + ph_pos=(3, 5), + error_type='tashkeel', + speech_error_type='replace', + expected_ph='مِ', + preditected_ph='مُ', + ) + + out = normalize_error_details([err])[0] + + assert out.expected_len == 2 + assert out.predicted_len == 2 + assert out.error_code == 'TASHKEEL_REPLACE' + assert out.symbol_metadata['م']['symbol_class'] == 'letter' + assert out.symbol_metadata['ِ']['attr'] == 'kasra' + assert out.symbol_metadata['ُ']['attr'] == 'dama'