UncleanCode's picture
Dynamic Accent Duplex 0.2.0: Dynamic ASR real as default ASR (omniASR fallback)
ac573f1 verified
Raw History Blame
8.42 kB
"""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))