""" GhSL Emotion Recognition — Hugging Face Space API Deployed at: https://huggingface.co/spaces/ransoppong/ghsl-emotion-api Provides named Gradio API endpoints for use by Deafly Health platform. Models are downloaded from ransoppong/ghsl-emosign-models on first run. """ import os import sys import json import pickle import traceback import numpy as np import torch from pathlib import Path # ── Disable MediaPipe GPU delegate (Tasks API tries EGL even on CPU) ────────── # Use MEDIAPIPE_DISABLE_GPU to avoid the libEGL.so.1 dlopen on headless HF Spaces. os.environ["MEDIAPIPE_DISABLE_GPU"] = "1" # ── Monkey-patch gradio_client bug present in Gradio 5.9.1 ─────────────────── # gradio_client/utils.py:887 does `if "const" in schema` where schema can be # a JSON Schema boolean (True/False) — TypeError. Patch before importing gradio. try: import gradio_client.utils as _gcu _orig_js2py = _gcu._json_schema_to_python_type def _patched_js2py(schema, defs=None): if not isinstance(schema, dict): return "Any" return _orig_js2py(schema, defs) _gcu._json_schema_to_python_type = _patched_js2py except Exception: pass import gradio as gr # Ensure local models_arch package is on the path sys.path.insert(0, str(Path(__file__).parent)) # ── Model download ──────────────────────────────────────────────────────────── MODEL_REPO = "ransoppong/ghsl-emosign-models" MODELS_DIR = Path("models") MODELS_DIR.mkdir(exist_ok=True) _download_errors = {} def _download_model(filename: str) -> Path: dest = MODELS_DIR / filename if dest.exists(): return dest try: from huggingface_hub import hf_hub_download print(f"Downloading {filename} from {MODEL_REPO}...") hf_hub_download(repo_id=MODEL_REPO, filename=filename, local_dir=str(MODELS_DIR)) print(f" -> {dest}") except Exception as e: _download_errors[filename] = str(e) print(f"Warning: could not download {filename}: {e}") return dest for _fname in [ "st_egn_best.pt", "ei_gn_best.pt", "emotion_clf_best.pkl", "pose_landmarker_lite.task", "hand_landmarker.task", "face_landmarker.task", ]: _download_model(_fname) # ── Architecture imports (from local models_arch/) ──────────────────────────── try: from models_arch.st_egn import build_stegn from models_arch.ei_gn import build_eign, EMOTIONS from models_arch.counselling import generate_response, StrategyPolicy, safety_check from models_arch.train import FACE_KEY_IDX, N_KEY _ARCH_OK = True except Exception as _arch_err: _ARCH_OK = False _arch_err_msg = traceback.format_exc() print(f"Architecture import error:\n{_arch_err_msg}") # Provide fallbacks so the rest of the file parses cleanly EMOTIONS = ["joy", "excited", "surprise_pos", "surprise_neg", "worry", "sadness", "fear", "disgust", "frustration", "anger"] FACE_KEY_IDX = list(range(84)) N_KEY = 84 # ── Constants ───────────────────────────────────────────────────────────────── CLASS_NAMES_BINARY = ["Negative", "Positive"] CLASS_NAMES_TERNARY = ["Negative", "Neutral", "Positive"] MAX_FRAMES = 60 N_POSE, N_FACE, N_HAND = 33, 478, 21 POSE_UPPER = [11, 12, 13, 14, 15, 16, 23, 24] _STRATEGY_DESC = { "reflect": "Reflect the patient's experience back to them to show understanding.", "validate": "Validate the patient's feelings as understandable and normal.", "ground": "Use grounding techniques to help the patient stay present and calm.", "refer": "Acknowledge the concern and refer to a specialist or emergency support.", } DEVICE = torch.device("cpu") # HF Spaces free tier — CPU only # ── Load models ─────────────────────────────────────────────────────────────── CLF = None # RandomForest pipeline STEGN = None # Spatio-Temporal Emotion Graph Network EIGN = None # Emotion Interaction Graph Network _model_status = { "rf": "not loaded", "stegn": "not loaded", "eign": "not loaded", "mediapipe": "not loaded", } def _load_models(): global CLF, STEGN, EIGN # RandomForest rf_path = MODELS_DIR / "emotion_clf_best.pkl" if rf_path.exists(): try: with open(rf_path, "rb") as f: _saved = pickle.load(f) CLF = _saved["pipeline"] _model_status["rf"] = "ok" print("RF loaded.") except Exception as e: _model_status["rf"] = f"error: {e}" else: _model_status["rf"] = "file not found" if not _ARCH_OK: _model_status["stegn"] = "arch import failed" _model_status["eign"] = "arch import failed" return # ST-EGN stegn_path = MODELS_DIR / "st_egn_best.pt" if stegn_path.exists(): try: ckpt = torch.load(stegn_path, map_location="cpu", weights_only=False) cfg = ckpt.get("cfg", {}) STEGN = build_stegn( num_classes = ckpt["num_classes"], hidden = ckpt["hidden"], num_nodes = N_KEY, gcn_layers = cfg.get("stegn_gcn_layers", 3), tf_layers = cfg.get("stegn_tf_layers", 2), ) STEGN.load_state_dict(ckpt["state_dict"]) STEGN.eval() _model_status["stegn"] = "ok" print("ST-EGN loaded.") except Exception as e: _model_status["stegn"] = f"error: {e}" print(f"ST-EGN load error: {e}") else: _model_status["stegn"] = "file not found" # EI-GN — instantiate EIGN class directly to pass tf_layers from checkpoint eign_path = MODELS_DIR / "ei_gn_best.pt" if eign_path.exists(): try: ckpt = torch.load(eign_path, map_location="cpu", weights_only=False) ecfg = ckpt.get("cfg", {}) # Import the class, not the factory, to avoid older build_eign signature from models_arch.ei_gn import EIGN as _EIGN_cls, NUM_EMOTIONS as _NUM_EMO EIGN = _EIGN_cls( num_emotions = _NUM_EMO, hidden = ckpt.get("hidden", 64), gcn_layers = ecfg.get("eign_gcn_layers", 3), tf_layers = ecfg.get("eign_tf_layers", 3), tf_heads = 2, num_classes = ckpt.get("num_classes", 2), dropout = 0.2, ) EIGN.load_state_dict(ckpt["state_dict"]) EIGN.eval() _model_status["eign"] = "ok" print("EI-GN loaded.") except Exception as e: _model_status["eign"] = f"error: {e}" print(f"EI-GN load error: {e}") else: _model_status["eign"] = "file not found" _load_models() # ── MediaPipe detectors (legacy Solutions API — no EGL required on CPU) ─────── POSE_D = None HAND_D = None FACE_D = None def _init_mediapipe(): global POSE_D, HAND_D, FACE_D try: from mediapipe.tasks import python as mp_python from mediapipe.tasks.python import vision as mp_vision from mediapipe.tasks.python.vision import RunningMode # Explicitly force CPU delegate — prevents libEGL.so.1 dlopen on headless servers CPU = mp_python.BaseOptions.Delegate.CPU base = mp_python.BaseOptions pose_path = MODELS_DIR / "pose_landmarker_lite.task" hand_path = MODELS_DIR / "hand_landmarker.task" face_path = MODELS_DIR / "face_landmarker.task" if pose_path.exists(): POSE_D = mp_vision.PoseLandmarker.create_from_options( mp_vision.PoseLandmarkerOptions( base_options=base(model_asset_path=str(pose_path), delegate=CPU), running_mode=RunningMode.IMAGE, num_poses=1, min_pose_detection_confidence=0.5, min_pose_presence_confidence=0.5, min_tracking_confidence=0.5, output_segmentation_masks=False)) if hand_path.exists(): HAND_D = mp_vision.HandLandmarker.create_from_options( mp_vision.HandLandmarkerOptions( base_options=base(model_asset_path=str(hand_path), delegate=CPU), running_mode=RunningMode.IMAGE, num_hands=2, min_hand_detection_confidence=0.5, min_hand_presence_confidence=0.5, min_tracking_confidence=0.5)) if face_path.exists(): FACE_D = mp_vision.FaceLandmarker.create_from_options( mp_vision.FaceLandmarkerOptions( base_options=base(model_asset_path=str(face_path), delegate=CPU), running_mode=RunningMode.IMAGE, num_faces=1, min_face_detection_confidence=0.5, min_face_presence_confidence=0.5, min_tracking_confidence=0.5, output_face_blendshapes=False, output_facial_transformation_matrixes=False)) _model_status["mediapipe"] = "ok" print("MediaPipe detectors ready.") except Exception as e: _model_status["mediapipe"] = f"error: {e}" print(f"MediaPipe init error: {e}") _init_mediapipe() # ── Video feature extraction ────────────────────────────────────────────────── def _extract_all(video_path: str): """ Returns (feat_vec, face_seq) where: feat_vec : (862,) pooled features for RF face_seq : (T, N_KEY, 3) raw key face landmarks for ST-EGN Either may be None on failure. """ try: import cv2 except ImportError: return None, None if FACE_D is None: return None, None cap = cv2.VideoCapture(video_path) if not cap.isOpened(): return None, None fp, ff, flh, frh = [], [], [], [] while True: ok, bgr = cap.read() if not ok: break rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) import mediapipe as mp img = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb) # Pose (Tasks API) if POSE_D is not None: pr = POSE_D.detect(img) p = (np.array([[lm.x, lm.y, lm.z, lm.visibility] for lm in pr.pose_landmarks[0]], np.float32) if pr.pose_landmarks else np.zeros((N_POSE, 4), np.float32)) else: p = np.zeros((N_POSE, 4), np.float32) fp.append(p) # Face (Tasks API) fr = FACE_D.detect(img) if fr.face_landmarks: raw = [[lm.x, lm.y, lm.z] for lm in fr.face_landmarks[0]] if len(raw) < N_FACE: raw += [[0.0, 0.0, 0.0]] * (N_FACE - len(raw)) f = np.array(raw[:N_FACE], np.float32) else: f = np.zeros((N_FACE, 3), np.float32) ff.append(f) # Hands (Tasks API) lh = rh = np.zeros((N_HAND, 3), np.float32) if HAND_D is not None: hr = HAND_D.detect(img) if hr.hand_landmarks: for i, cats in enumerate(hr.handedness): arr = np.array([[lm.x, lm.y, lm.z] for lm in hr.hand_landmarks[i]], np.float32) if cats[0].category_name == "Left": lh = arr else: rh = arr flh.append(lh) frh.append(rh) cap.release() if not fp: return None, None # RF feature vector: mean + std pooling over time pose_arr = np.stack(fp)[:, POSE_UPPER, :].reshape(len(fp), -1) face_arr = np.stack(ff)[:, FACE_KEY_IDX, :].reshape(len(ff), -1) lhand_arr = np.stack(flh).reshape(len(flh), -1) rhand_arr = np.stack(frh).reshape(len(frh), -1) feats = [] for part in [pose_arr, face_arr, lhand_arr, rhand_arr]: feats.extend([part.mean(axis=0), part.std(axis=0)]) feat_vec = np.nan_to_num( np.concatenate(feats), nan=0.0, posinf=0.0, neginf=0.0) # ST-EGN face sequence (key landmarks only) face_full = np.stack(ff) # (T, 478, 3) face_key_seq = face_full[:, FACE_KEY_IDX, :] # (T, N_KEY, 3) T = face_key_seq.shape[0] if T > MAX_FRAMES: indices = np.linspace(0, T - 1, MAX_FRAMES, dtype=int) face_key_seq = face_key_seq[indices] return feat_vec, face_key_seq def _stegn_predict(face_seq: np.ndarray): """Run ST-EGN on a (T, N_KEY, 3) array. Returns (pred_label, proba_array).""" T = face_seq.shape[0] pad_len = MAX_FRAMES - T mask = torch.zeros(1, MAX_FRAMES, dtype=torch.bool) if pad_len > 0: face_seq = np.concatenate( [face_seq, np.zeros((pad_len, N_KEY, 3), np.float32)]) mask[0, T:] = True face_tensor = torch.tensor(face_seq, dtype=torch.float32).unsqueeze(0) with torch.no_grad(): logits = STEGN(face_tensor, mask) proba = torch.softmax(logits, dim=-1)[0].numpy() pred_idx = int(np.argmax(proba)) return CLASS_NAMES_BINARY[pred_idx], proba # ── Endpoint 1: predict_video ───────────────────────────────────────────────── def predict_video(video_path): """ Predict emotional affect from a sign language video. Args: video_path: path to uploaded video file Returns: affect : "Negative" or "Positive" confidence : 0.0–1.0 strategy : counselling strategy string details : JSON string with per-model breakdown """ if video_path is None: return "No video uploaded.", 0.0, "", json.dumps({"error": "no video"}) feat_vec, face_seq = _extract_all(video_path) if feat_vec is None: return ( "Could not extract features from video.", 0.0, "", json.dumps({"error": "feature extraction failed — check MediaPipe task files"}), ) results = {} affect = "Negative" conf = 0.5 source = "none" # Try RF (fast baseline) if CLF is not None: try: proba_rf = CLF.predict_proba(feat_vec.reshape(1, -1))[0] idx_rf = int(np.argmax(proba_rf)) results["rf"] = { "affect": CLASS_NAMES_BINARY[idx_rf], "confidence": float(proba_rf[idx_rf]), "scores": {c: float(p) for c, p in zip(CLASS_NAMES_BINARY, proba_rf)}, } affect = CLASS_NAMES_BINARY[idx_rf] conf = float(proba_rf[idx_rf]) source = "RandomForest" except Exception as e: results["rf"] = {"error": str(e)} # Try ST-EGN if enough frames if STEGN is not None and face_seq is not None and face_seq.shape[0] >= 10: try: affect_stegn, proba_stegn = _stegn_predict(face_seq) results["stegn"] = { "affect": affect_stegn, "confidence": float(np.max(proba_stegn)), "scores": {c: float(p) for c, p in zip(CLASS_NAMES_BINARY, proba_stegn)}, "frames_used": int(min(face_seq.shape[0], MAX_FRAMES)), } # Prefer ST-EGN when we have enough frames if face_seq.shape[0] >= 30: affect = affect_stegn conf = float(np.max(proba_stegn)) source = "ST-EGN" except Exception as e: results["stegn"] = {"error": str(e)} # Build emotion scores from binary prediction for strategy emotion_scores = { "joy": 4.0 if affect == "Positive" else 1.0, "excited": 3.0 if affect == "Positive" else 1.0, "surprise_pos": 2.0, "surprise_neg": 2.0, "worry": 3.5 if affect == "Negative" else 1.0, "sadness": 3.0 if affect == "Negative" else 1.0, "fear": 2.5 if affect == "Negative" else 1.0, "disgust": 2.0 if affect == "Negative" else 1.0, "frustration": 2.5 if affect == "Negative" else 1.0, "anger": 2.0 if affect == "Negative" else 1.0, } strategy = "reflect" if _ARCH_OK: try: strategy = StrategyPolicy.rule_based(affect, emotion_scores) except Exception: pass results["final"] = {"affect": affect, "confidence": conf, "source": source} strategy_text = f"{strategy.upper()} — {_STRATEGY_DESC.get(strategy, '')}" return affect, round(conf, 4), strategy_text, json.dumps(results, indent=2) # ── Endpoint 2: predict_emotion_vec ────────────────────────────────────────── def predict_emotion_vec( joy: float, excited: float, surprise_pos: float, surprise_neg: float, worry: float, sadness: float, fear: float, disgust: float, frustration: float, anger: float, ): """ Predict affect from 10-dimensional emotion intensity scores (1–5 scale). Returns: affect : "Negative", "Neutral", or "Positive" confidence : 0.0–1.0 node_weights : JSON string mapping emotion names to attention weights """ scores = [joy, excited, surprise_pos, surprise_neg, worry, sadness, fear, disgust, frustration, anger] if EIGN is None: return ( "EI-GN model not loaded.", 0.0, json.dumps({"error": "EI-GN not loaded", "status": _model_status["eign"]}), ) try: vec = torch.tensor(scores, dtype=torch.float32).unsqueeze(0) # (1, 10) with torch.no_grad(): logits, node_w = EIGN(vec) proba = torch.softmax(logits, dim=-1)[0].numpy() node_weights = node_w[0].numpy() # Determine label set from output size n_cls = len(proba) label_set = CLASS_NAMES_TERNARY if n_cls == 3 else CLASS_NAMES_BINARY pred_idx = int(np.argmax(proba)) affect = label_set[pred_idx] if pred_idx < len(label_set) else str(pred_idx) conf = float(proba[pred_idx]) weights_dict = {EMOTIONS[i]: round(float(node_weights[i]), 4) for i in range(len(EMOTIONS))} weights_dict["_scores"] = {c: round(float(p), 4) for c, p in zip(label_set, proba)} return affect, round(conf, 4), json.dumps(weights_dict, indent=2) except Exception as e: return ( f"Inference error: {e}", 0.0, json.dumps({"error": str(e)}), ) # ── Endpoint 3: get_counselling ─────────────────────────────────────────────── def get_counselling( context_sentence: str, emotion_class: str, emotion_scores_json: str, strategy: str, category: str, openai_api_key: str, ): """ Generate a clinical counselling response via GPT-4o-mini. Args: context_sentence : text description of what the patient signed emotion_class : "Negative", "Neutral", or "Positive" emotion_scores_json : JSON string like '{"joy": 4, "sadness": 2, ...}' strategy : one of reflect / validate / ground / refer category : healthcare category e.g. "Mental health interactions" openai_api_key : OpenAI API key (or set OPENAI_API_KEY env var) Returns: response_text : final safe counselling response strategy_used : strategy applied safety_status : "safe" or comma-separated flags """ if not context_sentence.strip(): return "Please enter the patient's signing context.", "", "n/a" if not _ARCH_OK: return "Architecture not loaded — cannot generate counselling response.", "", "error" # Parse emotion scores try: emotion_scores = json.loads(emotion_scores_json) if emotion_scores_json.strip() else {} except json.JSONDecodeError as e: return f"Invalid emotion_scores_json: {e}", "", "error" # Default scores if not provided if not emotion_scores: emotion_scores = {e: 1.0 for e in EMOTIONS} # Auto-select strategy if not specified if not strategy or strategy not in _STRATEGY_DESC: try: strategy = StrategyPolicy.rule_based(emotion_class, emotion_scores) except Exception: strategy = "reflect" if not category: category = "General healthcare" api_key = (openai_api_key.strip() if openai_api_key else "") or \ os.environ.get("OPENAI_API_KEY", "") if not api_key: # Return rule-based fallback without LLM strategy_desc = _STRATEGY_DESC.get(strategy, "") fallback = ( f"[No API key — rule-based fallback]\n\n" f"Strategy: {strategy.upper()}\n" f"{strategy_desc}" ) return fallback, strategy, "n/a" try: result = generate_response( context_sentence=context_sentence, emotion_class=emotion_class, emotion_scores=emotion_scores, strategy=strategy, category=category, api_key=api_key, ) safety_status = "safe" if result["safe"] else ", ".join(result["safety_flags"]) return result["final_response"], result["strategy"], safety_status except Exception as e: return f"Response generation failed: {e}", strategy, "error" # ── Endpoint 4: health_check ────────────────────────────────────────────────── def health_check(): """Returns JSON status of all loaded models.""" status = { "version": "1.0.0", "device": str(DEVICE), "arch_ok": _ARCH_OK, "models": _model_status, "download_errors": _download_errors, } return json.dumps(status, indent=2) # ── Gradio UI ───────────────────────────────────────────────────────────────── _API_DOCS_MD = """ ## Calling from Deafly Health (Python) ```python from gradio_client import Client client = Client("ransoppong/ghsl-emotion-api") # 1. Predict from video affect, conf, strategy, details = client.predict( video_path="/path/to/video.mp4", api_name="/predict_video" ) # 2. Predict from 10-dim emotion scores affect, conf, node_weights = client.predict( joy=4.0, excited=2.0, surprise_pos=1.0, surprise_neg=1.0, worry=3.0, sadness=2.0, fear=2.0, disgust=1.0, frustration=2.0, anger=1.0, api_name="/predict_emotion_vec" ) # 3. Generate counselling response response, strategy, safety = client.predict( context_sentence="I have been feeling very anxious lately.", emotion_class="Negative", emotion_scores_json='{"joy":1,"excited":1,"surprise_pos":1,"surprise_neg":2,' '"worry":4,"sadness":3,"fear":3,"disgust":1,"frustration":2,"anger":1}', strategy="", # leave blank to auto-select category="Mental health interactions", openai_api_key="sk-...", api_name="/get_counselling" ) # 4. Health check status_json = client.predict(api_name="/health_check") ``` ## REST (cURL) ```bash curl -X POST https://ransoppong-ghsl-emotion-api.hf.space/run/health_check \\ -H "Content-Type: application/json" -d '{"data":[]}' ``` ## Emotion Score Scale Each of the 10 emotion dimensions uses a **1–5 scale** matching EmoSign annotations: | Score | Meaning | |-------|---------| | 1 | Not present | | 2 | Slightly present | | 3 | Moderately present | | 4 | Strongly present | | 5 | Dominantly present | Emotions: `joy`, `excited`, `surprise_pos`, `surprise_neg`, `worry`, `sadness`, `fear`, `disgust`, `frustration`, `anger` ## Strategy Meanings | Strategy | Guidance | |----------|---------| | **reflect** | Reflect the patient's experience back to show understanding | | **validate** | Validate feelings as understandable and normal | | **ground** | Use grounding techniques to help the patient stay present | | **refer** | Acknowledge concern and refer to a specialist / crisis line | """ with gr.Blocks(title="GhSL Emotion Recognition API", theme=gr.themes.Soft()) as demo: gr.Markdown(""" # GhSL Emotion Recognition API Real-time emotion recognition for Ghanaian Sign Language (GhSL) healthcare videos. Deployed models: **ST-EGN** · **EI-GN** · **RandomForest** """) with gr.Tabs(): # ── Tab 1: Video Prediction ──────────────────────────────────────── with gr.TabItem("Video Prediction"): gr.Markdown(""" Upload a sign language video. The system runs MediaPipe to extract face landmarks, then classifies emotional affect using RandomForest (fast) and ST-EGN (deep). ST-EGN is used when ≥30 frames are detected. """) with gr.Row(): with gr.Column(scale=1): video_in = gr.Video(label="Upload Sign Language Video") predict_btn = gr.Button("Analyse Video", variant="primary") with gr.Column(scale=1): affect_out = gr.Textbox(label="Detected Affect", interactive=False) conf_out = gr.Number(label="Confidence (0–1)", interactive=False) strategy_out = gr.Textbox(label="Recommended Strategy", interactive=False) details_out = gr.Textbox(label="Model Details (JSON)", lines=8, interactive=False) predict_btn.click( fn=predict_video, inputs=[video_in], outputs=[affect_out, conf_out, strategy_out, details_out], api_name="predict_video", ) # ── Tab 2: Emotion Score Prediction ─────────────────────────────── with gr.TabItem("Emotion Scores (EI-GN)"): gr.Markdown(""" Enter 10 emotion intensity scores (1 = not present, 5 = dominant). EI-GN (Emotion Interaction Graph Network) predicts the overall affect. """) with gr.Row(): with gr.Column(scale=1): slider_joy = gr.Slider(1, 5, value=2, step=0.5, label="Joy") slider_exc = gr.Slider(1, 5, value=2, step=0.5, label="Excited") slider_spos = gr.Slider(1, 5, value=1, step=0.5, label="Surprise (Positive)") slider_sneg = gr.Slider(1, 5, value=1, step=0.5, label="Surprise (Negative)") slider_worry = gr.Slider(1, 5, value=2, step=0.5, label="Worry") slider_sad = gr.Slider(1, 5, value=2, step=0.5, label="Sadness") slider_fear = gr.Slider(1, 5, value=2, step=0.5, label="Fear") slider_dis = gr.Slider(1, 5, value=1, step=0.5, label="Disgust") slider_frus = gr.Slider(1, 5, value=2, step=0.5, label="Frustration") slider_ang = gr.Slider(1, 5, value=1, step=0.5, label="Anger") emo_btn = gr.Button("Predict from Scores", variant="primary") with gr.Column(scale=1): emo_affect_out = gr.Textbox(label="Predicted Affect", interactive=False) emo_conf_out = gr.Number(label="Confidence (0–1)", interactive=False) emo_weights = gr.Textbox(label="Node Attention Weights (JSON)", lines=8, interactive=False) emo_btn.click( fn=predict_emotion_vec, inputs=[slider_joy, slider_exc, slider_spos, slider_sneg, slider_worry, slider_sad, slider_fear, slider_dis, slider_frus, slider_ang], outputs=[emo_affect_out, emo_conf_out, emo_weights], api_name="predict_emotion_vec", ) # ── Tab 3: Counselling Response ──────────────────────────────────── with gr.TabItem("Counselling Response"): gr.Markdown(""" Generate an empathetic, strategy-aligned counselling response for a clinician to deliver to the patient. Requires an OpenAI API key (GPT-4o-mini). """) with gr.Row(): with gr.Column(scale=1): ctx_in = gr.Textbox( label="Patient Signing Context", placeholder="Describe what the patient signed, e.g. 'I have been feeling very anxious lately.'", lines=3, ) emo_cls_in = gr.Dropdown( choices=["Negative", "Neutral", "Positive"], value="Negative", label="Detected Emotion Class", ) emo_json_in = gr.Textbox( label="Emotion Scores JSON (optional)", placeholder='{"joy":1,"worry":4,"sadness":3,"fear":3,...}', lines=2, ) strategy_in = gr.Dropdown( choices=["", "reflect", "validate", "ground", "refer"], value="", label="Strategy (leave blank to auto-select)", ) cat_in = gr.Textbox( label="Healthcare Category", placeholder="e.g. Mental health interactions", value="General healthcare", ) api_key_in = gr.Textbox( label="OpenAI API Key", placeholder="sk-... (or set OPENAI_API_KEY env var)", type="password", ) counsel_btn = gr.Button("Generate Response", variant="primary") with gr.Column(scale=1): response_out = gr.Textbox(label="Counselling Response", lines=6, interactive=False) strategy_used = gr.Textbox(label="Strategy Applied", interactive=False) safety_out = gr.Textbox(label="Safety Status", interactive=False) counsel_btn.click( fn=get_counselling, inputs=[ctx_in, emo_cls_in, emo_json_in, strategy_in, cat_in, api_key_in], outputs=[response_out, strategy_used, safety_out], api_name="get_counselling", ) # ── Tab 4: API Docs ──────────────────────────────────────────────── with gr.TabItem("API Docs"): gr.Markdown(_API_DOCS_MD) with gr.Row(): hc_btn = gr.Button("Run Health Check", variant="secondary") hc_output = gr.Textbox(label="System Status (JSON)", lines=10, interactive=False) hc_btn.click( fn=health_check, inputs=[], outputs=[hc_output], api_name="health_check", ) gr.Markdown(""" --- *GhSL-EmoCare · EmoSign + SignTalk-GH datasets · MediaPipe + PyTorch + Gradio* Model repo: [ransoppong/ghsl-emosign-models](https://huggingface.co/ransoppong/ghsl-emosign-models) """) if __name__ == "__main__": demo.launch()