GitHub Actions commited on
Commit
5ec0ee0
·
1 Parent(s): 6230766

Deploy backend from GitHub c356b3426d43adb1887f0fec5ae77e353c2e1271

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +0 -35
  2. README.md +9 -7
  3. backend/app/core/config.py +7 -12
  4. backend/app/main.py +18 -2
  5. backend/backend/app/__init__.py +0 -0
  6. backend/backend/app/api/__init__.py +0 -0
  7. backend/backend/app/api/auth_routes.py +0 -40
  8. backend/backend/app/api/models.py +0 -45
  9. backend/backend/app/api/routes.py +0 -207
  10. backend/backend/app/core/__init__.py +0 -0
  11. backend/backend/app/core/auth.py +0 -40
  12. backend/backend/app/core/config.py +0 -71
  13. backend/backend/app/core/logging.py +0 -46
  14. backend/backend/app/core/redis.py +0 -51
  15. backend/backend/app/db/__init__.py +0 -0
  16. backend/backend/app/db/firestore.py +0 -54
  17. backend/backend/app/db/session.py +0 -21
  18. backend/backend/app/main.py +0 -115
  19. backend/backend/app/models/__init__.py +0 -0
  20. backend/backend/app/models/schemas.py +0 -56
  21. backend/backend/app/services/__init__.py +0 -0
  22. backend/backend/app/services/ensemble.py +0 -115
  23. backend/backend/app/services/groq_service.py +0 -106
  24. backend/backend/app/services/hf_service.py +0 -102
  25. backend/backend/app/services/stylometry.py +0 -137
  26. backend/backend/app/services/vector_db.py +0 -86
  27. backend/backend/app/workers/__init__.py +0 -0
  28. backend/backend/app/workers/analysis_worker.py +0 -87
  29. backend/backend/migrations/env.py +0 -54
  30. backend/backend/migrations/versions/001_initial.py +0 -49
  31. backend/backend/requirements.txt +0 -34
  32. backend/backend/scripts/seed.py +0 -60
  33. backend/backend/tests/__init__.py +0 -0
  34. backend/backend/tests/test_api.py +0 -153
  35. backend/backend/tests/test_auth.py +0 -60
  36. backend/backend/tests/test_ensemble.py +0 -73
  37. backend/backend/tests/test_load.py +0 -79
  38. backend/backend/tests/test_services.py +0 -88
  39. backend/backend/tests/test_stylometry.py +0 -76
  40. static/Z7VY5ia_Y3He0t6ivhfkJ/_buildManifest.js +0 -11
  41. static/Z7VY5ia_Y3He0t6ivhfkJ/_clientMiddlewareManifest.json +0 -1
  42. static/Z7VY5ia_Y3He0t6ivhfkJ/_ssgManifest.js +0 -1
  43. static/_e0awxk9PvIowFECHYZlA/_buildManifest.js +0 -11
  44. static/_e0awxk9PvIowFECHYZlA/_clientMiddlewareManifest.json +0 -1
  45. static/_e0awxk9PvIowFECHYZlA/_ssgManifest.js +0 -1
  46. static/chunks/0f732eea04945f34.css +0 -3
  47. static/chunks/2f236954d6a65e12.js +0 -1
  48. static/chunks/4b9eae0c8dc7e975.js +0 -1
  49. static/chunks/57f15be2f5ca279f.css +0 -3
  50. static/chunks/a6dad97d9634a72d.js +0 -0
.gitattributes DELETED
@@ -1,35 +0,0 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -13,10 +13,12 @@ app_port: 7860
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
 
 
 
13
  FastAPI backend for the Sentinel LLM Misuse Detection System.
14
  Exposes a REST API on port 7860.
15
 
16
+ ## Secrets required (Space Settings → Repository secrets)
17
+ - `FIREBASE_PROJECT_ID`
18
+ - `FIREBASE_CREDENTIALS_JSON`
19
+ - `REDIS_URL`
20
+ - `HF_API_KEY`
21
+ - `GROQ_API_KEY`
22
+ - `CORS_ORIGINS` (your Vercel URL)
23
+ - `QDRANT_URL` + `QDRANT_API_KEY` (optional)
24
+ - `SENTRY_DSN` (optional)
backend/app/core/config.py CHANGED
@@ -7,8 +7,7 @@ Env vars: FIREBASE_PROJECT_ID, FIREBASE_CREDENTIALS_JSON,
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):
@@ -17,20 +16,16 @@ class Settings(BaseSettings):
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"
@@ -43,7 +38,7 @@ class Settings(BaseSettings):
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"
@@ -52,7 +47,7 @@ class Settings(BaseSettings):
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
 
7
  SENTRY_DSN, CORS_ORIGINS
8
  """
9
  from pydantic_settings import BaseSettings
10
+ from typing import Optional, List
 
11
 
12
 
13
  class Settings(BaseSettings):
 
16
  DEBUG: bool = False
17
 
18
  # Firebase
 
 
 
 
19
  FIREBASE_PROJECT_ID: str = ""
20
+ FIREBASE_CREDENTIALS_JSON: Optional[str] = None
21
 
22
  # Redis
23
  REDIS_URL: str = "redis://localhost:6379/0"
24
 
25
+ # CORS – defaults include both production frontend and local dev
26
+ CORS_ORIGINS: str = "https://security-three-mu.vercel.app,http://localhost:3000"
27
 
28
+ # HuggingFace Inference API
29
  HF_API_KEY: str = ""
30
  HF_DETECTOR_PRIMARY: str = "https://api-inference.huggingface.co/models/desklib/ai-text-detector-v1.01"
31
  HF_DETECTOR_FALLBACK: str = "https://api-inference.huggingface.co/models/fakespot-ai/roberta-base-ai-text-detection-v1"
 
38
  GROQ_MODEL: str = "llama-3.3-70b-versatile"
39
  GROQ_BASE_URL: str = "https://api.groq.com/openai/v1"
40
 
41
+ # Vector DB (Qdrant Cloud)
42
  QDRANT_URL: str = "http://localhost:6333"
43
  QDRANT_API_KEY: Optional[str] = None
44
  QDRANT_COLLECTION: str = "sentinel_embeddings"
 
47
  SENTRY_DSN: str = ""
48
  LOG_LEVEL: str = "INFO"
49
 
50
+ # Ensemble weights
51
  WEIGHT_AI_DETECT: float = 0.30
52
  WEIGHT_PERPLEXITY: float = 0.20
53
  WEIGHT_EMBEDDING: float = 0.15
backend/app/main.py CHANGED
@@ -6,12 +6,13 @@ Auth: Firebase Auth (frontend issues ID tokens; backend verifies via firebase-ad
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
@@ -41,7 +42,16 @@ async def lifespan(app: FastAPI):
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
 
@@ -89,6 +99,12 @@ async def metrics_middleware(request: Request, call_next):
89
  app.include_router(analysis_router)
90
 
91
 
 
 
 
 
 
 
92
  @app.get("/health", response_model=HealthResponse)
93
  async def health():
94
  return HealthResponse()
 
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 7860
10
  """
11
  from contextlib import asynccontextmanager
12
 
13
  from fastapi import FastAPI, Request, Response
14
  from fastapi.middleware.cors import CORSMiddleware
15
+ from fastapi.responses import RedirectResponse
16
  from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
17
 
18
  from backend.app.core.config import settings
 
42
  init_firebase()
43
  logger.info("Firebase + Firestore initialised")
44
  except Exception as e:
45
+ err = str(e)
46
+ if "Expecting value" in err or "line 1 column 1" in err:
47
+ logger.warning(
48
+ "Firebase init failed – FIREBASE_CREDENTIALS_JSON is set but contains invalid JSON. "
49
+ "Make sure you pasted the full service account JSON contents (not the filename). "
50
+ "Auth and DB will be unavailable.",
51
+ error=err,
52
+ )
53
+ else:
54
+ logger.warning("Firebase init failed – auth and DB will be unavailable", error=err)
55
  yield
56
  logger.info("Shutting down")
57
 
 
99
  app.include_router(analysis_router)
100
 
101
 
102
+ @app.get("/", include_in_schema=False)
103
+ async def root():
104
+ """Root redirect – keeps HF Space health checker happy."""
105
+ return RedirectResponse(url="/health")
106
+
107
+
108
  @app.get("/health", response_model=HealthResponse)
109
  async def health():
110
  return HealthResponse()
backend/backend/app/__init__.py DELETED
File without changes
backend/backend/app/api/__init__.py DELETED
File without changes
backend/backend/app/api/auth_routes.py DELETED
@@ -1,40 +0,0 @@
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/backend/app/api/models.py DELETED
@@ -1,45 +0,0 @@
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/backend/app/api/routes.py DELETED
@@ -1,207 +0,0 @@
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/backend/app/core/__init__.py DELETED
File without changes
backend/backend/app/core/auth.py DELETED
@@ -1,40 +0,0 @@
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/backend/app/core/config.py DELETED
@@ -1,71 +0,0 @@
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, List
11
-
12
-
13
- class Settings(BaseSettings):
14
- # Application
15
- APP_NAME: str = "LLM Misuse Detector"
16
- DEBUG: bool = False
17
-
18
- # Firebase
19
- FIREBASE_PROJECT_ID: str = ""
20
- FIREBASE_CREDENTIALS_JSON: Optional[str] = None
21
-
22
- # Redis
23
- REDIS_URL: str = "redis://localhost:6379/0"
24
-
25
- # CORS – defaults include both production frontend and local dev
26
- CORS_ORIGINS: str = "https://security-three-mu.vercel.app,http://localhost:3000"
27
-
28
- # HuggingFace Inference API
29
- HF_API_KEY: str = ""
30
- HF_DETECTOR_PRIMARY: str = "https://api-inference.huggingface.co/models/desklib/ai-text-detector-v1.01"
31
- HF_DETECTOR_FALLBACK: str = "https://api-inference.huggingface.co/models/fakespot-ai/roberta-base-ai-text-detection-v1"
32
- HF_EMBEDDINGS_PRIMARY: str = "https://api-inference.huggingface.co/models/sentence-transformers/all-mpnet-base-v2"
33
- HF_EMBEDDINGS_FALLBACK: str = "https://api-inference.huggingface.co/models/sentence-transformers/all-MiniLM-L6-v2"
34
- HF_HARM_CLASSIFIER: str = "https://api-inference.huggingface.co/models/facebook/roberta-hate-speech-dynabench-r4-target"
35
-
36
- # Groq
37
- GROQ_API_KEY: str = ""
38
- GROQ_MODEL: str = "llama-3.3-70b-versatile"
39
- GROQ_BASE_URL: str = "https://api.groq.com/openai/v1"
40
-
41
- # Vector DB (Qdrant Cloud)
42
- QDRANT_URL: str = "http://localhost:6333"
43
- QDRANT_API_KEY: Optional[str] = None
44
- QDRANT_COLLECTION: str = "sentinel_embeddings"
45
-
46
- # Observability
47
- SENTRY_DSN: str = ""
48
- LOG_LEVEL: str = "INFO"
49
-
50
- # Ensemble weights
51
- WEIGHT_AI_DETECT: float = 0.30
52
- WEIGHT_PERPLEXITY: float = 0.20
53
- WEIGHT_EMBEDDING: float = 0.15
54
- WEIGHT_EXTREMISM: float = 0.20
55
- WEIGHT_STYLOMETRY: float = 0.10
56
- WEIGHT_WATERMARK: float = 0.05
57
-
58
- # Cost control
59
- PERPLEXITY_THRESHOLD: float = 0.3
60
-
61
- # Rate limiting
62
- RATE_LIMIT_PER_MINUTE: int = 30
63
-
64
- @property
65
- def cors_origins_list(self) -> List[str]:
66
- return [o.strip() for o in self.CORS_ORIGINS.split(",")]
67
-
68
- model_config = {"env_file": ".env", "extra": "ignore"}
69
-
70
-
71
- settings = Settings()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/backend/app/core/logging.py DELETED
@@ -1,46 +0,0 @@
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/backend/app/core/redis.py DELETED
@@ -1,51 +0,0 @@
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/backend/app/db/__init__.py DELETED
File without changes
backend/backend/app/db/firestore.py DELETED
@@ -1,54 +0,0 @@
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/backend/app/db/session.py DELETED
@@ -1,21 +0,0 @@
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/backend/app/main.py DELETED
@@ -1,115 +0,0 @@
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 7860
10
- """
11
- from contextlib import asynccontextmanager
12
-
13
- from fastapi import FastAPI, Request, Response
14
- from fastapi.middleware.cors import CORSMiddleware
15
- from fastapi.responses import RedirectResponse
16
- from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
17
-
18
- from backend.app.core.config import settings
19
- from backend.app.core.logging import setup_logging, get_logger
20
- from backend.app.api.routes import router as analysis_router
21
- from backend.app.api.models import HealthResponse
22
- from backend.app.db.firestore import init_firebase
23
-
24
- # Sentry (optional)
25
- if settings.SENTRY_DSN:
26
- import sentry_sdk
27
- from sentry_sdk.integrations.fastapi import FastApiIntegration
28
- sentry_sdk.init(dsn=settings.SENTRY_DSN, integrations=[FastApiIntegration()])
29
-
30
- setup_logging(settings.LOG_LEVEL)
31
- logger = get_logger(__name__)
32
-
33
- # Prometheus metrics
34
- REQUEST_COUNT = Counter("http_requests_total", "Total HTTP requests", ["method", "endpoint", "status"])
35
- REQUEST_LATENCY = Histogram("http_request_duration_seconds", "Request latency", ["method", "endpoint"])
36
-
37
-
38
- @asynccontextmanager
39
- async def lifespan(app: FastAPI):
40
- logger.info("Starting LLM Misuse Detection API")
41
- try:
42
- init_firebase()
43
- logger.info("Firebase + Firestore initialised")
44
- except Exception as e:
45
- err = str(e)
46
- if "Expecting value" in err or "line 1 column 1" in err:
47
- logger.warning(
48
- "Firebase init failed – FIREBASE_CREDENTIALS_JSON is set but contains invalid JSON. "
49
- "Make sure you pasted the full service account JSON contents (not the filename). "
50
- "Auth and DB will be unavailable.",
51
- error=err,
52
- )
53
- else:
54
- logger.warning("Firebase init failed – auth and DB will be unavailable", error=err)
55
- yield
56
- logger.info("Shutting down")
57
-
58
-
59
- app = FastAPI(
60
- title=settings.APP_NAME,
61
- version="1.0.0",
62
- description="Production system for detecting and mitigating LLM misuse in information operations",
63
- lifespan=lifespan,
64
- )
65
-
66
- # CORS
67
- app.add_middleware(
68
- CORSMiddleware,
69
- allow_origins=settings.cors_origins_list,
70
- allow_credentials=True,
71
- allow_methods=["*"],
72
- allow_headers=["*"],
73
- )
74
-
75
-
76
- @app.middleware("http")
77
- async def add_security_headers(request: Request, call_next):
78
- response: Response = await call_next(request)
79
- response.headers["X-Content-Type-Options"] = "nosniff"
80
- response.headers["X-Frame-Options"] = "DENY"
81
- response.headers["X-XSS-Protection"] = "1; mode=block"
82
- response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
83
- response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
84
- response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
85
- return response
86
-
87
-
88
- @app.middleware("http")
89
- async def metrics_middleware(request: Request, call_next):
90
- import time
91
- start = time.time()
92
- response = await call_next(request)
93
- duration = time.time() - start
94
- REQUEST_COUNT.labels(request.method, request.url.path, response.status_code).inc()
95
- REQUEST_LATENCY.labels(request.method, request.url.path).observe(duration)
96
- return response
97
-
98
-
99
- app.include_router(analysis_router)
100
-
101
-
102
- @app.get("/", include_in_schema=False)
103
- async def root():
104
- """Root redirect – keeps HF Space health checker happy."""
105
- return RedirectResponse(url="/health")
106
-
107
-
108
- @app.get("/health", response_model=HealthResponse)
109
- async def health():
110
- return HealthResponse()
111
-
112
-
113
- @app.get("/metrics")
114
- async def metrics():
115
- return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/backend/app/models/__init__.py DELETED
File without changes
backend/backend/app/models/schemas.py DELETED
@@ -1,56 +0,0 @@
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/backend/app/services/__init__.py DELETED
File without changes
backend/backend/app/services/ensemble.py DELETED
@@ -1,115 +0,0 @@
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/backend/app/services/groq_service.py DELETED
@@ -1,106 +0,0 @@
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/backend/app/services/hf_service.py DELETED
@@ -1,102 +0,0 @@
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/backend/app/services/stylometry.py DELETED
@@ -1,137 +0,0 @@
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/backend/app/services/vector_db.py DELETED
@@ -1,86 +0,0 @@
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/backend/app/workers/__init__.py DELETED
File without changes
backend/backend/app/workers/analysis_worker.py DELETED
@@ -1,87 +0,0 @@
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/backend/migrations/env.py DELETED
@@ -1,54 +0,0 @@
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/backend/migrations/versions/001_initial.py DELETED
@@ -1,49 +0,0 @@
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/backend/requirements.txt DELETED
@@ -1,34 +0,0 @@
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/backend/scripts/seed.py DELETED
@@ -1,60 +0,0 @@
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/backend/tests/__init__.py DELETED
File without changes
backend/backend/tests/test_api.py DELETED
@@ -1,153 +0,0 @@
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/backend/tests/test_auth.py DELETED
@@ -1,60 +0,0 @@
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/backend/tests/test_ensemble.py DELETED
@@ -1,73 +0,0 @@
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/backend/tests/test_load.py DELETED
@@ -1,79 +0,0 @@
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/backend/tests/test_services.py DELETED
@@ -1,88 +0,0 @@
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/backend/tests/test_stylometry.py DELETED
@@ -1,76 +0,0 @@
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/Z7VY5ia_Y3He0t6ivhfkJ/_buildManifest.js DELETED
@@ -1,11 +0,0 @@
1
- self.__BUILD_MANIFEST = {
2
- "__rewrites": {
3
- "afterFiles": [],
4
- "beforeFiles": [],
5
- "fallback": []
6
- },
7
- "sortedPages": [
8
- "/_app",
9
- "/_error"
10
- ]
11
- };self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
 
 
 
 
 
 
 
 
 
 
 
 
static/Z7VY5ia_Y3He0t6ivhfkJ/_clientMiddlewareManifest.json DELETED
@@ -1 +0,0 @@
1
- []
 
 
static/Z7VY5ia_Y3He0t6ivhfkJ/_ssgManifest.js DELETED
@@ -1 +0,0 @@
1
- self.__SSG_MANIFEST=new Set([]);self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
 
 
static/_e0awxk9PvIowFECHYZlA/_buildManifest.js DELETED
@@ -1,11 +0,0 @@
1
- self.__BUILD_MANIFEST = {
2
- "__rewrites": {
3
- "afterFiles": [],
4
- "beforeFiles": [],
5
- "fallback": []
6
- },
7
- "sortedPages": [
8
- "/_app",
9
- "/_error"
10
- ]
11
- };self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
 
 
 
 
 
 
 
 
 
 
 
 
static/_e0awxk9PvIowFECHYZlA/_clientMiddlewareManifest.json DELETED
@@ -1 +0,0 @@
1
- []
 
 
static/_e0awxk9PvIowFECHYZlA/_ssgManifest.js DELETED
@@ -1 +0,0 @@
1
- self.__SSG_MANIFEST=new Set([]);self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
 
 
static/chunks/0f732eea04945f34.css DELETED
@@ -1,3 +0,0 @@
1
- @font-face{font-family:Geist;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/8a480f0b521d4e75-s.8e0177b5.woff2)format("woff2");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/7178b3e590c64307-s.b97b3418.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/caa3a2e1cccd8315-s.p.853070df.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Geist Fallback;src:local(Arial);ascent-override:95.94%;descent-override:28.16%;line-gap-override:0.0%;size-adjust:104.76%}.geist_a71539c9-module__T19VSG__className{font-family:Geist,Geist Fallback;font-style:normal}.geist_a71539c9-module__T19VSG__variable{--font-geist-sans:"Geist","Geist Fallback"}
2
- @font-face{font-family:Geist Mono;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/4fa387ec64143e14-s.c1fdd6c2.woff2)format("woff2");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist Mono;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/bbc41e54d2fcbd21-s.799d8ef8.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist Mono;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/797e433ab948586e-s.p.dbea232f.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Geist Mono Fallback;src:local(Arial);ascent-override:74.67%;descent-override:21.92%;line-gap-override:0.0%;size-adjust:134.59%}.geist_mono_8d43a2aa-module__8Li5zG__className{font-family:Geist Mono,Geist Mono Fallback;font-style:normal}.geist_mono_8d43a2aa-module__8Li5zG__variable{--font-geist-mono:"Geist Mono","Geist Mono Fallback"}
3
- @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:var(--font-geist-sans);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-xl:36rem;--container-2xl:42rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--text-6xl:3.75rem;--text-6xl--line-height:1;--text-8xl:6rem;--text-8xl--line-height:1;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--shadow-sm:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;--shadow-md:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--shadow-lg:0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a;--ease-out:cubic-bezier(0,0,.2,1);--animate-spin:spin 1s linear infinite;--blur-sm:8px;--blur-3xl:64px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-geist-sans);--default-mono-font-family:var(--font-geist-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.start{inset-inline-start:var(--spacing)}.top-4{top:calc(var(--spacing)*4)}.right-4{right:calc(var(--spacing)*4)}.z-10{z-index:10}.z-50{z-index:50}.mx-auto{margin-inline:auto}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-16{margin-top:calc(var(--spacing)*16)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.mb-12{margin-bottom:calc(var(--spacing)*12)}.block{display:block}.flex{display:flex}.grid{display:grid}.inline-block{display:inline-block}.h-3{height:calc(var(--spacing)*3)}.h-5{height:calc(var(--spacing)*5)}.h-40{height:calc(var(--spacing)*40)}.h-80{height:calc(var(--spacing)*80)}.h-96{height:calc(var(--spacing)*96)}.min-h-screen{min-height:100vh}.w-5{width:calc(var(--spacing)*5)}.w-80{width:calc(var(--spacing)*80)}.w-96{width:calc(var(--spacing)*96)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.translate-y-0{--tw-translate-y:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-8{--tw-translate-y:calc(var(--spacing)*8);translate:var(--tw-translate-x)var(--tw-translate-y)}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-spin{animation:var(--animate-spin)}.resize-none{resize:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing)*2)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-white\/20{border-color:#fff3}@supports (color:color-mix(in lab, red, red)){.border-white\/20{border-color:color-mix(in oklab,var(--color-white)20%,transparent)}}.bg-white\/15{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.bg-white\/15{background-color:color-mix(in oklab,var(--color-white)15%,transparent)}}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-8{padding-inline:calc(var(--spacing)*8)}.px-12{padding-inline:calc(var(--spacing)*12)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-5{padding-block:calc(var(--spacing)*5)}.py-8{padding-block:calc(var(--spacing)*8)}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:var(--font-geist-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.text-white{color:var(--color-white)}.text-white\/70{color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.text-white\/70{color:color-mix(in oklab,var(--color-white)70%,transparent)}}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab, red, red)){.text-white\/80{color:color-mix(in oklab,var(--color-white)80%,transparent)}}.text-white\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-white\/90{color:color-mix(in oklab,var(--color-white)90%,transparent)}}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.blur-3xl{--tw-blur:blur(var(--blur-3xl));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.delay-300{transition-delay:.3s}.duration-700{--tw-duration:.7s;transition-duration:.7s}.duration-1000{--tw-duration:1s;transition-duration:1s}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-4:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(4px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-white\/50:focus{--tw-ring-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.focus\:ring-white\/50:focus{--tw-ring-color:color-mix(in oklab,var(--color-white)50%,transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width:48rem){.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.md\:text-8xl{font-size:var(--text-8xl);line-height:var(--tw-leading,var(--text-8xl--line-height))}}}:root{--sentinel-coral:#ff6b6b;--sentinel-tangerine:#ff8e53;--sentinel-citrus:#ffc837;--sentinel-teal:#00bfa6;--sentinel-indigo:#5c6bc0;--sentinel-magenta:#e040fb;--background:#fafafa;--foreground:#1a1a2e;--card-bg:#fff;--card-border:#e8e8e8;--muted:#6b7280;--shadow-sm:0 1px 3px #00000014;--shadow-md:0 4px 12px #0000001a;--shadow-lg:0 8px 30px #0000001f}[data-theme=dark]{--background:#0f0f1a;--foreground:#f0f0f0;--card-bg:#1a1a2e;--card-border:#2a2a3e;--muted:#9ca3af;--shadow-sm:0 1px 3px #0000004d;--shadow-md:0 4px 12px #0006;--shadow-lg:0 8px 30px #00000080}body{background:var(--background);color:var(--foreground);font-family:var(--font-sans,Arial,Helvetica,sans-serif);transition:background .3s,color .3s}@keyframes fadeInUp{0%{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}}.animate-fade-in-up{animation:.6s ease-out forwards fadeInUp}.gradient-text{background:linear-gradient(135deg,var(--sentinel-coral),var(--sentinel-tangerine),var(--sentinel-citrus));-webkit-text-fill-color:transparent;-webkit-background-clip:text;background-clip:text}.cta-button{background:linear-gradient(135deg,var(--sentinel-coral),var(--sentinel-tangerine));transition:transform .2s,box-shadow .2s}.cta-button:hover{transform:translateY(-2px)scale(1.02);box-shadow:0 8px 25px #ff6b6b59}.cta-button:active{transform:translateY(0)scale(.98)}.score-bar{transition:width .8s ease-out}.card-hover{transition:transform .2s,box-shadow .2s}.card-hover:hover{box-shadow:var(--shadow-lg);transform:translateY(-2px)}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}
 
 
 
 
static/chunks/2f236954d6a65e12.js DELETED
@@ -1 +0,0 @@
1
- (globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,33525,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"warnOnce",{enumerable:!0,get:function(){return n}});let n=e=>{}},91915,(e,t,r)=>{"use strict";function n(e,t={}){if(t.onlyHashChange)return void e();let r=document.documentElement;if("smooth"!==r.dataset.scrollBehavior)return void e();let a=r.style.scrollBehavior;r.style.scrollBehavior="auto",t.dontForceLayout||r.getClientRects(),e(),r.style.scrollBehavior=a}Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"disableSmoothScrollDuringRouteTransition",{enumerable:!0,get:function(){return n}}),e.r(33525)},68017,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"HTTPAccessFallbackBoundary",{enumerable:!0,get:function(){return l}});let n=e.r(90809),a=e.r(43476),o=n._(e.r(71645)),i=e.r(90373),s=e.r(54394);e.r(33525);let c=e.r(8372);class u extends o.default.Component{constructor(e){super(e),this.state={triggeredStatus:void 0,previousPathname:e.pathname}}componentDidCatch(){}static getDerivedStateFromError(e){if((0,s.isHTTPAccessFallbackError)(e))return{triggeredStatus:(0,s.getAccessFallbackHTTPStatus)(e)};throw e}static getDerivedStateFromProps(e,t){return e.pathname!==t.previousPathname&&t.triggeredStatus?{triggeredStatus:void 0,previousPathname:e.pathname}:{triggeredStatus:t.triggeredStatus,previousPathname:e.pathname}}render(){let{notFound:e,forbidden:t,unauthorized:r,children:n}=this.props,{triggeredStatus:o}=this.state,i={[s.HTTPAccessErrorStatus.NOT_FOUND]:e,[s.HTTPAccessErrorStatus.FORBIDDEN]:t,[s.HTTPAccessErrorStatus.UNAUTHORIZED]:r};if(o){let c=o===s.HTTPAccessErrorStatus.NOT_FOUND&&e,u=o===s.HTTPAccessErrorStatus.FORBIDDEN&&t,l=o===s.HTTPAccessErrorStatus.UNAUTHORIZED&&r;return c||u||l?(0,a.jsxs)(a.Fragment,{children:[(0,a.jsx)("meta",{name:"robots",content:"noindex"}),!1,i[o]]}):n}return n}}function l({notFound:e,forbidden:t,unauthorized:r,children:n}){let s=(0,i.useUntrackedPathname)(),l=(0,o.useContext)(c.MissingSlotContext);return e||t||r?(0,a.jsx)(u,{pathname:s,notFound:e,forbidden:t,unauthorized:r,missingSlots:l,children:n}):(0,a.jsx)(a.Fragment,{children:n})}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},91798,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"useRouterBFCache",{enumerable:!0,get:function(){return a}});let n=e.r(71645);function a(e,t){let[r,a]=(0,n.useState)(()=>({tree:e,stateKey:t,next:null}));if(r.tree===e)return r;let o={tree:e,stateKey:t,next:null},i=1,s=r,c=o;for(;null!==s&&i<1;){if(s.stateKey===t){c.next=s.next;break}{i++;let e={tree:s.tree,stateKey:s.stateKey,next:null};c.next=e,c=e}s=s.next}return a(o),o}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},39756,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"default",{enumerable:!0,get:function(){return w}});let n=e.r(55682),a=e.r(90809),o=e.r(43476),i=a._(e.r(71645)),s=n._(e.r(74080)),c=e.r(8372),u=e.r(1244),l=e.r(72383),d=e.r(56019),f=e.r(91915),p=e.r(58442),h=e.r(68017),m=e.r(70725),g=e.r(91798);e.r(74180);let y=e.r(61994),b=e.r(33906),P=e.r(95871),_=s.default.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,S=["bottom","height","left","right","top","width","x","y"];function v(e,t){let r=e.getBoundingClientRect();return r.top>=0&&r.top<=t}class O extends i.default.Component{componentDidMount(){this.handlePotentialScroll()}componentDidUpdate(){this.props.focusAndScrollRef.apply&&this.handlePotentialScroll()}render(){return this.props.children}constructor(...e){super(...e),this.handlePotentialScroll=()=>{let{focusAndScrollRef:e,segmentPath:t}=this.props;if(e.apply){if(0!==e.segmentPaths.length&&!e.segmentPaths.some(e=>t.every((t,r)=>(0,d.matchSegment)(t,e[r]))))return;let r=null,n=e.hashFragment;if(n&&(r="top"===n?document.body:document.getElementById(n)??document.getElementsByName(n)[0]),r||(r="u"<typeof window?null:(0,_.findDOMNode)(this)),!(r instanceof Element))return;for(;!(r instanceof HTMLElement)||function(e){if(["sticky","fixed"].includes(getComputedStyle(e).position))return!0;let t=e.getBoundingClientRect();return S.every(e=>0===t[e])}(r);){if(null===r.nextElementSibling)return;r=r.nextElementSibling}e.apply=!1,e.hashFragment=null,e.segmentPaths=[],(0,f.disableSmoothScrollDuringRouteTransition)(()=>{if(n)return void r.scrollIntoView();let e=document.documentElement,t=e.clientHeight;!v(r,t)&&(e.scrollTop=0,v(r,t)||r.scrollIntoView())},{dontForceLayout:!0,onlyHashChange:e.onlyHashChange}),e.onlyHashChange=!1,r.focus()}}}}function R({segmentPath:e,children:t}){let r=(0,i.useContext)(c.GlobalLayoutRouterContext);if(!r)throw Object.defineProperty(Error("invariant global layout router not mounted"),"__NEXT_ERROR_CODE",{value:"E473",enumerable:!1,configurable:!0});return(0,o.jsx)(O,{segmentPath:e,focusAndScrollRef:r.focusAndScrollRef,children:t})}function E({tree:e,segmentPath:t,debugNameContext:r,cacheNode:n,params:a,url:s,isActive:l}){let d,f=(0,i.useContext)(c.GlobalLayoutRouterContext);if((0,i.useContext)(y.NavigationPromisesContext),!f)throw Object.defineProperty(Error("invariant global layout router not mounted"),"__NEXT_ERROR_CODE",{value:"E473",enumerable:!1,configurable:!0});let p=null!==n?n:(0,i.use)(u.unresolvedThenable),h=null!==p.prefetchRsc?p.prefetchRsc:p.rsc,m=(0,i.useDeferredValue)(p.rsc,h);if((0,P.isDeferredRsc)(m)){let e=(0,i.use)(m);null===e&&(0,i.use)(u.unresolvedThenable),d=e}else null===m&&(0,i.use)(u.unresolvedThenable),d=m;let g=d;return(0,o.jsx)(c.LayoutRouterContext.Provider,{value:{parentTree:e,parentCacheNode:p,parentSegmentPath:t,parentParams:a,debugNameContext:r,url:s,isActive:l},children:g})}function j({name:e,loading:t,children:r}){let n;if(n="object"==typeof t&&null!==t&&"function"==typeof t.then?(0,i.use)(t):t){let t=n[0],a=n[1],s=n[2];return(0,o.jsx)(i.Suspense,{name:e,fallback:(0,o.jsxs)(o.Fragment,{children:[a,s,t]}),children:r})}return(0,o.jsx)(o.Fragment,{children:r})}function w({parallelRouterKey:e,error:t,errorStyles:r,errorScripts:n,templateStyles:a,templateScripts:s,template:d,notFound:f,forbidden:y,unauthorized:P,segmentViewBoundaries:_}){let S=(0,i.useContext)(c.LayoutRouterContext);if(!S)throw Object.defineProperty(Error("invariant expected layout router to be mounted"),"__NEXT_ERROR_CODE",{value:"E56",enumerable:!1,configurable:!0});let{parentTree:v,parentCacheNode:O,parentSegmentPath:w,parentParams:C,url:T,isActive:x,debugNameContext:A}=S,M=O.parallelRoutes,D=M.get(e);D||(D=new Map,M.set(e,D));let F=v[0],I=null===w?[e]:w.concat([F,e]),k=v[1][e];void 0===k&&(0,i.use)(u.unresolvedThenable);let N=k[0],U=(0,m.createRouterCacheKey)(N,!0),B=(0,g.useRouterBFCache)(k,U),L=[];do{let e=B.tree,i=B.stateKey,u=e[0],g=(0,m.createRouterCacheKey)(u),_=D.get(g)??null,S=C;if(Array.isArray(u)){let e=u[0],t=u[1],r=u[2],n=(0,b.getParamValueFromCacheKey)(t,r);null!==n&&(S={...C,[e]:n})}let v=function(e){if("/"===e)return"/";if("string"==typeof e)if("(slot)"===e)return;else return e+"/";return e[1]+"/"}(u),w=v??A,M=void 0===v?void 0:A,F=O.loading,k=(0,o.jsxs)(c.TemplateContext.Provider,{value:(0,o.jsxs)(R,{segmentPath:I,children:[(0,o.jsx)(l.ErrorBoundary,{errorComponent:t,errorStyles:r,errorScripts:n,children:(0,o.jsx)(j,{name:M,loading:F,children:(0,o.jsx)(h.HTTPAccessFallbackBoundary,{notFound:f,forbidden:y,unauthorized:P,children:(0,o.jsxs)(p.RedirectBoundary,{children:[(0,o.jsx)(E,{url:T,tree:e,params:S,cacheNode:_,segmentPath:I,debugNameContext:w,isActive:x&&i===U}),null]})})})}),null]}),children:[a,s,d]},i);L.push(k),B=B.next}while(null!==B)return L}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},37457,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"default",{enumerable:!0,get:function(){return s}});let n=e.r(90809),a=e.r(43476),o=n._(e.r(71645)),i=e.r(8372);function s(){let e=(0,o.useContext)(i.TemplateContext);return(0,a.jsx)(a.Fragment,{children:e})}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},93504,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"createRenderSearchParamsFromClient",{enumerable:!0,get:function(){return a}});let n=new WeakMap;function a(e){let t=n.get(e);if(t)return t;let r=Promise.resolve(e);return n.set(e,r),r}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},66996,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"createRenderSearchParamsFromClient",{enumerable:!0,get:function(){return n}});let n=e.r(93504).createRenderSearchParamsFromClient;("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},6831,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"createRenderParamsFromClient",{enumerable:!0,get:function(){return a}});let n=new WeakMap;function a(e){let t=n.get(e);if(t)return t;let r=Promise.resolve(e);return n.set(e,r),r}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},97689,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"createRenderParamsFromClient",{enumerable:!0,get:function(){return n}});let n=e.r(6831).createRenderParamsFromClient;("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},42715,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"ReflectAdapter",{enumerable:!0,get:function(){return n}});class n{static get(e,t,r){let n=Reflect.get(e,t,r);return"function"==typeof n?n.bind(e):n}static set(e,t,r,n){return Reflect.set(e,t,r,n)}static has(e,t){return Reflect.has(e,t)}static deleteProperty(e,t){return Reflect.deleteProperty(e,t)}}},76361,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"createDedupedByCallsiteServerErrorLoggerDev",{enumerable:!0,get:function(){return c}});let n=function(e,t){if(e&&e.__esModule)return e;if(null===e||"object"!=typeof e&&"function"!=typeof e)return{default:e};var r=a(void 0);if(r&&r.has(e))return r.get(e);var n={__proto__:null},o=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var i in e)if("default"!==i&&Object.prototype.hasOwnProperty.call(e,i)){var s=o?Object.getOwnPropertyDescriptor(e,i):null;s&&(s.get||s.set)?Object.defineProperty(n,i,s):n[i]=e[i]}return n.default=e,r&&r.set(e,n),n}(e.r(71645));function a(e){if("function"!=typeof WeakMap)return null;var t=new WeakMap,r=new WeakMap;return(a=function(e){return e?r:t})(e)}let o={current:null},i="function"==typeof n.cache?n.cache:e=>e,s=console.warn;function c(e){return function(...t){s(e(...t))}}i(e=>{try{s(o.current)}finally{o.current=null}})},65932,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={describeHasCheckingStringProperty:function(){return s},describeStringPropertyAccess:function(){return i},wellKnownProperties:function(){return c}};for(var a in n)Object.defineProperty(r,a,{enumerable:!0,get:n[a]});let o=/^[A-Za-z_$][A-Za-z0-9_$]*$/;function i(e,t){return o.test(t)?`\`${e}.${t}\``:`\`${e}[${JSON.stringify(t)}]\``}function s(e,t){let r=JSON.stringify(t);return`\`Reflect.has(${e}, ${r})\`, \`${r} in ${e}\`, or similar`}let c=new Set(["hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toString","valueOf","toLocaleString","then","catch","finally","status","displayName","_debugInfo","toJSON","$$typeof","__esModule"])},83066,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"afterTaskAsyncStorageInstance",{enumerable:!0,get:function(){return n}});let n=(0,e.r(90317).createAsyncLocalStorage)()},41643,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"afterTaskAsyncStorage",{enumerable:!0,get:function(){return n.afterTaskAsyncStorageInstance}});let n=e.r(83066)},50999,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={isRequestAPICallableInsideAfter:function(){return u},throwForSearchParamsAccessInUseCache:function(){return c},throwWithStaticGenerationBailoutErrorWithDynamicError:function(){return s}};for(var a in n)Object.defineProperty(r,a,{enumerable:!0,get:n[a]});let o=e.r(43248),i=e.r(41643);function s(e,t){throw Object.defineProperty(new o.StaticGenBailoutError(`Route ${e} with \`dynamic = "error"\` couldn't be rendered statically because it used ${t}. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering`),"__NEXT_ERROR_CODE",{value:"E543",enumerable:!1,configurable:!0})}function c(e,t){let r=Object.defineProperty(Error(`Route ${e.route} used \`searchParams\` inside "use cache". Accessing dynamic request data inside a cache scope is not supported. If you need some search params inside a cached function await \`searchParams\` outside of the cached function and pass only the required search params as arguments to the cached function. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache`),"__NEXT_ERROR_CODE",{value:"E842",enumerable:!1,configurable:!0});throw Error.captureStackTrace(r,t),e.invalidDynamicUsageError??=r,r}function u(){let e=i.afterTaskAsyncStorage.getStore();return(null==e?void 0:e.rootTaskSpawnPhase)==="action"}},42852,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n,a={RenderStage:function(){return c},StagedRenderingController:function(){return u}};for(var o in a)Object.defineProperty(r,o,{enumerable:!0,get:a[o]});let i=e.r(12718),s=e.r(39470);var c=((n={})[n.Before=1]="Before",n[n.Static=2]="Static",n[n.Runtime=3]="Runtime",n[n.Dynamic=4]="Dynamic",n[n.Abandoned=5]="Abandoned",n);class u{constructor(e=null,t){this.abortSignal=e,this.hasRuntimePrefetch=t,this.currentStage=1,this.staticInterruptReason=null,this.runtimeInterruptReason=null,this.staticStageEndTime=1/0,this.runtimeStageEndTime=1/0,this.runtimeStageListeners=[],this.dynamicStageListeners=[],this.runtimeStagePromise=(0,s.createPromiseWithResolvers)(),this.dynamicStagePromise=(0,s.createPromiseWithResolvers)(),this.mayAbandon=!1,e&&(e.addEventListener("abort",()=>{let{reason:t}=e;this.currentStage<3&&(this.runtimeStagePromise.promise.catch(l),this.runtimeStagePromise.reject(t)),(this.currentStage<4||5===this.currentStage)&&(this.dynamicStagePromise.promise.catch(l),this.dynamicStagePromise.reject(t))},{once:!0}),this.mayAbandon=!0)}onStage(e,t){if(this.currentStage>=e)t();else if(3===e)this.runtimeStageListeners.push(t);else if(4===e)this.dynamicStageListeners.push(t);else throw Object.defineProperty(new i.InvariantError(`Invalid render stage: ${e}`),"__NEXT_ERROR_CODE",{value:"E881",enumerable:!1,configurable:!0})}canSyncInterrupt(){if(1===this.currentStage)return!1;let e=this.hasRuntimePrefetch?4:3;return this.currentStage<e}syncInterruptCurrentStageWithReason(e){if(1!==this.currentStage){if(this.mayAbandon)return this.abandonRenderImpl();switch(this.currentStage){case 2:this.staticInterruptReason=e,this.advanceStage(4);return;case 3:this.hasRuntimePrefetch&&(this.runtimeInterruptReason=e,this.advanceStage(4));return}}}getStaticInterruptReason(){return this.staticInterruptReason}getRuntimeInterruptReason(){return this.runtimeInterruptReason}getStaticStageEndTime(){return this.staticStageEndTime}getRuntimeStageEndTime(){return this.runtimeStageEndTime}abandonRender(){if(!this.mayAbandon)throw Object.defineProperty(new i.InvariantError("`abandonRender` called on a stage controller that cannot be abandoned."),"__NEXT_ERROR_CODE",{value:"E938",enumerable:!1,configurable:!0});this.abandonRenderImpl()}abandonRenderImpl(){let{currentStage:e}=this;switch(e){case 2:this.currentStage=5,this.resolveRuntimeStage();return;case 3:this.currentStage=5;return}}advanceStage(e){if(e<=this.currentStage)return;let t=this.currentStage;if(this.currentStage=e,t<3&&e>=3&&(this.staticStageEndTime=performance.now()+performance.timeOrigin,this.resolveRuntimeStage()),t<4&&e>=4){this.runtimeStageEndTime=performance.now()+performance.timeOrigin,this.resolveDynamicStage();return}}resolveRuntimeStage(){let e=this.runtimeStageListeners;for(let t=0;t<e.length;t++)e[t]();e.length=0,this.runtimeStagePromise.resolve()}resolveDynamicStage(){let e=this.dynamicStageListeners;for(let t=0;t<e.length;t++)e[t]();e.length=0,this.dynamicStagePromise.resolve()}getStagePromise(e){switch(e){case 3:return this.runtimeStagePromise.promise;case 4:return this.dynamicStagePromise.promise;default:throw Object.defineProperty(new i.InvariantError(`Invalid render stage: ${e}`),"__NEXT_ERROR_CODE",{value:"E881",enumerable:!1,configurable:!0})}}waitForStage(e){return this.getStagePromise(e)}delayUntilStage(e,t,r){var n,a,o;let i,s=(n=this.getStagePromise(e),a=t,o=r,i=new Promise((e,t)=>{n.then(e.bind(null,o),t)}),void 0!==a&&(i.displayName=a),i);return this.abortSignal&&s.catch(l),s}}function l(){}},69882,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={createPrerenderSearchParamsForClientPage:function(){return g},createSearchParamsFromClient:function(){return p},createServerSearchParamsForMetadata:function(){return h},createServerSearchParamsForServerPage:function(){return m},makeErroringSearchParamsForUseCache:function(){return S}};for(var a in n)Object.defineProperty(r,a,{enumerable:!0,get:n[a]});let o=e.r(42715),i=e.r(67673),s=e.r(62141),c=e.r(12718),u=e.r(63138),l=e.r(76361),d=e.r(65932),f=e.r(50999);function p(e,t){let r=s.workUnitAsyncStorage.getStore();if(r)switch(r.type){case"prerender":case"prerender-client":case"prerender-ppr":case"prerender-legacy":return y(t,r);case"prerender-runtime":throw Object.defineProperty(new c.InvariantError("createSearchParamsFromClient should not be called in a runtime prerender."),"__NEXT_ERROR_CODE",{value:"E769",enumerable:!1,configurable:!0});case"cache":case"private-cache":case"unstable-cache":throw Object.defineProperty(new c.InvariantError("createSearchParamsFromClient should not be called in cache contexts."),"__NEXT_ERROR_CODE",{value:"E739",enumerable:!1,configurable:!0});case"request":return b(e,t,r)}(0,s.throwInvariantForMissingStore)()}e.r(42852);let h=m;function m(e,t){let r=s.workUnitAsyncStorage.getStore();if(r)switch(r.type){case"prerender":case"prerender-client":case"prerender-ppr":case"prerender-legacy":return y(t,r);case"cache":case"private-cache":case"unstable-cache":throw Object.defineProperty(new c.InvariantError("createServerSearchParamsForServerPage should not be called in cache contexts."),"__NEXT_ERROR_CODE",{value:"E747",enumerable:!1,configurable:!0});case"prerender-runtime":var n,a;return n=e,a=r,(0,i.delayUntilRuntimeStage)(a,v(n));case"request":return b(e,t,r)}(0,s.throwInvariantForMissingStore)()}function g(e){if(e.forceStatic)return Promise.resolve({});let t=s.workUnitAsyncStorage.getStore();if(t)switch(t.type){case"prerender":case"prerender-client":return(0,u.makeHangingPromise)(t.renderSignal,e.route,"`searchParams`");case"prerender-runtime":throw Object.defineProperty(new c.InvariantError("createPrerenderSearchParamsForClientPage should not be called in a runtime prerender."),"__NEXT_ERROR_CODE",{value:"E768",enumerable:!1,configurable:!0});case"cache":case"private-cache":case"unstable-cache":throw Object.defineProperty(new c.InvariantError("createPrerenderSearchParamsForClientPage should not be called in cache contexts."),"__NEXT_ERROR_CODE",{value:"E746",enumerable:!1,configurable:!0});case"prerender-ppr":case"prerender-legacy":case"request":return Promise.resolve({})}(0,s.throwInvariantForMissingStore)()}function y(e,t){if(e.forceStatic)return Promise.resolve({});switch(t.type){case"prerender":case"prerender-client":var r=e,n=t;let a=P.get(n);if(a)return a;let s=(0,u.makeHangingPromise)(n.renderSignal,r.route,"`searchParams`"),c=new Proxy(s,{get(e,t,r){if(Object.hasOwn(s,t))return o.ReflectAdapter.get(e,t,r);switch(t){case"then":return(0,i.annotateDynamicAccess)("`await searchParams`, `searchParams.then`, or similar",n),o.ReflectAdapter.get(e,t,r);case"status":return(0,i.annotateDynamicAccess)("`use(searchParams)`, `searchParams.status`, or similar",n),o.ReflectAdapter.get(e,t,r);default:return o.ReflectAdapter.get(e,t,r)}}});return P.set(n,c),c;case"prerender-ppr":case"prerender-legacy":var l=e,d=t;let p=P.get(l);if(p)return p;let h=Promise.resolve({}),m=new Proxy(h,{get(e,t,r){if(Object.hasOwn(h,t))return o.ReflectAdapter.get(e,t,r);if("string"==typeof t&&"then"===t){let e="`await searchParams`, `searchParams.then`, or similar";l.dynamicShouldError?(0,f.throwWithStaticGenerationBailoutErrorWithDynamicError)(l.route,e):"prerender-ppr"===d.type?(0,i.postponeWithTracking)(l.route,e,d.dynamicTracking):(0,i.throwToInterruptStaticGeneration)(e,l,d)}return o.ReflectAdapter.get(e,t,r)}});return P.set(l,m),m;default:return t}}function b(e,t,r){return t.forceStatic?Promise.resolve({}):v(e)}let P=new WeakMap,_=new WeakMap;function S(e){let t=_.get(e);if(t)return t;let r=Promise.resolve({}),n=new Proxy(r,{get:function t(n,a,i){return Object.hasOwn(r,a)||"string"!=typeof a||"then"!==a&&d.wellKnownProperties.has(a)||(0,f.throwForSearchParamsAccessInUseCache)(e,t),o.ReflectAdapter.get(n,a,i)}});return _.set(e,n),n}function v(e){let t=P.get(e);if(t)return t;let r=Promise.resolve(e);return P.set(e,r),r}(0,l.createDedupedByCallsiteServerErrorLoggerDev)(function(e,t){let r=e?`Route "${e}" `:"This route ";return Object.defineProperty(Error(`${r}used ${t}. \`searchParams\` is a Promise and must be unwrapped with \`await\` or \`React.use()\` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis`),"__NEXT_ERROR_CODE",{value:"E848",enumerable:!1,configurable:!0})})},74804,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"dynamicAccessAsyncStorageInstance",{enumerable:!0,get:function(){return n}});let n=(0,e.r(90317).createAsyncLocalStorage)()},88276,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"dynamicAccessAsyncStorage",{enumerable:!0,get:function(){return n.dynamicAccessAsyncStorageInstance}});let n=e.r(74804)},41489,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={createParamsFromClient:function(){return h},createPrerenderParamsForClientSegment:function(){return b},createServerParamsForMetadata:function(){return m},createServerParamsForRoute:function(){return g},createServerParamsForServerSegment:function(){return y}};for(var a in n)Object.defineProperty(r,a,{enumerable:!0,get:n[a]});let o=e.r(63599),i=e.r(42715),s=e.r(67673),c=e.r(62141),u=e.r(12718),l=e.r(65932),d=e.r(63138),f=e.r(76361),p=e.r(88276);function h(e,t){let r=c.workUnitAsyncStorage.getStore();if(r)switch(r.type){case"prerender":case"prerender-client":case"prerender-ppr":case"prerender-legacy":return P(e,t,r);case"cache":case"private-cache":case"unstable-cache":throw Object.defineProperty(new u.InvariantError("createParamsFromClient should not be called in cache contexts."),"__NEXT_ERROR_CODE",{value:"E736",enumerable:!1,configurable:!0});case"prerender-runtime":throw Object.defineProperty(new u.InvariantError("createParamsFromClient should not be called in a runtime prerender."),"__NEXT_ERROR_CODE",{value:"E770",enumerable:!1,configurable:!0});case"request":return O(e)}(0,c.throwInvariantForMissingStore)()}e.r(42852);let m=y;function g(e,t){let r=c.workUnitAsyncStorage.getStore();if(r)switch(r.type){case"prerender":case"prerender-client":case"prerender-ppr":case"prerender-legacy":return P(e,t,r);case"cache":case"private-cache":case"unstable-cache":throw Object.defineProperty(new u.InvariantError("createServerParamsForRoute should not be called in cache contexts."),"__NEXT_ERROR_CODE",{value:"E738",enumerable:!1,configurable:!0});case"prerender-runtime":return _(e,r);case"request":return O(e)}(0,c.throwInvariantForMissingStore)()}function y(e,t){let r=c.workUnitAsyncStorage.getStore();if(r)switch(r.type){case"prerender":case"prerender-client":case"prerender-ppr":case"prerender-legacy":return P(e,t,r);case"cache":case"private-cache":case"unstable-cache":throw Object.defineProperty(new u.InvariantError("createServerParamsForServerSegment should not be called in cache contexts."),"__NEXT_ERROR_CODE",{value:"E743",enumerable:!1,configurable:!0});case"prerender-runtime":return _(e,r);case"request":return O(e)}(0,c.throwInvariantForMissingStore)()}function b(e){let t=o.workAsyncStorage.getStore();if(!t)throw Object.defineProperty(new u.InvariantError("Missing workStore in createPrerenderParamsForClientSegment"),"__NEXT_ERROR_CODE",{value:"E773",enumerable:!1,configurable:!0});let r=c.workUnitAsyncStorage.getStore();if(r)switch(r.type){case"prerender":case"prerender-client":let n=r.fallbackRouteParams;if(n){for(let a in e)if(n.has(a))return(0,d.makeHangingPromise)(r.renderSignal,t.route,"`params`")}break;case"cache":case"private-cache":case"unstable-cache":throw Object.defineProperty(new u.InvariantError("createPrerenderParamsForClientSegment should not be called in cache contexts."),"__NEXT_ERROR_CODE",{value:"E734",enumerable:!1,configurable:!0})}return Promise.resolve(e)}function P(e,t,r){switch(r.type){case"prerender":case"prerender-client":{let n=r.fallbackRouteParams;if(n){for(let a in e)if(n.has(a))return function(e,t,r){let n=S.get(e);if(n)return n;let a=new Proxy((0,d.makeHangingPromise)(r.renderSignal,t.route,"`params`"),v);return S.set(e,a),a}(e,t,r)}break}case"prerender-ppr":{let n=r.fallbackRouteParams;if(n){for(let a in e)if(n.has(a))return function(e,t,r,n){let a=S.get(e);if(a)return a;let o={...e},i=Promise.resolve(o);return S.set(e,i),Object.keys(e).forEach(e=>{l.wellKnownProperties.has(e)||t.has(e)&&Object.defineProperty(o,e,{get(){let t=(0,l.describeStringPropertyAccess)("params",e);"prerender-ppr"===n.type?(0,s.postponeWithTracking)(r.route,t,n.dynamicTracking):(0,s.throwToInterruptStaticGeneration)(t,r,n)},enumerable:!0})}),i}(e,n,t,r)}}}return O(e)}function _(e,t){return(0,s.delayUntilRuntimeStage)(t,O(e))}let S=new WeakMap,v={get:function(e,t,r){if("then"===t||"catch"===t||"finally"===t){let n=i.ReflectAdapter.get(e,t,r);return({[t]:(...t)=>{let r=p.dynamicAccessAsyncStorage.getStore();return r&&r.abortController.abort(Object.defineProperty(Error("Accessed fallback `params` during prerendering."),"__NEXT_ERROR_CODE",{value:"E691",enumerable:!1,configurable:!0})),new Proxy(n.apply(e,t),v)}})[t]}return i.ReflectAdapter.get(e,t,r)}};function O(e){let t=S.get(e);if(t)return t;let r=Promise.resolve(e);return S.set(e,r),r}(0,f.createDedupedByCallsiteServerErrorLoggerDev)(function(e,t){let r=e?`Route "${e}" `:"This route ";return Object.defineProperty(Error(`${r}used ${t}. \`params\` is a Promise and must be unwrapped with \`await\` or \`React.use()\` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis`),"__NEXT_ERROR_CODE",{value:"E834",enumerable:!1,configurable:!0})})},47257,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"ClientPageRoot",{enumerable:!0,get:function(){return u}});let n=e.r(43476),a=e.r(12718),o=e.r(8372),i=e.r(71645),s=e.r(33906),c=e.r(61994);function u({Component:t,serverProvidedParams:r}){let u,l;if(null!==r)u=r.searchParams,l=r.params;else{let e=(0,i.use)(o.LayoutRouterContext);l=null!==e?e.parentParams:{},u=(0,s.urlSearchParamsToParsedUrlQuery)((0,i.use)(c.SearchParamsContext))}if("u"<typeof window){let r,o,{workAsyncStorage:i}=e.r(63599),s=i.getStore();if(!s)throw Object.defineProperty(new a.InvariantError("Expected workStore to exist when handling searchParams in a client Page."),"__NEXT_ERROR_CODE",{value:"E564",enumerable:!1,configurable:!0});let{createSearchParamsFromClient:c}=e.r(69882);r=c(u,s);let{createParamsFromClient:d}=e.r(41489);return o=d(l,s),(0,n.jsx)(t,{params:o,searchParams:r})}{let{createRenderSearchParamsFromClient:r}=e.r(66996),a=r(u),{createRenderParamsFromClient:o}=e.r(97689),i=o(l);return(0,n.jsx)(t,{params:i,searchParams:a})}}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},92825,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"ClientSegmentRoot",{enumerable:!0,get:function(){return s}});let n=e.r(43476),a=e.r(12718),o=e.r(8372),i=e.r(71645);function s({Component:t,slots:r,serverProvidedParams:s}){let c;if(null!==s)c=s.params;else{let e=(0,i.use)(o.LayoutRouterContext);c=null!==e?e.parentParams:{}}if("u"<typeof window){let o,{workAsyncStorage:i}=e.r(63599),s=i.getStore();if(!s)throw Object.defineProperty(new a.InvariantError("Expected workStore to exist when handling params in a client segment such as a Layout or Template."),"__NEXT_ERROR_CODE",{value:"E600",enumerable:!1,configurable:!0});let{createParamsFromClient:u}=e.r(41489);return o=u(c,s),(0,n.jsx)(t,{...r,params:o})}{let{createRenderParamsFromClient:a}=e.r(97689),o=a(c);return(0,n.jsx)(t,{...r,params:o})}}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},27201,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"IconMark",{enumerable:!0,get:function(){return a}});let n=e.r(43476),a=()=>"u">typeof window?null:(0,n.jsx)("meta",{name:"«nxt-icon»"})}]);
 
 
static/chunks/4b9eae0c8dc7e975.js DELETED
@@ -1 +0,0 @@
1
- (globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,68027,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"default",{enumerable:!0,get:function(){return s}});let n=e.r(43476),o=e.r(12354),u={fontFamily:'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',height:"100vh",textAlign:"center",display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center"},i={fontSize:"14px",fontWeight:400,lineHeight:"28px",margin:"0 8px"},s=function({error:e}){let t=e?.digest;return(0,n.jsxs)("html",{id:"__next_error__",children:[(0,n.jsx)("head",{}),(0,n.jsxs)("body",{children:[(0,n.jsx)(o.HandleISRError,{error:e}),(0,n.jsx)("div",{style:u,children:(0,n.jsxs)("div",{children:[(0,n.jsxs)("h2",{style:i,children:["Application error: a ",t?"server":"client","-side exception has occurred while loading ",window.location.hostname," (see the"," ",t?"server logs":"browser console"," for more information)."]}),t?(0,n.jsx)("p",{style:i,children:`Digest: ${t}`}):null]})})]})]})};("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},35451,(e,t,r)=>{var n={229:function(e){var t,r,n,o=e.exports={};function u(){throw Error("setTimeout has not been defined")}function i(){throw Error("clearTimeout has not been defined")}try{t="function"==typeof setTimeout?setTimeout:u}catch(e){t=u}try{r="function"==typeof clearTimeout?clearTimeout:i}catch(e){r=i}function s(e){if(t===setTimeout)return setTimeout(e,0);if((t===u||!t)&&setTimeout)return t=setTimeout,setTimeout(e,0);try{return t(e,0)}catch(r){try{return t.call(null,e,0)}catch(r){return t.call(this,e,0)}}}var c=[],l=!1,a=-1;function f(){l&&n&&(l=!1,n.length?c=n.concat(c):a=-1,c.length&&p())}function p(){if(!l){var e=s(f);l=!0;for(var t=c.length;t;){for(n=c,c=[];++a<t;)n&&n[a].run();a=-1,t=c.length}n=null,l=!1,function(e){if(r===clearTimeout)return clearTimeout(e);if((r===i||!r)&&clearTimeout)return r=clearTimeout,clearTimeout(e);try{r(e)}catch(t){try{return r.call(null,e)}catch(t){return r.call(this,e)}}}(e)}}function d(e,t){this.fun=e,this.array=t}function y(){}o.nextTick=function(e){var t=Array(arguments.length-1);if(arguments.length>1)for(var r=1;r<arguments.length;r++)t[r-1]=arguments[r];c.push(new d(e,t)),1!==c.length||l||s(p)},d.prototype.run=function(){this.fun.apply(null,this.array)},o.title="browser",o.browser=!0,o.env={},o.argv=[],o.version="",o.versions={},o.on=y,o.addListener=y,o.once=y,o.off=y,o.removeListener=y,o.removeAllListeners=y,o.emit=y,o.prependListener=y,o.prependOnceListener=y,o.listeners=function(e){return[]},o.binding=function(e){throw Error("process.binding is not supported")},o.cwd=function(){return"/"},o.chdir=function(e){throw Error("process.chdir is not supported")},o.umask=function(){return 0}}},o={};function u(e){var t=o[e];if(void 0!==t)return t.exports;var r=o[e]={exports:{}},i=!0;try{n[e](r,r.exports,u),i=!1}finally{i&&delete o[e]}return r.exports}u.ab="/ROOT/node_modules/next/dist/compiled/process/",t.exports=u(229)},47167,(e,t,r)=>{"use strict";var n,o;t.exports=(null==(n=e.g.process)?void 0:n.env)&&"object"==typeof(null==(o=e.g.process)?void 0:o.env)?e.g.process:e.r(35451)},45689,(e,t,r)=>{"use strict";var n=Symbol.for("react.transitional.element");function o(e,t,r){var o=null;if(void 0!==r&&(o=""+r),void 0!==t.key&&(o=""+t.key),"key"in t)for(var u in r={},t)"key"!==u&&(r[u]=t[u]);else r=t;return{$$typeof:n,type:e,key:o,ref:void 0!==(t=r.ref)?t:null,props:r}}r.Fragment=Symbol.for("react.fragment"),r.jsx=o,r.jsxs=o},43476,(e,t,r)=>{"use strict";t.exports=e.r(45689)},90317,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={bindSnapshot:function(){return l},createAsyncLocalStorage:function(){return c},createSnapshot:function(){return a}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=Object.defineProperty(Error("Invariant: AsyncLocalStorage accessed in runtime where it is not available"),"__NEXT_ERROR_CODE",{value:"E504",enumerable:!1,configurable:!0});class i{disable(){throw u}getStore(){}run(){throw u}exit(){throw u}enterWith(){throw u}static bind(e){return e}}let s="u">typeof globalThis&&globalThis.AsyncLocalStorage;function c(){return s?new s:new i}function l(e){return s?s.bind(e):i.bind(e)}function a(){return s?s.snapshot():function(e,...t){return e(...t)}}},42344,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"workAsyncStorageInstance",{enumerable:!0,get:function(){return n}});let n=(0,e.r(90317).createAsyncLocalStorage)()},63599,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"workAsyncStorage",{enumerable:!0,get:function(){return n.workAsyncStorageInstance}});let n=e.r(42344)},12354,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"HandleISRError",{enumerable:!0,get:function(){return o}});let n="u"<typeof window?e.r(63599).workAsyncStorage:void 0;function o({error:e}){if(n){let t=n.getStore();if(t?.isStaticGeneration)throw e&&console.error(e),e}return null}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},50740,(e,t,r)=>{"use strict";var n=e.i(47167),o=Symbol.for("react.transitional.element"),u=Symbol.for("react.portal"),i=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),c=Symbol.for("react.profiler"),l=Symbol.for("react.consumer"),a=Symbol.for("react.context"),f=Symbol.for("react.forward_ref"),p=Symbol.for("react.suspense"),d=Symbol.for("react.memo"),y=Symbol.for("react.lazy"),h=Symbol.for("react.activity"),v=Symbol.for("react.view_transition"),b=Symbol.iterator,m={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},_=Object.assign,g={};function S(e,t,r){this.props=e,this.context=t,this.refs=g,this.updater=r||m}function j(){}function w(e,t,r){this.props=e,this.context=t,this.refs=g,this.updater=r||m}S.prototype.isReactComponent={},S.prototype.setState=function(e,t){if("object"!=typeof e&&"function"!=typeof e&&null!=e)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")},S.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")},j.prototype=S.prototype;var E=w.prototype=new j;E.constructor=w,_(E,S.prototype),E.isPureReactComponent=!0;var x=Array.isArray;function T(){}var O={H:null,A:null,T:null,S:null},k=Object.prototype.hasOwnProperty;function R(e,t,r){var n=r.ref;return{$$typeof:o,type:e,key:t,ref:void 0!==n?n:null,props:r}}function A(e){return"object"==typeof e&&null!==e&&e.$$typeof===o}var H=/\/+/g;function C(e,t){var r,n;return"object"==typeof e&&null!==e&&null!=e.key?(r=""+e.key,n={"=":"=0",":":"=2"},"$"+r.replace(/[=:]/g,function(e){return n[e]})):t.toString(36)}function P(e,t,r){if(null==e)return e;var n=[],i=0;return!function e(t,r,n,i,s){var c,l,a,f=typeof t;("undefined"===f||"boolean"===f)&&(t=null);var p=!1;if(null===t)p=!0;else switch(f){case"bigint":case"string":case"number":p=!0;break;case"object":switch(t.$$typeof){case o:case u:p=!0;break;case y:return e((p=t._init)(t._payload),r,n,i,s)}}if(p)return s=s(t),p=""===i?"."+C(t,0):i,x(s)?(n="",null!=p&&(n=p.replace(H,"$&/")+"/"),e(s,r,n,"",function(e){return e})):null!=s&&(A(s)&&(c=s,l=n+(null==s.key||t&&t.key===s.key?"":(""+s.key).replace(H,"$&/")+"/")+p,s=R(c.type,l,c.props)),r.push(s)),1;p=0;var d=""===i?".":i+":";if(x(t))for(var h=0;h<t.length;h++)f=d+C(i=t[h],h),p+=e(i,r,n,f,s);else if("function"==typeof(h=null===(a=t)||"object"!=typeof a?null:"function"==typeof(a=b&&a[b]||a["@@iterator"])?a:null))for(t=h.call(t),h=0;!(i=t.next()).done;)f=d+C(i=i.value,h++),p+=e(i,r,n,f,s);else if("object"===f){if("function"==typeof t.then)return e(function(e){switch(e.status){case"fulfilled":return e.value;case"rejected":throw e.reason;default:switch("string"==typeof e.status?e.then(T,T):(e.status="pending",e.then(function(t){"pending"===e.status&&(e.status="fulfilled",e.value=t)},function(t){"pending"===e.status&&(e.status="rejected",e.reason=t)})),e.status){case"fulfilled":return e.value;case"rejected":throw e.reason}}throw e}(t),r,n,i,s);throw Error("Objects are not valid as a React child (found: "+("[object Object]"===(r=String(t))?"object with keys {"+Object.keys(t).join(", ")+"}":r)+"). If you meant to render a collection of children, use an array instead.")}return p}(e,n,"","",function(e){return t.call(r,e,i++)}),n}function $(e){if(-1===e._status){var t=e._result;(t=t()).then(function(t){(0===e._status||-1===e._status)&&(e._status=1,e._result=t)},function(t){(0===e._status||-1===e._status)&&(e._status=2,e._result=t)}),-1===e._status&&(e._status=0,e._result=t)}if(1===e._status)return e._result.default;throw e._result}var I="function"==typeof reportError?reportError:function(e){if("object"==typeof window&&"function"==typeof window.ErrorEvent){var t=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:"object"==typeof e&&null!==e&&"string"==typeof e.message?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if("object"==typeof n.default&&"function"==typeof n.default.emit)return void n.default.emit("uncaughtException",e);console.error(e)};function M(e){var t=O.T,r={};r.types=null!==t?t.types:null,O.T=r;try{var n=e(),o=O.S;null!==o&&o(r,n),"object"==typeof n&&null!==n&&"function"==typeof n.then&&n.then(T,I)}catch(e){I(e)}finally{null!==t&&null!==r.types&&(t.types=r.types),O.T=t}}function L(e){var t=O.T;if(null!==t){var r=t.types;null===r?t.types=[e]:-1===r.indexOf(e)&&r.push(e)}else M(L.bind(null,e))}r.Activity=h,r.Children={map:P,forEach:function(e,t,r){P(e,function(){t.apply(this,arguments)},r)},count:function(e){var t=0;return P(e,function(){t++}),t},toArray:function(e){return P(e,function(e){return e})||[]},only:function(e){if(!A(e))throw Error("React.Children.only expected to receive a single React element child.");return e}},r.Component=S,r.Fragment=i,r.Profiler=c,r.PureComponent=w,r.StrictMode=s,r.Suspense=p,r.ViewTransition=v,r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=O,r.__COMPILER_RUNTIME={__proto__:null,c:function(e){return O.H.useMemoCache(e)}},r.addTransitionType=L,r.cache=function(e){return function(){return e.apply(null,arguments)}},r.cacheSignal=function(){return null},r.cloneElement=function(e,t,r){if(null==e)throw Error("The argument must be a React element, but you passed "+e+".");var n=_({},e.props),o=e.key;if(null!=t)for(u in void 0!==t.key&&(o=""+t.key),t)k.call(t,u)&&"key"!==u&&"__self"!==u&&"__source"!==u&&("ref"!==u||void 0!==t.ref)&&(n[u]=t[u]);var u=arguments.length-2;if(1===u)n.children=r;else if(1<u){for(var i=Array(u),s=0;s<u;s++)i[s]=arguments[s+2];n.children=i}return R(e.type,o,n)},r.createContext=function(e){return(e={$$typeof:a,_currentValue:e,_currentValue2:e,_threadCount:0,Provider:null,Consumer:null}).Provider=e,e.Consumer={$$typeof:l,_context:e},e},r.createElement=function(e,t,r){var n,o={},u=null;if(null!=t)for(n in void 0!==t.key&&(u=""+t.key),t)k.call(t,n)&&"key"!==n&&"__self"!==n&&"__source"!==n&&(o[n]=t[n]);var i=arguments.length-2;if(1===i)o.children=r;else if(1<i){for(var s=Array(i),c=0;c<i;c++)s[c]=arguments[c+2];o.children=s}if(e&&e.defaultProps)for(n in i=e.defaultProps)void 0===o[n]&&(o[n]=i[n]);return R(e,u,o)},r.createRef=function(){return{current:null}},r.forwardRef=function(e){return{$$typeof:f,render:e}},r.isValidElement=A,r.lazy=function(e){return{$$typeof:y,_payload:{_status:-1,_result:e},_init:$}},r.memo=function(e,t){return{$$typeof:d,type:e,compare:void 0===t?null:t}},r.startTransition=M,r.unstable_useCacheRefresh=function(){return O.H.useCacheRefresh()},r.use=function(e){return O.H.use(e)},r.useActionState=function(e,t,r){return O.H.useActionState(e,t,r)},r.useCallback=function(e,t){return O.H.useCallback(e,t)},r.useContext=function(e){return O.H.useContext(e)},r.useDebugValue=function(){},r.useDeferredValue=function(e,t){return O.H.useDeferredValue(e,t)},r.useEffect=function(e,t){return O.H.useEffect(e,t)},r.useEffectEvent=function(e){return O.H.useEffectEvent(e)},r.useId=function(){return O.H.useId()},r.useImperativeHandle=function(e,t,r){return O.H.useImperativeHandle(e,t,r)},r.useInsertionEffect=function(e,t){return O.H.useInsertionEffect(e,t)},r.useLayoutEffect=function(e,t){return O.H.useLayoutEffect(e,t)},r.useMemo=function(e,t){return O.H.useMemo(e,t)},r.useOptimistic=function(e,t){return O.H.useOptimistic(e,t)},r.useReducer=function(e,t,r){return O.H.useReducer(e,t,r)},r.useRef=function(e){return O.H.useRef(e)},r.useState=function(e){return O.H.useState(e)},r.useSyncExternalStore=function(e,t,r){return O.H.useSyncExternalStore(e,t,r)},r.useTransition=function(){return O.H.useTransition()},r.version="19.3.0-canary-f93b9fd4-20251217"},71645,(e,t,r)=>{"use strict";t.exports=e.r(50740)}]);
 
 
static/chunks/57f15be2f5ca279f.css DELETED
@@ -1,3 +0,0 @@
1
- @font-face{font-family:Geist;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/8a480f0b521d4e75-s.8e0177b5.woff2)format("woff2");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/7178b3e590c64307-s.b97b3418.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/caa3a2e1cccd8315-s.p.853070df.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Geist Fallback;src:local(Arial);ascent-override:95.94%;descent-override:28.16%;line-gap-override:0.0%;size-adjust:104.76%}.geist_a71539c9-module__T19VSG__className{font-family:Geist,Geist Fallback;font-style:normal}.geist_a71539c9-module__T19VSG__variable{--font-geist-sans:"Geist","Geist Fallback"}
2
- @font-face{font-family:Geist Mono;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/4fa387ec64143e14-s.c1fdd6c2.woff2)format("woff2");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist Mono;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/bbc41e54d2fcbd21-s.799d8ef8.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist Mono;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/797e433ab948586e-s.p.dbea232f.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Geist Mono Fallback;src:local(Arial);ascent-override:74.67%;descent-override:21.92%;line-gap-override:0.0%;size-adjust:134.59%}.geist_mono_8d43a2aa-module__8Li5zG__className{font-family:Geist Mono,Geist Mono Fallback;font-style:normal}.geist_mono_8d43a2aa-module__8Li5zG__variable{--font-geist-mono:"Geist Mono","Geist Mono Fallback"}
3
- @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:var(--font-geist-sans);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-xl:36rem;--container-2xl:42rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--text-6xl:3.75rem;--text-6xl--line-height:1;--text-8xl:6rem;--text-8xl--line-height:1;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--shadow-sm:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;--shadow-md:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--shadow-lg:0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a;--ease-out:cubic-bezier(0,0,.2,1);--animate-spin:spin 1s linear infinite;--blur-sm:8px;--blur-3xl:64px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-geist-sans);--default-mono-font-family:var(--font-geist-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.start{inset-inline-start:var(--spacing)}.top-4{top:calc(var(--spacing)*4)}.right-4{right:calc(var(--spacing)*4)}.z-10{z-index:10}.z-50{z-index:50}.mx-auto{margin-inline:auto}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-16{margin-top:calc(var(--spacing)*16)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.mb-12{margin-bottom:calc(var(--spacing)*12)}.block{display:block}.flex{display:flex}.grid{display:grid}.inline-block{display:inline-block}.h-3{height:calc(var(--spacing)*3)}.h-5{height:calc(var(--spacing)*5)}.h-40{height:calc(var(--spacing)*40)}.h-80{height:calc(var(--spacing)*80)}.h-96{height:calc(var(--spacing)*96)}.min-h-screen{min-height:100vh}.w-5{width:calc(var(--spacing)*5)}.w-80{width:calc(var(--spacing)*80)}.w-96{width:calc(var(--spacing)*96)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-spin{animation:var(--animate-spin)}.resize-none{resize:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing)*2)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-white\/20{border-color:#fff3}@supports (color:color-mix(in lab, red, red)){.border-white\/20{border-color:color-mix(in oklab,var(--color-white)20%,transparent)}}.bg-white\/15{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.bg-white\/15{background-color:color-mix(in oklab,var(--color-white)15%,transparent)}}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-8{padding-inline:calc(var(--spacing)*8)}.px-12{padding-inline:calc(var(--spacing)*12)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-5{padding-block:calc(var(--spacing)*5)}.py-8{padding-block:calc(var(--spacing)*8)}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:var(--font-geist-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.text-white{color:var(--color-white)}.text-white\/70{color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.text-white\/70{color:color-mix(in oklab,var(--color-white)70%,transparent)}}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab, red, red)){.text-white\/80{color:color-mix(in oklab,var(--color-white)80%,transparent)}}.text-white\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-white\/90{color:color-mix(in oklab,var(--color-white)90%,transparent)}}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.blur-3xl{--tw-blur:blur(var(--blur-3xl));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-4:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(4px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-white\/50:focus{--tw-ring-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.focus\:ring-white\/50:focus{--tw-ring-color:color-mix(in oklab,var(--color-white)50%,transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width:48rem){.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.md\:text-8xl{font-size:var(--text-8xl);line-height:var(--tw-leading,var(--text-8xl--line-height))}}}:root{--sentinel-coral:#ff6b6b;--sentinel-tangerine:#ff8e53;--sentinel-citrus:#ffc837;--sentinel-teal:#00bfa6;--sentinel-indigo:#5c6bc0;--sentinel-magenta:#e040fb;--background:#fafafa;--foreground:#1a1a2e;--card-bg:#fff;--card-border:#e8e8e8;--muted:#6b7280;--shadow-sm:0 1px 3px #00000014;--shadow-md:0 4px 12px #0000001a;--shadow-lg:0 8px 30px #0000001f}[data-theme=dark]{--background:#0f0f1a;--foreground:#f0f0f0;--card-bg:#1a1a2e;--card-border:#2a2a3e;--muted:#9ca3af;--shadow-sm:0 1px 3px #0000004d;--shadow-md:0 4px 12px #0006;--shadow-lg:0 8px 30px #00000080}body{background:var(--background);color:var(--foreground);font-family:var(--font-sans,Arial,Helvetica,sans-serif);transition:background .3s,color .3s}@keyframes fadeInUp{0%{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}}.animate-fade-in-up{animation:.6s ease-out forwards fadeInUp}.gradient-text{background:linear-gradient(135deg,var(--sentinel-coral),var(--sentinel-tangerine),var(--sentinel-citrus));-webkit-text-fill-color:transparent;-webkit-background-clip:text;background-clip:text}.cta-button{background:linear-gradient(135deg,var(--sentinel-coral),var(--sentinel-tangerine));transition:transform .2s,box-shadow .2s}.cta-button:hover{transform:translateY(-2px)scale(1.02);box-shadow:0 8px 25px #ff6b6b59}.cta-button:active{transform:translateY(0)scale(.98)}.score-bar{transition:width .8s ease-out}.card-hover{transition:transform .2s,box-shadow .2s}.card-hover:hover{box-shadow:var(--shadow-lg);transform:translateY(-2px)}.theme-toggle{color:#5c6bc0;background:#fff}[data-theme=dark] .theme-toggle{color:#ffc837;background:#2a2a3e}.theme-sun{display:none}.theme-moon,[data-theme=dark] .theme-sun{display:block}[data-theme=dark] .theme-moon{display:none}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}
 
 
 
 
static/chunks/a6dad97d9634a72d.js DELETED
The diff for this file is too large to render. See raw diff