File size: 8,421 Bytes
ac573f1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | """Per-word language ID + language-mix estimate for a user turn (and for a reply).
Reuses the Dynamic Accent release tagger (rule-based lexicons/diacritics/Pidgin markers, optional GlotLID clause scores).
estimate("Ndewo, how are you doing today?") ->
{"mix": {"eng": 0.67, "ibo": 0.33}, "matrix": "eng", "pattern": ["greeting:ibo"], "words": [...], "labels": [...]}"""
import re, unicodedata
from tagger import Tagger, fold
LANGS = ["eng", "pcm", "ibo", "yor", "hau"]
NAMES = {"eng": "English", "pcm": "Nigerian Pidgin", "ibo": "Igbo", "yor": "Yoruba", "hau": "Hausa"}
# greeting / closing / discourse lexicon (diacritics folded) -> language
GREET = {"ibo": ["nde wo", "ndewoo", "ndeewo", "kedu", "nnoo", "ututu oma", "ehihie oma", "mgbede oma", "ka chi fo", "kedu ka i mere", "i bola chi"],
"yor": ["e kaaro", "e kaasan", "e kurole", "e ku irole", "bawo", "se daadaa ni", "e nle", "pele", "e kaabo", "e ku abo", "ekaaro", "ekaasan", "ekaro", "e karo", "e kaaro o"],
"hau": ["sannu", "sanu", "sanno", "baka da yamma", "baka da safiya", "barka", "barka da safiya", "barka da yamma", "ina kwana", "ina wuni", "yaya dai", "lafiya lau", "maraba"],
"pcm": ["how far", "how body", "wetin dey", "how you dey", "how una dey", "i hail"]}
CLOSE = {"ibo": ["daalu", "dalu", "dalo", "daalo", "dalow", "ka o di", "ka emesia", "chukwu gozie"], "yor": ["e se", "o se", "o dabo", "e seun", "ese", "odabo"],
"hau": ["na gode", "sai an jima", "sai anjima", "nagode", "mun gode", "na godi", "nagodi"], "pcm": ["we go see", "thank you well well", "make we yarn later"]}
DISC = {"pcm": ["abeg", "abi", "sha", "o", "na so", "omo", "wahala"], "yor": ["sha", "o", "ehen", "abi"], "ibo": ["biko", "nna", "nne", "chai", "ehen", "ngwa"],
"hau": ["to", "kai", "wallahi", "haba"]}
_T = {}
def tagger(lid="glotlid"):
if lid not in _T:
try:
_T[lid] = Tagger(lid if lid != "none" else None)
except Exception as e: # no fasttext / GlotLID: rules only
print("tagger: GlotLID unavailable, rules only:", repr(e)[:120]); _T[lid] = Tagger(None)
return _T[lid]
def _find(fs, phrases):
for p in sorted(phrases, key=len, reverse=True):
if re.search(r"(^|\s)" + re.escape(p) + r"(\s|$)", fs):
return p
return None
PCM_MARK = {"dey", "don", "abeg", "wetin", "una", "gree", "wahala", "sabi", "comot", "chop", "oya", "abi", "shey", "wey", "dem", "wan",
"pikin", "oga", "sef", "jare", "naim", "dis", "dat", "wahala", "kpatakpata", "ehn", "sha", "nor", "abeg", "jor", "ehen", "na"}
PCM_BI = {("no", "gree"), ("no", "be"), ("e", "no"), ("make", "we"), ("how", "far"), ("na", "im"), ("i", "go"), ("you", "go"), ("we", "go"), ("e", "go"),
("no", "go"), ("i", "fit"), ("you", "fit"), ("e", "don"), ("i", "don"), ("my", "guy"), ("na", "so"), ("well", "well"), ("no", "dey")}
def pidgin_relabel(words, labs, text):
"""sentence-level Pidgin: a sentence of English-labelled words with Pidgin markers is Pidgin (the word tagger only flips the marker's clause)."""
if not labs: return labs
labs = list(labs); f = [fold(w).strip(".,!?;:'\"") for w in words]
ends = [i for i, w in enumerate(words) if re.search(r"[.!?]$", w)] + [len(words) - 1]
s0 = 0
for e in sorted(set(ends)):
idx = list(range(s0, e + 1)); s0 = e + 1
if not idx: continue
hits = sum(1 for i in idx if f[i] in PCM_MARK or f[i] == "omo") + sum(1 for a, b in zip(idx, idx[1:]) if (f[a], f[b]) in PCM_BI and not ((f[a], f[b]) == ("how", "far") and a != idx[0]))
hits += 1 if f[idx[-1]] == "o" and len(idx) > 2 else 0
lat = [i for i in idx if labs[i] in ("eng", "pcm")]
if hits == 0 and len(lat) > 4 and all(labs[i] == "pcm" for i in lat):
for i in lat: labs[i] = "eng"
continue
if lat and (hits >= 2 or (hits >= 1 and len(idx) <= 8)) and sum(labs[i] == "pcm" for i in lat) < len(lat):
for i in lat: labs[i] = "pcm"
return labs
def window_relabel(words, labs, t, win=4, thr=0.5):
"""ASR text has no punctuation/diacritics, so the clause-level tagger sees one long 'English' clause. Slide a GlotLID window
over the words and let confident window votes override the rule labels (rule label keeps a weight-1 vote)."""
from collections import Counter
n = len(words)
if t.lid is None or n < 3: return labs
votes = [Counter({labs[i]: 1.0}) for i in range(n)]
for s0 in range(0, max(1, n - win + 1)):
sc = t._lid_scores(" ".join(words[s0:s0 + win])); l = max(sc, key=sc.get)
if sc[l] >= thr:
for i in range(s0, min(n, s0 + win)): votes[i][l] += sc[l]
return [v.most_common(1)[0][0] for v in votes]
def estimate(text, lid="glotlid", prior=None, prior_w=0.0, asr=False):
"""prior: a previous mix dict (conversation-level style), blended with weight prior_w."""
t = tagger(lid)
toks, labs = t.word_langs(text) if text.strip() else ([], [])
words = [w for _, _, w in toks]
if asr: labs = window_relabel(words, list(labs), t)
labs = pidgin_relabel(words, labs, text)
n = max(1, len(labs))
mix = {l: labs.count(l) / n for l in LANGS if labs.count(l)}
if prior and prior_w > 0:
keys = set(mix) | set(prior)
mix = {k: (1 - prior_w) * mix.get(k, 0) + prior_w * prior.get(k, 0) for k in keys}
s = sum(mix.values()) or 1; mix = {k: v / s for k, v in mix.items() if v / s >= 0.03}
fs = " " + re.sub(r"[^\w\s']", " ", fold(text)) + " "
fs = re.sub(r"\s+", " ", fs)
pat = []
head = " ".join(fs.split()[:5]); tail = " ".join(fs.split()[-5:])
for l in ("ibo", "yor", "hau", "pcm"):
g = _find(head, GREET[l]); c = _find(tail, CLOSE[l])
if g: pat.append(f"greeting:{l}")
if c: pat.append(f"closing:{l}")
d = [p for p in DISC[l] if re.search(r"(^|\s)" + re.escape(p) + r"(\s|$)", fs)]
if d and l in mix: pat.append(f"discourse:{l}")
# ASR often spells a greeting/closing so that the word tagger calls it English: give the matched words to that language
toks_f = fs.split()
for p in pat:
k, _, l = p.partition(":")
if k in ("greeting", "closing") and mix.get(l, 0) < 0.1 and labs:
phr = _find(head if k == "greeting" else tail, (GREET if k == "greeting" else CLOSE)[l]) or ""
share = min(0.5, len(phr.split()) / max(1, len(labs)))
m2 = max(mix, key=mix.get)
mix[m2] = max(0.0, mix[m2] - share); mix[l] = mix.get(l, 0) + share
mix = {a: b for a, b in mix.items() if b > 0.01}
# switch style: count switches between labelled runs
sw = sum(1 for a, b in zip(labs, labs[1:]) if a != b)
if len(mix) > 1:
pat.append("intra-sentential" if sw >= 3 else "inter-sentential")
matrix = max(mix, key=mix.get) if mix else "eng"
return dict(mix={k: round(v, 2) for k, v in sorted(mix.items(), key=lambda x: -x[1])}, matrix=matrix, pattern=pat,
words=words, labels=labs, n_words=len(labs), switches=sw)
def describe(est):
"""natural-language mix instruction for the LLM."""
mix, pat = est["mix"], est["pattern"]
if not mix:
return "Reply in English."
parts = [f"{NAMES[l]} about {round(100 * p / 5) * 5}%" for l, p in mix.items()]
s = f"Match the user's language mix: {', '.join(parts)} of the words. The main (matrix) language is {NAMES[est['matrix']]}."
for p in pat:
k, _, l = p.partition(":")
if k == "greeting": s += f" The user greeted in {NAMES[l]}: open with a short {NAMES[l]} greeting."
if k == "closing": s += f" The user closed in {NAMES[l]}: you may close in {NAMES[l]}."
if k == "discourse": s += f" Sprinkle a {NAMES[l]} discourse word or two the way the user did."
if "intra-sentential" in pat: s += " The user switches inside sentences; do the same naturally."
if len(mix) == 1: s += f" Reply only in {NAMES[est['matrix']]}."
return s
def tvd(a, b):
k = set(a) | set(b)
return 0.5 * sum(abs(a.get(x, 0) - b.get(x, 0)) for x in k)
if __name__ == "__main__":
import sys, json
for s in sys.argv[1:] or ["Ndewo! How are you doing today? I wan ask you something.", "Ẹ káàárọ̀, ṣé dáadáa ni? I need help with my account."]:
e = estimate(s); print(json.dumps({k: e[k] for k in ("mix", "matrix", "pattern")}, ensure_ascii=False), "|", describe(e))
|