GitHub Actions commited on
Commit
2f073d3
·
1 Parent(s): 7f2ed0d

Deploy backend from GitHub 43a4c2cb381254b3c2fd54acd891b54847bb81d1

Browse files
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 LLM Misuse Detector
3
- emoji: 🛡️
4
- colorFrom: red
5
- colorTo: blue
6
- sdk: static
7
  pinned: false
 
8
  ---
9
 
10
- # Sentinel - LLM Misuse Detection System
11
 
12
- Frontend for the Sentinel system for detecting misuse of Large Language Models.
 
 
 
 
 
 
 
 
 
 
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