open-standards/aac/aac-rs/tools/ai/check_packs.py
Transaction Science 3e88f9fbc9 feat(aac sentence helper): whole sentences and replies, suggested on the device
The sentence helper is an optional download (Settings → Sentence helper):
Gemma 4 E2B in Google's web build, run by Google's LiteRT-LM web runtime on
the graphics chip, in a worker. ✨ beside the message turns the words chosen
into three sentences, fitting what the partner said if it was typed in, or
suggests four replies when no words are chosen yet. The suggestions take the
word predictions' place, at their height, so no cell moves (SPEC §3); the
person chooses one and speaks it. Nothing is spoken by itself, and nothing
typed leaves the device.

- ai_helper.rs: the catalogue (aac-ai-index/1), prompts, whether a device can
  run it (a WebGPU hardware adapter, never the processor), download, continue,
  remove and re-check, and suggestions that a later request supersedes. The
  model's parts (64 MiB, SHA-256 checked) and the one runtime build this
  browser runs live in the model store; the worker streams the parts from
  there into GPU memory.
- third_party/litert-lm: @litert-lm/core 0.17.1 vendored and pinned (npm
  integrity, each file's size and SHA-256), with our worker bundled by esbuild
  0.28.2; the runtime's CDN default is emptied.
- tools/ai: build_ai.py (the pinned model → parts and catalogue),
  check_packs.py, languages.json (English, Spanish, German and Hindi ready;
  Swahili not yet), and eval/: the 20 exchanges, a harness that runs the
  app's own prompts (a test keeps them word for word), and the judged answers.
- Measured: Chrome on an M5 Max, 0.4-0.7 s an answer. A real iPad Air (M3) in
  Safari 27: 101 s to download and check 2 GB, about 80 s for the first start,
  5.9 s for later starts, 0.8-1.4 s an answer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 06:52:48 -04:00

80 lines
2.9 KiB
Python

#!/usr/bin/env python3
"""Check a sentence-helper directory before a site build ships it.
python3 tools/ai/check_packs.py DIR
DIR must hold what tools/ai/build_ai.py built for the committed catalogue
(data/ai/index.json): its index.json points at index.<sha12>.json, whose
bytes are the committed catalogue's, and every part and licence file that
catalogue names is there with its exact size and SHA-256. Otherwise this exits
non-zero and says what is wrong, so a build never ships a model that
disagrees with the catalogue, or that is incomplete.
Standard library only; Python 3.9 or later (macOS's own python3).
"""
import hashlib
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
COMMITTED = HERE.parents[1] / "data" / "ai" / "index.json"
SHOW = 10
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 22), b""):
h.update(chunk)
return h.hexdigest()
def main(argv):
if len(argv) != 2:
print(__doc__, file=sys.stderr)
return 2
root = Path(argv[1])
problems = []
try:
pointer = json.loads((root / "index.json").read_text(encoding="utf-8"))
name = pointer["current"]
cat_bytes = (root / name).read_bytes()
except (OSError, ValueError, KeyError) as e:
print(f"✗ {root}: no readable index.json and catalogue ({e})", file=sys.stderr)
return 1
if cat_bytes != COMMITTED.read_bytes():
problems.append(f"{name} is not the committed catalogue ({COMMITTED})")
if name != f"index.{hashlib.sha256(cat_bytes).hexdigest()[:12]}.json":
problems.append(f"{name} is not named by its contents")
cat = json.loads(cat_bytes)
refs = []
for model in cat.get("models", {}).values():
refs += [(p["url"], p["bytes"], p["sha256"]) for p in model.get("parts", [])]
lf = model.get("licence_file")
if lf:
data = (root / lf).read_bytes() if (root / lf).is_file() else b""
refs.append((lf, len(data) if data else -1, hashlib.sha256(data).hexdigest() if data else ""))
if not (root / "LICENSES.md").is_file():
problems.append("LICENSES.md is missing")
for rel, size, digest in refs:
f = root / rel
if not f.is_file():
problems.append(f"missing {rel}")
elif f.stat().st_size != size:
problems.append(f"{rel} is {f.stat().st_size} bytes, the catalogue says {size}")
elif sha256(f) != digest:
problems.append(f"{rel} does not match its SHA-256")
if problems:
for p in problems[:SHOW]:
print(f"✗ {p}", file=sys.stderr)
if len(problems) > SHOW:
print(f" … and {len(problems) - SHOW} more", file=sys.stderr)
return 1
print(f"✓ {root}: {len(refs)} files match {name}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))