"""Lightweight ML route/risk router for EmpathRAG Core. This module deliberately uses small scikit-learn models so the demo can start without GPU, internet, or heavyweight transformer loading. Hard safety policy still owns final crisis decisions; ML routing is advisory with confidence. """ from __future__ import annotations from dataclasses import dataclass import pickle from pathlib import Path from typing import Any from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline from .v2_schema import SafetyTier, SupportRoute DEFAULT_MODEL_DIR = Path("models/router") ROUTE_MODEL_PATH = DEFAULT_MODEL_DIR / "route_classifier.pkl" TIER_MODEL_PATH = DEFAULT_MODEL_DIR / "tier_classifier.pkl" @dataclass(frozen=True) class MLRoutePrediction: route_label: str safety_tier: str route_confidence: float tier_confidence: float model_available: bool used_ml: bool reason: str def build_text_classifier() -> Pipeline: return Pipeline( steps=[ ("tfidf", TfidfVectorizer(ngram_range=(1, 2), min_df=1)), ("clf", LogisticRegression(max_iter=1000, class_weight="balanced")), ] ) def train_classifier(texts: list[str], labels: list[str]) -> Pipeline: model = build_text_classifier() model.fit(texts, labels) return model def save_models(route_model: Pipeline, tier_model: Pipeline, model_dir: Path = DEFAULT_MODEL_DIR) -> None: model_dir.mkdir(parents=True, exist_ok=True) with (model_dir / ROUTE_MODEL_PATH.name).open("wb") as handle: pickle.dump(route_model, handle) with (model_dir / TIER_MODEL_PATH.name).open("wb") as handle: pickle.dump(tier_model, handle) def load_models(model_dir: Path = DEFAULT_MODEL_DIR) -> tuple[Pipeline | None, Pipeline | None]: route_path = model_dir / ROUTE_MODEL_PATH.name tier_path = model_dir / TIER_MODEL_PATH.name if not route_path.exists() or not tier_path.exists(): return None, None with route_path.open("rb") as handle: route_model = pickle.load(handle) with tier_path.open("rb") as handle: tier_model = pickle.load(handle) return route_model, tier_model class MLRouter: def __init__(self, model_dir: Path = DEFAULT_MODEL_DIR, min_confidence: float = 0.35): self.model_dir = model_dir self.min_confidence = min_confidence self.route_model, self.tier_model = load_models(model_dir) @property def available(self) -> bool: return self.route_model is not None and self.tier_model is not None def predict( self, text: str, fallback_route: SupportRoute | str, fallback_tier: SafetyTier | str, ) -> MLRoutePrediction: fallback_route_value = fallback_route.value if isinstance(fallback_route, SupportRoute) else str(fallback_route) fallback_tier_value = fallback_tier.value if isinstance(fallback_tier, SafetyTier) else str(fallback_tier) if not self.available: return MLRoutePrediction( route_label=fallback_route_value, safety_tier=fallback_tier_value, route_confidence=0.0, tier_confidence=0.0, model_available=False, used_ml=False, reason="model_artifacts_missing", ) route_label, route_conf = _predict_one(self.route_model, text) tier_label, tier_conf = _predict_one(self.tier_model, text) if min(route_conf, tier_conf) < self.min_confidence: return MLRoutePrediction( route_label=fallback_route_value, safety_tier=fallback_tier_value, route_confidence=route_conf, tier_confidence=tier_conf, model_available=True, used_ml=False, reason="low_confidence_fallback", ) return MLRoutePrediction( route_label=route_label, safety_tier=tier_label, route_confidence=route_conf, tier_confidence=tier_conf, model_available=True, used_ml=True, reason="ml_prediction", ) def _predict_one(model: Any, text: str) -> tuple[str, float]: label = str(model.predict([text])[0]) if hasattr(model, "predict_proba"): probs = model.predict_proba([text])[0] classes = list(model.classes_) confidence = float(probs[classes.index(label)]) else: confidence = 1.0 return label, confidence