Our own page sets for the reference app and any Open Board Format app: - Adult conversation (41 boards): an always-row (Back, Yes, No, I don't know, Wait), a 0-10 scale, pain, comfort and feelings scales, repairs, word finding, six scripts as one-press-per-line sequences, cards that explain aphasia, ALS/MND, PPA, brain injury and Parkinson's in the person's own voice, care, positioning and breathing requests that claim nothing is sent, life-story and people templates for the person's photos, a page for banked messages, and a staged plan for PPA written for partners. - Intent first in 12, 20 and 40 cells: start pages by purpose, predictable branches, operational commands in one place on every page, popups that close after a choice, and page numbers for a printed partner book. - Themed phrases: nine 6x3 pages of starters, words and messages. - First switches and messages: one-message pages, sequences, press for fun, two and four choices, and learn-to-scan pages. tools/pagesets/phrases/build.py validates the YAML sources (layout, links, symbol concepts, proprietary names, medical-claim wording) and writes the packages, a catalogue and the licence into --out DIR, reproducibly; --check fails on a non-reproducible build or stale reports. Packages are generated by the site build, not committed. Every text was drafted, back-translated by a reader who never saw the English, and judged, in up to three rounds: 10,748 of 10,750 agreed (es fr de pt it zh ja ar hi ru), recorded per text as "agreed by two model readers; native review pending"; the two others stay in English, marked. A new aac-core test runs the generator, then imports every package with the app's importer and an independent OBF validator, and checks structure, links, symbol references, that no language moves a cell, that each cell is in one language and says so, and that translated cells import as the person's own words with their chosen symbols. The design notes propose (not implemented) a sequence extension, popups, speak-only pages, links that say words and one-target scanning, with SPEC text, and how the chooser should offer each set. Co-Authored-By: Claude Opus 5.5 <[email protected]>
291 lines
12 KiB
Python
291 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""The two-reader translation check for the phrase page sets.
|
|
|
|
A text is used in a language only when two independent readers agree on it:
|
|
|
|
1. draft A drafter translates the English (seeing it, with where each text
|
|
is used and what it is for).
|
|
2. blind A second reader, who never sees the English, back-translates the
|
|
drafter's words into English and says how they read (grammar,
|
|
register, whose gender they mark).
|
|
3. judge The English, the translation and the back-translation are
|
|
compared: "agree" only when the back-translation means what the
|
|
English means, with nothing added or lost, in natural words.
|
|
4. merge Agreed texts are recorded as "agreed" (used by generate.py);
|
|
the rest as "disputed" (left in English, visibly) and offered to
|
|
the next round, where the drafter revises them and a fresh blind
|
|
reader checks again.
|
|
|
|
Each round's record is kept under translations/review/<lang>/round<N>/, so
|
|
how each text was obtained is part of the data: draft-input.json (what the
|
|
drafter saw), draft.json, blind-input.json (what the blind reader saw, with
|
|
no English), back.json and verdict.json. The judge's input is derived from
|
|
those (`review.py judge` writes it again), and the <stage>-<n>.json parts
|
|
the readers wrote are joined into the files above.
|
|
|
|
python3 review.py prepare <lang> <round> write draft-input.json (texts
|
|
not yet agreed, with English)
|
|
python3 review.py blind <lang> <round> write blind-input.json from
|
|
draft.json (no English in it)
|
|
python3 review.py judge <lang> <round> write judge-input.json
|
|
python3 review.py join <lang> <round> <stage> join <stage>-<n>.json parts
|
|
(draft, back, verdict)
|
|
python3 review.py merge <lang> <round> fold verdict.json into
|
|
translations/<lang>.json
|
|
python3 review.py prune drop translations of retired texts
|
|
python3 review.py status one line per language
|
|
|
|
The readers were AI models; translations/<lang>.json says so, and that a
|
|
native speaker's review is still wanted for every text.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from build import AGREED, LANGUAGES, STATUS_TEXT, TRANSLATIONS, WORK
|
|
|
|
REVIEW = TRANSLATIONS / "review"
|
|
|
|
# What each language's words should sound like. Shared by every round.
|
|
LANGUAGE_NOTES = {
|
|
"es": "Spanish understood in Spain and Latin America: prefer words common to both; where they differ, use the most widely understood and note it.",
|
|
"fr": "French (France). Use a narrow no-break space (U+202F) before ! ? ; and a no-break space (U+00A0) before :, so large print never wraps them alone.",
|
|
"de": "German (Germany).",
|
|
"pt": "Brazilian Portuguese.",
|
|
"it": "Italian (Italy).",
|
|
"zh": "Simplified Chinese (Mainland), everyday spoken Mandarin.",
|
|
"ja": "Japanese. Polite です/ます for the adult set; natural, friendly speech elsewhere.",
|
|
"ar": "Modern Standard Arabic, simple and natural, as a speech voice reads it. Avoid addressing one gender: use the plural or a neutral construction for 'you'.",
|
|
"hi": "Hindi in Devanagari, everyday spoken Hindi (not Sanskritised). Use आप for 'you' in requests.",
|
|
"ru": "Russian. Use Вы for 'you' in the adult set; ты is natural in the emergent set.",
|
|
}
|
|
|
|
|
|
def sid(english: str) -> str:
|
|
"""A stable id for an English text."""
|
|
return hashlib.sha1(english.encode()).hexdigest()[:10]
|
|
|
|
|
|
def strings() -> dict[str, dict]:
|
|
path = WORK / "strings.json"
|
|
if not path.exists():
|
|
sys.exit("run `python3 generate.py strings` first")
|
|
return json.loads(path.read_text())
|
|
|
|
|
|
def family(uses: list[str]) -> list[str]:
|
|
fams = []
|
|
for u in uses:
|
|
f = u.split(" ")[0].rstrip(":")
|
|
if f in ("adult", "intent", "themes", "emergent") and f not in fams:
|
|
fams.append(f)
|
|
return fams or ["all"]
|
|
|
|
|
|
def lang_file(lang: str) -> Path:
|
|
return TRANSLATIONS / f"{lang}.json"
|
|
|
|
|
|
def load_lang(lang: str) -> dict:
|
|
path = lang_file(lang)
|
|
if path.exists():
|
|
return json.loads(path.read_text())
|
|
name, locale = LANGUAGES[lang]
|
|
return {
|
|
"language": name,
|
|
"locale": locale,
|
|
"about": (
|
|
"Translations of the phrase page sets. Each text was drafted, then back-translated by a second "
|
|
"reader who never saw the English, and the two compared. A text is used only where they agreed "
|
|
f"(status \"agreed\": {STATUS_TEXT}); \"disputed\" texts stay in English. The readers were AI "
|
|
"models (2026-10-05), not native speakers: a native speaker's review is still wanted for every text."
|
|
),
|
|
"strings": {},
|
|
}
|
|
|
|
|
|
PART_SIZE = 280
|
|
|
|
|
|
def write_parts(d: Path, stage: str, out: dict) -> int:
|
|
"""Write <stage>-input.json and <stage>-input-<n>.json parts of at most
|
|
PART_SIZE items, so a reader can take them one at a time. Returns the
|
|
number of parts."""
|
|
(d / f"{stage}-input.json").write_text(json.dumps(out, ensure_ascii=False, indent=1) + "\n")
|
|
for old in d.glob(f"{stage}-input-*.json"):
|
|
old.unlink()
|
|
items = out["items"]
|
|
n = 0
|
|
for n, i in enumerate(range(0, len(items), PART_SIZE), start=1):
|
|
part = dict(out, items=items[i:i + PART_SIZE], part=f"{n}")
|
|
(d / f"{stage}-input-{n}.json").write_text(json.dumps(part, ensure_ascii=False, indent=1) + "\n")
|
|
return n
|
|
|
|
|
|
def join(lang: str, rnd: int, stage: str) -> None:
|
|
"""Join <stage>-<n>.json parts into <stage>.json."""
|
|
d = round_dir(lang, rnd)
|
|
merged: dict = {}
|
|
parts = sorted(d.glob(f"{stage}-[0-9]*.json"), key=lambda p: int(p.stem.split("-")[-1]))
|
|
for part in parts:
|
|
merged.update(json.loads(part.read_text()))
|
|
(d / f"{stage}.json").write_text(json.dumps(merged, ensure_ascii=False, indent=1) + "\n")
|
|
source = {"draft": "draft", "back": "blind", "verdict": "judge"}[stage]
|
|
wanted = {i["id"] for i in json.loads((d / f"{source}-input.json").read_text())["items"]}
|
|
missing = sorted(wanted - set(merged))
|
|
bad = sorted(k for k, v in merged.items() if stage == "draft" and not (
|
|
isinstance(v.get("text"), str) or (isinstance(v.get("forms"), dict) and set(v["forms"]) == {"f", "m"})))
|
|
print(f"{lang} round {rnd}: {stage}.json has {len(merged)} entries from {len(parts)} parts;"
|
|
f" {len(missing)} missing{(': ' + ', '.join(missing[:10])) if missing else ''};"
|
|
f" {len(bad)} malformed{(': ' + ', '.join(bad[:10])) if bad else ''}")
|
|
|
|
|
|
def round_dir(lang: str, rnd: int) -> Path:
|
|
d = REVIEW / lang / f"round{rnd}"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
def prepare(lang: str, rnd: int) -> None:
|
|
done = load_lang(lang)["strings"]
|
|
previous = {}
|
|
if rnd > 1:
|
|
prev = REVIEW / lang / f"round{rnd - 1}"
|
|
verdicts = json.loads((prev / "verdict.json").read_text())
|
|
drafts = json.loads((prev / "draft.json").read_text())
|
|
previous = {k: {"previous": drafts.get(k), "why_not_agreed": v} for k, v in verdicts.items()}
|
|
items = []
|
|
for en, meta in strings().items():
|
|
k = sid(en)
|
|
if done.get(en, {}).get("status") == AGREED:
|
|
continue
|
|
if rnd > 1 and k not in previous and en in done:
|
|
# Agreed or still waiting in a round of its own: only texts that
|
|
# were disputed, or that no round has seen yet, go to this one.
|
|
continue
|
|
item = {"id": k, "en": en, "role": meta["roles"], "sets": family(meta["uses"]), "used_on": meta["uses"]}
|
|
if meta["notes"]:
|
|
item["notes"] = meta["notes"]
|
|
if k in previous:
|
|
item.update(previous[k])
|
|
items.append(item)
|
|
out = {"language": LANGUAGES[lang][0], "locale": LANGUAGES[lang][1], "guidance": LANGUAGE_NOTES[lang],
|
|
"items": items}
|
|
n = write_parts(round_dir(lang, rnd), "draft", out)
|
|
print(f"{lang} round {rnd}: {len(items)} texts to draft in {n} parts")
|
|
|
|
|
|
def blind(lang: str, rnd: int) -> None:
|
|
d = round_dir(lang, rnd)
|
|
draft = json.loads((d / "draft.json").read_text())
|
|
by_id = {sid(en): meta for en, meta in strings().items()}
|
|
items = []
|
|
for k, t in draft.items():
|
|
meta = by_id.get(k)
|
|
if meta is None:
|
|
continue # a text the sources no longer use
|
|
item = {"id": k, "role": [r for r in meta["roles"]], "sets": family(meta["uses"])}
|
|
if "forms" in t:
|
|
item["forms"] = t["forms"]
|
|
else:
|
|
item["text"] = t["text"]
|
|
items.append(item)
|
|
out = {"language": LANGUAGES[lang][0], "items": items}
|
|
n = write_parts(d, "blind", out)
|
|
print(f"{lang} round {rnd}: {len(items)} texts for the blind reader in {n} parts")
|
|
|
|
|
|
def judge(lang: str, rnd: int) -> None:
|
|
d = round_dir(lang, rnd)
|
|
draft = json.loads((d / "draft.json").read_text())
|
|
back = json.loads((d / "back.json").read_text())
|
|
by_id = {sid(en): (en, meta) for en, meta in strings().items()}
|
|
items = []
|
|
for k, t in draft.items():
|
|
if k not in by_id:
|
|
continue # a text the sources no longer use
|
|
en, meta = by_id[k]
|
|
item = {"id": k, "en": en, "role": meta["roles"], "sets": family(meta["uses"])}
|
|
item.update({kk: vv for kk, vv in t.items() if kk in ("text", "forms", "note")})
|
|
item["back_translation"] = back.get(k, {"missing": True})
|
|
items.append(item)
|
|
out = {"language": LANGUAGES[lang][0], "items": items}
|
|
n = write_parts(d, "judge", out)
|
|
print(f"{lang} round {rnd}: {len(items)} texts to judge in {n} parts")
|
|
|
|
|
|
def merge(lang: str, rnd: int) -> None:
|
|
d = round_dir(lang, rnd)
|
|
draft = json.loads((d / "draft.json").read_text())
|
|
back = json.loads((d / "back.json").read_text())
|
|
verdict = json.loads((d / "verdict.json").read_text())
|
|
by_id = {sid(en): en for en in strings()}
|
|
data = load_lang(lang)
|
|
agreed = disputed = 0
|
|
for k, t in draft.items():
|
|
en = by_id.get(k)
|
|
if en is None:
|
|
continue
|
|
v = verdict.get(k, {"verdict": "missing"})
|
|
entry = {"status": AGREED if v.get("verdict") == "agree" else "disputed", "round": rnd}
|
|
if "forms" in t:
|
|
entry["forms"] = t["forms"]
|
|
else:
|
|
entry["text"] = t["text"]
|
|
b = back.get(k, {})
|
|
entry["back_translation"] = b.get("back") or {g: b.get(f"back_{g}") for g in ("f", "m") if b.get(f"back_{g}")}
|
|
notes = [x for x in (t.get("note"), b.get("notes"), v.get("reason")) if x]
|
|
entry["notes"] = " / ".join(notes)
|
|
data["strings"][en] = entry
|
|
if entry["status"] == AGREED:
|
|
agreed += 1
|
|
else:
|
|
disputed += 1
|
|
data["strings"] = dict(sorted(data["strings"].items()))
|
|
lang_file(lang).write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n")
|
|
print(f"{lang} round {rnd}: {agreed} agreed, {disputed} disputed")
|
|
|
|
|
|
def prune() -> None:
|
|
"""Drop translations of English texts the sources no longer use (their
|
|
review records stay under translations/review/)."""
|
|
current = strings()
|
|
for lang in LANGUAGES:
|
|
data = load_lang(lang)
|
|
gone = [en for en in data["strings"] if en not in current]
|
|
for en in gone:
|
|
del data["strings"][en]
|
|
lang_file(lang).write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n")
|
|
print(f"{lang}: dropped {len(gone)} retired texts{(': ' + '; '.join(gone)) if gone else ''}")
|
|
|
|
|
|
def status() -> None:
|
|
all_s = strings()
|
|
for lang in LANGUAGES:
|
|
st = load_lang(lang)["strings"]
|
|
a = sum(1 for en in all_s if st.get(en, {}).get("status") == AGREED)
|
|
dsp = sum(1 for en in all_s if st.get(en, {}).get("status") == "disputed")
|
|
print(f"{lang}: {a} agreed, {dsp} disputed, {len(all_s) - a - dsp} not yet translated, of {len(all_s)}")
|
|
|
|
|
|
def main() -> None:
|
|
cmd = sys.argv[1]
|
|
if cmd == "status":
|
|
status()
|
|
return
|
|
if cmd == "prune":
|
|
prune()
|
|
return
|
|
lang, rnd = sys.argv[2], int(sys.argv[3])
|
|
if cmd == "join":
|
|
join(lang, rnd, sys.argv[4])
|
|
return
|
|
{"prepare": prepare, "blind": blind, "judge": judge, "merge": merge}[cmd](lang, rnd)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|