CoolFace
Apppublic

Sentient-Field/sgp-tribe3

sourceHugging Facecc-by-nc-4.0updated 6mo agoView on Hugging Face
0likes
app.py988 linesDownload Raw Back to root
1"""2SGP-Tribe3 — Main API Application3==================================4Multimodal brain encoding API with SGP 9-node parcellation.5Supports video+audio, audio-only, and text-only inputs via TRIBE v2.6 7Endpoints:8    GET  /              - Service info9    GET  /health         - Model load status10    POST /warmup         - Trigger model loading11    POST /predict        - Run inference on video file (video + audio encoding)12    POST /predict_text   - Run inference on text input (text-only encoding)13    POST /predict_audio   - Run inference on audio file (audio-only encoding)14    GET  /nodes          - SGP node definitions15    GET  /tracts          - White matter tract definitions16    GET  /results        - All stored stimulus results17    GET  /coactivation_matrix - Cross-stimulus co-activation matrix18 19Reference: Harvard MLSysBook - Machine Learning Systems20https://github.com/harvard-edge/cs249r_book21"""22 23# CRITICAL: Set CPU-only mode BEFORE any torch imports24import os25os.environ['CUDA_VISIBLE_DEVICES'] = ''26os.environ['TRANSFORMERS_DEVICE'] = 'cpu'27 28import sys29import warnings30import threading31import traceback32import tempfile33import subprocess34import uuid35import math36import json37import os38import numpy as np39import pandas as pd40 41# CRITICAL: Patch torch.cuda BEFORE any ML libraries are imported42# This must be at the very top to prevent CUDA lazy initialization43_original_cuda = sys.modules.get('torch.cuda')44import torch45 46class _CPUOnlyCUDA:47    """Dummy CUDA module that always reports CPU-only mode."""48    49    @staticmethod50    def is_available():51        return False52    53    @staticmethod54    def device_count():55        return 056    57    @staticmethod58    def current_device():59        return 060    61    @staticmethod62    def device(idx=0):63        # Return a device with type 'cuda' but mapped to CPU internally64        # This allows transformers to check device.type without crashing65        d = torch.device('cpu')66        # Patch the type to appear as cuda (trick the library)67        object.__setattr__(d, 'type', 'cuda')68        return d69    70    @staticmethod71    def set_device(idx):72        pass73    74    @staticmethod75    def synchronize(device=None):76        pass77    78    @staticmethod79    def empty_cache():80        pass81    82    @staticmethod83    def memory_allocated(device=None):84        return 085    86    @staticmethod87    def memory_reserved(device=None):88        return 089    90    @staticmethod91    def reset_peak_memory_stats(device=None):92        pass93    94    # Prevent any actual CUDA operations95    def __getattr__(self, name):96        return lambda *args, **kwargs: None97    98    def __repr__(self):99        return "<CPU-only CUDA mock>"100 101# Replace torch.cuda completely102sys.modules['torch.cuda'] = _CPUOnlyCUDA()103 104# Force the torch.cuda module to be "initialized" before any code runs105import torch106 107# Most importantly: patch _lazy_init to be a no-op108# This is the function that throws the assertion error when CUDA is not compiled109try:110    # Try to patch at the C level111    torch._C._lazy_init = lambda: None112except:113    pass114 115# Patch cuda module's lazy init116import torch.cuda117if hasattr(torch.cuda, '_lazy_init'):118    torch.cuda._lazy_init = lambda: None119 120# Prevent the assertion error by making is_initialized return True121torch.cuda.is_initialized = lambda: True122torch.cuda._is_initialized = lambda: True123torch.cuda._initialized = lambda: True124 125# The key: patch _is_compiled to say YES it was compiled126# This is checked in the lazy init127if hasattr(torch, '_C'):128    torch._C._is_compiled = lambda: True129    if hasattr(torch._C, '_CudaBase__is_compiled'):130        torch._C._CudaBase__is_compiled = lambda: True131 132print("[SGP-Tribe3] Patched torch._lazy_init extensively", flush=True)133 134warnings.filterwarnings("ignore")135 136# CRITICAL: Patch transformers at the VERY TOP before importing TRIBE137# This must happen before tribev2 is imported138# PATCHING AT HIGHEST PRIORITY - MUST WORK139try:140    import transformers.modeling_utils141    import torch142    import torch.nn as nn143    144    # CRITICAL: Replace .to() on torch.nn.Module FIRST145    # This is the base class that everything inherits from146    def noop_to(self, *args, **kwargs):147        return self148    149    nn.Module.to = noop_to150    151    # Save original __init__ FIRST152    orig_init = transformers.modeling_utils.PreTrainedModel.__init__153    154    # Replace .to() completely on ALL PreTrainedModel classes155    def patched_to(self, *args, **kwargs):156        return self  # No-op - don't move model anywhere157    158    def patched_init(self, *args, **kwargs):159        import torch160        if 'device' not in kwargs or kwargs['device'] is None:161            kwargs['device'] = torch.device('cpu')162        elif isinstance(kwargs['device'], str) and kwargs['device'].startswith('cuda'):163            kwargs['device'] = torch.device('cpu')164        return orig_init(self, *args, **kwargs)165    166    transformers.modeling_utils.PreTrainedModel.__init__ = patched_init167    transformers.modeling_utils.PreTrainedModel.to = patched_to168    169    print("[SGP-Tribe3] Patched nn.Module.to and transformers PreTrainedModel", flush=True)170except Exception as e:171    print(f"[SGP-Tribe3] Early patch error (non-fatal): {e}", flush=True)172 173from flask import Flask, request, jsonify174from sgp_parcellation import get_parcellator, SGP_NODE_DEFINITIONS, SGP_TRACT_DEFINITIONS175 176app = Flask(__name__)177 178# ─── Global model state ───────────────────────────────────────────────────────179_model = None180_model_loaded = False181_model_loading = False182_model_error = None183_model_lock = threading.Lock()184 185# ─── Configuration ────────────────────────────────────────────────────────────186HF_TOKEN = os.environ.get("HF_TOKEN", "")187CKPT = os.environ.get("TRIBE_CKPT", "facebook/tribev2")188MAX_VIDEO_DURATION = int(os.environ.get("MAX_VIDEO_DURATION", "120"))189MAX_AUDIO_DURATION = int(os.environ.get("MAX_AUDIO_DURATION", "120"))190CACHE_DIR = os.environ.get("SGP_CACHE_DIR", "/tmp/sgp_atlas")191 192os.environ.setdefault("HF_HUB_CACHE", "/tmp/hf_hub_cache")193os.environ.setdefault("WHISPER_CACHE_DIR", "/tmp/whisper_cache")194os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")195os.makedirs(os.environ["HF_HUB_CACHE"], exist_ok=True)196os.makedirs(os.environ["WHISPER_CACHE_DIR"], exist_ok=True)197 198# ─── Result storage (in-memory for now; extend to HF dataset for persistence) ───199_stimulus_results = {}200 201# ─── Metrics tracking (MLOps best practice) ──────────────────────────────────202_metrics = {203    "start_time": None,204    "total_predictions": 0,205    "predictions_by_modality": {"video": 0, "audio": 0, "text": 0},206    "inference_times": [],207}208 209 210# ─── Model loading ────────────────────────────────────────────────────────────211 212def _load_model():213    global _model, _model_loaded, _model_loading, _model_error, _metrics214 215    with _model_lock:216        if _model_loaded or _model_loading:217            return218        _model_loading = True219        _model_error = None220 221    try:222        print("[SGP-Tribe3] Starting model load...", flush=True)223        _metrics["start_time"] = pd.Timestamp.now().isoformat()224 225        if HF_TOKEN:226            os.environ["HUGGING_FACE_HUB_TOKEN"] = HF_TOKEN227            os.environ["HF_TOKEN"] = HF_TOKEN228            try:229                from huggingface_hub import login230                login(token=HF_TOKEN, add_to_git_credential=False)231                print(f"[SGP-Tribe3] HF login OK", flush=True)232            except Exception as e:233                print(f"[SGP-Tribe3] HF login warning: {e}", flush=True)234        else:235            print("[SGP-Tribe3] WARNING: No HF_TOKEN set — LLaMA encoder may fail", flush=True)236 237        import torch238        print(f"[SGP-Tribe3] PyTorch {torch.__version__}", flush=True)239        240        # CRITICAL: Patch torch.cuda._lazy_init to not throw assertion error241        # The error happens in _lazy_init checking if torch was compiled with CUDA242        import torch.cuda243        if hasattr(torch.cuda, '_lazy_init'):244            _orig_lazy_init = torch.cuda._lazy_init245            def _safe_lazy_init():246                try:247                    return _orig_lazy_init()248                except AssertionError:249                    # Swallow the "Torch not compiled with CUDA enabled" error250                    pass251            torch.cuda._lazy_init = _safe_lazy_init252            print("[SGP-Tribe3] Patched torch.cuda._lazy_init to be safe", flush=True)253 254        # Force CPU mode via environment255        os.environ['CUDA_VISIBLE_DEVICES'] = ''256        257        # Patch neuralset/transformers AFTER torch is imported but BEFORE model loads258        try:259            # Import first260            import neuralset.extractors.base261            # Patch the device property on all extractors to return CPU262            neuralset.extractors.base.BaseExtractor.device = property(lambda self: 'cpu')263            print("[SGP-Tribe3] Patched BaseExtractor.device to CPU", flush=True)264            265            # Patch ALL extractor subclasses266            from neuralset.extractors import audio, video, text267            for module in [audio, video, text]:268                for name in dir(module):269                    cls = getattr(module, name, None)270                    if cls and isinstance(cls, type) and hasattr(cls, 'device'):271                        try:272                            cls.device = property(lambda self: 'cpu')273                        except:274                            pass275            print("[SGP-Tribe3] Patched all extractor device properties", flush=True)276        except Exception as e:277            print(f"[SGP-Tribe3] Extractor patch warning: {e}", flush=True)278 279        # Also patch transformers' PreTrainedModel.to() method and __init__280        try:281            import transformers.modeling_utils282            283            # Patch PreTrainedModel.__init__ to default to cpu284            # Note: Don't use orig_init here - use the one from top of file285            orig_init = transformers.modeling_utils.PreTrainedModel.__init__286            287            def patched_init(self, *args, **kwargs):288                # Force device to cpu in kwargs289                import torch290                if 'device' not in kwargs or kwargs['device'] is None:291                    kwargs['device'] = torch.device('cpu')292                elif isinstance(kwargs['device'], str) and kwargs['device'].startswith('cuda'):293                    kwargs['device'] = torch.device('cpu')294                return orig_init(self, *args, **kwargs)295            296            # Also patch torch.nn.Module._apply at the base level297            import torch.nn as nn298            orig_apply = nn.Module._apply299            300            def cpu_apply(self, fn):301                # This intercepts _apply which is called by .to()302                def wrapped_fn(t):303                    return t  # Skip the conversion - keep on CPU304                return orig_apply(self, wrapped_fn)305            306            nn.Module._apply = cpu_apply307            308            # Also patch PreTrainedModel.to - use the top-level patch we already defined309            # Don't re-patch - just make sure it's using our no-op version310            311            # Also patch the device property to always return cpu312            try:313                import torch314                # Get the original device property315                orig_device = transformers.modeling_utils.PreTrainedModel.device316                317                def patched_device(self):318                    return torch.device('cpu')319                320                # Replace the property321                transformers.modeling_utils.PreTrainedModel.device = property(patched_device)322            except Exception as e:323                print(f"[SGP-Tribe3] device property patch warning: {e}", flush=True)324            325            # Also patch all subclasses of PreTrainedModel326            import torch327            for cls_name in dir(transformers.modeling_utils):328                cls = getattr(transformers.modeling_utils, cls_name, None)329                if cls and isinstance(cls, type) and issubclass(cls, transformers.modeling_utils.PreTrainedModel):330                    try:331                        cls.device = property(lambda self: torch.device('cpu'))332                    except:333                        pass334            335            transformers.modeling_utils.PreTrainedModel.__init__ = patched_init336            print("[SGP-Tribe3] Patched transformers PreTrainedModel __init__ and device", flush=True)337        except Exception as e:338            print(f"[SGP-Tribe3] transformers patch warning: {e}", flush=True)339 340        # Load TRIBE v2 model341        from tribev2 import TribeModel342        print("[SGP-Tribe3] Loading TribeModel...", flush=True)343        model = TribeModel.from_pretrained(CKPT, device='cpu')344        print("[SGP-Tribe3] TribeModel loaded!", flush=True)345 346        # Pre-warm the parcellator (downloads Schaefer atlas if needed)347        print("[SGP-Tribe3] Initializing SGP parcellator...", flush=True)348        parcellator = get_parcellator(CACHE_DIR)349        _ = parcellator.get_vertex_map()350        print("[SGP-Tribe3] Parcellator ready!", flush=True)351 352        with _model_lock:353            _model = model354            _model_loaded = True355            _model_loading = False356        print("[SGP-Tribe3] READY", flush=True)357 358    except Exception as e:359        err = traceback.format_exc()360        print(f"[SGP-Tribe3] LOAD ERROR:\n{err}", flush=True)361        with _model_lock:362            _model_loading = False363            _model_error = str(e)364 365 366# ─── Video/Audio preprocessing ────────────────────────────────────────────────367 368def _get_video_duration(video_path: str) -> float:369    """Get video duration in seconds using ffprobe."""370    import json371    cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", video_path]372    result = subprocess.run(cmd, capture_output=True, text=True)373    if result.returncode == 0:374        try:375            data = json.loads(result.stdout)376            return float(data.get("format", {}).get("duration", 0))377        except:378            pass379    return 0.0380 381 382def _preprocess_video(video_path: str, max_duration: int = MAX_VIDEO_DURATION) -> str:383    """384    Trim video to max_duration and normalize to TRIBE v2 expected format.385    Returns path to processed video file.386    """387    actual_duration = _get_video_duration(video_path)388    clip_duration = min(max_duration, actual_duration) if actual_duration > 0 else max_duration389    390    output_path = video_path.replace(".mp4", "_processed.mp4")391 392    cmd = [393        "ffmpeg", "-y",394        "-i", video_path,395        "-t", str(clip_duration),396        "-c:v", "libx264", "-preset", "fast",397        "-c:a", "aac", "-ar", "16000", "-ac", "1",398        "-vf", "scale=320:240",399        output_path400    ]401 402    result = subprocess.run(cmd, capture_output=True, text=True)403    if result.returncode != 0:404        raise ValueError(f"ffmpeg preprocessing failed: {result.stderr}")405 406    return output_path407 408 409def _get_audio_duration(audio_path: str) -> float:410    """Get audio duration in seconds using ffprobe."""411    import json412    cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", audio_path]413    result = subprocess.run(cmd, capture_output=True, text=True)414    if result.returncode == 0:415        try:416            data = json.loads(result.stdout)417            return float(data.get("format", {}).get("duration", 0))418        except:419            pass420    return 0.0421 422 423def _preprocess_audio(audio_path: str, max_duration: int = MAX_AUDIO_DURATION) -> str:424    """425    Convert audio to wav format and normalize for TRIBE v2.426    Returns path to processed audio file.427    """428    actual_duration = _get_audio_duration(audio_path)429    clip_duration = min(max_duration, actual_duration) if actual_duration > 0 else max_duration430    431    output_path = audio_path.replace(audio_path.split(".")[-1], "wav")432    if output_path == audio_path:433        output_path = audio_path.rsplit(".", 1)[0] + "_processed.wav"434 435    cmd = [436        "ffmpeg", "-y",437        "-i", audio_path,438        "-t", str(clip_duration),439        "-ar", "16000",440        "-ac", "1",441        "-acodec", "pcm_s16le",442        output_path443    ]444 445    result = subprocess.run(cmd, capture_output=True, text=True)446    if result.returncode != 0:447        raise ValueError(f"ffmpeg audio preprocessing failed: {result.stderr}")448 449    return output_path450 451 452# ─── Core inference ──────────────────────────────────────────────────────────453 454def _run_inference_from_events(events_df: pd.DataFrame) -> dict:455    """456    Run TRIBE v2 inference from an events DataFrame and return SGP parcellation.457    This is the core function used by all three modality endpoints.458    """459    import time460    start_time = time.time()461 462    try:463        # Run TRIBE v2 prediction464        # Note: standardize_events is called inside get_loaders, so we need to ensure465        # our events have the right schema BEFORE calling predict466        preds, segments = _model.predict(events=events_df, verbose=False)467 468        # Convert to numpy469        if hasattr(preds, "numpy"):470            pred_array = preds.numpy()471        else:472            pred_array = np.array(preds)473 474        if pred_array.ndim == 1:475            pred_array = pred_array.reshape(1, -1)476 477        inference_time = time.time() - start_time478        print(f"[SGP-Tribe3] Prediction shape: {pred_array.shape}, time: {inference_time:.1f}s", flush=True)479 480        # Apply SGP parcellation481        parcellator = get_parcellator(CACHE_DIR)482        result = parcellator.parcellate(pred_array)483 484        # Add activation timeline (mean activation per timestep)485        result["activation_timeline"] = [486            round(float(np.abs(pred_array[t]).mean()), 4)487            for t in range(pred_array.shape[0])488        ]489 490        # Add inference metadata491        result["inference_time_seconds"] = round(inference_time, 2)492        result["n_segments"] = pred_array.shape[0]493        result["n_vertices"] = pred_array.shape[1]494 495        # Update metrics496        _metrics["total_predictions"] += 1497        _metrics["inference_times"].append(inference_time)498        if len(_metrics["inference_times"]) > 100:499            _metrics["inference_times"] = _metrics["inference_times"][-100:]500 501        return result502 503    except Exception as e:504        raise RuntimeError(f"TRIBE v2 inference failed: {e}")505 506 507def _run_video_inference(video_path: str) -> dict:508    """509    Run inference on video file (video + audio modalities).510    Uses TRIBE v2's get_audio_and_text_events with audio_only=True.511    """512    from tribev2.demo_utils import get_audio_and_text_events513 514    processed_path = _preprocess_video(video_path)515    actual_duration = _get_video_duration(processed_path)516    clip_duration = int(actual_duration) if actual_duration > 0 else MAX_VIDEO_DURATION517    518    try:519        # Create initial video event with ALL required columns for TRIBE v2 schema520        event = pd.DataFrame([{521            "type": "Video",522            "filepath": processed_path,523            "start": 0.0,524            "timeline": "default",525            "subject": "default",526            "duration": clip_duration,527            "offset": 0.0,528            "frequency": 1.0,529            "extra": {}530        }])531        532        # Use TRIBE v2 pipeline: extracts audio, chunks, but SKIPS whisperx533        events_df = get_audio_and_text_events(event, audio_only=True)534 535        # FIX: Ensure every single row has timeline and other required fields536        # Replace any missing/None values with defaults537        if "timeline" not in events_df.columns:538            events_df["timeline"] = "default"539        events_df["timeline"] = events_df["timeline"].fillna("default")540        541        if "subject" not in events_df.columns:542            events_df["subject"] = "default"543        events_df["subject"] = events_df["subject"].fillna("default")544        545        if "duration" not in events_df.columns:546            events_df["duration"] = MAX_VIDEO_DURATION547        events_df["duration"] = events_df["duration"].fillna(MAX_VIDEO_DURATION)548        549        if "offset" not in events_df.columns:550            events_df["offset"] = 0.0551        events_df["offset"] = events_df["offset"].fillna(0.0)552        553        if "frequency" not in events_df.columns:554            events_df["frequency"] = 1.0555        events_df["frequency"] = events_df["frequency"].fillna(1.0)556        557        if "extra" not in events_df.columns:558            events_df["extra"] = {}559        events_df["extra"] = events_df["extra"].apply(lambda x: x if x is not None else {})560 561        event_types = events_df['type'].unique().tolist()562        print(f"[SGP-Tribe3] Video inference: {len(events_df)} events, types: {event_types}", flush=True)563 564        _metrics["predictions_by_modality"]["video"] += 1565        return _run_inference_from_events(events_df)566 567    finally:568        if os.path.exists(processed_path) and processed_path != video_path:569            os.remove(processed_path)570 571 572def _run_audio_inference(audio_path: str) -> dict:573    """574    Run inference on audio file (audio-only modality).575    Uses TRIBE v2's get_audio_and_text_events with audio_only=True.576    """577    from tribev2.demo_utils import get_audio_and_text_events578 579    processed_path = _preprocess_audio(audio_path)580    actual_duration = _get_audio_duration(processed_path)581    clip_duration = int(actual_duration) if actual_duration > 0 else MAX_AUDIO_DURATION582    583    try:584        # Create initial audio event with ALL required columns585        event = pd.DataFrame([{586            "type": "Audio",587            "filepath": processed_path,588            "start": 0.0,589            "timeline": "default",590            "subject": "default",591            "duration": clip_duration,592            "offset": 0.0,593            "frequency": 1.0,594            "extra": {}595        }])596 597        # Use TRIBE v2 pipeline with audio_only=True598        events_df = get_audio_and_text_events(event, audio_only=True)599 600        # FIX: Ensure every single row has timeline and other required fields601        if "timeline" not in events_df.columns:602            events_df["timeline"] = "default"603        events_df["timeline"] = events_df["timeline"].fillna("default")604        605        if "subject" not in events_df.columns:606            events_df["subject"] = "default"607        events_df["subject"] = events_df["subject"].fillna("default")608        609        if "duration" not in events_df.columns:610            events_df["duration"] = MAX_AUDIO_DURATION611        events_df["duration"] = events_df["duration"].fillna(MAX_AUDIO_DURATION)612        613        if "offset" not in events_df.columns:614            events_df["offset"] = 0.0615        events_df["offset"] = events_df["offset"].fillna(0.0)616        617        if "frequency" not in events_df.columns:618            events_df["frequency"] = 1.0619        events_df["frequency"] = events_df["frequency"].fillna(1.0)620        621        if "extra" not in events_df.columns:622            events_df["extra"] = {}623        events_df["extra"] = events_df["extra"].apply(lambda x: x if x is not None else {})624 625        event_types = events_df['type'].unique().tolist()626        print(f"[SGP-Tribe3] Audio inference: {len(events_df)} events, types: {event_types}", flush=True)627 628        _metrics["predictions_by_modality"]["audio"] += 1629        return _run_inference_from_events(events_df)630 631    finally:632        if os.path.exists(processed_path) and processed_path != audio_path:633            os.remove(processed_path)634 635 636def _run_text_inference(text: str) -> dict:637    """638    Run inference on text input (text-only modality).639    Creates Word events manually with accumulating context.640    641    CRITICAL: Patches all neuralset extractors to use CPU before TRIBE v2 predict().642    This prevents the CUDA assertion error that occurs when audio/video extractors643    try to move to GPU during text-only inference.644    """645    words = text.split()646    if not words:647        raise ValueError("Empty text provided")648 649    word_events = []650    context = ""651    for i, word in enumerate(words):652        context = f"{context} {word}" if context else word653        word_events.append({654            "type": "Word",655            "start": 0.0,656            "duration": 1.0,657            "text": word,658            "context": context,659            "timeline": "default",660            "subject": "default",661            "sequence_id": 0,662            "sentence": text,663            "language": "english",664            "offset": 0.0,665            "frequency": 1.0,666            "filepath": None,667            "extra": {}668        })669 670    events_df = pd.DataFrame(word_events)671    672    print(f"[SGP-Tribe3] Text inference: {len(words)} words", flush=True)673 674    # CRITICAL: Patch ALL extractors to use CPU BEFORE calling predict()675    # This prevents Wav2Vec-BERT and other audio/video extractors from676    # trying to move to CUDA (which fails on CPU-only PyTorch build)677    try:678        from neuralset.extractors import base, audio, video, text679        680        # Patch BaseExtractor681        base.BaseExtractor.device = property(lambda self: 'cpu')682        base.BaseExtractor._device = 'cpu'683        684        # Patch audio extractors685        for name in dir(audio):686            cls = getattr(audio, name, None)687            if cls and isinstance(cls, type) and hasattr(cls, 'device'):688                cls.device = property(lambda self: 'cpu')689        690        # Patch video extractors  691        for name in dir(video):692            cls = getattr(video, name, None)693            if cls and isinstance(cls, type) and hasattr(cls, 'device'):694                cls.device = property(lambda self: 'cpu')695        696        # Patch text extractors697        for name in dir(text):698            cls = getattr(text, name, None)699            if cls and isinstance(cls, type) and hasattr(cls, 'device'):700                cls.device = property(lambda self: 'cpu')701        702        print("[SGP-Tribe3] Patched all extractors to CPU for text inference", flush=True)703    except Exception as e:704        print(f"[SGP-Tribe3] Extractor patch warning: {e}", flush=True)705 706    _metrics["predictions_by_modality"]["text"] += 1707    result = _run_inference_from_events(events_df)708    result["text_length"] = len(text)709    result["word_count"] = len(words)710 711    return result712 713 714# ─── Routes ───────────────────────────────────────────────────────────────────715 716@app.route("/", methods=["GET"])717def index():718    return jsonify({719        "service": "SGP-Tribe3",720        "version": "1.1.0",721        "description": "Sentient Generative Principal — Brain Encoding Calibration System",722        "status": "ok",723        "modality_support": {724            "video": "Video + audio encoding (V-JEPA2 + DINOv2 + Wav2Vec-BERT)",725            "audio": "Audio-only encoding (Wav2Vec-BERT)",726            "text": "Text-only encoding (LLaMA 3.2 embeddings)"727        },728        "endpoints": {729            "GET /health": "Model load status",730            "POST /warmup": "Trigger model loading",731            "POST /predict": "Run inference on video file (multipart/form-data, field: video)",732            "POST /predict_text": "Run inference on text (form field: text)",733            "POST /predict_audio": "Run inference on audio file (field: audio)",734            "GET /nodes": "SGP node definitions",735            "GET /tracts": "White matter tract definitions",736            "GET /results": "All stored stimulus results",737            "GET /coactivation_matrix": "Cross-stimulus co-activation matrix",738            "GET /metrics": "Service metrics and monitoring",739        }740    })741 742 743@app.route("/health", methods=["GET"])744def health():745    return jsonify({746        "status": "ready" if _model_loaded else ("loading" if _model_loading else "offline"),747        "model_loaded": _model_loaded,748        "model_loading": _model_loading,749        "error": _model_error,750        "n_stored_results": len(_stimulus_results),751    })752 753 754@app.route("/warmup", methods=["POST"])755def warmup():756    if not _model_loaded and not _model_loading:757        threading.Thread(target=_load_model, daemon=True).start()758    return jsonify({759        "status": "warming_up",760        "model_loaded": _model_loaded,761        "model_loading": _model_loading,762    })763 764 765@app.route("/predict", methods=["POST"])766def predict():767    """Run inference on video file (video + audio modalities)."""768    if not _model_loaded:769        return jsonify({770            "error": "Model not loaded. POST to /warmup first.",771            "model_loading": _model_loading,772            "load_error": _model_error,773        }), 503774 775    if "video" not in request.files:776        return jsonify({"error": "No video file provided. Use multipart/form-data with 'video' field."}), 400777 778    video_file = request.files["video"]779    if video_file.filename == "":780        return jsonify({"error": "Empty filename"}), 400781 782    stimulus_id = request.form.get("stimulus_id", str(uuid.uuid4()))783    stimulus_label = request.form.get("label", "unlabeled")784    target_node = request.form.get("target_node", "unknown")785 786    suffix = os.path.splitext(video_file.filename)[1] or ".mp4"787    with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:788        video_file.save(tmp.name)789        tmp_path = tmp.name790 791    try:792        print(f"[SGP-Tribe3] Video inference: stimulus_id={stimulus_id}, label={stimulus_label}", flush=True)793        result = _run_video_inference(tmp_path)794 795        result["stimulus_id"] = stimulus_id796        result["label"] = stimulus_label797        result["target_node"] = target_node798        result["modality"] = "video"799 800        _stimulus_results[stimulus_id] = result801        return jsonify({"status": "ok", "result": result})802 803    except Exception as e:804        err = traceback.format_exc()805        print(f"[SGP-Tribe3] Video inference error:\n{err}", flush=True)806        return jsonify({"error": str(e), "trace": err}), 500807 808    finally:809        if os.path.exists(tmp_path):810            os.remove(tmp_path)811 812 813@app.route("/predict_text", methods=["POST"])814def predict_text():815    """Run inference on plain text input (text-only modality)."""816    if not _model_loaded:817        return jsonify({818            "error": "Model not loaded. POST to /warmup first.",819            "model_loading": _model_loading,820            "load_error": _model_error,821        }), 503822 823    text = request.form.get("text", "").strip()824    if not text:825        return jsonify({"error": "No text provided. Use form field 'text'."}), 400826 827    stimulus_id = request.form.get("stimulus_id", str(uuid.uuid4()))828    stimulus_label = request.form.get("label", "text_stimulus")829    target_node = request.form.get("target_node", "unknown")830 831    try:832        print(f"[SGP-Tribe3] Text inference: stimulus_id={stimulus_id}, label={stimulus_label}", flush=True)833        result = _run_text_inference(text)834 835        result["stimulus_id"] = stimulus_id836        result["label"] = stimulus_label837        result["target_node"] = target_node838        result["modality"] = "text"839 840        _stimulus_results[stimulus_id] = result841        return jsonify({"status": "ok", "result": result})842 843    except Exception as e:844        err = traceback.format_exc()845        print(f"[SGP-Tribe3] Text inference error:\n{err}", flush=True)846        return jsonify({"error": str(e), "trace": err}), 500847 848 849@app.route("/predict_audio", methods=["POST"])850def predict_audio():851    """Run inference on audio file (audio-only modality)."""852    if not _model_loaded:853        return jsonify({854            "error": "Model not loaded. POST to /warmup first.",855            "model_loading": _model_loading,856            "load_error": _model_error,857        }), 503858 859    if "audio" not in request.files:860        return jsonify({"error": "No audio file. Use multipart/form-data with 'audio' field."}), 400861 862    audio_file = request.files["audio"]863    if audio_file.filename == "":864        return jsonify({"error": "Empty filename"}), 400865 866    stimulus_id = request.form.get("stimulus_id", str(uuid.uuid4()))867    stimulus_label = request.form.get("label", "audio_stimulus")868    target_node = request.form.get("target_node", "unknown")869 870    suffix = os.path.splitext(audio_file.filename)[1] or ".wav"871    with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:872        audio_file.save(tmp.name)873        tmp_path = tmp.name874 875    try:876        print(f"[SGP-Tribe3] Audio inference: stimulus_id={stimulus_id}, label={stimulus_label}", flush=True)877        result = _run_audio_inference(tmp_path)878 879        result["stimulus_id"] = stimulus_id880        result["label"] = stimulus_label881        result["target_node"] = target_node882        result["modality"] = "audio"883 884        _stimulus_results[stimulus_id] = result885        return jsonify({"status": "ok", "result": result})886 887    except Exception as e:888        err = traceback.format_exc()889        print(f"[SGP-Tribe3] Audio inference error:\n{err}", flush=True)890        return jsonify({"error": str(e), "trace": err}), 500891 892    finally:893        if os.path.exists(tmp_path):894            os.remove(tmp_path)895 896 897@app.route("/nodes", methods=["GET"])898def nodes():899    return jsonify({900        "sgp_nodes": SGP_NODE_DEFINITIONS,901        "count": len(SGP_NODE_DEFINITIONS),902    })903 904 905@app.route("/tracts", methods=["GET"])906def tracts():907    return jsonify({908        "white_matter_tracts": SGP_TRACT_DEFINITIONS,909        "count": len(SGP_TRACT_DEFINITIONS),910    })911 912 913@app.route("/results", methods=["GET"])914def results():915    return jsonify({916        "n_results": len(_stimulus_results),917        "results": _stimulus_results,918    })919 920 921@app.route("/coactivation_matrix", methods=["GET"])922def coactivation_matrix():923    """Compute the co-activation matrix across all stored stimulus results."""924    if len(_stimulus_results) < 2:925        return jsonify({926            "error": "Need at least 2 stimulus results to compute co-activation matrix.",927            "n_results": len(_stimulus_results),928        }), 400929 930    node_ids = list(SGP_NODE_DEFINITIONS.keys())931    activation_matrix = []932    stimulus_labels = []933    modalities = []934 935    for sid, res in _stimulus_results.items():936        row = [res["sgp_nodes"].get(nid, 0.0) for nid in node_ids]937        activation_matrix.append(row)938        stimulus_labels.append(res.get("label", sid))939        modalities.append(res.get("modality", "unknown"))940 941    A = np.array(activation_matrix)942 943    if A.shape[0] > 1:944        corr_matrix = np.corrcoef(A.T)945    else:946        corr_matrix = np.eye(len(node_ids))947 948    mean_activation = A.mean(axis=0)949 950    return jsonify({951        "node_ids": node_ids,952        "n_stimuli": len(_stimulus_results),953        "stimulus_labels": stimulus_labels,954        "modalities": modalities,955        "coactivation_matrix": corr_matrix.round(4).tolist(),956        "mean_activation_per_node": dict(zip(node_ids, mean_activation.round(4).tolist())),957        "interpretation": "coactivation_matrix[i][j] = Pearson correlation of node_i and node_j activation across stimuli. Use as Resonance Graph edge weights.",958    })959 960 961@app.route("/metrics", methods=["GET"])962def metrics():963    """Service metrics following MLOps best practices (MLSysBook Ch 13)."""964    mean_inference_time = 0.0965    if _metrics["inference_times"]:966        mean_inference_time = round(sum(_metrics["inference_times"]) / len(_metrics["inference_times"]), 2)967 968    uptime_seconds = None969    if _metrics["start_time"]:970        uptime_seconds = (pd.Timestamp.now() - pd.Timestamp(_metrics["start_time"])).total_seconds()971 972    return jsonify({973        "service_uptime_seconds": uptime_seconds,974        "total_predictions": _metrics["total_predictions"],975        "predictions_by_modality": _metrics["predictions_by_modality"],976        "mean_inference_time_seconds": mean_inference_time,977        "n_stored_results": len(_stimulus_results),978        "model_loaded": _model_loaded,979    })980 981 982# ─── Entry point ──────────────────────────────────────────────────────────────983 984if __name__ == "__main__":985    threading.Thread(target=_load_model, daemon=True).start()986    port = int(os.environ.get("PORT", 7860))987    app.run(host="0.0.0.0", port=port, debug=False)988