WhistleBloom / backend /coach_model.py
ZhaoJingzi's picture
Upload 27 files
a1772f6 verified
Raw
History Blame Contribute Delete
7.12 kB
"""Nemotron coach reasoning wrapper with rule-based fallback."""
from __future__ import annotations
import json
import os
import urllib.request
from typing import Any
from .prompts import SYSTEM_PROMPT, build_coach_prompt
from .schemas import CoachResponse
class NemotronCoach:
"""Small replaceable coach-reasoning module.
V1 keeps this optional. Set NEMOTRON_API_URL, NEMOTRON_API_KEY, and
NEMOTRON_MODEL to call a hosted Nemotron-compatible chat endpoint. Without
those variables, the deterministic fallback keeps the Space fully working.
"""
def __init__(self) -> None:
self.api_url = os.getenv("NEMOTRON_API_URL", "").strip()
self.api_key = os.getenv("NEMOTRON_API_KEY", "").strip()
self.model = os.getenv("NEMOTRON_MODEL", "nvidia/nemotron-nano-4b-v1")
def reason(self, practice_state: dict[str, Any], history: list[dict[str, Any]]) -> dict[str, Any]:
if self.api_url and self.api_key:
try:
return self._call_nemotron(practice_state, history)
except Exception as error: # noqa: BLE001 - fallback is intentional.
fallback = self._fallback(practice_state)
fallback["diagnosis"] += f" Nemotron fallback used: {error.__class__.__name__}."
return fallback
return self._fallback(practice_state)
def _call_nemotron(self, practice_state: dict[str, Any], history: list[dict[str, Any]]) -> dict[str, Any]:
payload = {
"model": self.model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_coach_prompt(practice_state, history)},
],
"temperature": 0.2,
"max_tokens": 220,
}
request = urllib.request.Request(
self.api_url,
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request, timeout=10) as response:
raw = json.loads(response.read().decode("utf-8"))
content = raw.get("choices", [{}])[0].get("message", {}).get("content", "{}")
parsed = self._parse_model_json(content)
parsed["model_source"] = "nemotron"
return self._normalize_response(parsed, practice_state)
def _parse_model_json(self, content: str) -> dict[str, Any]:
"""Parse strict JSON, tolerating fenced responses from hosted endpoints."""
content = content.strip()
if content.startswith("```"):
content = content.strip("`")
if content.startswith("json"):
content = content[4:].strip()
start = content.find("{")
end = content.rfind("}")
if start >= 0 and end > start:
content = content[start : end + 1]
return json.loads(content)
def _normalize_response(self, response: dict[str, Any], practice_state: dict[str, Any]) -> dict[str, Any]:
fallback = self._fallback(practice_state)
normalized = {**fallback, **response}
normalized["overlay_cues"] = list(normalized.get("overlay_cues") or [])
normalized["agent_trace"] = list(normalized.get("agent_trace") or fallback["agent_trace"])
normalized["success_trigger"] = bool(practice_state.get("success_trigger", normalized.get("success_trigger", False)))
return normalized
def _fallback(self, practice_state: dict[str, Any]) -> dict[str, Any]:
state = practice_state.get("state", "idle")
active_step = practice_state.get("active_step", "start")
success = bool(practice_state.get("success_trigger", False))
face = practice_state.get("face", {}) or {}
audio = practice_state.get("audio", {}) or {}
messages = {
"idle": "Press Start Practice when you are ready.",
"no_face": "Please face the camera and center your mouth in the frame.",
"mouth_too_open": "Make the opening smaller and relax your jaw.",
"not_rounded": "Pull both lip corners inward, like saying 'u'.",
"asymmetric_mouth": "Keep both lip corners level and centered.",
"mouth_ready_no_airflow": "Good mouth shape. Now blow gently.",
"airflow_no_tone": "You are close. Make the air stream narrower.",
"stable_whistle": "Great! Hold that whistle steady.",
}
drills = {
"idle": "start_session",
"no_face": "center_face",
"mouth_too_open": "small_opening_hold",
"not_rounded": "lip_rounding",
"asymmetric_mouth": "corner_leveling",
"mouth_ready_no_airflow": "gentle_airflow",
"airflow_no_tone": "narrow_air_stream",
"stable_whistle": "steady_tone_hold",
}
cues = {
"mouth_too_open": [{"type": "mouth_opening", "direction": "smaller"}],
"not_rounded": [{"type": "lip_corners", "direction": "inward"}],
"asymmetric_mouth": [{"type": "symmetry", "direction": "level"}],
"airflow_no_tone": [{"type": "air_stream", "direction": "narrower"}],
"stable_whistle": [{"type": "success", "direction": "hold"}],
}
trace = [
{
"step": "Observe",
"action": "read_state",
"detail": (
f"mouth {round(float(face.get('mouth_shape_score', 0)) * 100)}%, "
f"airflow {round(float(audio.get('airflow_score', 0)) * 100)}%, "
f"tone {round(float(audio.get('pitch_stability_score', 0)) * 100)}%"
),
},
{
"step": "Diagnose",
"action": state,
"detail": f"Current blocker is {state}.",
},
{
"step": "Plan",
"action": drills.get(state, "observe"),
"detail": f"Focus on one micro-drill: {drills.get(state, 'observe')}.",
},
{
"step": "Act",
"action": "coach_message",
"detail": messages.get(state, "Keep adjusting one small thing at a time."),
},
]
response = CoachResponse(
coach_message=messages.get(state, "Keep adjusting one small thing at a time."),
diagnosis=f"Rule fallback classified state as {state}.",
overlay_cues=cues.get(state, []),
active_step=active_step,
agent_trace=trace,
next_drill=drills.get(state, "observe"),
difficulty_adjustment="easier" if state in {"no_face", "mouth_too_open"} else "hold",
success_trigger=success,
model_source="rule_fallback",
)
return response.to_dict()