"""Wren, the /r/ practice coach. Two interchangeable backends: * router (default) — HF Inference Providers, Llama-3.1-Nemotron-Nano-8B-v1 served by featherless-ai. Cloud, fast, needs HF_TOKEN. * local — llama-cpp-python loading NVIDIA-Nemotron-3-Nano-4B (Q4_K_M GGUF, ~2.84 GB). Zero cloud calls, qualifies for the hackathon's "Off the Grid" bonus badge. Pick via COACH_BACKEND env var. coach_turn() signature is identical for both — callers don't care which path runs. coach_turn(state, score_dict, user_transcript) -> dict spoken_reply (str, < 55 words for low TTS latency) next_target_word (str | None) cue_type ("retroflex" | "bunched" | "shaping_from_ear" | "auditory_discrimination" | "needs_lowering" | "none") is_correct (bool) """ from __future__ import annotations import json import os import re import time from functools import lru_cache from dotenv import load_dotenv load_dotenv() # Zero GPU detection. spaces.GPU is a no-op decorator when not on Zero GPU # hardware, but we still want to know whether we're running there so we can # skip the eager startup preload (the GPU isn't allocated at import time). ZERO_GPU = bool(os.environ.get("SPACES_ZERO_GPU")) try: import spaces # type: ignore _HAS_SPACES = True except ImportError: _HAS_SPACES = False class _SpacesShim: @staticmethod def GPU(*_a, **_kw): def deco(fn): return fn return deco spaces = _SpacesShim() # type: ignore # --------------------------------------------------------------------------- # Backend selection # --------------------------------------------------------------------------- # Default to the HF Inference router because the in-process llama.cpp path # is too slow on CPU-tier Spaces and unreliable on ZeroGPU. To run the model # locally (e.g. Apple Silicon dev with Metal), set COACH_BACKEND=local. BACKEND = os.environ.get("COACH_BACKEND", "router").lower() if BACKEND not in {"router", "local"}: print(f"[coach] unknown COACH_BACKEND={BACKEND!r}, falling back to 'router'") BACKEND = "router" # --- router backend (HF Inference Providers) --- ROUTER_MODEL_ID = os.environ.get( "COACH_MODEL_ID", "nvidia/Llama-3.1-Nemotron-Nano-8B-v1:featherless-ai", ) ROUTER_BASE_URL = "https://router.huggingface.co/v1" # Backwards-compat alias (used by anything that imported MODEL_ID directly). MODEL_ID = ROUTER_MODEL_ID BASE_URL = ROUTER_BASE_URL # --- local backend (llama.cpp + GGUF) --- LOCAL_REPO = os.environ.get( "COACH_LOCAL_REPO", "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF", ) LOCAL_QUANT = os.environ.get("COACH_LOCAL_QUANT", "Q4_K_M") # The coach SYSTEM_PROMPT below is ~3.5k tokens; combined with the user # message + room for the JSON reply we routinely cross 4096. 8192 gives # comfortable headroom without bloating the KV cache. Nemotron-3-Nano-4B # itself supports up to 262K, so this is the safe lower bound, not a ceiling. LOCAL_CTX = int(os.environ.get("COACH_LOCAL_CTX", "8192")) SYSTEM_PROMPT = """You are Wren, a direct and encouraging speech coach helping an 18-year-old fix their /r/ sound. Your words will be spoken aloud by a text-to-speech voice — write for the ear, not the eye. Be concise, specific, and treat the user as a competent adult. ═══════════════════════════════════════════════════════════════════════ VOCABULARY RULES — strict ═══════════════════════════════════════════════════════════════════════ NEVER say: formant, F3, hertz, Hz, phoneme, IPA, retroflex, bunched, rhotic, alveolar, articulation, score, percent, accuracy, or any number from the scoring data (no "your F3 was 2400", no "0.6 score"). USE these anatomical terms freely: tongue, tongue tip, tongue back/root, lips, teeth, jaw, roof of mouth, the ridge behind your top teeth (or "the bump"), throat, mirror. USE these verbs: curl, bunch, pull back, push up, hold, freeze, drop, spread, round, open, relax, point, press, lift. ═══════════════════════════════════════════════════════════════════════ EXERCISE TYPES — tone shifts by context ═══════════════════════════════════════════════════════════════════════ SYLLABLE mode (ra, re, ri, etc.): The user is drilling the isolated /r/ sound. Focus only on tongue position. Keep intro very short: name the syllable, give ONE tip, go. WORD mode (single words): Standard practice. Use the 3-part structure below. PHRASE mode (two or more words): Harder — /r/ in connected speech. Acknowledge the added difficulty. Focus on keeping tongue position stable across the whole phrase. ═══════════════════════════════════════════════════════════════════════ CRITICAL: WHEN r_quality IS "correct" — CELEBRATE, DON'T CORRECT ═══════════════════════════════════════════════════════════════════════ If the scoring data says r_quality is "correct", the user produced a clean /r/. They do NOT need a corrective cue. They need acknowledgment. Format: ONE specific celebration sentence (≤12 words) + one short ask to repeat or move on. Total under 25 words. Set is_correct = true. GOOD: "Clean R — tongue stayed in position the whole time. Nice." GOOD: "That's the one — locked in. Try it once more." BAD: "Good try, but make sure to curl your tongue..." (DON'T CORRECT) BAD: "Almost — try again with your tongue tip up." (DON'T CORRECT) If r_quality is "approaching" — acknowledge the progress but offer the needs_lowering cue. Set is_correct = false but be warm. ═══════════════════════════════════════════════════════════════════════ FEEDBACK STRUCTURE — every imperfect attempt (under 55 words total) ═══════════════════════════════════════════════════════════════════════ 1) SHORT REACTION (≤8 words) — be specific, not generic. GOOD: "That R softened near the end." GOOD: "Your lips rounded a bit — keep them flat." BAD: "Good try!" / "Keep it up!" 2) ONE PHYSICAL CUE from the cue bank (1–2 sentences). One cue only — never stack two. 3) CLEAR NEXT INSTRUCTION (1 sentence). GOOD: "Try it again, slow." GOOD: "Stretch the R: 'rrrrred'." ═══════════════════════════════════════════════════════════════════════ CUE BANK — pick exactly ONE per reply based on what went wrong ═══════════════════════════════════════════════════════════════════════ → W-SUBSTITUTION (error_detail = "w_substitution"): "Check your lips — are they rounding into a circle? Spread them flat. Only your tongue moves for R, not your lips." → APPROACHING / F3 BORDERLINE (error_detail = "needs_lowering" or r_quality = "approaching"): "Your tongue is almost there. Pull the back of your tongue a little further back and down — like you're making a bit more space in your throat. Hold that shape." → FLAT TONGUE / DISTORTION (r_quality = "unclear", error_detail = "distortion"): "Curl your tongue tip up toward the bump just behind your top front teeth. Don't let it touch — just aim at it and hold." → ALTERNATIVE SHAPE — bunched (use after 2+ retroflex cues fail): "Try a different shape: push the BACK of your tongue up toward the roof of your mouth, like hiding something behind your back teeth. Tongue tip points down." → ALTERNATIVE SHAPE — retroflex (use after 2+ bunched cues fail): "Switch it up: curl the tongue tip up and back, pointing toward the bump behind your top teeth. Keep it there the whole time you say R." → TONGUE DROPS MID-WORD (R starts okay but fades): "Hold the R shape a half-second longer before moving on — like 'rrrrred'. Don't let your tongue escape early." → SHAPING FROM A SOUND YOU ALREADY KNOW (use shaping_from_ear when the user is stuck on isolated R but can produce 'ear' or 'er'): "Say 'ear' slowly — feel where your tongue ends up at the very end? That's the R position. Now start from there: 'ear...r-ed'." → TIGHT JAW: "Open your jaw a bit more — a finger's width between your teeth. A clenched jaw blocks the R." → OMISSION (error_detail = "omission"): "The R got lost this time. Start from the R before anything else — get your tongue in position first, then say the word." → AFTER 3+ FAILED ATTEMPTS — ear training: "Let's reset. I'll say the word twice — notice exactly where the R sound sits. Then try once more." ═══════════════════════════════════════════════════════════════════════ WORD SELECTION ═══════════════════════════════════════════════════════════════════════ Always work with the EXACT "Current target" in the user message. Set next_target_word equal to it. Never suggest moving to a different target — the app controls progression. On a CORRECT attempt: celebrate briefly (specific, not generic), then ask them to repeat once to lock it in. Don't move them on — the Next button does that. GOOD: "That's it — clean R, tongue held the shape. Do it once more." BAD: "Great! Now let's try 'tree'." ═══════════════════════════════════════════════════════════════════════ ADAPT TO HISTORY ═══════════════════════════════════════════════════════════════════════ - Same word failed 3+ times → switch cue type AND try the ear-training framing. Stay on the same word. - Correct 2+ times in a row → get briefer. "Locked in. One more." - After a struggle then a win → be specific about what changed: "That time your tongue stayed in position — that's the difference." ═══════════════════════════════════════════════════════════════════════ INTRO MODE (no scoring data, context = INTRODUCING) ═══════════════════════════════════════════════════════════════════════ Structure: (a) say the target clearly, (b) ONE physical tip, (c) invite. Under 40 words. Name the exact word given — never substitute another. ═══════════════════════════════════════════════════════════════════════ OUTPUT FORMAT — JSON only, no markdown, no prose around it ═══════════════════════════════════════════════════════════════════════ { "spoken_reply": string (under 55 words, ear-friendly, no banned vocab), "next_target_word": string or null, "cue_type": "retroflex"|"bunched"|"shaping_from_ear"| "auditory_discrimination"|"needs_lowering"|"none", "is_correct": boolean } ═══════════════════════════════════════════════════════════════════════ EXAMPLES ═══════════════════════════════════════════════════════════════════════ Ex 1 — Word "red", error_detail=w_substitution, first attempt: { "spoken_reply": "Lips rounded — that came out as a W. Spread your lips flat and keep them still. Only your tongue does the work for R. Try again.", "next_target_word": "red", "cue_type": "retroflex", "is_correct": false } Ex 2 — Syllable "ra", r_quality=approaching: { "spoken_reply": "Getting close. Pull the back of your tongue a little further back — make a bit more space in your throat. Now say 'ra' again.", "next_target_word": "ra", "cue_type": "needs_lowering", "is_correct": false } Ex 3 — Word "rocket", r_quality=correct, second correct in a row: { "spoken_reply": "There it is — R held the whole way through. Once more to lock it in.", "next_target_word": "rocket", "cue_type": "none", "is_correct": true } Ex 4 — Intro for phrase "red rose": { "spoken_reply": "Now say 'red rose' — keep your tongue in R position as you move from the first word into the second. Don't drop it between words. Go.", "next_target_word": "red rose", "cue_type": "retroflex", "is_correct": false } """ @lru_cache(maxsize=1) def _get_router_client(): """OpenAI-protocol client pointed at HF's Inference Providers router.""" from openai import OpenAI token = os.environ.get("HF_TOKEN") if not token: raise RuntimeError( "HF_TOKEN not set — cannot call the router backend. " "Either set HF_TOKEN or switch to COACH_BACKEND=local." ) return OpenAI(base_url=ROUTER_BASE_URL, api_key=token) @lru_cache(maxsize=1) def _get_local_llm(): """Lazily build the llama-cpp-python handle and cache it for the process.""" from llama_cpp import Llama print(f"[coach] loading local model {LOCAL_REPO} ({LOCAL_QUANT})...") t0 = time.time() llm = Llama.from_pretrained( repo_id=LOCAL_REPO, filename=f"*{LOCAL_QUANT}*", n_ctx=LOCAL_CTX, n_gpu_layers=-1, # full Metal/CUDA offload where available; harmless on CPU n_batch=1024, # bigger prefill batches → faster prompt processing on Metal n_threads=int(os.environ.get("COACH_LOCAL_THREADS", "0")) or None, verbose=os.environ.get("COACH_LOCAL_VERBOSE", "0") == "1", ) print(f"[coach] local model loaded in {time.time() - t0:.1f}s") return llm def _chat_router(messages: list[dict]) -> str: """One JSON-mode round trip to the HF router. Returns raw assistant text.""" client = _get_router_client() resp = client.chat.completions.create( model=ROUTER_MODEL_ID, messages=messages, response_format={"type": "json_object"}, temperature=0.6, max_tokens=400, ) return resp.choices[0].message.content def _chat_local(messages: list[dict]) -> str: """One JSON-mode round trip to the locally-loaded GGUF model.""" llm = _get_local_llm() resp = llm.create_chat_completion( messages=messages, response_format={"type": "json_object"}, temperature=0.6, max_tokens=400, ) return resp["choices"][0]["message"]["content"] # Public helper so app.py can preload the model at startup when BACKEND=local def preload() -> None: """Force-load whichever backend we're configured for. Safe to call twice.""" if BACKEND == "local": _get_local_llm() else: # Router path doesn't really preload — but probe the token now so we # fail loudly at startup instead of silently on the first turn. try: _get_router_client() except RuntimeError as e: print(f"[coach] {e}") def _format_recent(history: list[dict]) -> str: if not history: return " (no prior attempts)" lines = [] for h in history[-5:]: ex_type = h.get("exercise_type", "word") lines.append( f" - [{ex_type}] target={h.get('target_word')!r}, " f"r_quality={h.get('r_quality')}, " f"error_detail={h.get('error_detail')!r}, " f"score={h.get('overall_score')}, " f"correct={h.get('is_correct')}" ) return "\n".join(lines) def _build_user_message( state: dict, score_dict: dict | None, user_transcript: str | None, ) -> str: target = state.get("current_target_word") or "(none)" history = state.get("history", []) exercise_type = state.get("exercise_type", "word") type_label = {"syllable": "SYLLABLE", "phrase": "PHRASE"}.get(exercise_type, "WORD") if score_dict is None: if not history: context = ( f"FIRST TURN — introduce yourself briefly, then introduce the " f"{type_label} target '{target}' with one physical tip. " f"Your spoken_reply MUST contain '{target}'." ) else: context = ( f"INTRODUCING NEW {type_label} TARGET — say '{target}' and give " f"one physical tip for producing it. Your spoken_reply MUST " f"contain '{target}'." ) else: context = ( f"GIVING FEEDBACK on attempt at {type_label} '{target}'. " f"Translate scoring into concrete body cues — no numbers or jargon." ) parts = [ f"Context: {context}", f"Exercise type: {type_label}", f"Current target: {target}", f"User said (STT): {user_transcript!r}" if user_transcript else "User said: (no transcript)", ] if score_dict: parts += [ "Scoring data:", f" detected_phonemes: {score_dict.get('detected_phonemes')!r}", f" target_phonemes: {score_dict.get('target_phonemes')!r}", f" r_quality: {score_dict.get('r_quality')}", f" error_detail: {score_dict.get('error_detail')}", f" f3_hz: {score_dict.get('f3_hz')} Hz " "(< 2400 = good /r/; > 2600 = /w/-like)", f" phoneme_match: {score_dict.get('phoneme_match')}", f" overall_score: {score_dict.get('overall_score')}", ] else: parts.append("Scoring: (none — intro turn)") parts.append("Recent attempts:") parts.append(_format_recent(history)) parts.append("\nOutput JSON only.") return "\n".join(parts) _JSON_FENCE = re.compile(r"^```(?:json)?\s*(.*?)\s*```$", re.DOTALL) def _parse_json(text: str) -> dict | None: if not text: return None s = text.strip() # Strip model reasoning block if present if "" in s: s = re.sub(r".*?", "", s, flags=re.DOTALL).strip() m = _JSON_FENCE.match(s) if m: s = m.group(1).strip() try: return json.loads(s) except json.JSONDecodeError: pass m = re.search(r"\{.*\}", s, re.DOTALL) if m: try: return json.loads(m.group(0)) except json.JSONDecodeError: pass # Truncated JSON recovery if s.startswith("{"): for trim in range(len(s), 0, -1): candidate = s[:trim].rstrip().rstrip(",") opens = candidate.count("{") - candidate.count("}") if opens > 0: candidate += "}" * opens try: return json.loads(candidate) except json.JSONDecodeError: continue return None _VALID_CUES = { "retroflex", "bunched", "shaping_from_ear", "auditory_discrimination", "needs_lowering", "none", } def _default_response(state: dict, score_dict: dict | None) -> dict: target = state.get("current_target_word") or "red" ex_type = state.get("exercise_type", "word") if not score_dict: if ex_type == "syllable": return { "spoken_reply": ( f"Let's work on '{target}'. Curl your tongue tip up toward the bump " f"behind your top teeth and hold it. Say '{target}'." ), "next_target_word": target, "cue_type": "retroflex", "is_correct": False, } return { "spoken_reply": ( f"Let's try '{target}'. Curl your tongue tip up toward the bump behind " f"your top front teeth. Keep your lips relaxed. Go." ), "next_target_word": target, "cue_type": "retroflex", "is_correct": False, } r_q = score_dict.get("r_quality") err = score_dict.get("error_detail", "") if r_q == "correct": return { "spoken_reply": "Clean R — tongue held the shape. Do it once more to lock it in.", "next_target_word": target, "cue_type": "none", "is_correct": True, } if err == "w_substitution": return { "spoken_reply": ( "Lips rounded there — that's a W. Spread your lips flat and keep them still. " "Only your tongue works for R. Try again." ), "next_target_word": target, "cue_type": "retroflex", "is_correct": False, } if err == "needs_lowering": return { "spoken_reply": ( "Getting there. Pull the back of your tongue a bit further back and down — " "create more space in your throat. Hold that and try again." ), "next_target_word": target, "cue_type": "needs_lowering", "is_correct": False, } return { "spoken_reply": ( "Curl your tongue tip up toward the bump behind your top teeth. " "Keep it there the whole time you say the R. Try again." ), "next_target_word": target, "cue_type": "retroflex", "is_correct": False, } def _validate(parsed: dict, default: dict) -> dict: out = dict(default) if isinstance(parsed.get("spoken_reply"), str) and parsed["spoken_reply"].strip(): out["spoken_reply"] = parsed["spoken_reply"].strip() if "next_target_word" in parsed: v = parsed["next_target_word"] if v is None or (isinstance(v, str) and v.strip()): out["next_target_word"] = v.strip() if isinstance(v, str) else None if isinstance(parsed.get("cue_type"), str) and parsed["cue_type"] in _VALID_CUES: out["cue_type"] = parsed["cue_type"] if isinstance(parsed.get("is_correct"), bool): out["is_correct"] = parsed["is_correct"] return out def coach_turn( state: dict, score_dict: dict | None, user_transcript: str | None, ) -> dict: default = _default_response(state, score_dict) messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": _build_user_message(state, score_dict, user_transcript)}, ] t0 = time.time() try: content = _chat_local(messages) if BACKEND == "local" else _chat_router(messages) except Exception as e: print(f"[coach] {BACKEND} inference error: {e}") return default label = LOCAL_REPO if BACKEND == "local" else ROUTER_MODEL_ID print(f"[coach] {BACKEND}:{label} round-trip {time.time() - t0:.2f}s") parsed = _parse_json(content) if not isinstance(parsed, dict): print(f"[coach] unparseable: {content!r}") return default return _validate(parsed, default) # Backward-compatible wrapper def generate_feedback(scoring_result: dict, target_word: str, attempt_history: list) -> dict: state = {"current_target_word": target_word, "history": attempt_history} result = coach_turn(state, scoring_result, None) return { "verdict": "That R landed!" if result["is_correct"] else "Let's try again.", "feedback": result["spoken_reply"], "encouragement":"Locked in." if result["is_correct"] else "You're making progress.", "suggest_next": "harder" if result["is_correct"] else "same", } if __name__ == "__main__": state = {"current_target_word": "red", "history": [], "exercise_type": "word"} print("--- intro ---") print(coach_turn(state, None, None)) score = { "detected_phonemes": "w ɛ d", "target_phonemes": "r ɛ d", "phoneme_match": 0.66, "f3_hz": 2700.0, "r_quality": "substituted_w", "error_detail": "w_substitution", "overall_score": 0.4, } print("\n--- w-sub feedback ---") print(coach_turn(state, score, "wed"))