Spaces:
Sleeping
Sleeping
| """ | |
| AI vs Human Text Detector — Inference API | |
| Wraps the saved RoBERTa classifier (ai-detector-model-v2) in a small FastAPI | |
| service so HumanPen (or any browser-based tool) can call it over HTTP, since | |
| the model can't run directly in-browser like Groq/Anthropic API calls do. | |
| Endpoints: | |
| GET / -> health check | |
| POST /detect -> score a single piece of text (paragraph-level recommended) | |
| POST /detect_batch -> score multiple pieces of text in one call (more efficient | |
| for HumanPen's "scan whole document" use case) | |
| Designed to run on Hugging Face Spaces (Docker SDK) but works anywhere that | |
| can run a Python container exposing port 7860. | |
| """ | |
| import os | |
| from typing import List | |
| import torch | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| from transformers import RobertaTokenizer, RobertaForSequenceClassification | |
| # --------------------------------------------------------------------------- | |
| # Config | |
| # --------------------------------------------------------------------------- | |
| # On Hugging Face Spaces, you'll upload the model files into a folder named | |
| # "model" alongside this app.py (see DEPLOY.md for the exact layout). | |
| MODEL_PATH = os.environ.get("MODEL_PATH", "./model") | |
| # Matches what the training notebook used — texts longer than this were | |
| # truncated during training, so keep inference consistent with that. | |
| MAX_CHARS = 2000 | |
| MAX_TOKENS = 512 | |
| # --------------------------------------------------------------------------- | |
| # Load model once at startup (NOT per-request — this is the expensive part) | |
| # --------------------------------------------------------------------------- | |
| print(f"Loading tokenizer from {MODEL_PATH} ...") | |
| tokenizer = RobertaTokenizer.from_pretrained(MODEL_PATH) | |
| print(f"Loading model from {MODEL_PATH} ...") | |
| model = RobertaForSequenceClassification.from_pretrained(MODEL_PATH) | |
| model.eval() | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| model = model.to(device) | |
| print(f"Model loaded. Using device: {device}") | |
| def score_text(text: str) -> dict: | |
| """Run a single piece of text through the model and return a clean result. | |
| Returns: | |
| { | |
| "label": "AI" | "Human", | |
| "confidence": float (0-1, confidence in the predicted label), | |
| "ai_probability": float (0-1, raw probability of the AI class, | |
| useful for a continuous heatmap score | |
| rather than just a binary verdict), | |
| "truncated": bool (whether input was cut to MAX_CHARS) | |
| } | |
| """ | |
| truncated = len(text) > MAX_CHARS | |
| text_for_model = text[:MAX_CHARS] | |
| inputs = tokenizer( | |
| text_for_model, | |
| truncation=True, | |
| padding=True, | |
| max_length=MAX_TOKENS, | |
| return_tensors="pt", | |
| ).to(device) | |
| with torch.no_grad(): | |
| logits = model(**inputs).logits | |
| probs = torch.softmax(logits, dim=-1)[0] | |
| ai_probability = float(probs[1]) # label 1 = AI, per training notebook | |
| pred_label = int(torch.argmax(probs)) | |
| confidence = float(probs[pred_label]) | |
| return { | |
| "label": "AI" if pred_label == 1 else "Human", | |
| "confidence": round(confidence, 4), | |
| "ai_probability": round(ai_probability, 4), | |
| "truncated": truncated, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # API schema | |
| # --------------------------------------------------------------------------- | |
| class DetectRequest(BaseModel): | |
| text: str | |
| class DetectBatchRequest(BaseModel): | |
| texts: List[str] | |
| class DetectResponse(BaseModel): | |
| label: str | |
| confidence: float | |
| ai_probability: float | |
| truncated: bool | |
| class DebugRequest(BaseModel): | |
| text: str | |
| # --------------------------------------------------------------------------- | |
| # App | |
| # --------------------------------------------------------------------------- | |
| app = FastAPI(title="AI vs Human Text Detector API") | |
| # Allow HumanPen (running as a local HTML file or hosted elsewhere) to call | |
| # this from the browser. Restrict allow_origins in production if you want | |
| # to lock this down to a specific domain instead of "*". | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["POST", "GET"], | |
| allow_headers=["*"], | |
| ) | |
| def health_check(): | |
| return {"status": "ok", "model": "ai-detector-model-v2", "device": str(device)} | |
| def detect(req: DetectRequest): | |
| if not req.text or not req.text.strip(): | |
| raise HTTPException(status_code=400, detail="Text must not be empty.") | |
| return score_text(req.text) | |
| def detect_batch(req: DetectBatchRequest): | |
| if not req.texts: | |
| raise HTTPException(status_code=400, detail="texts list must not be empty.") | |
| results = [] | |
| for text in req.texts: | |
| if not text or not text.strip(): | |
| results.append({"label": "Human", "confidence": 0.0, "ai_probability": 0.0, "truncated": False, "skipped": True}) | |
| continue | |
| results.append(score_text(text)) | |
| return {"results": results} | |
| def debug(req: DebugRequest): | |
| """TEMPORARY diagnostic endpoint — remove once the deployment bug is found. | |
| Returns raw logits, token IDs, and model/tokenizer fingerprints so we can | |
| see exactly what's happening inside the container for a given input. | |
| """ | |
| text = req.text[:MAX_CHARS] | |
| inputs = tokenizer( | |
| text, truncation=True, padding=True, max_length=MAX_TOKENS, return_tensors="pt" | |
| ).to(device) | |
| with torch.no_grad(): | |
| logits = model(**inputs).logits | |
| probs = torch.softmax(logits, dim=-1)[0] | |
| first_param = next(model.parameters()) | |
| weight_fingerprint = float(first_param.flatten()[0].item()) | |
| classifier_fingerprint = None | |
| for name, param in model.named_parameters(): | |
| if "classifier" in name: | |
| classifier_fingerprint = { | |
| "param_name": name, | |
| "shape": list(param.shape), | |
| "first_5_values": param.flatten()[:5].tolist(), | |
| } | |
| break | |
| import transformers as _tf | |
| import torch as _torch | |
| return { | |
| "input_text_received": text[:100], | |
| "input_text_length": len(text), | |
| "token_ids_first_10": inputs["input_ids"][0][:10].tolist(), | |
| "token_ids_last_10": inputs["input_ids"][0][-10:].tolist(), | |
| "num_tokens": int(inputs["input_ids"].shape[1]), | |
| "raw_logits": logits[0].tolist(), | |
| "softmax_probs": probs.tolist(), | |
| "model_weight_fingerprint": weight_fingerprint, | |
| "model_training_mode": model.training, | |
| "classifier_head_fingerprint": classifier_fingerprint, | |
| "transformers_version": _tf.__version__, | |
| "torch_version": _torch.__version__, | |
| } | |