File size: 4,704 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
"""Reply validator: tags well-formed, language proportions vs target mix, greeting pattern kept, length, loops.
Scores a candidate; the agent keeps the best passing candidate, regenerates up to N rounds, then falls back."""
import re
from collections import Counter
import mix as M

TAG = re.compile(r"<(eng|ibo|yor|hau|pcm)>(.*?)</\1>", re.S)

def parse(out):
    out = out.strip().splitlines()[0].strip() if out.strip() else ""
    spans = TAG.findall(out)
    rest = TAG.sub(" ", out)
    return out, spans, re.sub(r"[\s.,!?;:'\"\-–—…]", "", rest)

def check(out, target, max_words=40, min_words=2, lid="glotlid", tvd_max=0.3, user=None):
    out, spans, rest = parse(out)
    r = dict(text=out, ok=False, why=[], tvd=1.0)
    if not spans:
        r["why"].append("no tags"); return r
    if rest:
        r["why"].append("text outside tags")
    words = " ".join(t for _, t in spans).split()
    if not (min_words <= len(words) <= max_words):
        r["why"].append(f"length {len(words)}")
    low = [w.lower().strip(".,!?") for w in words]
    tri = Counter(tuple(low[i:i + 3]) for i in range(len(low) - 2))
    if tri and tri.most_common(1)[0][1] > 2:
        r["why"].append("loop")
    if user:   # parroting: the LLM copies the user's (often mis-recognised) words instead of answering
        uw = [w.lower().strip(".,!?") for w in user.split()]
        ug = {tuple(uw[i:i + 3]) for i in range(len(uw) - 2)}
        rg = [tuple(low[i:i + 3]) for i in range(len(low) - 2)]
        r["echo"] = round(sum(g in ug for g in rg) / len(rg), 2) if rg else 0.0
        if r["echo"] > 0.5 and len(low) >= 6: r["why"].append(f"echo {r['echo']}")   # short closings may legitimately mirror the user
    # declared mix (by tag) and measured mix (re-tag the plain text with the LID tagger)
    n = max(1, len(words))
    decl = Counter()
    for l, t in spans: decl[l] += len(t.split())
    decl = {k: v / n for k, v in decl.items()}
    plain = " ".join(t for _, t in spans)
    meas = M.estimate(plain, lid=lid)
    # measured labels can confuse English/Pidgin; merge eng+pcm disagreement softly by averaging declared and measured
    mixd = {k: 0.5 * decl.get(k, 0) + 0.5 * meas["mix"].get(k, 0) for k in set(decl) | set(meas["mix"])}
    tgt = target["mix"]
    d = M.tvd(mixd, tgt)
    r.update(declared={k: round(v, 2) for k, v in decl.items()}, measured=meas["mix"], mix=({k: round(v, 2) for k, v in mixd.items() if v > 0.01}), tvd=round(d, 3))
    # tag honesty: words tagged ibo/yor/hau that the tagger thinks are English
    lab = meas["labels"]; i = 0; mism = 0; ng = 0
    for l, t in spans:
        k = len(M.tagger(lid).word_langs(t)[1]) if t.strip() else 0
        if l in ("ibo", "yor", "hau"):
            ng += k; mism += sum(1 for x in lab[i:i + k] if x == "eng")
        i += k
    r["tag_mismatch"] = round(mism / ng, 2) if ng else 0.0
    if r["tag_mismatch"] > 0.5: r["why"].append("non-English tags over English words")
    if d > tvd_max: r["why"].append(f"mix drift tvd={d:.2f}")
    for l, p in tgt.items():                     # every substantial target language must appear
        if p >= 0.15 and mixd.get(l, 0) < 0.05: r["why"].append(f"missing {l}")
    for p in target.get("pattern", []):          # greeting pattern preserved
        k, _, l = p.partition(":")
        if k == "greeting" and spans and spans[0][0] != l: r["why"].append(f"no {l} greeting")
    r["ok"] = not r["why"]
    r["score"] = d + 0.25 * len(r["why"]) + 0.2 * r["tag_mismatch"]
    return r

SAFE = {"ibo": "Ndewo!", "yor": "Ẹ pẹ̀lẹ́ o!", "hau": "Sannu!", "pcm": "Abeg no vex,"}

def fallback(text_candidates, target):
    """all rounds failed. Echoing/drifting native-language output -> an honest short reply (a correct phrasebook opener in the user's
    language + a clarification request); otherwise the best-scoring candidate if its tags parse; else plain text in the matrix language."""
    best = min(text_candidates, key=lambda c: c["score"] if "score" in c else 9, default=None)
    m = target.get("matrix", "eng")
    if m in SAFE and (best is None or any(w.startswith(("echo", "mix drift", "missing", "no tags", "non-English")) for w in best.get("why", []))):
        tail = "<pcm>I no too hear you well. Abeg talk am again?</pcm>" if m == "pcm" else "<eng>Sorry, I did not catch all of that. Could you say it again, or in English?</eng>"
        return f"<{m}>{SAFE[m]}</{m}> {tail}", "safe_phrasebook"
    if best and TAG.search(best["text"] or ""):
        return best["text"], "best_failed"
    m = target.get("matrix", "eng"); raw = re.sub(r"</?\w+>", "", (best or {}).get("text", "") or "Sorry, say that again?")
    return f"<{m}>{raw.strip()}</{m}>", "plain_matrix"