EmpathRAG / src /pipeline /ml_router.py
MukulRay's picture
Ingest Core dataset and harden router policy
f046303
Raw
History Blame
4.62 kB
"""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