open-standards/aac/aac-rs/tools/ai/eval/judge.py
Transaction Science 2344443d9f feat(aac sentence helper): a larger model for the languages the small one cannot write
The catalogue can now list more than one model. Each language names the
smallest model that passed its test; a model may cover a smaller one (it
serves that one's languages too), and asks for a device with at least
min_memory_gb of memory. A device keeps one model: in a language the model
on it does not serve, Settings says the language uses a larger model, with
its download size and memory, and offers it in place of the one there.
Nothing downloads by itself.

build_ai.py builds any set of pinned models (--source ID=FILE, --models).
The evaluation kit gains run.mjs LANGUAGES=1 (test languages before they
are published) and ITEMS=, merge_languages.py (add a language's exchanges
and examples as not yet tested), and judge.py (a local Gemma 4 31B as one
more reader of the answers).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 18:39:17 -04:00

128 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Judge the sentence helper's answers with a large local model.
python tools/ai/eval/judge.py ANSWERS.json OUT.json [--model mlx-community/gemma-4-31b-it-8bit] [lang...]
ANSWERS.json is what run.mjs wrote. For every "sentence" answer (the words
chosen and what the partner said), the judge says whether one of the three
suggestions says what the person meant, as a native speaker would say it.
For every "reply" answer, it counts the replies that are natural, grammatical
and fit what the partner said. The judge sees the person's intended sentence
(the item's "target") only for sentences.
This is one reader among others: languages are marked ready only when the
people reviewing agree (see ../README.md). Needs mlx-lm (Apple silicon).
"""
import argparse
import json
import re
import sys
from pathlib import Path
NAMES = None # filled from the answers' languages when known
SENTENCE_RUBRIC = """You check suggestions made by an AAC app for a person who cannot speak. The person chose a few words (and their partner may have said something first); the app suggested three sentences in {lang}. You know what the person meant.
Partner said: {partner}
Words chosen: {words}
What the person meant: {target}
Suggestions:
1. {s1}
2. {s2}
3. {s3}
Is at least one suggestion a sentence a native {lang} speaker would naturally say, with correct grammar, that means what the person meant? Small differences are fine (a synonym, slightly more or fewer words, another tense or a gendered form, a polite variant). Not fine: a different or reversed meaning, speaking for the partner instead of the person, or broken grammar.
Answer with JSON only: {{"usable": true or false, "best": the number of the best suggestion or 0, "why": "a few words"}}"""
REPLY_RUBRIC = """You check reply suggestions made by an AAC app for a person who cannot speak. Their partner said something in {lang}; the app suggested replies the person might say back.
Partner said: {partner}
Replies:
{replies}
Count the replies that a native {lang} speaker would naturally say back to this, in the first person, with correct grammar, and that make sense as an answer.
Answer with JSON only: {{"good": a number from 0 to {n}, "why": "a few words"}}"""
def lines(text, n):
out = []
for line in text.splitlines():
s = re.sub(r"^\s*(?:[-*•]|\d+[.)])\s*", "", line).strip().strip('"').strip()
if s:
out.append(s)
return (out + ["(none)"] * n)[:n]
def parse(text):
m = re.search(r"\{.*\}", text, re.S)
if not m:
return None
try:
return json.loads(m.group(0))
except json.JSONDecodeError:
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("answers")
ap.add_argument("out")
ap.add_argument("langs", nargs="*")
ap.add_argument("--model", default="mlx-community/gemma-4-31b-it-8bit")
ap.add_argument("--names", default=str(Path(__file__).resolve().parent.parent / "languages.json"))
args = ap.parse_args()
from mlx_lm import generate, load
names = {c: l["name"] for c, l in json.loads(Path(args.names).read_text())["languages"].items()}
data = json.loads(Path(args.answers).read_text())
answers = [a for a in data["answers"] if not args.langs or a["lang"] in args.langs]
model, tok = load(args.model)
def ask(prompt):
msgs = [{"role": "user", "content": prompt}]
try:
text = tok.apply_chat_template(msgs, add_generation_prompt=True, tokenize=False, enable_thinking=False)
except TypeError:
text = tok.apply_chat_template(msgs, add_generation_prompt=True, tokenize=False)
return generate(model, tok, prompt=text, max_tokens=80, verbose=False)
out, done = [], 0
for a in answers:
lang = names.get(a["lang"], a["lang"])
if a["kind"] == "sentence":
s = lines(a["text"], 3)
verdict = parse(ask(SENTENCE_RUBRIC.format(lang=lang, partner=a["partner"] or "(nothing)", words=a["words"],
target=a["target"], s1=s[0], s2=s[1], s3=s[2])))
out.append({"lang": a["lang"], "kind": "sentence", "words": a["words"], "target": a["target"],
"usable": bool(verdict and verdict.get("usable")), "verdict": verdict})
elif a["kind"] == "reply":
r = lines(a["text"], 4)
verdict = parse(ask(REPLY_RUBRIC.format(lang=lang, partner=a["partner"], n=4,
replies="\n".join(f"{i + 1}. {x}" for i, x in enumerate(r)))))
good = verdict.get("good") if verdict else None
out.append({"lang": a["lang"], "kind": "reply", "partner": a["partner"],
"good": good if isinstance(good, int) else None, "verdict": verdict})
done += 1
if done % 20 == 0:
print(f" {done}/{len(answers)}", file=sys.stderr, flush=True)
summary = {}
for code in sorted({o["lang"] for o in out}):
sent = [o for o in out if o["lang"] == code and o["kind"] == "sentence"]
reps = [o for o in out if o["lang"] == code and o["kind"] == "reply" and o["good"] is not None]
summary[code] = {
"sentences_usable": sum(o["usable"] for o in sent), "of": len(sent),
"replies_good": sum(o["good"] for o in reps), "replies_of": 4 * len(reps),
}
Path(args.out).write_text(json.dumps({"judge": args.model, "summary": summary, "items": out},
ensure_ascii=False, indent=1) + "\n")
for code, s in summary.items():
print(f"{code}: sentences {s['sentences_usable']}/{s['of']}, replies {s['replies_good']}/{s['replies_of']}")
if __name__ == "__main__":
main()