GitHub Actions commited on
Commit ·
2f073d3
1
Parent(s): 7f2ed0d
Deploy backend from GitHub 43a4c2cb381254b3c2fd54acd891b54847bb81d1
Browse files- Dockerfile +40 -0
- README.md +17 -7
- backend/app/__init__.py +0 -0
- backend/app/api/__init__.py +0 -0
- backend/app/api/auth_routes.py +40 -0
- backend/app/api/models.py +45 -0
- backend/app/api/routes.py +207 -0
- backend/app/core/__init__.py +0 -0
- backend/app/core/auth.py +40 -0
- backend/app/core/config.py +76 -0
- backend/app/core/logging.py +46 -0
- backend/app/core/redis.py +51 -0
- backend/app/db/__init__.py +0 -0
- backend/app/db/firestore.py +54 -0
- backend/app/db/session.py +21 -0
- backend/app/main.py +99 -0
- backend/app/models/__init__.py +0 -0
- backend/app/models/schemas.py +56 -0
- backend/app/services/__init__.py +0 -0
- backend/app/services/ensemble.py +115 -0
- backend/app/services/groq_service.py +106 -0
- backend/app/services/hf_service.py +102 -0
- backend/app/services/stylometry.py +137 -0
- backend/app/services/vector_db.py +86 -0
- backend/app/workers/__init__.py +0 -0
- backend/app/workers/analysis_worker.py +87 -0
- backend/migrations/env.py +54 -0
- backend/migrations/versions/001_initial.py +49 -0
- backend/requirements.txt +34 -0
- backend/scripts/seed.py +60 -0
- backend/tests/__init__.py +0 -0
- backend/tests/test_api.py +153 -0
- backend/tests/test_auth.py +60 -0
- backend/tests/test_ensemble.py +73 -0
- backend/tests/test_load.py +79 -0
- backend/tests/test_services.py +88 -0
- backend/tests/test_stylometry.py +76 -0
Dockerfile
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces Docker – Sentinel Backend API
|
| 2 |
+
# HF Spaces requires the app to listen on port 7860.
|
| 3 |
+
#
|
| 4 |
+
# Required Secrets (set in Space Settings -> Repository secrets):
|
| 5 |
+
# FIREBASE_PROJECT_ID – Firebase project ID
|
| 6 |
+
# FIREBASE_CREDENTIALS_JSON – Full service account JSON as a single-line string
|
| 7 |
+
# REDIS_URL – Upstash Redis URL (rediss://...)
|
| 8 |
+
# HF_API_KEY – Hugging Face API token
|
| 9 |
+
# GROQ_API_KEY – Groq API key
|
| 10 |
+
# CORS_ORIGINS – Comma-separated allowed origins (your Vercel URL)
|
| 11 |
+
# QDRANT_URL / QDRANT_API_KEY – Optional, for vector clustering signal
|
| 12 |
+
# SENTRY_DSN – Optional error tracking
|
| 13 |
+
|
| 14 |
+
FROM python:3.12-slim
|
| 15 |
+
|
| 16 |
+
WORKDIR /app
|
| 17 |
+
|
| 18 |
+
# System dependencies
|
| 19 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 20 |
+
build-essential curl && \
|
| 21 |
+
rm -rf /var/lib/apt/lists/*
|
| 22 |
+
|
| 23 |
+
# Python dependencies
|
| 24 |
+
COPY backend/requirements.txt .
|
| 25 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 26 |
+
|
| 27 |
+
# Application code
|
| 28 |
+
COPY backend/ ./backend/
|
| 29 |
+
|
| 30 |
+
# Non-root user (HF Spaces security best practice)
|
| 31 |
+
RUN useradd -m appuser && chown -R appuser:appuser /app
|
| 32 |
+
USER appuser
|
| 33 |
+
|
| 34 |
+
# HF Spaces requires port 7860
|
| 35 |
+
EXPOSE 7860
|
| 36 |
+
|
| 37 |
+
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
| 38 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
| 39 |
+
|
| 40 |
+
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,12 +1,22 @@
|
|
| 1 |
---
|
| 2 |
-
title: Sentinel
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
# Sentinel
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Sentinel Backend API
|
| 3 |
+
emoji: 🔒
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Sentinel Backend API
|
| 12 |
|
| 13 |
+
FastAPI backend for the Sentinel LLM Misuse Detection System.
|
| 14 |
+
Exposes a REST API on port 7860.
|
| 15 |
+
|
| 16 |
+
## Required Secrets (Space Settings → Repository secrets)
|
| 17 |
+
- `FIREBASE_PROJECT_ID` – Firebase project ID
|
| 18 |
+
- `FIREBASE_CREDENTIALS_JSON` – Service account JSON (single-line string)
|
| 19 |
+
- `REDIS_URL` – Upstash Redis URL
|
| 20 |
+
- `HF_API_KEY` – Hugging Face API token
|
| 21 |
+
- `GROQ_API_KEY` – Groq API key
|
| 22 |
+
- `CORS_ORIGINS` – Your Vercel frontend URL
|
backend/app/__init__.py
ADDED
|
File without changes
|
backend/app/api/__init__.py
ADDED
|
File without changes
|
backend/app/api/auth_routes.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication routes for user registration and login.
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 5 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
+
from sqlalchemy import select
|
| 7 |
+
|
| 8 |
+
from backend.app.api.models import AuthRequest, TokenResponse
|
| 9 |
+
from backend.app.core.auth import hash_password, verify_password, create_access_token
|
| 10 |
+
from backend.app.db.session import get_session
|
| 11 |
+
from backend.app.models.schemas import User
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/api/auth", tags=["authentication"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post("/register", response_model=TokenResponse, status_code=201)
|
| 17 |
+
async def register(req: AuthRequest, session: AsyncSession = Depends(get_session)):
|
| 18 |
+
"""Register a new user."""
|
| 19 |
+
stmt = select(User).where(User.email == req.email)
|
| 20 |
+
existing = await session.execute(stmt)
|
| 21 |
+
if existing.scalar_one_or_none():
|
| 22 |
+
raise HTTPException(status_code=409, detail="Email already registered")
|
| 23 |
+
|
| 24 |
+
user = User(email=req.email, hashed_password=hash_password(req.password))
|
| 25 |
+
session.add(user)
|
| 26 |
+
await session.commit()
|
| 27 |
+
token = create_access_token(subject=user.id)
|
| 28 |
+
return TokenResponse(access_token=token)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.post("/login", response_model=TokenResponse)
|
| 32 |
+
async def login(req: AuthRequest, session: AsyncSession = Depends(get_session)):
|
| 33 |
+
"""Login with email and password."""
|
| 34 |
+
stmt = select(User).where(User.email == req.email)
|
| 35 |
+
result = await session.execute(stmt)
|
| 36 |
+
user = result.scalar_one_or_none()
|
| 37 |
+
if not user or not verify_password(req.password, user.hashed_password):
|
| 38 |
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
| 39 |
+
token = create_access_token(subject=user.id)
|
| 40 |
+
return TokenResponse(access_token=token)
|
backend/app/api/models.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic request/response models for the API.
|
| 3 |
+
Auth is handled entirely by Firebase on the frontend — no auth models here.
|
| 4 |
+
"""
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
from typing import List, Optional
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class AnalyzeRequest(BaseModel):
|
| 10 |
+
text: str = Field(..., min_length=10, max_length=50000, description="Text to analyze")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class BulkAnalyzeRequest(BaseModel):
|
| 14 |
+
texts: List[str] = Field(..., min_length=1, max_length=20)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class SignalScores(BaseModel):
|
| 18 |
+
p_ai: Optional[float] = Field(None, description="AI-generation probability (ensemble)")
|
| 19 |
+
s_perp: Optional[float] = Field(None, description="Normalized perplexity score")
|
| 20 |
+
s_embed_cluster: Optional[float] = Field(None, description="Embedding cluster outlier score")
|
| 21 |
+
p_ext: Optional[float] = Field(None, description="Extremism/harm probability")
|
| 22 |
+
s_styl: Optional[float] = Field(None, description="Stylometry anomaly score")
|
| 23 |
+
p_watermark: Optional[float] = Field(None, description="Watermark detection (negative signal)")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ExplainabilityItem(BaseModel):
|
| 27 |
+
signal: str
|
| 28 |
+
value: float
|
| 29 |
+
weight: float
|
| 30 |
+
contribution: float
|
| 31 |
+
description: str
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class AnalyzeResponse(BaseModel):
|
| 35 |
+
id: str
|
| 36 |
+
status: str
|
| 37 |
+
threat_score: Optional[float] = None
|
| 38 |
+
signals: Optional[SignalScores] = None
|
| 39 |
+
explainability: Optional[List[ExplainabilityItem]] = None
|
| 40 |
+
processing_time_ms: Optional[int] = None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class HealthResponse(BaseModel):
|
| 44 |
+
status: str = "ok"
|
| 45 |
+
version: str = "1.0.0"
|
backend/app/api/routes.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Main API routes for the LLM Misuse Detection system.
|
| 3 |
+
Endpoints: /api/analyze, /api/analyze/bulk, /api/results/{id}
|
| 4 |
+
Persistence: Firestore (replaces PostgreSQL)
|
| 5 |
+
"""
|
| 6 |
+
import hashlib
|
| 7 |
+
import time
|
| 8 |
+
from datetime import datetime, timezone
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 11 |
+
|
| 12 |
+
from backend.app.api.models import (
|
| 13 |
+
AnalyzeRequest, AnalyzeResponse, BulkAnalyzeRequest,
|
| 14 |
+
SignalScores, ExplainabilityItem,
|
| 15 |
+
)
|
| 16 |
+
from backend.app.core.auth import get_current_user
|
| 17 |
+
from backend.app.core.config import settings
|
| 18 |
+
from backend.app.core.redis import check_rate_limit, get_cached, set_cached
|
| 19 |
+
from backend.app.db.firestore import get_db
|
| 20 |
+
from backend.app.models.schemas import AnalysisResult
|
| 21 |
+
from backend.app.services.ensemble import compute_ensemble
|
| 22 |
+
from backend.app.services.hf_service import detect_ai_text, get_embeddings, detect_harm
|
| 23 |
+
from backend.app.services.groq_service import compute_perplexity
|
| 24 |
+
from backend.app.services.stylometry import compute_stylometry_score
|
| 25 |
+
from backend.app.services.vector_db import compute_cluster_score, upsert_embedding
|
| 26 |
+
from backend.app.core.logging import get_logger
|
| 27 |
+
|
| 28 |
+
import json
|
| 29 |
+
|
| 30 |
+
logger = get_logger(__name__)
|
| 31 |
+
router = APIRouter(prefix="/api", tags=["analysis"])
|
| 32 |
+
|
| 33 |
+
COLLECTION = "analysis_results"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def _analyze_text(text: str, user_id: str = None) -> dict:
|
| 37 |
+
"""Core analysis pipeline for a single text."""
|
| 38 |
+
start_time = time.time()
|
| 39 |
+
text_hash = hashlib.sha256(text.encode()).hexdigest()
|
| 40 |
+
|
| 41 |
+
# Check cache
|
| 42 |
+
cached = await get_cached(f"analysis:{text_hash}")
|
| 43 |
+
if cached:
|
| 44 |
+
return json.loads(cached)
|
| 45 |
+
|
| 46 |
+
# Step 1: AI detection
|
| 47 |
+
try:
|
| 48 |
+
p_ai = await detect_ai_text(text)
|
| 49 |
+
except Exception:
|
| 50 |
+
p_ai = None
|
| 51 |
+
|
| 52 |
+
# Step 2: Perplexity (cost-gated)
|
| 53 |
+
s_perp = None
|
| 54 |
+
if p_ai is not None and p_ai > settings.PERPLEXITY_THRESHOLD:
|
| 55 |
+
s_perp = await compute_perplexity(text)
|
| 56 |
+
|
| 57 |
+
# Step 3: Embeddings + cluster score
|
| 58 |
+
s_embed_cluster = None
|
| 59 |
+
try:
|
| 60 |
+
embeddings = await get_embeddings(text)
|
| 61 |
+
s_embed_cluster = await compute_cluster_score(embeddings)
|
| 62 |
+
await upsert_embedding(text_hash[:16], embeddings, {"text_preview": text[:200]})
|
| 63 |
+
except Exception:
|
| 64 |
+
pass
|
| 65 |
+
|
| 66 |
+
# Step 4: Harm/extremism
|
| 67 |
+
p_ext = await detect_harm(text)
|
| 68 |
+
|
| 69 |
+
# Step 5: Stylometry
|
| 70 |
+
s_styl = compute_stylometry_score(text)
|
| 71 |
+
|
| 72 |
+
# Step 6: Watermark placeholder
|
| 73 |
+
p_watermark = None
|
| 74 |
+
|
| 75 |
+
# Step 7: Ensemble
|
| 76 |
+
ensemble_result = compute_ensemble(
|
| 77 |
+
p_ai=p_ai,
|
| 78 |
+
s_perp=s_perp,
|
| 79 |
+
s_embed_cluster=s_embed_cluster,
|
| 80 |
+
p_ext=p_ext,
|
| 81 |
+
s_styl=s_styl,
|
| 82 |
+
p_watermark=p_watermark,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
processing_time_ms = int((time.time() - start_time) * 1000)
|
| 86 |
+
|
| 87 |
+
result = {
|
| 88 |
+
"text_hash": text_hash,
|
| 89 |
+
"p_ai": p_ai,
|
| 90 |
+
"s_perp": s_perp,
|
| 91 |
+
"s_embed_cluster": s_embed_cluster,
|
| 92 |
+
"p_ext": p_ext,
|
| 93 |
+
"s_styl": s_styl,
|
| 94 |
+
"p_watermark": p_watermark,
|
| 95 |
+
"threat_score": ensemble_result["threat_score"],
|
| 96 |
+
"explainability": ensemble_result["explainability"],
|
| 97 |
+
"processing_time_ms": processing_time_ms,
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
# Cache
|
| 101 |
+
try:
|
| 102 |
+
await set_cached(f"analysis:{text_hash}", json.dumps(result), ttl=600)
|
| 103 |
+
except Exception:
|
| 104 |
+
pass
|
| 105 |
+
|
| 106 |
+
# Persist to Firestore
|
| 107 |
+
try:
|
| 108 |
+
db = get_db()
|
| 109 |
+
doc = AnalysisResult(
|
| 110 |
+
input_text=text,
|
| 111 |
+
text_hash=text_hash,
|
| 112 |
+
user_id=user_id,
|
| 113 |
+
p_ai=p_ai,
|
| 114 |
+
s_perp=s_perp,
|
| 115 |
+
s_embed_cluster=s_embed_cluster,
|
| 116 |
+
p_ext=p_ext,
|
| 117 |
+
s_styl=s_styl,
|
| 118 |
+
p_watermark=p_watermark,
|
| 119 |
+
threat_score=ensemble_result["threat_score"],
|
| 120 |
+
explainability=ensemble_result["explainability"],
|
| 121 |
+
status="done",
|
| 122 |
+
completed_at=datetime.now(timezone.utc),
|
| 123 |
+
processing_time_ms=processing_time_ms,
|
| 124 |
+
)
|
| 125 |
+
db.collection(COLLECTION).document(doc.id).set(doc.to_dict())
|
| 126 |
+
result["id"] = doc.id
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.warning("Firestore persist failed", error=str(e))
|
| 129 |
+
result["id"] = text_hash
|
| 130 |
+
|
| 131 |
+
return result
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@router.post("/analyze", response_model=AnalyzeResponse)
|
| 135 |
+
async def analyze_text(request: AnalyzeRequest):
|
| 136 |
+
"""Analyze a single text for LLM misuse indicators."""
|
| 137 |
+
rate_ok = await check_rate_limit("analyze:global")
|
| 138 |
+
if not rate_ok:
|
| 139 |
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
| 140 |
+
|
| 141 |
+
result = await _analyze_text(request.text)
|
| 142 |
+
|
| 143 |
+
return AnalyzeResponse(
|
| 144 |
+
id=result.get("id", result.get("text_hash", "")),
|
| 145 |
+
status="done",
|
| 146 |
+
threat_score=result["threat_score"],
|
| 147 |
+
signals=SignalScores(
|
| 148 |
+
p_ai=result["p_ai"],
|
| 149 |
+
s_perp=result["s_perp"],
|
| 150 |
+
s_embed_cluster=result["s_embed_cluster"],
|
| 151 |
+
p_ext=result["p_ext"],
|
| 152 |
+
s_styl=result["s_styl"],
|
| 153 |
+
p_watermark=result["p_watermark"],
|
| 154 |
+
),
|
| 155 |
+
explainability=[
|
| 156 |
+
ExplainabilityItem(**e) for e in result["explainability"]
|
| 157 |
+
],
|
| 158 |
+
processing_time_ms=result["processing_time_ms"],
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@router.post("/analyze/bulk")
|
| 163 |
+
async def bulk_analyze(request: BulkAnalyzeRequest):
|
| 164 |
+
"""Analyze multiple texts (max 20)."""
|
| 165 |
+
rate_ok = await check_rate_limit("analyze:bulk:global", limit=5)
|
| 166 |
+
if not rate_ok:
|
| 167 |
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
| 168 |
+
|
| 169 |
+
results = []
|
| 170 |
+
for text in request.texts:
|
| 171 |
+
try:
|
| 172 |
+
r = await _analyze_text(text)
|
| 173 |
+
results.append({"status": "done", **r})
|
| 174 |
+
except Exception as e:
|
| 175 |
+
results.append({"status": "error", "error": str(e)})
|
| 176 |
+
return {"results": results}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@router.get("/results/{result_id}")
|
| 180 |
+
async def get_result(
|
| 181 |
+
result_id: str,
|
| 182 |
+
user_id: str = Depends(get_current_user),
|
| 183 |
+
):
|
| 184 |
+
"""Fetch a previously computed analysis result by Firestore document ID."""
|
| 185 |
+
db = get_db()
|
| 186 |
+
doc_ref = db.collection(COLLECTION).document(result_id)
|
| 187 |
+
doc = doc_ref.get()
|
| 188 |
+
if not doc.exists:
|
| 189 |
+
raise HTTPException(status_code=404, detail="Result not found")
|
| 190 |
+
data = doc.to_dict()
|
| 191 |
+
return AnalyzeResponse(
|
| 192 |
+
id=data["id"],
|
| 193 |
+
status=data["status"],
|
| 194 |
+
threat_score=data.get("threat_score"),
|
| 195 |
+
signals=SignalScores(
|
| 196 |
+
p_ai=data.get("p_ai"),
|
| 197 |
+
s_perp=data.get("s_perp"),
|
| 198 |
+
s_embed_cluster=data.get("s_embed_cluster"),
|
| 199 |
+
p_ext=data.get("p_ext"),
|
| 200 |
+
s_styl=data.get("s_styl"),
|
| 201 |
+
p_watermark=data.get("p_watermark"),
|
| 202 |
+
),
|
| 203 |
+
explainability=[
|
| 204 |
+
ExplainabilityItem(**e) for e in (data.get("explainability") or [])
|
| 205 |
+
],
|
| 206 |
+
processing_time_ms=data.get("processing_time_ms"),
|
| 207 |
+
)
|
backend/app/core/__init__.py
ADDED
|
File without changes
|
backend/app/core/auth.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Firebase Authentication utilities.
|
| 3 |
+
Verifies Firebase ID tokens issued by the frontend (Firebase Auth SDK).
|
| 4 |
+
|
| 5 |
+
Env vars: FIREBASE_PROJECT_ID (used implicitly by firebase-admin)
|
| 6 |
+
"""
|
| 7 |
+
from fastapi import Depends, HTTPException, status
|
| 8 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 9 |
+
from firebase_admin import auth as firebase_auth
|
| 10 |
+
|
| 11 |
+
security_scheme = HTTPBearer()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def get_current_user(
|
| 15 |
+
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
|
| 16 |
+
) -> str:
|
| 17 |
+
"""
|
| 18 |
+
Dependency: extracts and verifies the Firebase ID token from the
|
| 19 |
+
Authorization: Bearer <id_token> header.
|
| 20 |
+
Returns the Firebase UID of the authenticated user.
|
| 21 |
+
"""
|
| 22 |
+
id_token = credentials.credentials
|
| 23 |
+
try:
|
| 24 |
+
decoded = firebase_auth.verify_id_token(id_token)
|
| 25 |
+
except firebase_auth.ExpiredIdTokenError:
|
| 26 |
+
raise HTTPException(
|
| 27 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 28 |
+
detail="Firebase ID token has expired. Please re-authenticate.",
|
| 29 |
+
)
|
| 30 |
+
except firebase_auth.InvalidIdTokenError:
|
| 31 |
+
raise HTTPException(
|
| 32 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 33 |
+
detail="Invalid Firebase ID token.",
|
| 34 |
+
)
|
| 35 |
+
except Exception as e:
|
| 36 |
+
raise HTTPException(
|
| 37 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 38 |
+
detail=f"Token verification failed: {str(e)}",
|
| 39 |
+
)
|
| 40 |
+
return decoded["uid"]
|
backend/app/core/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration module for the LLM Misuse Detection backend.
|
| 3 |
+
Reads all settings from environment variables.
|
| 4 |
+
|
| 5 |
+
Env vars: FIREBASE_PROJECT_ID, FIREBASE_CREDENTIALS_JSON,
|
| 6 |
+
REDIS_URL, HF_API_KEY, GROQ_API_KEY,
|
| 7 |
+
SENTRY_DSN, CORS_ORIGINS
|
| 8 |
+
"""
|
| 9 |
+
from pydantic_settings import BaseSettings
|
| 10 |
+
from typing import Optional
|
| 11 |
+
from typing import List
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Settings(BaseSettings):
|
| 15 |
+
# Application
|
| 16 |
+
APP_NAME: str = "LLM Misuse Detector"
|
| 17 |
+
DEBUG: bool = False
|
| 18 |
+
|
| 19 |
+
# Firebase
|
| 20 |
+
# Set FIREBASE_PROJECT_ID to your Firebase project ID.
|
| 21 |
+
# Set FIREBASE_CREDENTIALS_JSON to the *contents* of your service account
|
| 22 |
+
# JSON (as a single-line escaped string), OR set GOOGLE_APPLICATION_CREDENTIALS
|
| 23 |
+
# to the path of the JSON file on disk. The latter is preferred for local dev.
|
| 24 |
+
FIREBASE_PROJECT_ID: str = ""
|
| 25 |
+
FIREBASE_CREDENTIALS_JSON: Optional[str] = None # JSON string (for Render env vars)
|
| 26 |
+
|
| 27 |
+
# Redis
|
| 28 |
+
REDIS_URL: str = "redis://localhost:6379/0"
|
| 29 |
+
|
| 30 |
+
# CORS
|
| 31 |
+
CORS_ORIGINS: str = "http://localhost:3000"
|
| 32 |
+
|
| 33 |
+
# HuggingFace
|
| 34 |
+
HF_API_KEY: str = ""
|
| 35 |
+
HF_DETECTOR_PRIMARY: str = "https://api-inference.huggingface.co/models/desklib/ai-text-detector-v1.01"
|
| 36 |
+
HF_DETECTOR_FALLBACK: str = "https://api-inference.huggingface.co/models/fakespot-ai/roberta-base-ai-text-detection-v1"
|
| 37 |
+
HF_EMBEDDINGS_PRIMARY: str = "https://api-inference.huggingface.co/models/sentence-transformers/all-mpnet-base-v2"
|
| 38 |
+
HF_EMBEDDINGS_FALLBACK: str = "https://api-inference.huggingface.co/models/sentence-transformers/all-MiniLM-L6-v2"
|
| 39 |
+
HF_HARM_CLASSIFIER: str = "https://api-inference.huggingface.co/models/facebook/roberta-hate-speech-dynabench-r4-target"
|
| 40 |
+
|
| 41 |
+
# Groq
|
| 42 |
+
GROQ_API_KEY: str = ""
|
| 43 |
+
GROQ_MODEL: str = "llama-3.3-70b-versatile"
|
| 44 |
+
GROQ_BASE_URL: str = "https://api.groq.com/openai/v1"
|
| 45 |
+
|
| 46 |
+
# Vector DB (Qdrant)
|
| 47 |
+
QDRANT_URL: str = "http://localhost:6333"
|
| 48 |
+
QDRANT_API_KEY: Optional[str] = None
|
| 49 |
+
QDRANT_COLLECTION: str = "sentinel_embeddings"
|
| 50 |
+
|
| 51 |
+
# Observability
|
| 52 |
+
SENTRY_DSN: str = ""
|
| 53 |
+
LOG_LEVEL: str = "INFO"
|
| 54 |
+
|
| 55 |
+
# Ensemble weights (defaults)
|
| 56 |
+
WEIGHT_AI_DETECT: float = 0.30
|
| 57 |
+
WEIGHT_PERPLEXITY: float = 0.20
|
| 58 |
+
WEIGHT_EMBEDDING: float = 0.15
|
| 59 |
+
WEIGHT_EXTREMISM: float = 0.20
|
| 60 |
+
WEIGHT_STYLOMETRY: float = 0.10
|
| 61 |
+
WEIGHT_WATERMARK: float = 0.05
|
| 62 |
+
|
| 63 |
+
# Cost control
|
| 64 |
+
PERPLEXITY_THRESHOLD: float = 0.3
|
| 65 |
+
|
| 66 |
+
# Rate limiting
|
| 67 |
+
RATE_LIMIT_PER_MINUTE: int = 30
|
| 68 |
+
|
| 69 |
+
@property
|
| 70 |
+
def cors_origins_list(self) -> List[str]:
|
| 71 |
+
return [o.strip() for o in self.CORS_ORIGINS.split(",")]
|
| 72 |
+
|
| 73 |
+
model_config = {"env_file": ".env", "extra": "ignore"}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
settings = Settings()
|
backend/app/core/logging.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Structured logging configuration with PII redaction.
|
| 3 |
+
Uses structlog for JSON-formatted log output.
|
| 4 |
+
"""
|
| 5 |
+
import logging
|
| 6 |
+
import re
|
| 7 |
+
import structlog
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
_EMAIL_RE = re.compile(r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+")
|
| 11 |
+
_PHONE_RE = re.compile(r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _redact_pii(_logger, _method, event_dict):
|
| 15 |
+
"""Redact emails and phone numbers from log messages."""
|
| 16 |
+
msg = event_dict.get("event", "")
|
| 17 |
+
if isinstance(msg, str):
|
| 18 |
+
msg = _EMAIL_RE.sub("[REDACTED_EMAIL]", msg)
|
| 19 |
+
msg = _PHONE_RE.sub("[REDACTED_PHONE]", msg)
|
| 20 |
+
event_dict["event"] = msg
|
| 21 |
+
return event_dict
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def setup_logging(log_level: str = "INFO"):
|
| 25 |
+
structlog.configure(
|
| 26 |
+
processors=[
|
| 27 |
+
structlog.contextvars.merge_contextvars,
|
| 28 |
+
structlog.stdlib.filter_by_level,
|
| 29 |
+
structlog.stdlib.add_logger_name,
|
| 30 |
+
structlog.stdlib.add_log_level,
|
| 31 |
+
structlog.processors.TimeStamper(fmt="iso"),
|
| 32 |
+
_redact_pii,
|
| 33 |
+
structlog.processors.StackInfoRenderer(),
|
| 34 |
+
structlog.processors.format_exc_info,
|
| 35 |
+
structlog.processors.JSONRenderer(),
|
| 36 |
+
],
|
| 37 |
+
wrapper_class=structlog.stdlib.BoundLogger,
|
| 38 |
+
context_class=dict,
|
| 39 |
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
| 40 |
+
cache_logger_on_first_use=True,
|
| 41 |
+
)
|
| 42 |
+
logging.basicConfig(format="%(message)s", level=getattr(logging, log_level))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def get_logger(name: str = __name__):
|
| 46 |
+
return structlog.get_logger(name)
|
backend/app/core/redis.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Redis-based rate limiter using a sliding window approach.
|
| 3 |
+
Env vars: REDIS_URL, RATE_LIMIT_PER_MINUTE
|
| 4 |
+
"""
|
| 5 |
+
import time
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
import redis.asyncio as aioredis
|
| 9 |
+
|
| 10 |
+
from backend.app.core.config import settings
|
| 11 |
+
|
| 12 |
+
_redis_client: Optional[aioredis.Redis] = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def get_redis() -> aioredis.Redis:
|
| 16 |
+
global _redis_client
|
| 17 |
+
if _redis_client is None:
|
| 18 |
+
_redis_client = aioredis.from_url(
|
| 19 |
+
settings.REDIS_URL, decode_responses=True, socket_connect_timeout=5
|
| 20 |
+
)
|
| 21 |
+
return _redis_client
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def check_rate_limit(key: str, limit: int = 0, window: int = 60) -> bool:
|
| 25 |
+
"""
|
| 26 |
+
Returns True if under limit, False if rate-limited.
|
| 27 |
+
Uses sorted set with timestamps for sliding window.
|
| 28 |
+
"""
|
| 29 |
+
if limit <= 0:
|
| 30 |
+
limit = settings.RATE_LIMIT_PER_MINUTE
|
| 31 |
+
r = await get_redis()
|
| 32 |
+
now = time.time()
|
| 33 |
+
window_start = now - window
|
| 34 |
+
pipe = r.pipeline()
|
| 35 |
+
pipe.zremrangebyscore(key, 0, window_start)
|
| 36 |
+
pipe.zadd(key, {str(now): now})
|
| 37 |
+
pipe.zcard(key)
|
| 38 |
+
pipe.expire(key, window + 1)
|
| 39 |
+
results = await pipe.execute()
|
| 40 |
+
count = results[2]
|
| 41 |
+
return count <= limit
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def get_cached(key: str) -> Optional[str]:
|
| 45 |
+
r = await get_redis()
|
| 46 |
+
return await r.get(key)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def set_cached(key: str, value: str, ttl: int = 300):
|
| 50 |
+
r = await get_redis()
|
| 51 |
+
await r.setex(key, ttl, value)
|
backend/app/db/__init__.py
ADDED
|
File without changes
|
backend/app/db/firestore.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Firebase Admin SDK initialisation and Firestore client.
|
| 3 |
+
|
| 4 |
+
Priority for credentials (in order):
|
| 5 |
+
1. FIREBASE_CREDENTIALS_JSON env var – JSON string of the service account
|
| 6 |
+
(paste the whole file contents as a single escaped string in Render/CI)
|
| 7 |
+
2. GOOGLE_APPLICATION_CREDENTIALS env var – path to the JSON file on disk
|
| 8 |
+
(recommended for local development)
|
| 9 |
+
|
| 10 |
+
Call `get_db()` anywhere to obtain the Firestore async client.
|
| 11 |
+
"""
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
|
| 15 |
+
import firebase_admin
|
| 16 |
+
from firebase_admin import credentials, firestore
|
| 17 |
+
|
| 18 |
+
from backend.app.core.config import settings
|
| 19 |
+
|
| 20 |
+
_app: firebase_admin.App | None = None
|
| 21 |
+
_db = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def init_firebase() -> None:
|
| 25 |
+
"""Initialise the Firebase Admin SDK (idempotent)."""
|
| 26 |
+
global _app, _db
|
| 27 |
+
if _app is not None:
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
+
if settings.FIREBASE_CREDENTIALS_JSON:
|
| 31 |
+
# Credentials supplied as a JSON string (production / Render)
|
| 32 |
+
cred_dict = json.loads(settings.FIREBASE_CREDENTIALS_JSON)
|
| 33 |
+
cred = credentials.Certificate(cred_dict)
|
| 34 |
+
elif os.getenv("GOOGLE_APPLICATION_CREDENTIALS"):
|
| 35 |
+
# Path to JSON file on disk (local dev)
|
| 36 |
+
cred = credentials.ApplicationDefault()
|
| 37 |
+
else:
|
| 38 |
+
raise RuntimeError(
|
| 39 |
+
"Firebase credentials not configured. "
|
| 40 |
+
"Set FIREBASE_CREDENTIALS_JSON or GOOGLE_APPLICATION_CREDENTIALS."
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
_app = firebase_admin.initialize_app(
|
| 44 |
+
cred,
|
| 45 |
+
{"projectId": settings.FIREBASE_PROJECT_ID},
|
| 46 |
+
)
|
| 47 |
+
_db = firestore.client()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def get_db():
|
| 51 |
+
"""Return the Firestore client. Call init_firebase() first."""
|
| 52 |
+
if _db is None:
|
| 53 |
+
raise RuntimeError("Firestore not initialised. Call init_firebase() on startup.")
|
| 54 |
+
return _db
|
backend/app/db/session.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database connection and session management.
|
| 3 |
+
Env vars: DATABASE_URL
|
| 4 |
+
"""
|
| 5 |
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
| 6 |
+
|
| 7 |
+
from backend.app.core.config import settings
|
| 8 |
+
|
| 9 |
+
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG, pool_size=10)
|
| 10 |
+
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def get_session() -> AsyncSession:
|
| 14 |
+
async with async_session() as session:
|
| 15 |
+
yield session
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def init_db():
|
| 19 |
+
from backend.app.models.schemas import Base
|
| 20 |
+
async with engine.begin() as conn:
|
| 21 |
+
await conn.run_sync(Base.metadata.create_all)
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI main application entry point.
|
| 3 |
+
Configures CORS, secure headers, routes, and observability.
|
| 4 |
+
|
| 5 |
+
Auth: Firebase Auth (frontend issues ID tokens; backend verifies via firebase-admin)
|
| 6 |
+
DB: Firestore (via firebase-admin)
|
| 7 |
+
|
| 8 |
+
Env vars: All from core/config.py
|
| 9 |
+
Run: uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
|
| 10 |
+
"""
|
| 11 |
+
from contextlib import asynccontextmanager
|
| 12 |
+
|
| 13 |
+
from fastapi import FastAPI, Request, Response
|
| 14 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 15 |
+
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
|
| 16 |
+
|
| 17 |
+
from backend.app.core.config import settings
|
| 18 |
+
from backend.app.core.logging import setup_logging, get_logger
|
| 19 |
+
from backend.app.api.routes import router as analysis_router
|
| 20 |
+
from backend.app.api.models import HealthResponse
|
| 21 |
+
from backend.app.db.firestore import init_firebase
|
| 22 |
+
|
| 23 |
+
# Sentry (optional)
|
| 24 |
+
if settings.SENTRY_DSN:
|
| 25 |
+
import sentry_sdk
|
| 26 |
+
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
| 27 |
+
sentry_sdk.init(dsn=settings.SENTRY_DSN, integrations=[FastApiIntegration()])
|
| 28 |
+
|
| 29 |
+
setup_logging(settings.LOG_LEVEL)
|
| 30 |
+
logger = get_logger(__name__)
|
| 31 |
+
|
| 32 |
+
# Prometheus metrics
|
| 33 |
+
REQUEST_COUNT = Counter("http_requests_total", "Total HTTP requests", ["method", "endpoint", "status"])
|
| 34 |
+
REQUEST_LATENCY = Histogram("http_request_duration_seconds", "Request latency", ["method", "endpoint"])
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@asynccontextmanager
|
| 38 |
+
async def lifespan(app: FastAPI):
|
| 39 |
+
logger.info("Starting LLM Misuse Detection API")
|
| 40 |
+
try:
|
| 41 |
+
init_firebase()
|
| 42 |
+
logger.info("Firebase + Firestore initialised")
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger.warning("Firebase init failed – auth and DB will be unavailable", error=str(e))
|
| 45 |
+
yield
|
| 46 |
+
logger.info("Shutting down")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
app = FastAPI(
|
| 50 |
+
title=settings.APP_NAME,
|
| 51 |
+
version="1.0.0",
|
| 52 |
+
description="Production system for detecting and mitigating LLM misuse in information operations",
|
| 53 |
+
lifespan=lifespan,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# CORS
|
| 57 |
+
app.add_middleware(
|
| 58 |
+
CORSMiddleware,
|
| 59 |
+
allow_origins=settings.cors_origins_list,
|
| 60 |
+
allow_credentials=True,
|
| 61 |
+
allow_methods=["*"],
|
| 62 |
+
allow_headers=["*"],
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@app.middleware("http")
|
| 67 |
+
async def add_security_headers(request: Request, call_next):
|
| 68 |
+
response: Response = await call_next(request)
|
| 69 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 70 |
+
response.headers["X-Frame-Options"] = "DENY"
|
| 71 |
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
| 72 |
+
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
| 73 |
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
| 74 |
+
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
| 75 |
+
return response
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@app.middleware("http")
|
| 79 |
+
async def metrics_middleware(request: Request, call_next):
|
| 80 |
+
import time
|
| 81 |
+
start = time.time()
|
| 82 |
+
response = await call_next(request)
|
| 83 |
+
duration = time.time() - start
|
| 84 |
+
REQUEST_COUNT.labels(request.method, request.url.path, response.status_code).inc()
|
| 85 |
+
REQUEST_LATENCY.labels(request.method, request.url.path).observe(duration)
|
| 86 |
+
return response
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
app.include_router(analysis_router)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@app.get("/health", response_model=HealthResponse)
|
| 93 |
+
async def health():
|
| 94 |
+
return HealthResponse()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@app.get("/metrics")
|
| 98 |
+
async def metrics():
|
| 99 |
+
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
backend/app/models/__init__.py
ADDED
|
File without changes
|
backend/app/models/schemas.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data models for the LLM Misuse Detection system.
|
| 3 |
+
Plain dataclasses – no ORM layer (Firestore is schemaless).
|
| 4 |
+
"""
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from typing import Optional, Any
|
| 8 |
+
import uuid
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class AnalysisResult:
|
| 13 |
+
"""Mirrors the Firestore document structure stored under 'analysis_results'."""
|
| 14 |
+
input_text: str
|
| 15 |
+
text_hash: str
|
| 16 |
+
|
| 17 |
+
# Per-signal scores
|
| 18 |
+
p_ai: Optional[float] = None
|
| 19 |
+
s_perp: Optional[float] = None
|
| 20 |
+
s_embed_cluster: Optional[float] = None
|
| 21 |
+
p_ext: Optional[float] = None
|
| 22 |
+
s_styl: Optional[float] = None
|
| 23 |
+
p_watermark: Optional[float] = None
|
| 24 |
+
|
| 25 |
+
# Ensemble
|
| 26 |
+
threat_score: Optional[float] = None
|
| 27 |
+
explainability: Optional[Any] = None
|
| 28 |
+
|
| 29 |
+
# Metadata
|
| 30 |
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
| 31 |
+
user_id: Optional[str] = None
|
| 32 |
+
status: str = "done"
|
| 33 |
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
| 34 |
+
completed_at: Optional[datetime] = None
|
| 35 |
+
processing_time_ms: Optional[int] = None
|
| 36 |
+
|
| 37 |
+
def to_dict(self) -> dict:
|
| 38 |
+
"""Serialise to a plain dict suitable for Firestore."""
|
| 39 |
+
return {
|
| 40 |
+
"id": self.id,
|
| 41 |
+
"user_id": self.user_id,
|
| 42 |
+
"input_text": self.input_text[:10000],
|
| 43 |
+
"text_hash": self.text_hash,
|
| 44 |
+
"p_ai": self.p_ai,
|
| 45 |
+
"s_perp": self.s_perp,
|
| 46 |
+
"s_embed_cluster": self.s_embed_cluster,
|
| 47 |
+
"p_ext": self.p_ext,
|
| 48 |
+
"s_styl": self.s_styl,
|
| 49 |
+
"p_watermark": self.p_watermark,
|
| 50 |
+
"threat_score": self.threat_score,
|
| 51 |
+
"explainability": self.explainability,
|
| 52 |
+
"status": self.status,
|
| 53 |
+
"created_at": self.created_at,
|
| 54 |
+
"completed_at": self.completed_at,
|
| 55 |
+
"processing_time_ms": self.processing_time_ms,
|
| 56 |
+
}
|
backend/app/services/__init__.py
ADDED
|
File without changes
|
backend/app/services/ensemble.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Ensemble scoring module.
|
| 3 |
+
Combines all signal scores into a final threat_score with explainability.
|
| 4 |
+
|
| 5 |
+
Configurable weights via environment variables.
|
| 6 |
+
"""
|
| 7 |
+
from typing import Optional, List, Dict
|
| 8 |
+
from backend.app.core.config import settings
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def compute_ensemble(
|
| 12 |
+
p_ai: Optional[float] = None,
|
| 13 |
+
s_perp: Optional[float] = None,
|
| 14 |
+
s_embed_cluster: Optional[float] = None,
|
| 15 |
+
p_ext: Optional[float] = None,
|
| 16 |
+
s_styl: Optional[float] = None,
|
| 17 |
+
p_watermark: Optional[float] = None,
|
| 18 |
+
) -> Dict:
|
| 19 |
+
"""
|
| 20 |
+
Deterministic ensemble scoring.
|
| 21 |
+
Returns threat_score (0-1) and explainability breakdown.
|
| 22 |
+
"""
|
| 23 |
+
signals = {
|
| 24 |
+
"p_ai": {"value": p_ai, "weight": settings.WEIGHT_AI_DETECT, "desc": "AI-generated text probability"},
|
| 25 |
+
"s_perp": {"value": s_perp, "weight": settings.WEIGHT_PERPLEXITY, "desc": "Perplexity anomaly score"},
|
| 26 |
+
"s_embed_cluster": {"value": s_embed_cluster, "weight": settings.WEIGHT_EMBEDDING, "desc": "Embedding cluster outlier score"},
|
| 27 |
+
"p_ext": {"value": p_ext, "weight": settings.WEIGHT_EXTREMISM, "desc": "Extremism/harm probability"},
|
| 28 |
+
"s_styl": {"value": s_styl, "weight": settings.WEIGHT_STYLOMETRY, "desc": "Stylometry anomaly score"},
|
| 29 |
+
"p_watermark": {"value": p_watermark, "weight": settings.WEIGHT_WATERMARK, "desc": "Watermark detection (negative signal)"},
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
total_weight = 0.0
|
| 33 |
+
weighted_sum = 0.0
|
| 34 |
+
explainability: List[Dict] = []
|
| 35 |
+
|
| 36 |
+
for name, sig in signals.items():
|
| 37 |
+
value = sig["value"]
|
| 38 |
+
if value is None:
|
| 39 |
+
continue
|
| 40 |
+
|
| 41 |
+
weight = sig["weight"]
|
| 42 |
+
|
| 43 |
+
# Watermark is a negative signal: if detected, reduce threat
|
| 44 |
+
if name == "p_watermark":
|
| 45 |
+
contribution = -value * weight
|
| 46 |
+
else:
|
| 47 |
+
contribution = value * weight
|
| 48 |
+
|
| 49 |
+
weighted_sum += contribution
|
| 50 |
+
total_weight += weight
|
| 51 |
+
|
| 52 |
+
explainability.append({
|
| 53 |
+
"signal": name,
|
| 54 |
+
"value": round(value, 4),
|
| 55 |
+
"weight": round(weight, 4),
|
| 56 |
+
"contribution": round(contribution, 4),
|
| 57 |
+
"description": sig["desc"],
|
| 58 |
+
})
|
| 59 |
+
|
| 60 |
+
# Normalize
|
| 61 |
+
if total_weight > 0:
|
| 62 |
+
threat_score = max(0.0, min(1.0, weighted_sum / total_weight))
|
| 63 |
+
else:
|
| 64 |
+
threat_score = 0.0
|
| 65 |
+
|
| 66 |
+
return {
|
| 67 |
+
"threat_score": round(threat_score, 4),
|
| 68 |
+
"explainability": explainability,
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def calibrate_weights(sample_results: List[Dict]) -> Dict[str, float]:
|
| 73 |
+
"""
|
| 74 |
+
Auto-calibrate weights based on sample dataset.
|
| 75 |
+
This does NOT train any model - just adjusts weight vector using
|
| 76 |
+
signal variance and correlation heuristics.
|
| 77 |
+
Returns updated weight dict.
|
| 78 |
+
"""
|
| 79 |
+
if not sample_results:
|
| 80 |
+
return {
|
| 81 |
+
"WEIGHT_AI_DETECT": settings.WEIGHT_AI_DETECT,
|
| 82 |
+
"WEIGHT_PERPLEXITY": settings.WEIGHT_PERPLEXITY,
|
| 83 |
+
"WEIGHT_EMBEDDING": settings.WEIGHT_EMBEDDING,
|
| 84 |
+
"WEIGHT_EXTREMISM": settings.WEIGHT_EXTREMISM,
|
| 85 |
+
"WEIGHT_STYLOMETRY": settings.WEIGHT_STYLOMETRY,
|
| 86 |
+
"WEIGHT_WATERMARK": settings.WEIGHT_WATERMARK,
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
# Compute variance per signal to weight higher-variance signals more
|
| 90 |
+
signal_names = ["p_ai", "s_perp", "s_embed_cluster", "p_ext", "s_styl", "p_watermark"]
|
| 91 |
+
variances = {}
|
| 92 |
+
for sig in signal_names:
|
| 93 |
+
vals = [r.get(sig) for r in sample_results if r.get(sig) is not None]
|
| 94 |
+
if len(vals) >= 2:
|
| 95 |
+
mean = sum(vals) / len(vals)
|
| 96 |
+
var = sum((v - mean) ** 2 for v in vals) / len(vals)
|
| 97 |
+
variances[sig] = var
|
| 98 |
+
else:
|
| 99 |
+
variances[sig] = 0.01
|
| 100 |
+
|
| 101 |
+
# Normalize variances to sum to 1
|
| 102 |
+
total_var = sum(variances.values())
|
| 103 |
+
if total_var > 0:
|
| 104 |
+
weights = {k: v / total_var for k, v in variances.items()}
|
| 105 |
+
else:
|
| 106 |
+
weights = {k: 1.0 / len(signal_names) for k in signal_names}
|
| 107 |
+
|
| 108 |
+
return {
|
| 109 |
+
"WEIGHT_AI_DETECT": round(weights.get("p_ai", 0.3), 4),
|
| 110 |
+
"WEIGHT_PERPLEXITY": round(weights.get("s_perp", 0.2), 4),
|
| 111 |
+
"WEIGHT_EMBEDDING": round(weights.get("s_embed_cluster", 0.15), 4),
|
| 112 |
+
"WEIGHT_EXTREMISM": round(weights.get("p_ext", 0.2), 4),
|
| 113 |
+
"WEIGHT_STYLOMETRY": round(weights.get("s_styl", 0.1), 4),
|
| 114 |
+
"WEIGHT_WATERMARK": round(weights.get("p_watermark", 0.05), 4),
|
| 115 |
+
}
|
backend/app/services/groq_service.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Groq API client for perplexity scoring using Llama models.
|
| 3 |
+
Computes token-level log-probabilities to produce perplexity scores.
|
| 4 |
+
|
| 5 |
+
Env vars: GROQ_API_KEY, GROQ_MODEL, GROQ_BASE_URL
|
| 6 |
+
"""
|
| 7 |
+
import math
|
| 8 |
+
import httpx
|
| 9 |
+
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from backend.app.core.config import settings
|
| 13 |
+
from backend.app.core.logging import get_logger
|
| 14 |
+
|
| 15 |
+
logger = get_logger(__name__)
|
| 16 |
+
|
| 17 |
+
_TIMEOUT = httpx.Timeout(60.0, connect=10.0)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class GroqServiceError(Exception):
|
| 21 |
+
pass
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@retry(
|
| 25 |
+
stop=stop_after_attempt(3),
|
| 26 |
+
wait=wait_exponential(multiplier=1, min=2, max=15),
|
| 27 |
+
retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.ConnectError)),
|
| 28 |
+
)
|
| 29 |
+
async def _groq_chat_completion(text: str) -> dict:
|
| 30 |
+
"""Call Groq chat completion with logprobs enabled.
|
| 31 |
+
Note: Input is truncated to 2000 chars for cost control. Perplexity
|
| 32 |
+
scores for longer texts reflect only the first 2000 characters.
|
| 33 |
+
"""
|
| 34 |
+
headers = {
|
| 35 |
+
"Authorization": f"Bearer {settings.GROQ_API_KEY}",
|
| 36 |
+
"Content-Type": "application/json",
|
| 37 |
+
}
|
| 38 |
+
payload = {
|
| 39 |
+
"model": settings.GROQ_MODEL,
|
| 40 |
+
"messages": [
|
| 41 |
+
{"role": "system", "content": "Repeat the following text exactly:"},
|
| 42 |
+
{"role": "user", "content": text[:2000]}, # Truncated for cost control
|
| 43 |
+
],
|
| 44 |
+
"max_tokens": 1,
|
| 45 |
+
"temperature": 0,
|
| 46 |
+
"logprobs": True,
|
| 47 |
+
"top_logprobs": 1,
|
| 48 |
+
}
|
| 49 |
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
| 50 |
+
resp = await client.post(
|
| 51 |
+
f"{settings.GROQ_BASE_URL}/chat/completions",
|
| 52 |
+
json=payload,
|
| 53 |
+
headers=headers,
|
| 54 |
+
)
|
| 55 |
+
resp.raise_for_status()
|
| 56 |
+
return resp.json()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
async def compute_perplexity(text: str) -> Optional[float]:
|
| 60 |
+
"""
|
| 61 |
+
Compute a normalized perplexity score using Groq Llama endpoints.
|
| 62 |
+
Returns a score between 0 and 1 where higher = more anomalous.
|
| 63 |
+
|
| 64 |
+
Strategy: Use logprobs from a single completion call to estimate
|
| 65 |
+
the model's surprise at the input text.
|
| 66 |
+
"""
|
| 67 |
+
try:
|
| 68 |
+
result = await _groq_chat_completion(text)
|
| 69 |
+
choices = result.get("choices", [])
|
| 70 |
+
if not choices:
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
logprobs_data = choices[0].get("logprobs", {})
|
| 74 |
+
if not logprobs_data:
|
| 75 |
+
# If logprobs not available, use usage-based heuristic
|
| 76 |
+
usage = result.get("usage", {})
|
| 77 |
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
| 78 |
+
if prompt_tokens > 0:
|
| 79 |
+
text_len = len(text.split())
|
| 80 |
+
ratio = prompt_tokens / max(text_len, 1)
|
| 81 |
+
# Normalize: high token ratio suggests unusual tokenization
|
| 82 |
+
return min(1.0, max(0.0, (ratio - 1.0) / 2.0))
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
content = logprobs_data.get("content", [])
|
| 86 |
+
if not content:
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
# Compute perplexity from log-probabilities
|
| 90 |
+
log_probs = []
|
| 91 |
+
for token_info in content:
|
| 92 |
+
lp = token_info.get("logprob")
|
| 93 |
+
if lp is not None:
|
| 94 |
+
log_probs.append(lp)
|
| 95 |
+
|
| 96 |
+
if not log_probs:
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
avg_log_prob = sum(log_probs) / len(log_probs)
|
| 100 |
+
perplexity = math.exp(-avg_log_prob)
|
| 101 |
+
# Normalize to 0-1 range (perplexity of 1 = perfectly predicted, >100 = very unusual)
|
| 102 |
+
normalized = min(1.0, max(0.0, (math.log(perplexity + 1) / math.log(101))))
|
| 103 |
+
return round(normalized, 4)
|
| 104 |
+
except Exception as e:
|
| 105 |
+
logger.warning("Groq perplexity computation failed", error=str(e))
|
| 106 |
+
return None
|
backend/app/services/hf_service.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hugging Face Inference API client.
|
| 3 |
+
Calls AI-text detectors and embedding models hosted on HF Inference Endpoints.
|
| 4 |
+
Implements retry/backoff and circuit-breaker behavior.
|
| 5 |
+
|
| 6 |
+
Env vars: HF_API_KEY, HF_DETECTOR_PRIMARY, HF_DETECTOR_FALLBACK,
|
| 7 |
+
HF_EMBEDDINGS_PRIMARY, HF_EMBEDDINGS_FALLBACK, HF_HARM_CLASSIFIER
|
| 8 |
+
"""
|
| 9 |
+
import httpx
|
| 10 |
+
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
| 11 |
+
from typing import List, Optional, Dict, Any
|
| 12 |
+
|
| 13 |
+
from backend.app.core.config import settings
|
| 14 |
+
from backend.app.core.logging import get_logger
|
| 15 |
+
|
| 16 |
+
logger = get_logger(__name__)
|
| 17 |
+
|
| 18 |
+
_HEADERS = lambda: {"Authorization": f"Bearer {settings.HF_API_KEY}"}
|
| 19 |
+
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class HFServiceError(Exception):
|
| 23 |
+
pass
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@retry(
|
| 27 |
+
stop=stop_after_attempt(3),
|
| 28 |
+
wait=wait_exponential(multiplier=1, min=1, max=10),
|
| 29 |
+
retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.ConnectError)),
|
| 30 |
+
)
|
| 31 |
+
async def _hf_request(url: str, payload: dict) -> Any:
|
| 32 |
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
| 33 |
+
resp = await client.post(url, json=payload, headers=_HEADERS())
|
| 34 |
+
resp.raise_for_status()
|
| 35 |
+
return resp.json()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def detect_ai_text(text: str) -> float:
|
| 39 |
+
"""
|
| 40 |
+
Call AI text detector ensemble (primary + fallback).
|
| 41 |
+
Returns probability that text is AI-generated (0-1).
|
| 42 |
+
"""
|
| 43 |
+
scores = []
|
| 44 |
+
for url in [settings.HF_DETECTOR_PRIMARY, settings.HF_DETECTOR_FALLBACK]:
|
| 45 |
+
try:
|
| 46 |
+
result = await _hf_request(url, {"inputs": text})
|
| 47 |
+
# HF classification returns [[{label, score}, ...]]
|
| 48 |
+
if isinstance(result, list) and len(result) > 0:
|
| 49 |
+
labels = result[0] if isinstance(result[0], list) else result
|
| 50 |
+
for item in labels:
|
| 51 |
+
label = item.get("label", "").lower()
|
| 52 |
+
if label in ("ai", "fake", "machine", "ai-generated", "generated"):
|
| 53 |
+
scores.append(item["score"])
|
| 54 |
+
break
|
| 55 |
+
else:
|
| 56 |
+
# If no matching label found, use first score as proxy
|
| 57 |
+
if labels:
|
| 58 |
+
scores.append(labels[0].get("score", 0.5))
|
| 59 |
+
except Exception as e:
|
| 60 |
+
logger.warning("HF detector call failed", url=url, error=str(e))
|
| 61 |
+
if not scores:
|
| 62 |
+
raise HFServiceError("All AI detectors failed")
|
| 63 |
+
return sum(scores) / len(scores)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def get_embeddings(text: str) -> List[float]:
|
| 67 |
+
"""Get text embeddings from HF sentence-transformers endpoint."""
|
| 68 |
+
for url in [settings.HF_EMBEDDINGS_PRIMARY, settings.HF_EMBEDDINGS_FALLBACK]:
|
| 69 |
+
try:
|
| 70 |
+
result = await _hf_request(url, {"inputs": text})
|
| 71 |
+
if isinstance(result, list) and len(result) > 0:
|
| 72 |
+
# Returns a list of floats (embedding vector)
|
| 73 |
+
if isinstance(result[0], float):
|
| 74 |
+
return result
|
| 75 |
+
if isinstance(result[0], list):
|
| 76 |
+
return result[0]
|
| 77 |
+
return result
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.warning("HF embeddings call failed", url=url, error=str(e))
|
| 80 |
+
raise HFServiceError("All embedding endpoints failed")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def detect_harm(text: str) -> float:
|
| 84 |
+
"""
|
| 85 |
+
Call harm/extremism classifier on HF.
|
| 86 |
+
Returns probability of harmful/extremist content (0-1).
|
| 87 |
+
"""
|
| 88 |
+
try:
|
| 89 |
+
result = await _hf_request(settings.HF_HARM_CLASSIFIER, {"inputs": text})
|
| 90 |
+
if isinstance(result, list) and len(result) > 0:
|
| 91 |
+
labels = result[0] if isinstance(result[0], list) else result
|
| 92 |
+
for item in labels:
|
| 93 |
+
label = item.get("label", "").lower()
|
| 94 |
+
if label in ("hate", "toxic", "harmful", "extremist", "hateful"):
|
| 95 |
+
return item["score"]
|
| 96 |
+
# Fallback: return highest score
|
| 97 |
+
if labels:
|
| 98 |
+
return max(item.get("score", 0.0) for item in labels)
|
| 99 |
+
return 0.0
|
| 100 |
+
except Exception as e:
|
| 101 |
+
logger.warning("HF harm classifier failed", error=str(e))
|
| 102 |
+
return 0.0
|
backend/app/services/stylometry.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stylometry analysis service.
|
| 3 |
+
Lightweight CPU-only feature extraction: char n-grams, function-word frequencies,
|
| 4 |
+
punctuation patterns, and read-time heuristics.
|
| 5 |
+
No external models required.
|
| 6 |
+
"""
|
| 7 |
+
import re
|
| 8 |
+
import math
|
| 9 |
+
from collections import Counter
|
| 10 |
+
from typing import Dict
|
| 11 |
+
|
| 12 |
+
# Common English function words
|
| 13 |
+
FUNCTION_WORDS = {
|
| 14 |
+
"the", "be", "to", "of", "and", "a", "in", "that", "have", "i",
|
| 15 |
+
"it", "for", "not", "on", "with", "he", "as", "you", "do", "at",
|
| 16 |
+
"this", "but", "his", "by", "from", "they", "we", "say", "her", "she",
|
| 17 |
+
"or", "an", "will", "my", "one", "all", "would", "there", "their",
|
| 18 |
+
"what", "so", "up", "out", "if", "about", "who", "get", "which", "go",
|
| 19 |
+
"me", "when", "make", "can", "like", "time", "no", "just", "him",
|
| 20 |
+
"know", "take", "people", "into", "year", "your", "good", "some",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _char_ngrams(text: str, n: int = 3) -> Dict[str, int]:
|
| 25 |
+
"""Extract character n-gram frequency distribution."""
|
| 26 |
+
ngrams = Counter()
|
| 27 |
+
text_lower = text.lower()
|
| 28 |
+
for i in range(len(text_lower) - n + 1):
|
| 29 |
+
ngrams[text_lower[i:i + n]] += 1
|
| 30 |
+
return dict(ngrams)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _function_word_freq(text: str) -> float:
|
| 34 |
+
"""Compute ratio of function words to total words."""
|
| 35 |
+
words = text.lower().split()
|
| 36 |
+
if not words:
|
| 37 |
+
return 0.0
|
| 38 |
+
fw_count = sum(1 for w in words if w.strip(".,!?;:\"'") in FUNCTION_WORDS)
|
| 39 |
+
return fw_count / len(words)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _punctuation_pattern(text: str) -> Dict[str, float]:
|
| 43 |
+
"""Extract punctuation density and diversity metrics."""
|
| 44 |
+
if not text:
|
| 45 |
+
return {"density": 0.0, "diversity": 0.0}
|
| 46 |
+
puncts = re.findall(r"[^\w\s]", text)
|
| 47 |
+
density = len(puncts) / len(text)
|
| 48 |
+
diversity = len(set(puncts)) / max(len(puncts), 1)
|
| 49 |
+
return {"density": round(density, 4), "diversity": round(diversity, 4)}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _readability_score(text: str) -> float:
|
| 53 |
+
"""Simple Automated Readability Index approximation."""
|
| 54 |
+
sentences = max(len(re.split(r"[.!?]+", text)), 1)
|
| 55 |
+
words = text.split()
|
| 56 |
+
word_count = max(len(words), 1)
|
| 57 |
+
char_count = sum(len(w) for w in words)
|
| 58 |
+
ari = 4.71 * (char_count / word_count) + 0.5 * (word_count / sentences) - 21.43
|
| 59 |
+
return max(0, min(20, ari))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _sentence_length_variance(text: str) -> float:
|
| 63 |
+
"""Compute variance in sentence lengths (words per sentence)."""
|
| 64 |
+
sentences = re.split(r"[.!?]+", text)
|
| 65 |
+
lengths = [len(s.split()) for s in sentences if s.strip()]
|
| 66 |
+
if len(lengths) < 2:
|
| 67 |
+
return 0.0
|
| 68 |
+
mean = sum(lengths) / len(lengths)
|
| 69 |
+
variance = sum((l - mean) ** 2 for l in lengths) / len(lengths)
|
| 70 |
+
return round(math.sqrt(variance), 4)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def compute_stylometry_score(text: str) -> float:
|
| 74 |
+
"""
|
| 75 |
+
Compute a stylometry anomaly score (0-1).
|
| 76 |
+
Higher scores indicate more anomalous writing patterns
|
| 77 |
+
(potentially AI-generated or coordinated).
|
| 78 |
+
|
| 79 |
+
Uses a combination of features compared against typical human baselines.
|
| 80 |
+
"""
|
| 81 |
+
if not text or len(text) < 20:
|
| 82 |
+
return 0.0
|
| 83 |
+
|
| 84 |
+
features = []
|
| 85 |
+
|
| 86 |
+
# Feature 1: Function word ratio (human ~0.4-0.55, AI tends to be more uniform)
|
| 87 |
+
fw_ratio = _function_word_freq(text)
|
| 88 |
+
fw_anomaly = abs(fw_ratio - 0.47) / 0.47 # Distance from typical human ratio
|
| 89 |
+
features.append(min(1.0, fw_anomaly))
|
| 90 |
+
|
| 91 |
+
# Feature 2: Punctuation patterns
|
| 92 |
+
punct = _punctuation_pattern(text)
|
| 93 |
+
# Very low or very high punctuation density is anomalous
|
| 94 |
+
punct_anomaly = abs(punct["density"] - 0.06) / 0.06 if punct["density"] > 0 else 0.5
|
| 95 |
+
features.append(min(1.0, punct_anomaly))
|
| 96 |
+
|
| 97 |
+
# Feature 3: Sentence length variance (low variance = more AI-like)
|
| 98 |
+
slv = _sentence_length_variance(text)
|
| 99 |
+
# Typical human variance is 5-15 words; very low suggests AI
|
| 100 |
+
slv_anomaly = max(0, 1.0 - slv / 10.0) if slv < 10 else 0.0
|
| 101 |
+
features.append(slv_anomaly)
|
| 102 |
+
|
| 103 |
+
# Feature 4: Readability consistency
|
| 104 |
+
ari = _readability_score(text)
|
| 105 |
+
# Very consistent readability (middle range) is more AI-like
|
| 106 |
+
ari_anomaly = max(0, 1.0 - abs(ari - 10) / 10)
|
| 107 |
+
features.append(ari_anomaly)
|
| 108 |
+
|
| 109 |
+
# Feature 5: Character n-gram entropy
|
| 110 |
+
ngrams = _char_ngrams(text, 3)
|
| 111 |
+
if ngrams:
|
| 112 |
+
total = sum(ngrams.values())
|
| 113 |
+
probs = [c / total for c in ngrams.values()]
|
| 114 |
+
entropy = -sum(p * math.log2(p) for p in probs if p > 0)
|
| 115 |
+
max_entropy = math.log2(max(len(ngrams), 1))
|
| 116 |
+
# Very high entropy = unusual; normalize
|
| 117 |
+
norm_entropy = entropy / max_entropy if max_entropy > 0 else 0
|
| 118 |
+
# AI text tends to have moderate-high entropy
|
| 119 |
+
features.append(max(0, norm_entropy - 0.5) * 2)
|
| 120 |
+
else:
|
| 121 |
+
features.append(0.0)
|
| 122 |
+
|
| 123 |
+
# Weighted average
|
| 124 |
+
weights = [0.25, 0.15, 0.25, 0.15, 0.20]
|
| 125 |
+
score = sum(f * w for f, w in zip(features, weights))
|
| 126 |
+
return round(min(1.0, max(0.0, score)), 4)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def extract_features(text: str) -> Dict:
|
| 130 |
+
"""Extract all stylometry features for analysis."""
|
| 131 |
+
return {
|
| 132 |
+
"function_word_ratio": _function_word_freq(text),
|
| 133 |
+
"punctuation": _punctuation_pattern(text),
|
| 134 |
+
"readability_ari": _readability_score(text),
|
| 135 |
+
"sentence_length_variance": _sentence_length_variance(text),
|
| 136 |
+
"stylometry_score": compute_stylometry_score(text),
|
| 137 |
+
}
|
backend/app/services/vector_db.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Vector DB integration using Qdrant for semantic embedding storage and similarity search.
|
| 3 |
+
Env vars: QDRANT_URL, QDRANT_API_KEY, QDRANT_COLLECTION
|
| 4 |
+
"""
|
| 5 |
+
import httpx
|
| 6 |
+
from typing import List, Optional, Dict
|
| 7 |
+
from backend.app.core.config import settings
|
| 8 |
+
from backend.app.core.logging import get_logger
|
| 9 |
+
|
| 10 |
+
logger = get_logger(__name__)
|
| 11 |
+
|
| 12 |
+
_TIMEOUT = httpx.Timeout(15.0, connect=5.0)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _headers() -> Dict[str, str]:
|
| 16 |
+
h = {"Content-Type": "application/json"}
|
| 17 |
+
if settings.QDRANT_API_KEY:
|
| 18 |
+
h["api-key"] = settings.QDRANT_API_KEY
|
| 19 |
+
return h
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def ensure_collection(vector_size: int = 768):
|
| 23 |
+
"""Create collection if it doesn't exist."""
|
| 24 |
+
try:
|
| 25 |
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
| 26 |
+
resp = await client.get(
|
| 27 |
+
f"{settings.QDRANT_URL}/collections/{settings.QDRANT_COLLECTION}",
|
| 28 |
+
headers=_headers(),
|
| 29 |
+
)
|
| 30 |
+
if resp.status_code == 404:
|
| 31 |
+
await client.put(
|
| 32 |
+
f"{settings.QDRANT_URL}/collections/{settings.QDRANT_COLLECTION}",
|
| 33 |
+
json={"vectors": {"size": vector_size, "distance": "Cosine"}},
|
| 34 |
+
headers=_headers(),
|
| 35 |
+
)
|
| 36 |
+
logger.info("Created Qdrant collection", collection=settings.QDRANT_COLLECTION)
|
| 37 |
+
except Exception as e:
|
| 38 |
+
logger.warning("Qdrant collection setup failed (non-fatal)", error=str(e))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def upsert_embedding(point_id: str, vector: List[float], payload: Optional[Dict] = None):
|
| 42 |
+
"""Store an embedding vector in Qdrant."""
|
| 43 |
+
try:
|
| 44 |
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
| 45 |
+
await client.put(
|
| 46 |
+
f"{settings.QDRANT_URL}/collections/{settings.QDRANT_COLLECTION}/points",
|
| 47 |
+
json={
|
| 48 |
+
"points": [
|
| 49 |
+
{"id": point_id, "vector": vector, "payload": payload or {}}
|
| 50 |
+
]
|
| 51 |
+
},
|
| 52 |
+
headers=_headers(),
|
| 53 |
+
)
|
| 54 |
+
except Exception as e:
|
| 55 |
+
logger.warning("Qdrant upsert failed (non-fatal)", error=str(e))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
async def search_similar(vector: List[float], top_k: int = 5) -> List[Dict]:
|
| 59 |
+
"""Search for similar embeddings in Qdrant."""
|
| 60 |
+
try:
|
| 61 |
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
| 62 |
+
resp = await client.post(
|
| 63 |
+
f"{settings.QDRANT_URL}/collections/{settings.QDRANT_COLLECTION}/points/search",
|
| 64 |
+
json={"vector": vector, "limit": top_k, "with_payload": True},
|
| 65 |
+
headers=_headers(),
|
| 66 |
+
)
|
| 67 |
+
resp.raise_for_status()
|
| 68 |
+
data = resp.json()
|
| 69 |
+
return data.get("result", [])
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logger.warning("Qdrant search failed (non-fatal)", error=str(e))
|
| 72 |
+
return []
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def compute_cluster_score(vector: List[float]) -> float:
|
| 76 |
+
"""
|
| 77 |
+
Compute a cluster density score for the given vector.
|
| 78 |
+
Higher score = more similar to existing content (potential coordinated campaign).
|
| 79 |
+
Returns 0 if no similar items found.
|
| 80 |
+
"""
|
| 81 |
+
similar = await search_similar(vector, top_k=10)
|
| 82 |
+
if not similar:
|
| 83 |
+
return 0.0
|
| 84 |
+
scores = [item.get("score", 0.0) for item in similar]
|
| 85 |
+
avg_similarity = sum(scores) / len(scores)
|
| 86 |
+
return round(min(1.0, avg_similarity), 4)
|
backend/app/workers/__init__.py
ADDED
|
File without changes
|
backend/app/workers/analysis_worker.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Async worker for processing analysis jobs via Redis queue.
|
| 3 |
+
Handles heavy inference tasks that are offloaded from the main API.
|
| 4 |
+
|
| 5 |
+
Run: python -m backend.app.workers.analysis_worker
|
| 6 |
+
"""
|
| 7 |
+
import asyncio
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
import redis
|
| 11 |
+
|
| 12 |
+
from backend.app.core.config import settings
|
| 13 |
+
from backend.app.core.logging import setup_logging, get_logger
|
| 14 |
+
from backend.app.services.hf_service import detect_ai_text, get_embeddings, detect_harm
|
| 15 |
+
from backend.app.services.groq_service import compute_perplexity
|
| 16 |
+
from backend.app.services.stylometry import compute_stylometry_score
|
| 17 |
+
from backend.app.services.ensemble import compute_ensemble
|
| 18 |
+
from backend.app.services.vector_db import compute_cluster_score, upsert_embedding
|
| 19 |
+
|
| 20 |
+
setup_logging(settings.LOG_LEVEL)
|
| 21 |
+
logger = get_logger(__name__)
|
| 22 |
+
|
| 23 |
+
QUEUE_NAME = "analysis_jobs"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def run_worker():
|
| 27 |
+
"""Blocking worker loop that processes analysis jobs from Redis queue."""
|
| 28 |
+
r = redis.from_url(settings.REDIS_URL, decode_responses=True)
|
| 29 |
+
logger.info("Worker started, listening on queue", queue=QUEUE_NAME)
|
| 30 |
+
|
| 31 |
+
while True:
|
| 32 |
+
try:
|
| 33 |
+
_, raw = r.brpop(QUEUE_NAME, timeout=30)
|
| 34 |
+
if raw is None:
|
| 35 |
+
continue
|
| 36 |
+
job = json.loads(raw)
|
| 37 |
+
text = job.get("text", "")
|
| 38 |
+
job_id = job.get("id", "unknown")
|
| 39 |
+
logger.info("Processing job", job_id=job_id)
|
| 40 |
+
|
| 41 |
+
result = asyncio.run(_process_job(text))
|
| 42 |
+
r.setex(f"result:{job_id}", 3600, json.dumps(result))
|
| 43 |
+
logger.info("Job completed", job_id=job_id)
|
| 44 |
+
except Exception as e:
|
| 45 |
+
logger.error("Worker error", error=str(e))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def _process_job(text: str) -> dict:
|
| 49 |
+
"""Process a single analysis job."""
|
| 50 |
+
try:
|
| 51 |
+
p_ai = await detect_ai_text(text)
|
| 52 |
+
except Exception:
|
| 53 |
+
p_ai = None
|
| 54 |
+
|
| 55 |
+
s_perp = None
|
| 56 |
+
if p_ai is not None and p_ai > settings.PERPLEXITY_THRESHOLD:
|
| 57 |
+
s_perp = await compute_perplexity(text)
|
| 58 |
+
|
| 59 |
+
s_embed_cluster = None
|
| 60 |
+
try:
|
| 61 |
+
embeddings = await get_embeddings(text)
|
| 62 |
+
s_embed_cluster = await compute_cluster_score(embeddings)
|
| 63 |
+
await upsert_embedding(f"worker_{hash(text)}", embeddings)
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
p_ext = await detect_harm(text)
|
| 68 |
+
s_styl = compute_stylometry_score(text)
|
| 69 |
+
|
| 70 |
+
ensemble_result = compute_ensemble(
|
| 71 |
+
p_ai=p_ai, s_perp=s_perp, s_embed_cluster=s_embed_cluster,
|
| 72 |
+
p_ext=p_ext, s_styl=s_styl,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
return {
|
| 76 |
+
"p_ai": p_ai,
|
| 77 |
+
"s_perp": s_perp,
|
| 78 |
+
"s_embed_cluster": s_embed_cluster,
|
| 79 |
+
"p_ext": p_ext,
|
| 80 |
+
"s_styl": s_styl,
|
| 81 |
+
"threat_score": ensemble_result["threat_score"],
|
| 82 |
+
"explainability": ensemble_result["explainability"],
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
if __name__ == "__main__":
|
| 87 |
+
run_worker()
|
backend/migrations/env.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Alembic configuration for database migrations.
|
| 3 |
+
"""
|
| 4 |
+
from logging.config import fileConfig
|
| 5 |
+
from sqlalchemy import pool
|
| 6 |
+
from sqlalchemy.ext.asyncio import async_engine_from_config
|
| 7 |
+
from alembic import context
|
| 8 |
+
import asyncio
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
config = context.config
|
| 12 |
+
|
| 13 |
+
if config.config_file_name is not None:
|
| 14 |
+
fileConfig(config.config_file_name)
|
| 15 |
+
|
| 16 |
+
from backend.app.models.schemas import Base
|
| 17 |
+
target_metadata = Base.metadata
|
| 18 |
+
|
| 19 |
+
database_url = os.environ.get("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/sentinel")
|
| 20 |
+
config.set_main_option("sqlalchemy.url", database_url)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def run_migrations_offline():
|
| 24 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 25 |
+
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
| 26 |
+
with context.begin_transaction():
|
| 27 |
+
context.run_migrations()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def do_run_migrations(connection):
|
| 31 |
+
context.configure(connection=connection, target_metadata=target_metadata)
|
| 32 |
+
with context.begin_transaction():
|
| 33 |
+
context.run_migrations()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def run_async_migrations():
|
| 37 |
+
connectable = async_engine_from_config(
|
| 38 |
+
config.get_section(config.config_ini_section, {}),
|
| 39 |
+
prefix="sqlalchemy.",
|
| 40 |
+
poolclass=pool.NullPool,
|
| 41 |
+
)
|
| 42 |
+
async with connectable.connect() as connection:
|
| 43 |
+
await connection.run_sync(do_run_migrations)
|
| 44 |
+
await connectable.dispose()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def run_migrations_online():
|
| 48 |
+
asyncio.run(run_async_migrations())
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if context.is_offline_mode():
|
| 52 |
+
run_migrations_offline()
|
| 53 |
+
else:
|
| 54 |
+
run_migrations_online()
|
backend/migrations/versions/001_initial.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Initial migration: create users and analysis_results tables.
|
| 3 |
+
"""
|
| 4 |
+
from alembic import op
|
| 5 |
+
import sqlalchemy as sa
|
| 6 |
+
|
| 7 |
+
revision = "001_initial"
|
| 8 |
+
down_revision = None
|
| 9 |
+
branch_labels = None
|
| 10 |
+
depends_on = None
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def upgrade():
|
| 14 |
+
op.create_table(
|
| 15 |
+
"users",
|
| 16 |
+
sa.Column("id", sa.String(), primary_key=True),
|
| 17 |
+
sa.Column("email", sa.String(255), unique=True, nullable=False),
|
| 18 |
+
sa.Column("hashed_password", sa.String(255), nullable=False),
|
| 19 |
+
sa.Column("is_active", sa.Boolean(), default=True),
|
| 20 |
+
sa.Column("created_at", sa.DateTime()),
|
| 21 |
+
)
|
| 22 |
+
op.create_index("ix_users_email", "users", ["email"])
|
| 23 |
+
|
| 24 |
+
op.create_table(
|
| 25 |
+
"analysis_results",
|
| 26 |
+
sa.Column("id", sa.String(), primary_key=True),
|
| 27 |
+
sa.Column("user_id", sa.String(), nullable=True),
|
| 28 |
+
sa.Column("input_text", sa.Text(), nullable=False),
|
| 29 |
+
sa.Column("text_hash", sa.String(64), nullable=False),
|
| 30 |
+
sa.Column("p_ai", sa.Float(), nullable=True),
|
| 31 |
+
sa.Column("s_perp", sa.Float(), nullable=True),
|
| 32 |
+
sa.Column("s_embed_cluster", sa.Float(), nullable=True),
|
| 33 |
+
sa.Column("p_ext", sa.Float(), nullable=True),
|
| 34 |
+
sa.Column("s_styl", sa.Float(), nullable=True),
|
| 35 |
+
sa.Column("p_watermark", sa.Float(), nullable=True),
|
| 36 |
+
sa.Column("threat_score", sa.Float(), nullable=True),
|
| 37 |
+
sa.Column("explainability", sa.JSON(), nullable=True),
|
| 38 |
+
sa.Column("status", sa.String(20), default="pending"),
|
| 39 |
+
sa.Column("created_at", sa.DateTime()),
|
| 40 |
+
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
| 41 |
+
sa.Column("processing_time_ms", sa.Integer(), nullable=True),
|
| 42 |
+
)
|
| 43 |
+
op.create_index("ix_analysis_results_user_id", "analysis_results", ["user_id"])
|
| 44 |
+
op.create_index("ix_analysis_results_text_hash", "analysis_results", ["text_hash"])
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def downgrade():
|
| 48 |
+
op.drop_table("analysis_results")
|
| 49 |
+
op.drop_table("users")
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend dependencies for LLM Misuse Detection System
|
| 2 |
+
# Core
|
| 3 |
+
fastapi==0.115.6
|
| 4 |
+
uvicorn[standard]==0.34.0
|
| 5 |
+
pydantic==2.10.4
|
| 6 |
+
pydantic-settings==2.7.1
|
| 7 |
+
|
| 8 |
+
# Firebase (Auth verification + Firestore)
|
| 9 |
+
firebase-admin==6.5.0
|
| 10 |
+
|
| 11 |
+
# Redis / Queue
|
| 12 |
+
redis==5.2.1
|
| 13 |
+
rq==2.1.0
|
| 14 |
+
|
| 15 |
+
# HTTP client
|
| 16 |
+
httpx==0.28.1
|
| 17 |
+
tenacity==9.0.0
|
| 18 |
+
|
| 19 |
+
# Observability
|
| 20 |
+
structlog==24.4.0
|
| 21 |
+
sentry-sdk[fastapi]==2.19.2
|
| 22 |
+
prometheus-client==0.21.1
|
| 23 |
+
|
| 24 |
+
# Security
|
| 25 |
+
secure==1.0.1
|
| 26 |
+
|
| 27 |
+
# Testing
|
| 28 |
+
pytest==8.3.4
|
| 29 |
+
pytest-asyncio==0.25.0
|
| 30 |
+
pytest-cov==6.0.0
|
| 31 |
+
|
| 32 |
+
# Utilities
|
| 33 |
+
python-dotenv==1.0.1
|
| 34 |
+
numpy==2.2.1
|
backend/scripts/seed.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database seed script with sample data for testing.
|
| 3 |
+
Run: python -m backend.scripts.seed
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
from backend.app.db.session import async_session, init_db
|
| 7 |
+
from backend.app.models.schemas import User, AnalysisResult
|
| 8 |
+
from backend.app.core.auth import hash_password
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
SAMPLE_TEXTS = [
|
| 12 |
+
{
|
| 13 |
+
"text": "URGENT: Your account has been compromised! Click here immediately to verify your identity and prevent unauthorized access to your bank account.",
|
| 14 |
+
"label": "phishing",
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"text": "According to multiple unnamed sources, the government has been secretly adding mind-control chemicals to the water supply since 2019. Mainstream media refuses to report on this.",
|
| 18 |
+
"label": "disinformation",
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"text": "Join our movement. The time for peaceful protest is over. We must take direct action against those who oppress us. Armed resistance is the only path to true liberation.",
|
| 22 |
+
"label": "extremist_recruitment",
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"text": "The Federal Reserve announced today that it will maintain current interest rates through the end of the quarter, citing stable employment numbers and moderate inflation indicators.",
|
| 26 |
+
"label": "benign_news",
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"text": "I just saw the new movie and honestly it was pretty good. The acting was solid and the plot kept me engaged throughout. Would recommend to anyone who likes thrillers. Has anyone else seen it? What did you think?",
|
| 30 |
+
"label": "benign_discussion",
|
| 31 |
+
},
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def seed():
|
| 36 |
+
await init_db()
|
| 37 |
+
async with async_session() as session:
|
| 38 |
+
# Create demo user
|
| 39 |
+
demo_user = User(
|
| 40 |
+
email="demo@sentinel.dev",
|
| 41 |
+
hashed_password=hash_password("demo123456"),
|
| 42 |
+
)
|
| 43 |
+
session.add(demo_user)
|
| 44 |
+
|
| 45 |
+
# Create sample analysis results
|
| 46 |
+
for sample in SAMPLE_TEXTS:
|
| 47 |
+
result = AnalysisResult(
|
| 48 |
+
user_id=demo_user.id,
|
| 49 |
+
input_text=sample["text"],
|
| 50 |
+
text_hash=f"seed_{sample['label']}",
|
| 51 |
+
status="seed",
|
| 52 |
+
)
|
| 53 |
+
session.add(result)
|
| 54 |
+
|
| 55 |
+
await session.commit()
|
| 56 |
+
print(f"Seeded database with demo user and {len(SAMPLE_TEXTS)} samples")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
if __name__ == "__main__":
|
| 60 |
+
asyncio.run(seed())
|
backend/tests/__init__.py
ADDED
|
File without changes
|
backend/tests/test_api.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Integration tests for the API endpoints with mocked external services.
|
| 3 |
+
Tests /api/analyze, /health, /metrics — no real Firebase, Firestore, or Redis needed.
|
| 4 |
+
"""
|
| 5 |
+
import pytest
|
| 6 |
+
from unittest.mock import AsyncMock, patch, MagicMock
|
| 7 |
+
from fastapi.testclient import TestClient
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _make_client():
|
| 11 |
+
"""Build a TestClient with Firebase init and Firestore writes mocked out."""
|
| 12 |
+
# Patch firebase_admin before app is imported to prevent SDK initialisation
|
| 13 |
+
with patch("backend.app.db.firestore.firebase_admin"), \
|
| 14 |
+
patch("backend.app.db.firestore.firestore"):
|
| 15 |
+
from backend.app.main import app
|
| 16 |
+
|
| 17 |
+
# Mock get_db() so Firestore document writes are no-ops
|
| 18 |
+
mock_db = MagicMock()
|
| 19 |
+
mock_collection = MagicMock()
|
| 20 |
+
mock_doc_ref = MagicMock()
|
| 21 |
+
mock_db.collection.return_value = mock_collection
|
| 22 |
+
mock_collection.document.return_value = mock_doc_ref
|
| 23 |
+
mock_doc_ref.set.return_value = None
|
| 24 |
+
|
| 25 |
+
# Mock get_result Firestore read
|
| 26 |
+
mock_existing_doc = MagicMock()
|
| 27 |
+
mock_existing_doc.exists = False
|
| 28 |
+
mock_doc_ref.get.return_value = mock_existing_doc
|
| 29 |
+
|
| 30 |
+
app.dependency_overrides = {}
|
| 31 |
+
with patch("backend.app.api.routes.get_db", return_value=mock_db), \
|
| 32 |
+
patch("backend.app.db.firestore.init_firebase"):
|
| 33 |
+
client = TestClient(app)
|
| 34 |
+
return client, mock_db
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@pytest.fixture
|
| 38 |
+
def client():
|
| 39 |
+
c, _ = _make_client()
|
| 40 |
+
return c
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class TestHealthEndpoint:
|
| 44 |
+
def test_health_check(self, client):
|
| 45 |
+
response = client.get("/health")
|
| 46 |
+
assert response.status_code == 200
|
| 47 |
+
data = response.json()
|
| 48 |
+
assert data["status"] == "ok"
|
| 49 |
+
assert data["version"] == "1.0.0"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class TestMetricsEndpoint:
|
| 53 |
+
def test_metrics(self, client):
|
| 54 |
+
response = client.get("/metrics")
|
| 55 |
+
assert response.status_code == 200
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class TestAnalyzeEndpoint:
|
| 59 |
+
@patch("backend.app.api.routes.check_rate_limit", new_callable=AsyncMock, return_value=True)
|
| 60 |
+
@patch("backend.app.api.routes.detect_ai_text", new_callable=AsyncMock, return_value=0.85)
|
| 61 |
+
@patch("backend.app.api.routes.compute_perplexity", new_callable=AsyncMock, return_value=0.6)
|
| 62 |
+
@patch("backend.app.api.routes.get_embeddings", new_callable=AsyncMock, return_value=[0.1] * 768)
|
| 63 |
+
@patch("backend.app.api.routes.compute_cluster_score", new_callable=AsyncMock, return_value=0.3)
|
| 64 |
+
@patch("backend.app.api.routes.upsert_embedding", new_callable=AsyncMock)
|
| 65 |
+
@patch("backend.app.api.routes.detect_harm", new_callable=AsyncMock, return_value=0.2)
|
| 66 |
+
@patch("backend.app.api.routes.get_cached", new_callable=AsyncMock, return_value=None)
|
| 67 |
+
@patch("backend.app.api.routes.set_cached", new_callable=AsyncMock)
|
| 68 |
+
@patch("backend.app.api.routes.get_db")
|
| 69 |
+
def test_analyze_returns_scores(
|
| 70 |
+
self, mock_get_db, mock_set_cache, mock_get_cache, mock_harm, mock_upsert,
|
| 71 |
+
mock_cluster, mock_embed, mock_perp, mock_ai, mock_rate, client
|
| 72 |
+
):
|
| 73 |
+
mock_db = MagicMock()
|
| 74 |
+
mock_db.collection.return_value.document.return_value.set.return_value = None
|
| 75 |
+
mock_get_db.return_value = mock_db
|
| 76 |
+
|
| 77 |
+
response = client.post(
|
| 78 |
+
"/api/analyze",
|
| 79 |
+
json={"text": "This is a test text that should be analyzed for potential misuse patterns."},
|
| 80 |
+
)
|
| 81 |
+
assert response.status_code == 200
|
| 82 |
+
data = response.json()
|
| 83 |
+
assert "threat_score" in data
|
| 84 |
+
assert "signals" in data
|
| 85 |
+
assert "explainability" in data
|
| 86 |
+
assert data["status"] == "done"
|
| 87 |
+
assert data["signals"]["p_ai"] == 0.85
|
| 88 |
+
|
| 89 |
+
@patch("backend.app.api.routes.check_rate_limit", new_callable=AsyncMock, return_value=False)
|
| 90 |
+
def test_rate_limited(self, mock_rate, client):
|
| 91 |
+
response = client.post(
|
| 92 |
+
"/api/analyze",
|
| 93 |
+
json={"text": "Test text for rate limiting check."},
|
| 94 |
+
)
|
| 95 |
+
assert response.status_code == 429
|
| 96 |
+
|
| 97 |
+
def test_text_too_short(self, client):
|
| 98 |
+
response = client.post("/api/analyze", json={"text": "short"})
|
| 99 |
+
assert response.status_code == 422
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class TestAttackSimulations:
|
| 103 |
+
|
| 104 |
+
@patch("backend.app.api.routes.check_rate_limit", new_callable=AsyncMock, return_value=True)
|
| 105 |
+
@patch("backend.app.api.routes.detect_ai_text", new_callable=AsyncMock, return_value=0.95)
|
| 106 |
+
@patch("backend.app.api.routes.compute_perplexity", new_callable=AsyncMock, return_value=0.8)
|
| 107 |
+
@patch("backend.app.api.routes.get_embeddings", new_callable=AsyncMock, return_value=[0.1] * 768)
|
| 108 |
+
@patch("backend.app.api.routes.compute_cluster_score", new_callable=AsyncMock, return_value=0.7)
|
| 109 |
+
@patch("backend.app.api.routes.upsert_embedding", new_callable=AsyncMock)
|
| 110 |
+
@patch("backend.app.api.routes.detect_harm", new_callable=AsyncMock, return_value=0.9)
|
| 111 |
+
@patch("backend.app.api.routes.get_cached", new_callable=AsyncMock, return_value=None)
|
| 112 |
+
@patch("backend.app.api.routes.set_cached", new_callable=AsyncMock)
|
| 113 |
+
@patch("backend.app.api.routes.get_db")
|
| 114 |
+
def test_high_threat_detection(
|
| 115 |
+
self, mock_get_db, mock_set_cache, mock_get_cache, mock_harm, mock_upsert,
|
| 116 |
+
mock_cluster, mock_embed, mock_perp, mock_ai, mock_rate, client
|
| 117 |
+
):
|
| 118 |
+
mock_db = MagicMock()
|
| 119 |
+
mock_db.collection.return_value.document.return_value.set.return_value = None
|
| 120 |
+
mock_get_db.return_value = mock_db
|
| 121 |
+
|
| 122 |
+
response = client.post(
|
| 123 |
+
"/api/analyze",
|
| 124 |
+
json={"text": "Simulated high-threat content for testing purposes only. This is a test."},
|
| 125 |
+
)
|
| 126 |
+
assert response.status_code == 200
|
| 127 |
+
data = response.json()
|
| 128 |
+
assert data["threat_score"] > 0.5
|
| 129 |
+
|
| 130 |
+
@patch("backend.app.api.routes.check_rate_limit", new_callable=AsyncMock, return_value=True)
|
| 131 |
+
@patch("backend.app.api.routes.detect_ai_text", new_callable=AsyncMock, return_value=0.05)
|
| 132 |
+
@patch("backend.app.api.routes.get_embeddings", new_callable=AsyncMock, return_value=[0.1] * 768)
|
| 133 |
+
@patch("backend.app.api.routes.compute_cluster_score", new_callable=AsyncMock, return_value=0.1)
|
| 134 |
+
@patch("backend.app.api.routes.upsert_embedding", new_callable=AsyncMock)
|
| 135 |
+
@patch("backend.app.api.routes.detect_harm", new_callable=AsyncMock, return_value=0.02)
|
| 136 |
+
@patch("backend.app.api.routes.get_cached", new_callable=AsyncMock, return_value=None)
|
| 137 |
+
@patch("backend.app.api.routes.set_cached", new_callable=AsyncMock)
|
| 138 |
+
@patch("backend.app.api.routes.get_db")
|
| 139 |
+
def test_benign_text_low_threat(
|
| 140 |
+
self, mock_get_db, mock_set_cache, mock_get_cache, mock_harm, mock_upsert,
|
| 141 |
+
mock_cluster, mock_embed, mock_ai, mock_rate, client
|
| 142 |
+
):
|
| 143 |
+
mock_db = MagicMock()
|
| 144 |
+
mock_db.collection.return_value.document.return_value.set.return_value = None
|
| 145 |
+
mock_get_db.return_value = mock_db
|
| 146 |
+
|
| 147 |
+
response = client.post(
|
| 148 |
+
"/api/analyze",
|
| 149 |
+
json={"text": "The weather today is sunny with clear skies and mild temperatures across the region."},
|
| 150 |
+
)
|
| 151 |
+
assert response.status_code == 200
|
| 152 |
+
data = response.json()
|
| 153 |
+
assert data["threat_score"] < 0.3
|
backend/tests/test_auth.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for Firebase token verification (auth.py).
|
| 3 |
+
Mocks firebase_admin.auth so no real Firebase project is needed in CI.
|
| 4 |
+
"""
|
| 5 |
+
import pytest
|
| 6 |
+
from unittest.mock import patch, MagicMock
|
| 7 |
+
from fastapi import HTTPException
|
| 8 |
+
from fastapi.security import HTTPAuthorizationCredentials
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TestFirebaseTokenVerification:
|
| 12 |
+
|
| 13 |
+
@patch("backend.app.core.auth.firebase_auth")
|
| 14 |
+
async def test_valid_token_returns_uid(self, mock_fb_auth):
|
| 15 |
+
"""A valid Firebase ID token should return the uid."""
|
| 16 |
+
from backend.app.core.auth import get_current_user
|
| 17 |
+
|
| 18 |
+
mock_fb_auth.verify_id_token.return_value = {"uid": "user-abc-123"}
|
| 19 |
+
mock_fb_auth.ExpiredIdTokenError = Exception
|
| 20 |
+
mock_fb_auth.InvalidIdTokenError = Exception
|
| 21 |
+
|
| 22 |
+
creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials="fake-valid-token")
|
| 23 |
+
uid = await get_current_user(credentials=creds)
|
| 24 |
+
assert uid == "user-abc-123"
|
| 25 |
+
mock_fb_auth.verify_id_token.assert_called_once_with("fake-valid-token")
|
| 26 |
+
|
| 27 |
+
@patch("backend.app.core.auth.firebase_auth")
|
| 28 |
+
async def test_expired_token_raises_401(self, mock_fb_auth):
|
| 29 |
+
"""An expired Firebase token should raise HTTP 401."""
|
| 30 |
+
from backend.app.core.auth import get_current_user
|
| 31 |
+
|
| 32 |
+
class FakeExpiredError(Exception):
|
| 33 |
+
pass
|
| 34 |
+
|
| 35 |
+
mock_fb_auth.ExpiredIdTokenError = FakeExpiredError
|
| 36 |
+
mock_fb_auth.InvalidIdTokenError = Exception
|
| 37 |
+
mock_fb_auth.verify_id_token.side_effect = FakeExpiredError("expired")
|
| 38 |
+
|
| 39 |
+
creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials="expired-token")
|
| 40 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 41 |
+
await get_current_user(credentials=creds)
|
| 42 |
+
assert exc_info.value.status_code == 401
|
| 43 |
+
assert "expired" in exc_info.value.detail.lower()
|
| 44 |
+
|
| 45 |
+
@patch("backend.app.core.auth.firebase_auth")
|
| 46 |
+
async def test_invalid_token_raises_401(self, mock_fb_auth):
|
| 47 |
+
"""A tampered / invalid Firebase token should raise HTTP 401."""
|
| 48 |
+
from backend.app.core.auth import get_current_user
|
| 49 |
+
|
| 50 |
+
class FakeInvalidError(Exception):
|
| 51 |
+
pass
|
| 52 |
+
|
| 53 |
+
mock_fb_auth.ExpiredIdTokenError = Exception
|
| 54 |
+
mock_fb_auth.InvalidIdTokenError = FakeInvalidError
|
| 55 |
+
mock_fb_auth.verify_id_token.side_effect = FakeInvalidError("invalid")
|
| 56 |
+
|
| 57 |
+
creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials="bad-token")
|
| 58 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 59 |
+
await get_current_user(credentials=creds)
|
| 60 |
+
assert exc_info.value.status_code == 401
|
backend/tests/test_ensemble.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for the ensemble scoring module.
|
| 3 |
+
"""
|
| 4 |
+
import pytest
|
| 5 |
+
from backend.app.services.ensemble import compute_ensemble, calibrate_weights
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class TestComputeEnsemble:
|
| 9 |
+
def test_all_signals_provided(self):
|
| 10 |
+
result = compute_ensemble(
|
| 11 |
+
p_ai=0.8, s_perp=0.6, s_embed_cluster=0.4,
|
| 12 |
+
p_ext=0.3, s_styl=0.5, p_watermark=0.0
|
| 13 |
+
)
|
| 14 |
+
assert "threat_score" in result
|
| 15 |
+
assert "explainability" in result
|
| 16 |
+
assert 0.0 <= result["threat_score"] <= 1.0
|
| 17 |
+
assert len(result["explainability"]) == 6
|
| 18 |
+
|
| 19 |
+
def test_partial_signals(self):
|
| 20 |
+
result = compute_ensemble(p_ai=0.9, s_styl=0.3)
|
| 21 |
+
assert 0.0 <= result["threat_score"] <= 1.0
|
| 22 |
+
assert len(result["explainability"]) == 2
|
| 23 |
+
|
| 24 |
+
def test_no_signals(self):
|
| 25 |
+
result = compute_ensemble()
|
| 26 |
+
assert result["threat_score"] == 0.0
|
| 27 |
+
assert result["explainability"] == []
|
| 28 |
+
|
| 29 |
+
def test_watermark_reduces_score(self):
|
| 30 |
+
without_wm = compute_ensemble(p_ai=0.8, p_ext=0.5)
|
| 31 |
+
with_wm = compute_ensemble(p_ai=0.8, p_ext=0.5, p_watermark=1.0)
|
| 32 |
+
assert with_wm["threat_score"] < without_wm["threat_score"]
|
| 33 |
+
|
| 34 |
+
def test_high_threat_signals(self):
|
| 35 |
+
result = compute_ensemble(
|
| 36 |
+
p_ai=1.0, s_perp=1.0, s_embed_cluster=1.0,
|
| 37 |
+
p_ext=1.0, s_styl=1.0, p_watermark=0.0
|
| 38 |
+
)
|
| 39 |
+
assert result["threat_score"] > 0.8
|
| 40 |
+
|
| 41 |
+
def test_low_threat_signals(self):
|
| 42 |
+
result = compute_ensemble(
|
| 43 |
+
p_ai=0.0, s_perp=0.0, s_embed_cluster=0.0,
|
| 44 |
+
p_ext=0.0, s_styl=0.0, p_watermark=1.0
|
| 45 |
+
)
|
| 46 |
+
assert result["threat_score"] == 0.0
|
| 47 |
+
|
| 48 |
+
def test_explainability_structure(self):
|
| 49 |
+
result = compute_ensemble(p_ai=0.5, p_ext=0.3)
|
| 50 |
+
for item in result["explainability"]:
|
| 51 |
+
assert "signal" in item
|
| 52 |
+
assert "value" in item
|
| 53 |
+
assert "weight" in item
|
| 54 |
+
assert "contribution" in item
|
| 55 |
+
assert "description" in item
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class TestCalibrateWeights:
|
| 59 |
+
def test_empty_samples(self):
|
| 60 |
+
weights = calibrate_weights([])
|
| 61 |
+
assert "WEIGHT_AI_DETECT" in weights
|
| 62 |
+
|
| 63 |
+
def test_with_samples(self):
|
| 64 |
+
samples = [
|
| 65 |
+
{"p_ai": 0.9, "s_perp": 0.5, "p_ext": 0.1, "s_styl": 0.3},
|
| 66 |
+
{"p_ai": 0.1, "s_perp": 0.8, "p_ext": 0.9, "s_styl": 0.2},
|
| 67 |
+
{"p_ai": 0.5, "s_perp": 0.3, "p_ext": 0.5, "s_styl": 0.7},
|
| 68 |
+
]
|
| 69 |
+
weights = calibrate_weights(samples)
|
| 70 |
+
total = sum(weights.values())
|
| 71 |
+
assert total > 0
|
| 72 |
+
for v in weights.values():
|
| 73 |
+
assert v >= 0
|
backend/tests/test_load.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Lightweight load test script for the API.
|
| 3 |
+
Runs concurrent requests against the /api/analyze endpoint.
|
| 4 |
+
|
| 5 |
+
Usage: python -m backend.tests.test_load [--url URL] [--concurrency N] [--requests N]
|
| 6 |
+
"""
|
| 7 |
+
import argparse
|
| 8 |
+
import asyncio
|
| 9 |
+
import time
|
| 10 |
+
import httpx
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def single_request(client: httpx.AsyncClient, url: str, text: str) -> dict:
|
| 14 |
+
start = time.time()
|
| 15 |
+
try:
|
| 16 |
+
resp = await client.post(
|
| 17 |
+
f"{url}/api/analyze",
|
| 18 |
+
json={"text": text},
|
| 19 |
+
timeout=30.0,
|
| 20 |
+
)
|
| 21 |
+
return {
|
| 22 |
+
"status": resp.status_code,
|
| 23 |
+
"latency_ms": int((time.time() - start) * 1000),
|
| 24 |
+
"success": resp.status_code == 200,
|
| 25 |
+
}
|
| 26 |
+
except Exception as e:
|
| 27 |
+
return {
|
| 28 |
+
"status": 0,
|
| 29 |
+
"latency_ms": int((time.time() - start) * 1000),
|
| 30 |
+
"success": False,
|
| 31 |
+
"error": str(e),
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def run_load_test(url: str, concurrency: int, total_requests: int):
|
| 36 |
+
text = (
|
| 37 |
+
"The Federal Reserve announced today that it will maintain current interest "
|
| 38 |
+
"rates through the end of the quarter, citing stable employment numbers."
|
| 39 |
+
)
|
| 40 |
+
results = []
|
| 41 |
+
semaphore = asyncio.Semaphore(concurrency)
|
| 42 |
+
|
| 43 |
+
async def bounded_request(client):
|
| 44 |
+
async with semaphore:
|
| 45 |
+
return await single_request(client, url, text)
|
| 46 |
+
|
| 47 |
+
start = time.time()
|
| 48 |
+
async with httpx.AsyncClient() as client:
|
| 49 |
+
tasks = [bounded_request(client) for _ in range(total_requests)]
|
| 50 |
+
results = await asyncio.gather(*tasks)
|
| 51 |
+
total_time = time.time() - start
|
| 52 |
+
|
| 53 |
+
successes = sum(1 for r in results if r["success"])
|
| 54 |
+
latencies = [r["latency_ms"] for r in results if r["success"]]
|
| 55 |
+
|
| 56 |
+
print(f"\n{'='*50}")
|
| 57 |
+
print(f"Load Test Results")
|
| 58 |
+
print(f"{'='*50}")
|
| 59 |
+
print(f"Total requests: {total_requests}")
|
| 60 |
+
print(f"Concurrency: {concurrency}")
|
| 61 |
+
print(f"Total time: {total_time:.2f}s")
|
| 62 |
+
print(f"Successes: {successes}/{total_requests}")
|
| 63 |
+
print(f"RPS: {total_requests/total_time:.1f}")
|
| 64 |
+
if latencies:
|
| 65 |
+
latencies.sort()
|
| 66 |
+
print(f"Avg latency: {sum(latencies)/len(latencies):.0f}ms")
|
| 67 |
+
print(f"P50 latency: {latencies[len(latencies)//2]}ms")
|
| 68 |
+
print(f"P95 latency: {latencies[int(len(latencies)*0.95)]}ms")
|
| 69 |
+
print(f"P99 latency: {latencies[int(len(latencies)*0.99)]}ms")
|
| 70 |
+
print(f"{'='*50}\n")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
if __name__ == "__main__":
|
| 74 |
+
parser = argparse.ArgumentParser()
|
| 75 |
+
parser.add_argument("--url", default="http://localhost:8000")
|
| 76 |
+
parser.add_argument("--concurrency", type=int, default=10)
|
| 77 |
+
parser.add_argument("--requests", type=int, default=50)
|
| 78 |
+
args = parser.parse_args()
|
| 79 |
+
asyncio.run(run_load_test(args.url, args.concurrency, args.requests))
|
backend/tests/test_services.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Integration tests for HF and Groq service modules (mocked).
|
| 3 |
+
Tests retry behavior and error handling.
|
| 4 |
+
"""
|
| 5 |
+
import pytest
|
| 6 |
+
from unittest.mock import patch, AsyncMock, MagicMock
|
| 7 |
+
import httpx
|
| 8 |
+
|
| 9 |
+
from backend.app.services.hf_service import detect_ai_text, get_embeddings, detect_harm
|
| 10 |
+
from backend.app.services.groq_service import compute_perplexity
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class TestHFService:
|
| 14 |
+
@pytest.mark.asyncio
|
| 15 |
+
@patch("backend.app.services.hf_service._hf_request", new_callable=AsyncMock)
|
| 16 |
+
async def test_detect_ai_text_success(self, mock_request):
|
| 17 |
+
mock_request.return_value = [[
|
| 18 |
+
{"label": "AI", "score": 0.92},
|
| 19 |
+
{"label": "Human", "score": 0.08},
|
| 20 |
+
]]
|
| 21 |
+
score = await detect_ai_text("Test text for detection")
|
| 22 |
+
assert 0.0 <= score <= 1.0
|
| 23 |
+
|
| 24 |
+
@pytest.mark.asyncio
|
| 25 |
+
@patch("backend.app.services.hf_service._hf_request", new_callable=AsyncMock)
|
| 26 |
+
async def test_detect_ai_text_fallback(self, mock_request):
|
| 27 |
+
"""If primary fails, should try fallback."""
|
| 28 |
+
mock_request.side_effect = [
|
| 29 |
+
Exception("Primary failed"),
|
| 30 |
+
[[{"label": "FAKE", "score": 0.75}]],
|
| 31 |
+
]
|
| 32 |
+
score = await detect_ai_text("Test text")
|
| 33 |
+
assert 0.0 <= score <= 1.0
|
| 34 |
+
|
| 35 |
+
@pytest.mark.asyncio
|
| 36 |
+
@patch("backend.app.services.hf_service._hf_request", new_callable=AsyncMock)
|
| 37 |
+
async def test_get_embeddings_success(self, mock_request):
|
| 38 |
+
mock_request.return_value = [0.1] * 768
|
| 39 |
+
result = await get_embeddings("Test text")
|
| 40 |
+
assert len(result) == 768
|
| 41 |
+
|
| 42 |
+
@pytest.mark.asyncio
|
| 43 |
+
@patch("backend.app.services.hf_service._hf_request", new_callable=AsyncMock)
|
| 44 |
+
async def test_detect_harm_success(self, mock_request):
|
| 45 |
+
mock_request.return_value = [[
|
| 46 |
+
{"label": "hate", "score": 0.15},
|
| 47 |
+
{"label": "not_hate", "score": 0.85},
|
| 48 |
+
]]
|
| 49 |
+
score = await detect_harm("Test text")
|
| 50 |
+
assert 0.0 <= score <= 1.0
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class TestGroqService:
|
| 54 |
+
@pytest.mark.asyncio
|
| 55 |
+
@patch("backend.app.services.groq_service._groq_chat_completion", new_callable=AsyncMock)
|
| 56 |
+
async def test_compute_perplexity_with_logprobs(self, mock_groq):
|
| 57 |
+
mock_groq.return_value = {
|
| 58 |
+
"choices": [{
|
| 59 |
+
"logprobs": {
|
| 60 |
+
"content": [
|
| 61 |
+
{"logprob": -2.5},
|
| 62 |
+
{"logprob": -1.8},
|
| 63 |
+
{"logprob": -3.2},
|
| 64 |
+
]
|
| 65 |
+
}
|
| 66 |
+
}]
|
| 67 |
+
}
|
| 68 |
+
score = await compute_perplexity("Test text for perplexity")
|
| 69 |
+
assert score is not None
|
| 70 |
+
assert 0.0 <= score <= 1.0
|
| 71 |
+
|
| 72 |
+
@pytest.mark.asyncio
|
| 73 |
+
@patch("backend.app.services.groq_service._groq_chat_completion", new_callable=AsyncMock)
|
| 74 |
+
async def test_compute_perplexity_no_logprobs(self, mock_groq):
|
| 75 |
+
mock_groq.return_value = {
|
| 76 |
+
"choices": [{}],
|
| 77 |
+
"usage": {"prompt_tokens": 15},
|
| 78 |
+
}
|
| 79 |
+
score = await compute_perplexity("Test text without logprobs available")
|
| 80 |
+
# May return None or a heuristic value
|
| 81 |
+
assert score is None or 0.0 <= score <= 1.0
|
| 82 |
+
|
| 83 |
+
@pytest.mark.asyncio
|
| 84 |
+
@patch("backend.app.services.groq_service._groq_chat_completion", new_callable=AsyncMock)
|
| 85 |
+
async def test_compute_perplexity_error(self, mock_groq):
|
| 86 |
+
mock_groq.side_effect = Exception("Groq API error")
|
| 87 |
+
score = await compute_perplexity("Test text")
|
| 88 |
+
assert score is None
|
backend/tests/test_stylometry.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for the stylometry analysis service.
|
| 3 |
+
"""
|
| 4 |
+
import pytest
|
| 5 |
+
from backend.app.services.stylometry import (
|
| 6 |
+
compute_stylometry_score,
|
| 7 |
+
extract_features,
|
| 8 |
+
_function_word_freq,
|
| 9 |
+
_punctuation_pattern,
|
| 10 |
+
_readability_score,
|
| 11 |
+
_sentence_length_variance,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TestStylometry:
|
| 16 |
+
def test_compute_score_normal_text(self):
|
| 17 |
+
text = (
|
| 18 |
+
"The Federal Reserve announced today that it will maintain current "
|
| 19 |
+
"interest rates through the end of the quarter, citing stable employment "
|
| 20 |
+
"numbers and moderate inflation indicators."
|
| 21 |
+
)
|
| 22 |
+
score = compute_stylometry_score(text)
|
| 23 |
+
assert 0.0 <= score <= 1.0
|
| 24 |
+
|
| 25 |
+
def test_compute_score_empty(self):
|
| 26 |
+
assert compute_stylometry_score("") == 0.0
|
| 27 |
+
|
| 28 |
+
def test_compute_score_short_text(self):
|
| 29 |
+
assert compute_stylometry_score("Hello") == 0.0
|
| 30 |
+
|
| 31 |
+
def test_function_word_frequency(self):
|
| 32 |
+
text = "the dog and the cat are on the mat"
|
| 33 |
+
ratio = _function_word_freq(text)
|
| 34 |
+
assert 0.0 <= ratio <= 1.0
|
| 35 |
+
assert ratio > 0.3 # High function word density
|
| 36 |
+
|
| 37 |
+
def test_punctuation_pattern(self):
|
| 38 |
+
text = "Hello, world! How are you? I'm fine."
|
| 39 |
+
result = _punctuation_pattern(text)
|
| 40 |
+
assert "density" in result
|
| 41 |
+
assert "diversity" in result
|
| 42 |
+
assert result["density"] > 0
|
| 43 |
+
|
| 44 |
+
def test_readability(self):
|
| 45 |
+
text = "This is a simple test sentence. It has easy words."
|
| 46 |
+
score = _readability_score(text)
|
| 47 |
+
assert score >= 0
|
| 48 |
+
|
| 49 |
+
def test_sentence_length_variance(self):
|
| 50 |
+
text = "Short. This is a longer sentence with more words. Tiny."
|
| 51 |
+
var = _sentence_length_variance(text)
|
| 52 |
+
assert var >= 0
|
| 53 |
+
|
| 54 |
+
def test_extract_features(self):
|
| 55 |
+
text = "This is a test of the feature extraction system for analysis."
|
| 56 |
+
features = extract_features(text)
|
| 57 |
+
assert "function_word_ratio" in features
|
| 58 |
+
assert "punctuation" in features
|
| 59 |
+
assert "readability_ari" in features
|
| 60 |
+
assert "stylometry_score" in features
|
| 61 |
+
|
| 62 |
+
def test_different_text_styles(self):
|
| 63 |
+
formal = (
|
| 64 |
+
"The comprehensive analysis of macroeconomic indicators suggests that "
|
| 65 |
+
"the prevailing monetary policy framework requires substantial revision "
|
| 66 |
+
"in light of unprecedented fiscal challenges."
|
| 67 |
+
)
|
| 68 |
+
casual = (
|
| 69 |
+
"yo so I was thinking maybe we should grab some food later? "
|
| 70 |
+
"idk what u want but pizza sounds good to me lol"
|
| 71 |
+
)
|
| 72 |
+
score_formal = compute_stylometry_score(formal)
|
| 73 |
+
score_casual = compute_stylometry_score(casual)
|
| 74 |
+
# Both should produce valid scores
|
| 75 |
+
assert 0.0 <= score_formal <= 1.0
|
| 76 |
+
assert 0.0 <= score_casual <= 1.0
|