Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions MASTER_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,3 +371,8 @@ on ours. License check before every download.
debt (measured):** 16 transformer signs the avatar cannot play — BOOK, BUY, FEEL,
HE, LEARN, LIKE, LIVE, MAKE, MILK, MY, NAME, PHONE, SOUTH AFRICA, TEACH, THINK,
YOUR. Record real signer data for these (plus CALL, HERE) before expanding further.
- 2026-07-05 — First dev-tag mining complete: offline phrase library (60 phrases,
Tier 0 exact-match before all other tiers) + contraction/SA-slang normalisation
ported from `rescue/dev-2026-07` into the rules-first pipeline. Copula 'AM'
glosses removed from the phrase data (copulas are not signed); the full file is
flagged for SASL interpreter review (D15). 8 new tests.
63 changes: 63 additions & 0 deletions backend/data/offline_phrases.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"_comment": "Offline phrase → SASL gloss map, mined from tag rescue/dev-2026-07 (FEAT-2). English copula 'AM' glosses removed (copulas are not signed); all other sequences preserved verbatim. review_status: draft — entire file awaits SASL interpreter review (MASTER_PLAN D15).",
"where does it hurt": ["WHERE", "HURT", "YOU"],
"i need a doctor": ["DOCTOR", "NEED", "I"],
"call an ambulance": ["AMBULANCE", "CALL", "PLEASE"],
"i am in pain": ["PAIN", "I", "HAVE"],
"i cannot breathe": ["BREATHE", "CAN", "NOT", "I"],
"i am allergic": ["ALLERGIC", "I"],
"i am diabetic": ["DIABETIC", "I"],
"i take medication": ["MEDICINE", "I", "TAKE"],
"i am pregnant": ["PREGNANT", "I"],
"i feel dizzy": ["DIZZY", "I", "FEEL"],
"i feel nauseous": ["SICK", "I", "FEEL"],
"i have a fever": ["FEVER", "I", "HAVE"],
"my chest hurts": ["CHEST", "HURT", "MY"],
"i cannot walk": ["WALK", "CAN", "NOT", "I"],
"i need water": ["WATER", "NEED", "I"],
"i need food": ["FOOD", "NEED", "I"],
"i need the toilet": ["TOILET", "NEED", "I"],
"i need help": ["HELP", "NEED", "I"],
"please help me": ["HELP", "PLEASE", "ME"],
"call the police": ["POLICE", "CALL", "PLEASE"],
"i am deaf": ["DEAF", "I"],
"i use sign language": ["SIGN", "I", "USE"],
"please write it down": ["WRITE", "PLEASE"],
"please speak slowly": ["SLOW", "PLEASE", "SPEAK"],
"i do not understand": ["UNDERSTAND", "NOT", "I"],
"can you repeat that": ["REPEAT", "CAN", "YOU"],
"yes": ["YES"],
"no": ["NO"],
"thank you": ["THANK YOU"],
"please": ["PLEASE"],
"i agree": ["AGREE", "I"],
"i disagree": ["DISAGREE", "I"],
"i am scared": ["SCARED", "I"],
"i am angry": ["ANGRY", "I"],
"i am sad": ["SAD", "I"],
"i am happy": ["HAPPY", "I"],
"i am tired": ["TIRED", "I"],
"i am hungry": ["HUNGRY", "I"],
"i am thirsty": ["THIRSTY", "I"],
"i need my medication": ["MEDICINE", "MY", "NEED", "I"],
"i have a headache": ["HEAD", "HURT", "I", "HAVE"],
"my blood pressure is high": ["BLOOD", "PRESSURE", "HIGH"],
"i have asthma": ["ASTHMA", "I", "HAVE"],
"i have epilepsy": ["EPILEPSY", "I", "HAVE"],
"i am having a seizure": ["SEIZURE", "I", "HAVE"],
"i need an interpreter": ["INTERPRETER", "NEED", "I"],
"this is an emergency": ["EMERGENCY"],
"i am lost": ["LOST", "I"],
"where is the hospital": ["HOSPITAL", "WHERE"],
"where is the bathroom": ["TOILET", "WHERE"],
"how much does it cost": ["COST", "HOW MUCH"],
"i cannot afford it": ["MONEY", "NOT", "ENOUGH", "I"],
"i need a wheelchair": ["WHEELCHAIR", "NEED", "I"],
"i have rights": ["RIGHTS", "I", "HAVE"],
"i want a lawyer": ["LAWYER", "WANT", "I"],
"i do not consent": ["CONSENT", "NOT", "I"],
"stop": ["STOP"],
"wait": ["WAIT"],
"come here": ["COME", "HERE"],
"go away": ["GO", "AWAY"]
}
82 changes: 82 additions & 0 deletions backend/services/sasl_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@
All AI runs locally via Ollama — no cloud API keys needed.
"""

import json
import logging
import os
import time as _time
from pathlib import Path

logger = logging.getLogger(__name__)

Expand All @@ -35,6 +37,68 @@
# rules leave sign coverage below this fraction of tokens.
LLM_ASSIST_COVERAGE_THRESHOLD = 0.70

# ── Offline phrase library (mined from tag rescue/dev-2026-07, FEAT-2) ──────
# Exact-match phrase → SASL gloss sequence, checked before any other tier.
# Loaded once at import time from backend/data/offline_phrases.json.
_OFFLINE_PHRASES: dict[str, list[str]] = {}
_OFFLINE_PHRASES_PATH = Path(__file__).resolve().parent.parent / "data" / "offline_phrases.json"


def _load_offline_phrases() -> None:
global _OFFLINE_PHRASES
try:
with open(_OFFLINE_PHRASES_PATH, encoding="utf-8") as fh:
data = json.load(fh)
_OFFLINE_PHRASES = {k: v for k, v in data.items() if not k.startswith("_")}
logger.info("[SASL] Loaded %d offline phrases", len(_OFFLINE_PHRASES))
except Exception as exc:
logger.warning("[SASL] Could not load offline phrases: %s", exc)


_load_offline_phrases()

# ── Contraction / SA slang normalisation map (mined from rescue/dev-2026-07) ─
_CONTRACTIONS: dict[str, str] = {
"i'm": "i am", "i'll": "i will", "i've": "i have", "i'd": "i would",
"you're": "you are", "you'll": "you will", "you've": "you have",
"he's": "he is", "she's": "she is", "it's": "it is",
"we're": "we are", "we'll": "we will", "we've": "we have",
"they're": "they are", "they'll": "they will", "they've": "they have",
"don't": "do not", "doesn't": "does not", "didn't": "did not",
"won't": "will not", "wouldn't": "would not", "couldn't": "could not",
"shouldn't": "should not", "can't": "cannot", "isn't": "is not",
"aren't": "are not", "wasn't": "was not", "weren't": "were not",
"haven't": "have not", "hasn't": "has not", "hadn't": "had not",
"gonna": "going to", "wanna": "want to", "gotta": "got to",
# SA informal words
"howzit": "hello how are you",
"eish": "", "yoh": "",
"lekker": "good", "sharp": "okay",
"ja": "yes", "nee": "no",
}


def _normalize_informal(text: str) -> str:
"""Expand contractions and normalise SA informal words before the pipeline.

Multi-word keys are replaced as substrings first (longest first); then
single-word tokens are expanded. Empty expansions drop the word.
"""
text_lower = text.lower()
for phrase, expansion in sorted(_CONTRACTIONS.items(), key=lambda x: len(x[0]), reverse=True):
if " " in phrase and phrase in text_lower:
text_lower = text_lower.replace(phrase, expansion if expansion else "", 1)

words = text_lower.split()
result = []
for w in words:
expanded = _CONTRACTIONS.get(w)
if expanded is None:
result.append(w)
elif expanded:
result.extend(expanded.split())
return " ".join(result)

# ── FEAT-5: Multilingual constants ─────────────────────────────────────────

# Ollama base URL and model for translation (reuse shared env vars)
Expand Down Expand Up @@ -208,6 +272,24 @@ def _build_result(signs, gloss_text, english, **extras):
# Import once — used by both tier 1 and tier 2
from sasl_transformer.models import TranslationRequest

# ── Step 0: normalise informal input, then exact offline phrase match ─
# Checked after normalisation so contractions expand first:
# "I'm in pain!" → "i am in pain" → matches the offline entry.
text = _normalize_informal(text)
_phrase_key = text.lower().strip(" .!?,")
if _phrase_key in _OFFLINE_PHRASES:
_signs = _OFFLINE_PHRASES[_phrase_key]
_gloss = " ".join(_signs)
_lib = _sasl_transformer.sign_library
_known = sum(1 for s in _signs if _lib.has_sign(s))
_coverage = round(_known / len(_signs), 3) if _signs else 1.0
logger.info("[SASL] Tier 0 (phrase) '%s' → '%s' coverage=%.2f", _phrase_key, _gloss, _coverage)
return _build_result(
_signs, _gloss, text,
sign_coverage=_coverage,
fingerspelled=[s for s in _signs if not _lib.has_sign(s)],
)

# ── D4 (MASTER_PLAN): deterministic rules run FIRST. ──────────────────
# The LLM is an optional assist consulted only when rules leave too many
# words unsigned, and its output is used only if it measurably improves
Expand Down
66 changes: 66 additions & 0 deletions tests/test_offline_phrases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""
Tests for the offline phrase library tier (mined from rescue/dev-2026-07).

Proves:
- exact phrase match bypasses all other tiers (Tier 0)
- contraction/SA-slang normalisation feeds the phrase match
- punctuation does not break matching
- no copula glosses (AM/IS/ARE) remain in the phrase data
- non-phrase input still flows to the rules tier

Run with: pytest tests/test_offline_phrases.py -v
No Ollama needed — phrase and rules tiers are fully offline.
"""

import asyncio

from backend.services import sasl_pipeline


def _run(text):
return asyncio.run(sasl_pipeline.text_to_sasl_signs(text))


def test_phrases_loaded():
assert len(sasl_pipeline._OFFLINE_PHRASES) >= 50


def test_exact_phrase_match():
result = _run("I need help")
assert result["signs"] == ["HELP", "NEED", "I"]


def test_punctuation_does_not_break_match():
result = _run("I need help!")
assert result["signs"] == ["HELP", "NEED", "I"]


def test_contraction_normalisation_feeds_phrase_match():
"""'I'm hungry' must expand to 'i am hungry' and hit the phrase entry."""
result = _run("I'm hungry")
assert result["signs"] == ["HUNGRY", "I"]


def test_sa_slang_normalisation():
"""'Howzit' expands to the greeting phrase before translation."""
result = _run("Howzit")
assert "HELLO" in result["signs"]


def test_no_copula_glosses_in_phrase_data():
"""English copulas are not SASL signs and must never be glossed."""
for phrase, signs in sasl_pipeline._OFFLINE_PHRASES.items():
assert "AM" not in signs, f"{phrase!r} glosses the copula AM"
assert "IS" not in signs, f"{phrase!r} glosses the copula IS"
assert "ARE" not in signs, f"{phrase!r} glosses the copula ARE"


def test_non_phrase_falls_through_to_rules():
result = _run("Tomorrow I will go to the doctor")
assert result["signs"][0] == "TOMORROW", "rules tier should still apply time-first order"
assert "WILL" in result["signs"]


def test_emergency_phrase():
result = _run("This is an emergency")
assert result["signs"] == ["EMERGENCY"]
Loading