sentiment-scope / backend /app /model_registry.py
melkholy's picture
Ship hardening wave: off-loop CSV inference, model_ids cap/dedupe, real detector Hub ids, comment fixes
019b6f4 verified
Raw
History Blame Contribute Delete
5.77 kB
"""Explicit model choices for an educational ML app.
The registry is task-aware: sentiment models and AI text detectors share lazy-loading
machinery but never share comparison endpoints.
"""
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from typing import Literal
# Closed set of output heads the app knows how to decode. A Literal (not a
# plain str) makes a typo like "sigmod" a type-check error instead of a
# silently-misbehaving registry entry.
OutputAdapter = Literal["softmax", "single_logit_sigmoid"]
class ModelTask(StrEnum):
SENTIMENT = "sentiment"
AI_TEXT_DETECTION = "ai_text_detection"
@dataclass(frozen=True)
class ModelConfig:
id: str
name: str
task: ModelTask
labels: tuple[str, ...]
domain: str
note: str
local_path: str | None = None
default: bool = False
# How raw model output maps to canonical label scores. "softmax" covers
# standard N-class heads; "single_logit_sigmoid" covers detectors like
# desklib that emit ONE logit where sigmoid(logit) = P(ai).
output_adapter: OutputAdapter = "softmax"
MODEL_REGISTRY: dict[str, ModelConfig] = {
# Sentiment models
"twitter-roberta": ModelConfig(
id="twitter-roberta",
name="cardiffnlp/twitter-roberta-base-sentiment-latest",
task=ModelTask.SENTIMENT,
labels=("negative", "neutral", "positive"),
domain="social / short English text",
note="Default sentiment model; used by analyze, batch, csv, and explain.",
local_path="models/twitter-roberta-base-sentiment-latest",
default=True,
),
"distilbert-sst2": ModelConfig(
id="distilbert-sst2",
name="distilbert/distilbert-base-uncased-finetuned-sst-2-english",
task=ModelTask.SENTIMENT,
labels=("negative", "positive"),
domain="general binary sentiment",
note="Fast binary baseline; no neutral class, so compare label mismatch explicitly.",
),
"finbert": ModelConfig(
id="finbert",
name="ProsusAI/finbert",
task=ModelTask.SENTIMENT,
labels=("positive", "negative", "neutral"),
domain="financial text",
note="Useful for finance/news sentences; misleading outside that domain.",
),
"xlm-twitter": ModelConfig(
id="xlm-twitter",
name="cardiffnlp/twitter-xlm-roberta-base-sentiment",
task=ModelTask.SENTIMENT,
labels=("negative", "neutral", "positive"),
domain="multilingual social text",
note="Multilingual social-text model trained on tweets.",
),
# AI text detector models
"desklib-ai-detector": ModelConfig(
id="desklib-ai-detector",
name="desklib/ai-text-detector-v1.01",
task=ModelTask.AI_TEXT_DETECTION,
labels=("human", "ai"),
domain="general AI-written text detection",
note=(
"Default detector: a DeBERTa-v3-large model fine-tuned to flag AI-written "
"English text. It outputs a single probability, P(ai) — higher means the "
"text more closely resembles AI-generated writing."
),
local_path="models/desklib-ai-text-detector-v1.01",
default=True,
output_adapter="single_logit_sigmoid",
),
"fakespot-ai-detector": ModelConfig(
id="fakespot-ai-detector",
name="fakespot-ai/roberta-base-ai-text-detection-v1",
task=ModelTask.AI_TEXT_DETECTION,
labels=("human", "ai"),
domain="AI-generated review/text detection",
note="RoBERTa-based detector. Use for detector comparison, not sentiment.",
local_path="models/fakespot-roberta-base-ai-text-detection-v1",
),
"oxidane-ai-detector": ModelConfig(
id="oxidane-ai-detector",
name="Oxidane/tmr-ai-text-detector",
task=ModelTask.AI_TEXT_DETECTION,
labels=("human", "ai"),
domain="general AI text detection",
note=(
"A general AI-text detector included as a third opinion — compare its "
"verdict with the others to gauge agreement."
),
local_path="models/oxidane-tmr-ai-text-detector",
),
}
def models_for_task(task: ModelTask) -> dict[str, ModelConfig]:
return {k: v for k, v in MODEL_REGISTRY.items() if v.task == task}
def get_default_model_id(task: ModelTask) -> str:
for k, v in MODEL_REGISTRY.items():
if v.task == task and v.default:
return k
# Fail loudly: a missing default is a registry bug. next() would surface
# it as an opaque StopIteration deep inside a request handler.
raise ValueError(f"No default model configured for task: {task}")
def get_model_config(model_id: str | None = None) -> ModelConfig:
key = model_id or get_default_model_id(ModelTask.SENTIMENT)
try:
return MODEL_REGISTRY[key]
except KeyError:
raise ValueError(f"Unknown model_id: {key}")
# Registry local_path entries are relative to the REPO ROOT (models/ is a
# sibling of backend/), not to whatever directory uvicorn was started from.
# This file lives at backend/app/model_registry.py → parents[2] is the root.
_REPO_ROOT = Path(__file__).resolve().parents[2]
def resolve_model_source(config: ModelConfig) -> str:
"""Local weights directory when present, else the HF Hub model name.
Why the fallback matters: models/ is a local-only, untracked directory.
A fresh clone, CI, or a Docker build has no local weights — without this
check, from_pretrained("models/...") dies with a path error instead of
downloading from the Hub.
"""
if config.local_path:
local = _REPO_ROOT / config.local_path
if local.exists():
return str(local)
return config.name