| """ |
| src/pipeline/pipeline.py |
| EmpathRAG β Full 5-Stage Pipeline Orchestrator |
| |
| Stage 1: Emotion Classifier (RoBERTa + LoRA, CPU) |
| Stage 2: Safety Guardrail (DeBERTa NLI stub until checkpoint lands) |
| Stage 3: Query Router (deterministic template, uses session trajectory) |
| Stage 4: FAISS Retrieval (all-mpnet-base-v2, GPUβCPU offload) |
| Stage 5: Mistral 7B Generator (GGUF, 28 GPU layers) |
| |
| VRAM sequencing on RTX 3060 6GB: |
| - RoBERTa + DeBERTa: CPU only β 0 MB GPU |
| - Sentence transformer: load to GPU (~440 MB), run query, offload back to CPU |
| - Mistral: always resident on GPU (~3,870 MB) |
| - Peak during retrieval: ~440 MB (ST) β Mistral not yet loaded at that point |
| - Peak during generation: ~3,870 MB (Mistral) β ST already offloaded |
| - Absolute peak: ~4,310 MB β well within 6 GB ceiling |
| """ |
|
|
| import asyncio |
| import sqlite3 |
| import time |
| from pathlib import Path |
| import torch |
| import numpy as np |
| import faiss |
|
|
| from transformers import AutoTokenizer, AutoModelForSequenceClassification |
| from peft import PeftModel |
| from sentence_transformers import SentenceTransformer |
| from llama_cpp import Llama |
|
|
| from .session_tracker import SessionTracker |
| from .query_router import route_query, LABEL_NAMES |
| from .safety_policy import SafetyLevel, SafetyTriagePolicy |
|
|
|
|
| |
|
|
| SAFE_RESPONSE = ( |
| "I can hear that you're going through something really difficult. " |
| "You're not alone in this. Please reach out to the 988 Suicide " |
| "& Crisis Lifeline by calling or texting 988 β they're available " |
| "24/7. If you're a student, your campus counseling center is " |
| "also a great resource. Would you like help finding their contact info?" |
| ) |
|
|
| SYSTEM_PROMPT = """You are a warm peer companion for graduate students. You write like a caring friend in a chat window. |
| |
| Here is an example of a perfect response: |
| Student: "I've been feeling really anxious about my qualifying exam." |
| Response: "Qualifying exams are genuinely terrifying, and that knot-in-your-stomach feeling is something so many grad students know too well.\n\nWhat's been weighing on you most about it?" |
| |
| Follow that exact format every time. Two short paragraphs separated by a blank line. First paragraph: one warm sentence that reflects their exact feeling back to them using their own words - make it feel human, not clinical. Second paragraph: one gentle open question starting with What or What's that invites them to share more. Nothing else. No advice. No suggestions. No lists. No context references.""" |
|
|
| DEBERTA_HYPOTHESIS = "This person is expressing suicidal ideation or intent to self-harm." |
|
|
| |
| SAFETY_SCORE_MAP = {0: 0.0, 1: 0.0, 2: 0.3, 3: 0.7, 4: 1.0} |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| class _GuardrailStub: |
| """ |
| Passthrough stub for SafetyGuardrail. |
| Returns (is_crisis=False, confidence=0.0, token_attributions=[]). |
| Replace with real guardrail once DeBERTa checkpoint is available: |
| |
| from src.models.guardrail_ig import SafetyGuardrail |
| self.guardrail = SafetyGuardrail() |
| """ |
| def check(self, text: str, threshold: float = 0.5, skip_ig: bool = False): |
| return False, 0.0, [] |
|
|
|
|
| |
|
|
| class EmpathRAGPipeline: |
| """ |
| Full EmpathRAG pipeline. |
| |
| Usage: |
| pipeline = EmpathRAGPipeline() |
| result = pipeline.run("I feel completely overwhelmed") |
| |
| Result dict keys: |
| response str β generated or safe-template response |
| emotion int β 0-4 emotion label for this turn |
| emotion_name str β human-readable label |
| trajectory str β session trajectory type |
| crisis bool β True if guardrail fired |
| crisis_confidence float β guardrail entailment probability |
| ig_highlights list β [(token, score), ...] top-5 if crisis |
| retrieved_chunks list β list of retrieved text strings (empty if crisis) |
| latency_ms dict β per-stage latency breakdown |
| """ |
|
|
| def __init__( |
| self, |
| ec_checkpoint: str = "models/emotion_classifier", |
| guardrail_ckpt: str = "models/safety_guardrail", |
| faiss_index_path:str = "data/indexes/faiss_flat.index", |
| db_path: str = "data/indexes/metadata.db", |
| retrieval_corpus: str = "reddit_research", |
| curated_index_path: str = "data/curated/indexes/faiss_curated.index", |
| curated_db_path: str = "data/curated/indexes/metadata_curated.db", |
| mistral_path: str = "models/generator/mistral-7b-instruct-v0.2.Q4_K_M.gguf", |
| st_model: str = "sentence-transformers/all-mpnet-base-v2", |
| n_gpu_layers: int = 28, |
| n_ctx: int = 4096, |
| generation_max_tokens: int = 200, |
| top_k: int = 5, |
| tracker_n: int = 3, |
| guardrail_threshold: float = 0.5, |
| use_real_guardrail: bool = True, |
| allow_stub_guardrail: bool = False, |
| ): |
| self.top_k = top_k |
| self.generation_max_tokens = generation_max_tokens |
| self.guardrail_threshold = guardrail_threshold |
| self.retrieval_corpus = self._resolve_retrieval_corpus( |
| retrieval_corpus, curated_index_path, curated_db_path |
| ) |
| self.faiss_index_path = curated_index_path if self.retrieval_corpus == "curated_support" else faiss_index_path |
| self.db_path = curated_db_path if self.retrieval_corpus == "curated_support" else db_path |
| self.safety_policy = SafetyTriagePolicy( |
| support_threshold=guardrail_threshold |
| ) |
|
|
| print("[EmpathRAG] Loading emotion classifier (CPU)...") |
| self.ec_tok = AutoTokenizer.from_pretrained(ec_checkpoint) |
| _base = AutoModelForSequenceClassification.from_pretrained( |
| "roberta-base", num_labels=5 |
| ) |
| self.ec_model = PeftModel.from_pretrained(_base, ec_checkpoint).eval() |
| |
| print(f"[EmpathRAG] Emotion classifier ready. " |
| f"VRAM: {torch.cuda.memory_allocated()/1e9:.2f} GB") |
|
|
| print("[EmpathRAG] Loading safety guardrail...") |
| if use_real_guardrail: |
| |
| try: |
| try: |
| from src.models.guardrail_ig import SafetyGuardrail |
| except ImportError: |
| from models.guardrail_ig import SafetyGuardrail |
| self.guardrail = SafetyGuardrail() |
| print("[EmpathRAG] Real DeBERTa guardrail loaded (CPU).") |
| except Exception as e: |
| if not allow_stub_guardrail: |
| raise RuntimeError( |
| "Real safety guardrail failed to load. EmpathRAG v2 fails " |
| "closed by default; pass allow_stub_guardrail=True only for " |
| "offline development or retrieval-only experiments." |
| ) from e |
| print(f"[EmpathRAG] WARNING: Real guardrail failed to load ({e}). " |
| f"Falling back to stub because allow_stub_guardrail=True.") |
| self.guardrail = _GuardrailStub() |
| else: |
| if not allow_stub_guardrail: |
| raise RuntimeError( |
| "use_real_guardrail=False disables the crisis guardrail. Pass " |
| "allow_stub_guardrail=True only for controlled development or " |
| "component-level evaluation." |
| ) |
| self.guardrail = _GuardrailStub() |
| print("[EmpathRAG] Guardrail stub active β swap to real once " |
| "models/safety_guardrail/ is populated.") |
|
|
| print("[EmpathRAG] Loading sentence transformer (will GPU-offload after each query)...") |
| self.st_model_name = st_model |
| self.encoder = SentenceTransformer(st_model, device="cpu") |
| |
|
|
| print("[EmpathRAG] Loading FAISS index...") |
| self.faiss_index = faiss.read_index(self.faiss_index_path) |
| print(f"[EmpathRAG] Retrieval corpus: {self.retrieval_corpus}") |
| print(f"[EmpathRAG] FAISS: {self.faiss_index.ntotal:,} vectors") |
|
|
| print("[EmpathRAG] Loading Mistral 7B (GPU)...") |
| self.llm = Llama( |
| model_path = mistral_path, |
| n_ctx = n_ctx, |
| n_gpu_layers = n_gpu_layers, |
| verbose = False, |
| ) |
| print(f"[EmpathRAG] Mistral ready. " |
| f"VRAM: {torch.cuda.memory_allocated()/1e9:.2f} GB") |
|
|
| self.tracker = SessionTracker(N=tracker_n) |
| self.conv_history = [] |
| print("[EmpathRAG] Pipeline initialised. Ready for inference.") |
|
|
| def _resolve_retrieval_corpus( |
| self, |
| retrieval_corpus: str, |
| curated_index_path: str, |
| curated_db_path: str, |
| ) -> str: |
| allowed = {"reddit_research", "curated_support", "auto"} |
| if retrieval_corpus not in allowed: |
| raise ValueError(f"retrieval_corpus must be one of {sorted(allowed)}") |
| if retrieval_corpus == "auto": |
| curated_ready = Path(curated_index_path).exists() and Path(curated_db_path).exists() |
| return "curated_support" if curated_ready else "reddit_research" |
| return retrieval_corpus |
|
|
| |
|
|
| def _classify_emotion(self, text: str) -> int: |
| """Returns integer emotion label 0-4. Runs on CPU.""" |
| enc = self.ec_tok( |
| text, |
| truncation=True, |
| max_length=128, |
| return_tensors="pt", |
| ) |
| with torch.no_grad(): |
| logits = self.ec_model(**enc).logits |
| return int(logits.argmax(-1).item()) |
|
|
| |
|
|
| def _retrieve( |
| self, |
| query: str, |
| emotion_label: int, |
| safety_level: SafetyLevel = SafetyLevel.PASS, |
| ) -> list[dict]: |
| """ |
| Encodes query on GPU, searches FAISS, filters via SQLite. |
| Returns top_k chunk metadata dicts. |
| GPU usage: ~440 MB during encode, freed before returning. |
| """ |
| |
| self.encoder.to("cuda") |
| q_vec = self.encoder.encode( |
| [query], |
| normalize_embeddings=True, |
| convert_to_numpy=True, |
| ) |
| |
| self.encoder.to("cpu") |
| torch.cuda.empty_cache() |
|
|
| |
| search_multiplier = 8 if self.retrieval_corpus == "curated_support" else 3 |
| distances, ids = self.faiss_index.search( |
| q_vec.astype(np.float32), self.top_k * search_multiplier |
| ) |
| candidate_ids = [int(i) for i in ids[0] if i >= 0] |
|
|
| if not candidate_ids: |
| return [] |
|
|
| if self.retrieval_corpus == "curated_support": |
| return self._fetch_curated_rows(candidate_ids, safety_level=safety_level) |
|
|
| |
| placeholders = ",".join("?" * len(candidate_ids)) |
| conn = sqlite3.connect(self.db_path) |
| rows = conn.execute( |
| f"SELECT id, text, emotion_label, safety_score FROM chunks " |
| f"WHERE id IN ({placeholders})", |
| candidate_ids, |
| ).fetchall() |
| conn.close() |
|
|
| |
| def _score(row): |
| _, _, chunk_emotion, safety = row |
| match_bonus = 2.0 if chunk_emotion == emotion_label else 0.0 |
| return match_bonus + safety |
|
|
| rows_sorted = sorted(rows, key=_score, reverse=True)[: self.top_k] |
| return [ |
| { |
| "id": r[0], |
| "text": r[1], |
| "emotion_label": r[2], |
| "safety_score": r[3], |
| "source_name": "Reddit Mental Health", |
| "source_type": "research_corpus", |
| "title": "Reddit Mental Health chunk", |
| "url": "", |
| "topic": "", |
| "risk_level": "research_only", |
| "usage_mode": "retrieval", |
| } |
| for r in rows_sorted |
| ] |
|
|
| def _fetch_curated_rows( |
| self, |
| candidate_ids: list[int], |
| safety_level: SafetyLevel = SafetyLevel.PASS, |
| ) -> list[dict]: |
| placeholders = ",".join("?" * len(candidate_ids)) |
| conn = sqlite3.connect(self.db_path) |
| rows = conn.execute( |
| f""" |
| SELECT id, resource_id, text, source_id, source_name, source_type, |
| title, url, topic, audience, risk_level, usage_mode, summary, |
| last_checked, notes |
| FROM chunks |
| WHERE id IN ({placeholders}) |
| """, |
| candidate_ids, |
| ).fetchall() |
| conn.close() |
|
|
| by_id = {row[0]: row for row in rows} |
| ordered = [by_id[i] for i in candidate_ids if i in by_id] |
| allowed_usage_modes = self._allowed_curated_usage_modes(safety_level) |
| filtered_candidates = [ |
| row for row in ordered |
| if row[10] != "exclude" and row[11] != "metadata_only" |
| and row[11] in allowed_usage_modes |
| ] |
| filtered = self._limit_curated_source_repetition(filtered_candidates) |
| return [ |
| { |
| "id": row[0], |
| "resource_id": row[1], |
| "text": row[2], |
| "source_id": row[3], |
| "source_name": row[4], |
| "source_type": row[5], |
| "title": row[6], |
| "url": row[7], |
| "topic": row[8], |
| "audience": row[9], |
| "risk_level": row[10], |
| "usage_mode": row[11], |
| "summary": row[12], |
| "last_checked": row[13], |
| "notes": row[14], |
| } |
| for row in filtered |
| ] |
|
|
| def _allowed_curated_usage_modes(self, safety_level: SafetyLevel) -> set[str]: |
| if safety_level in {SafetyLevel.CRISIS, SafetyLevel.EMERGENCY}: |
| return {"crisis_only"} |
| if safety_level == SafetyLevel.WELLBEING_SUPPORT: |
| return {"retrieval", "wellbeing_only"} |
| return {"retrieval"} |
|
|
| def _limit_curated_source_repetition(self, rows: list[tuple]) -> list[tuple]: |
| selected = [] |
| source_counts: dict[str, int] = {} |
| for row in rows: |
| source_name = row[4] |
| if source_counts.get(source_name, 0) >= 2: |
| continue |
| selected.append(row) |
| source_counts[source_name] = source_counts.get(source_name, 0) + 1 |
| if len(selected) == self.top_k: |
| return selected |
|
|
| if len(selected) < self.top_k: |
| selected_ids = {row[0] for row in selected} |
| for row in rows: |
| if row[0] in selected_ids: |
| continue |
| selected.append(row) |
| if len(selected) == self.top_k: |
| break |
| return selected |
|
|
| def _retrieve_crisis_support_sources(self, emotion_label: int) -> list[dict]: |
| if self.retrieval_corpus != "curated_support": |
| return [] |
| query = ( |
| "immediate crisis help for a UMD student, 988 Suicide and Crisis " |
| "Lifeline, emergency services, after-hours counseling support" |
| ) |
| try: |
| return self._retrieve( |
| query, |
| emotion_label, |
| safety_level=SafetyLevel.CRISIS, |
| ) |
| except Exception as exc: |
| print(f"[EmpathRAG] WARNING: crisis source retrieval failed: {exc}") |
| return [] |
|
|
| |
|
|
| def _generate(self, user_message: str, chunks: list[str]) -> str: |
| """Generates empathetic response conditioned on retrieved context and conversation history.""" |
| context = "\n\n".join(f"[{i+1}] {c}" for i, c in enumerate(chunks)) |
|
|
| |
| |
| prompt_parts = [] |
|
|
| |
| first_user = ( |
| f"{SYSTEM_PROMPT}\n\n" |
| f"Context (for emotional grounding only - never reference this directly):\n{context}\n\n" |
| f"Student: {user_message}" |
| ) |
|
|
| if not self.conv_history: |
| |
| prompt = f"[INST] {first_user}\n\nResponse: [/INST]" |
| else: |
| |
| |
| prompt = "<s>" |
| for i, entry in enumerate(self.conv_history): |
| if entry["role"] == "user": |
| |
| if i == 0: |
| turn_content = ( |
| f"{SYSTEM_PROMPT}\n\n" |
| f"Context (for emotional grounding only - never reference this directly):\n{context}\n\n" |
| f"Student: {entry['content']}" |
| ) |
| else: |
| turn_content = f"Student: {entry['content']}" |
| prompt += f"[INST] {turn_content} [/INST]" |
| else: |
| |
| prompt += f" {entry['content']}</s>" |
| |
| prompt += f"[INST] Student: {user_message}\n\nResponse: [/INST]" |
|
|
| out = self.llm( |
| prompt, |
| max_tokens = self.generation_max_tokens, |
| temperature = 0.75, |
| stop = ["[INST]", "Student:", "\n\n\n", "</s>"], |
| ) |
| raw = out["choices"][0]["text"].strip() |
|
|
| |
| if "\n\n" in raw: |
| return raw |
| |
| if "?" in raw: |
| sentences = raw.replace(" ", " ").split(". ") |
| question_idx = None |
| for i, s in enumerate(sentences): |
| if "?" in s: |
| question_idx = i |
| break |
| if question_idx is not None and question_idx > 0: |
| para1 = ". ".join(sentences[:question_idx]).strip() |
| if not para1.endswith("."): |
| para1 += "." |
| para2 = sentences[question_idx].strip() |
| if not para2.endswith("?"): |
| para2 += "?" |
| return para1 + "\n\n" + para2 |
| return raw |
|
|
| |
|
|
| def run(self, user_message: str) -> dict: |
| """ |
| Run the full 5-stage pipeline on a single user message. |
| |
| Returns structured dict β see class docstring for keys. |
| """ |
| latency = {} |
| token_count = len(user_message.split()) |
|
|
| |
| t0 = time.perf_counter() |
| emotion_label = self._classify_emotion(user_message) |
| latency["emotion_ms"] = round((time.perf_counter() - t0) * 1000) |
|
|
| |
| t0 = time.perf_counter() |
| is_crisis, confidence, ig_highlights = self.guardrail.check( |
| user_message, threshold=self.guardrail_threshold |
| ) |
| latency["guardrail_ms"] = round((time.perf_counter() - t0) * 1000) |
| safety_decision = self.safety_policy.classify( |
| user_message, confidence=confidence, model_flag=is_crisis |
| ) |
|
|
| |
| self.tracker.update(emotion_label, token_count) |
| trajectory = self.tracker.trajectory() |
|
|
| |
| if safety_decision.should_intercept: |
| t0 = time.perf_counter() |
| crisis_sources = self._retrieve_crisis_support_sources(emotion_label) |
| latency["crisis_retrieval_ms"] = round((time.perf_counter() - t0) * 1000) |
| return { |
| "response": safety_decision.response or SAFE_RESPONSE, |
| "emotion": emotion_label, |
| "emotion_name": LABEL_NAMES[emotion_label], |
| "trajectory": trajectory, |
| "crisis": safety_decision.level in {SafetyLevel.CRISIS, SafetyLevel.EMERGENCY}, |
| "crisis_confidence": confidence, |
| "safety_level": safety_decision.level.value, |
| "safety_reason": safety_decision.reason, |
| "ig_highlights": ig_highlights, |
| "retrieved_chunks": [], |
| "retrieved_sources": self._source_summaries(crisis_sources), |
| "retrieval_corpus": self.retrieval_corpus, |
| "latency_ms": latency, |
| } |
|
|
| |
| t0 = time.perf_counter() |
| routed_query = route_query(user_message, emotion_label, trajectory) |
| latency["router_ms"] = round((time.perf_counter() - t0) * 1000) |
|
|
| |
| t0 = time.perf_counter() |
| retrieved = self._retrieve( |
| routed_query, |
| emotion_label, |
| safety_level=safety_decision.level, |
| ) |
| chunks = [row["text"] for row in retrieved] |
| latency["retrieval_ms"] = round((time.perf_counter() - t0) * 1000) |
|
|
| |
| t0 = time.perf_counter() |
| response = self._generate(user_message, chunks) |
| |
| self.conv_history.append({"role": "user", "content": user_message}) |
| self.conv_history.append({"role": "assistant", "content": response}) |
| if len(self.conv_history) > 6: |
| self.conv_history = self.conv_history[-6:] |
| latency["generation_ms"] = round((time.perf_counter() - t0) * 1000) |
|
|
| latency["total_ms"] = sum(latency.values()) |
|
|
| return { |
| "response": response, |
| "emotion": emotion_label, |
| "emotion_name": LABEL_NAMES[emotion_label], |
| "trajectory": trajectory, |
| "crisis": False, |
| "crisis_confidence": 0.0, |
| "safety_level": safety_decision.level.value, |
| "safety_reason": safety_decision.reason, |
| "ig_highlights": [], |
| "retrieved_chunks": chunks, |
| "retrieved_sources": self._source_summaries(retrieved), |
| "retrieval_corpus": self.retrieval_corpus, |
| "latency_ms": latency, |
| } |
|
|
| def _source_summaries(self, retrieved: list[dict]) -> list[dict]: |
| return [ |
| { |
| "title": row.get("title", ""), |
| "source_name": row.get("source_name", ""), |
| "url": row.get("url", ""), |
| "topic": row.get("topic", ""), |
| "risk_level": row.get("risk_level", ""), |
| "usage_mode": row.get("usage_mode", ""), |
| } |
| for row in retrieved |
| ] |
|
|
| def reset_session(self): |
| """Clear session emotion history and conversation history.""" |
| self.tracker.reset() |
| self.conv_history = [] |
|
|