mathly / app /texts.py
3v324v23's picture
Add text handling functionality and update scoring tests
3b6eb00
Raw History Blame Contribute Delete
10.3 kB
"""Texts the teacher adds in the panel: a story or a dialogue, practised line by line like the course's conversation.
The teacher gives each line twice: in Hebrew, and in Arabic written in Hebrew letters, as the course writes it
(pointed, geresh for the sounds Hebrew lacks, ّ for doubling: כִּיף חַאלַכּ). The scorer needs the line's phonemes
and the answer's voice needs it in Arabic letters, so each word is read here:
a word of the course (LEX in workbook.py), found by its transliteration, gets the course's own Arabic spelling and
accepted pronunciations. Dagesh, shva and the final letters' forms are not compared, and a word written without
points is found by its letters alone;
any other word is read by rule, letter by letter (`_guess`). Short vowels come from the points, so an unpointed
word that the course does not have is read badly. Such words are reported back to the teacher (`guessed`).
"""
from __future__ import annotations
import re
from .workbook import LEX, _variants
SHVA, DAGESH, SHADDA = "\u05b0", "\u05bc", "\u0651"
MARKS = re.compile("[\u05b0-\u05bc\u05c1\u05c2\u05c7\u0651]")
GERESH = "'\u05f3\u2019\u2018`\u00b4"
FINALS = {"ך": "כ", "ם": "מ", "ן": "נ", "ף": "פ", "ץ": "צ"}
VOWEL_OF = {"\u05b7": "a", "\u05b8": "a", "\u05b2": "a", "\u05b4": "i", "\u05bb": "u", "\u05b6": "e", "\u05b1": "e",
"\u05b5": "E", "\u05b9": "o", "\u05ba": "o", "\u05b3": "o", "\u05c7": "o"} # E: tsere, i alone and eː before י
# letter -> (phoneme, Arabic letter); ק is the urban ʔ, with q as a second reading
CONS = {"א": ("ʔ", "ا"), "ב": ("b", "ب"), "ג": ("g", "ج"), "ד": ("d", "د"), "ה": ("h", "ه"), "ו": ("w", "و"),
"ז": ("z", "ز"), "ח": ("ħ", "ح"), "ט": ("tˤ", "ط"), "י": ("j", "ي"), "כ": ("k", "ك"), "ל": ("l", "ل"),
"מ": ("m", "م"), "נ": ("n", "ن"), "ס": ("s", "س"), "ע": ("ʕ", "ع"), "פ": ("f", "ف"), "צ": ("sˤ", "ص"),
"ק": ("ʔ", "ق"), "ר": ("r", "ر"), "ש": ("ʃ", "ش"), "ת": ("t", "ت")}
GERESHED = {"ג": ("dʒ", "ج"), "ח": ("x", "خ"), "ע": ("ɣ", "غ"), "ד": ("dˤ", "ض"), "ט": ("ðˤ", "ظ"), "ת": ("t", "ث"),
"ז": ("dʒ", "ج"), "צ": ("sˤ", "ص")}
PUNCT_AR = {"?": "؟", ",": "،", ";": "؛"}
Cluster = tuple[str, bool, str] # a letter, whether it has a geresh, its points
def _clusters(word: str) -> list[Cluster]:
"""The word's letters, each with its geresh and points. A letter in brackets, the article's silent (ל), is left out."""
out: list[list] = []
for ch in re.sub(r"\([^)]*\)", "", word):
if "א" <= ch <= "ת":
out.append([FINALS.get(ch, ch), False, ""])
elif out and ch in GERESH:
out[-1][1] = True
elif out and MARKS.fullmatch(ch):
out[-1][2] += ch
return [(l, g, m) for l, g, m in out]
def _key(cl: list[Cluster], points: bool = True) -> str:
"""What two spellings of one word share: the letters and their vowels, without dagesh (but the vav's) and shva.
The article's vowel is one, hiriq or tsere."""
def marks(k: int, letter: str, m: str) -> str:
keep = {"\u05b7" if c == "\u05b8" else c for c in m if c != SHVA and (c != DAGESH or letter == "ו")}
if k == 0 and letter == "א" and "\u05b4" in keep:
keep = keep - {"\u05b4"} | {"\u05b5"}
return "".join(sorted(keep))
return "".join(l + "'" * g + (marks(k, l, m) if points else "") for k, (l, g, m) in enumerate(cl))
def _lexicon() -> tuple[dict, dict]:
pointed, plain = {}, {}
for ar, (tr, ipas) in LEX.items():
cl = _clusters(tr)
entry = (ar.partition("#")[0], ipas)
pointed.setdefault(_key(cl), entry)
plain.setdefault(_key(cl, False), entry)
return pointed, plain
POINTED, PLAIN = _lexicon()
def _guess(cl: list[Cluster], silent_l: bool = False) -> tuple[str, list[str]]:
"""(Arabic spelling, readings) of a word the course does not have, from its letters and points. `silent_l`: the
word was written with the article's (ל), so its first vowel is the article."""
phs: list[str] = []
ar = ""
n, i = len(cl), 0
bare = lambda c: not c[1] and not any(m in VOWEL_OF or m == SHADDA for m in c[2]) # no vowel of its own
pointed = any(m in VOWEL_OF for _, _, marks in cl for m in marks)
if n > 1 and cl[0][0] == "ו" and bare(cl[0]) and (DAGESH in cl[0][2] or SHVA in cl[0][2]): # "and" on the word
phs, ar, i = ["u"], "و", 1
start, prev = i, None # prev: the vowel the letter before ended in
while i < n:
letter, geresh, marks = cl[i]
last, vowel = i == n - 1, next((VOWEL_OF[m] for m in marks if m in VOWEL_OF), None)
double = SHADDA in marks
i += 1
if bare((letter, geresh, marks)) and i - 1 > start: # a letter that only lengthens the vowel before it
long = None
if letter == "א":
long, ar = ("a" if last else "aː"), ar + "ا"
if prev:
phs.pop()
elif letter == "י" and (prev in ("i", "E") or not prev and last or not pointed and not last):
long, ar = ("eː" if prev == "E" else "i" if last else "iː"), ar + "ي"
if prev:
phs.pop()
elif letter == "ו" and not prev and (DAGESH in marks or not pointed and not last):
long, ar = ("u" if last else "uː"), ar + "و"
elif letter == "ה" and last and (prev in ("a", "e", "E") or not pointed): # the feminine ending
ar += "ة"
if not prev:
phs.append("a")
elif prev == "E":
phs[-1] = "e"
break
if long:
phs.append(long)
prev = None
continue
if letter == "ו" and DAGESH in marks and double and not vowel and not prev: # וּّ
phs += ["u", "w", "w"]
ar, prev = ar + "وّ", None
continue
if letter == "ו" and vowel == "o" and not double and not prev and i - 1 > start: # וֹ
phs.append("o" if last else "oː")
ar, prev = ar + "و", None
continue
ph, ar_letter = (GERESHED if geresh and letter in GERESHED else CONS)[letter]
if letter == "א" and i - 1 == start and vowel in ("i", "a", "E") and i < n and (
cl[i][0] == "ל" and bare(cl[i]) and i + 1 < n or silent_l): # the article: אִלְ, or אִ(ל) before a doubled letter
phs.append("i")
ar += "ال"
if cl[i][0] == "ל" and bare(cl[i]):
phs.append("l")
i += 1
prev = None
continue
phs += [ph, ph] if double else [ph]
ar += ("ا" if i - 1 == start else "أ") if letter == "א" else ar_letter + (SHADDA if double else "")
prev = vowel
if vowel:
phs.append(("e" if last else "i") if vowel == "E" else vowel) # a tsere is i inside the word, e at its end
main = " ".join(phs)
return ar, [main] + ([main.replace("ʔ", "q") if not cl[start][0] == "א" else "ʔ" + main[1:].replace("ʔ", "q")]
if any(l == "ק" for l, _, _ in cl) else [])
def _word(token: str) -> tuple[str, list[str], bool] | None:
"""(Arabic, readings, whether the course has the word) of one written word; None when it has no letters."""
cl = _clusters(token)
if not cl:
return None
tail = "".join(PUNCT_AR.get(c, c) for c in re.search(r"[?!.,;:]*$", token)[0])
pointed = any(m in VOWEL_OF for _, _, marks in cl for m in marks)
for cut, pre_ar, pre_ipa in ((0, "", ""), (1, "و", "u ")): # the word itself, then "and" written on it
if cut and not (len(cl) > 1 and cl[0][0] == "ו" and not cl[0][1] and not any(m in VOWEL_OF for m in cl[0][2])):
break
hit = POINTED.get(_key(cl[cut:])) if pointed else PLAIN.get(_key(cl[cut:], False))
if hit:
return pre_ar + hit[0] + tail, [pre_ipa + ipa for ipa in hit[1]], True
rest = cl[2:] if len(cl) > 2 and cl[0][0] == "א" and cl[1][0] == "ל" and not any(m in VOWEL_OF for m in cl[1][2]) else \
cl[1:] if len(cl) > 1 and cl[0][0] == "א" and "(ל)" in token else None
hit = rest and (POINTED.get(_key(rest)) if pointed else PLAIN.get(_key(rest, False)))
if hit and not hit[0].startswith("ال"): # the article on a word of the course: אִלְבֵּית, אִ(ל)סַّיַּארַה
said = "i l " if rest is cl[2:] or len(rest) == len(cl) - 2 else "i "
double = lambda ipa: ipa if said == "i l " else f"{ipa.split()[0]} {ipa}"
return "ال" + hit[0] + tail, [said + double(ipa) for ipa in hit[1]], True
ar, ipas = _guess(cl, "(ל)" in token)
return (ar + tail, ipas, False) if ipas[0] else None
def read_line(tr: str) -> tuple[list[tuple[str, str, list[str]]], list[str]]:
"""A line of Arabic in Hebrew letters as the words of a phrase, (Arabic, transliteration, readings) each, and
the words that were read by rule, as written."""
words, guessed = [], []
for token in tr.split():
word = _word(token)
if word is None: # a dash, a number
continue
ar, ipas, known = word
words.append((ar, token, ipas))
if not known:
guessed.append(token.strip("?!.,;:\"()"))
return words, guessed
def text_phrases(cat: str, lines: list[list[str]]) -> tuple[list[dict], list[str]]:
"""A text's lines ([Hebrew, transliteration] each) as phrases in the format of phrases.py, in the text's order,
and its words that were read by rule. `key` is what a recording of the line is kept under: the Hebrew, numbered
from its second appearance in the text ("כן (2)"), so that two lines with the same Hebrew stay apart."""
out, guessed, seen = [], [], {}
for seq, (he, tr) in enumerate(lines, 1):
words, new = read_line(tr)
guessed += [w for w in new if w not in guessed]
seen[he] = seen.get(he, 0) + 1
out.append(dict(cat=cat, he=he, tr=tr, variants=_variants(words), seq=seq,
key=he if seen[he] == 1 else f"{he} ({seen[he]})"))
return out, guessed