uploaded the all the dependencies
Browse files- Backend/Dockerfile +52 -0
- Backend/app/__init__.py +1 -0
- Backend/app/__pycache__/__init__.cpython-310.pyc +0 -0
- Backend/app/__pycache__/auth.cpython-310.pyc +0 -0
- Backend/app/__pycache__/database.cpython-310.pyc +0 -0
- Backend/app/__pycache__/limiter.cpython-310.pyc +0 -0
- Backend/app/__pycache__/main.cpython-310.pyc +0 -0
- Backend/app/api/__init__.py +1 -0
- Backend/app/api/__pycache__/__init__.cpython-310.pyc +0 -0
- Backend/app/api/__pycache__/auth_routes.cpython-310.pyc +0 -0
- Backend/app/api/__pycache__/routes.cpython-310.pyc +0 -0
- Backend/app/api/auth_routes.py +221 -0
- Backend/app/api/routes.py +372 -0
- Backend/app/auth.py +90 -0
- Backend/app/database.py +50 -0
- Backend/app/limiter.py +6 -0
- Backend/app/main.py +84 -0
- Backend/app/models/__init__.py +1 -0
- Backend/app/models/__pycache__/__init__.cpython-310.pyc +0 -0
- Backend/app/models/__pycache__/bert_model.cpython-310.pyc +0 -0
- Backend/app/models/bert_model.py +207 -0
- Backend/app/schemas/__init__.py +1 -0
- Backend/app/schemas/__pycache__/__init__.cpython-310.pyc +0 -0
- Backend/app/schemas/__pycache__/auth.cpython-310.pyc +0 -0
- Backend/app/schemas/__pycache__/prediction.cpython-310.pyc +0 -0
- Backend/app/schemas/auth.py +86 -0
- Backend/app/schemas/prediction.py +73 -0
- Backend/app/utils/__init__.py +1 -0
- Backend/app/utils/__pycache__/__init__.cpython-310.pyc +0 -0
- Backend/app/utils/__pycache__/ai_verification.cpython-310.pyc +0 -0
- Backend/app/utils/__pycache__/image_ocr.cpython-310.pyc +0 -0
- Backend/app/utils/__pycache__/logger.cpython-310.pyc +0 -0
- Backend/app/utils/__pycache__/news_validator.cpython-310.pyc +0 -0
- Backend/app/utils/ai_verification.py +284 -0
- Backend/app/utils/image_ocr.py +275 -0
- Backend/app/utils/logger.py +50 -0
- Backend/app/utils/news_validator.py +471 -0
- Backend/enhanced_bert_liar_model/special_tokens_map.json +7 -0
- Backend/enhanced_bert_liar_model/tokenizer.json +0 -0
- Backend/enhanced_bert_liar_model/tokenizer_config.json +56 -0
- Backend/enhanced_bert_liar_model/vocab.txt +0 -0
- Backend/enhanced_bert_welfake_model/tokenizer.json +0 -0
- Backend/enhanced_bert_welfake_model/tokenizer_config.json +14 -0
- Backend/enhanced_bert_welfake_model/vocab.txt +0 -0
- Backend/run_api.py +49 -0
Backend/Dockerfile
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile for Hugging Face Spaces deployment
|
| 2 |
+
# HF Spaces requires the app to listen on port 7860
|
| 3 |
+
FROM python:3.11-slim
|
| 4 |
+
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
+
build-essential \
|
| 7 |
+
libgl1 \
|
| 8 |
+
libglib2.0-0 \
|
| 9 |
+
curl \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
WORKDIR /app
|
| 13 |
+
|
| 14 |
+
COPY pyproject.toml ./
|
| 15 |
+
RUN pip install --upgrade pip \
|
| 16 |
+
&& pip install --no-cache-dir \
|
| 17 |
+
fastapi \
|
| 18 |
+
"uvicorn[standard]" \
|
| 19 |
+
torch \
|
| 20 |
+
transformers \
|
| 21 |
+
pillow \
|
| 22 |
+
requests \
|
| 23 |
+
pydantic \
|
| 24 |
+
"python-multipart" \
|
| 25 |
+
"google-genai" \
|
| 26 |
+
python-dotenv \
|
| 27 |
+
newsapi-python \
|
| 28 |
+
beautifulsoup4 \
|
| 29 |
+
serpapi \
|
| 30 |
+
motor \
|
| 31 |
+
pymongo \
|
| 32 |
+
"python-jose[cryptography]" \
|
| 33 |
+
"passlib[bcrypt]" \
|
| 34 |
+
email-validator \
|
| 35 |
+
mistralai \
|
| 36 |
+
slowapi
|
| 37 |
+
|
| 38 |
+
COPY app/ ./app/
|
| 39 |
+
COPY enhanced_bert_liar_model/ ./enhanced_bert_liar_model/
|
| 40 |
+
COPY enhanced_bert_welfake_model/ ./enhanced_bert_welfake_model/
|
| 41 |
+
COPY run_api.py ./
|
| 42 |
+
|
| 43 |
+
RUN mkdir -p logs
|
| 44 |
+
|
| 45 |
+
# HF Spaces requires port 7860
|
| 46 |
+
EXPOSE 7860
|
| 47 |
+
|
| 48 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
|
| 49 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
| 50 |
+
|
| 51 |
+
# Run on port 7860 for HF Spaces
|
| 52 |
+
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
Backend/app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# FastAPI application package
|
Backend/app/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (128 Bytes). View file
|
|
|
Backend/app/__pycache__/auth.cpython-310.pyc
ADDED
|
Binary file (2.76 kB). View file
|
|
|
Backend/app/__pycache__/database.cpython-310.pyc
ADDED
|
Binary file (1.48 kB). View file
|
|
|
Backend/app/__pycache__/limiter.cpython-310.pyc
ADDED
|
Binary file (254 Bytes). View file
|
|
|
Backend/app/__pycache__/main.cpython-310.pyc
ADDED
|
Binary file (2.66 kB). View file
|
|
|
Backend/app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API package
|
Backend/app/api/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (132 Bytes). View file
|
|
|
Backend/app/api/__pycache__/auth_routes.cpython-310.pyc
ADDED
|
Binary file (5.21 kB). View file
|
|
|
Backend/app/api/__pycache__/routes.cpython-310.pyc
ADDED
|
Binary file (8.91 kB). View file
|
|
|
Backend/app/api/auth_routes.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
from bson import ObjectId
|
| 4 |
+
from app.database import get_users_collection, get_predictions_collection
|
| 5 |
+
from app.schemas.auth import UserCreate, UserLogin, UserResponse, Token
|
| 6 |
+
from app.auth import (
|
| 7 |
+
get_password_hash,
|
| 8 |
+
verify_password,
|
| 9 |
+
create_access_token,
|
| 10 |
+
get_current_user,
|
| 11 |
+
ACCESS_TOKEN_EXPIRE_MINUTES
|
| 12 |
+
)
|
| 13 |
+
from app.limiter import limiter
|
| 14 |
+
from app.utils.logger import get_logger
|
| 15 |
+
|
| 16 |
+
logger = get_logger(__name__)
|
| 17 |
+
router = APIRouter(prefix="/auth", tags=["authentication"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED)
|
| 21 |
+
@limiter.limit("3/minute")
|
| 22 |
+
async def register(request: Request, user_data: UserCreate):
|
| 23 |
+
"""
|
| 24 |
+
Register a new user account.
|
| 25 |
+
|
| 26 |
+
- **email**: Valid email address (must be unique)
|
| 27 |
+
- **username**: Username (3-50 characters, must be unique)
|
| 28 |
+
- **password**: Password (min 6 characters)
|
| 29 |
+
- **full_name**: Optional full name
|
| 30 |
+
"""
|
| 31 |
+
users_collection = get_users_collection()
|
| 32 |
+
|
| 33 |
+
# Check if email already exists
|
| 34 |
+
existing_user = await users_collection.find_one({"email": user_data.email})
|
| 35 |
+
if existing_user:
|
| 36 |
+
raise HTTPException(
|
| 37 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 38 |
+
detail="Email already registered"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# Check if username already exists
|
| 42 |
+
existing_username = await users_collection.find_one({"username": user_data.username})
|
| 43 |
+
if existing_username:
|
| 44 |
+
raise HTTPException(
|
| 45 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 46 |
+
detail="Username already taken"
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Create new user
|
| 50 |
+
new_user = {
|
| 51 |
+
"email": user_data.email,
|
| 52 |
+
"username": user_data.username,
|
| 53 |
+
"full_name": user_data.full_name,
|
| 54 |
+
"hashed_password": get_password_hash(user_data.password),
|
| 55 |
+
"is_active": True,
|
| 56 |
+
"created_at": datetime.utcnow(),
|
| 57 |
+
"updated_at": datetime.utcnow()
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
result = await users_collection.insert_one(new_user)
|
| 61 |
+
user_id = str(result.inserted_id)
|
| 62 |
+
logger.info("[register] New user: id=%s | username=%s | email=%s", user_id, user_data.username, user_data.email)
|
| 63 |
+
|
| 64 |
+
# Create access token
|
| 65 |
+
access_token = create_access_token(
|
| 66 |
+
data={"sub": user_id, "email": user_data.email},
|
| 67 |
+
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
return Token(
|
| 71 |
+
access_token=access_token,
|
| 72 |
+
token_type="bearer",
|
| 73 |
+
user=UserResponse(
|
| 74 |
+
id=user_id,
|
| 75 |
+
email=new_user["email"],
|
| 76 |
+
username=new_user["username"],
|
| 77 |
+
full_name=new_user["full_name"],
|
| 78 |
+
created_at=new_user["created_at"],
|
| 79 |
+
is_active=new_user["is_active"]
|
| 80 |
+
)
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@router.post("/login", response_model=Token)
|
| 85 |
+
@limiter.limit("5/minute")
|
| 86 |
+
async def login(request: Request, credentials: UserLogin):
|
| 87 |
+
"""
|
| 88 |
+
Login with email and password to get access token.
|
| 89 |
+
|
| 90 |
+
- **email**: Registered email address
|
| 91 |
+
- **password**: Account password
|
| 92 |
+
"""
|
| 93 |
+
users_collection = get_users_collection()
|
| 94 |
+
|
| 95 |
+
# Find user by email
|
| 96 |
+
user = await users_collection.find_one({"email": credentials.email})
|
| 97 |
+
|
| 98 |
+
if not user:
|
| 99 |
+
logger.warning("[login] FAILED (unknown email) | email=%s", credentials.email)
|
| 100 |
+
raise HTTPException(
|
| 101 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 102 |
+
detail="Invalid email or password",
|
| 103 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Verify password
|
| 107 |
+
if not verify_password(credentials.password, user["hashed_password"]):
|
| 108 |
+
logger.warning("[login] FAILED (wrong password) | email=%s", credentials.email)
|
| 109 |
+
raise HTTPException(
|
| 110 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 111 |
+
detail="Invalid email or password",
|
| 112 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
# Check if user is active
|
| 116 |
+
if not user.get("is_active", True):
|
| 117 |
+
raise HTTPException(
|
| 118 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 119 |
+
detail="Account is disabled"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Create access token
|
| 123 |
+
user_id = str(user["_id"])
|
| 124 |
+
logger.info("[login] SUCCESS | id=%s | username=%s | email=%s", user_id, user["username"], user["email"])
|
| 125 |
+
access_token = create_access_token(
|
| 126 |
+
data={"sub": user_id, "email": user["email"]},
|
| 127 |
+
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
return Token(
|
| 131 |
+
access_token=access_token,
|
| 132 |
+
token_type="bearer",
|
| 133 |
+
user=UserResponse(
|
| 134 |
+
id=user_id,
|
| 135 |
+
email=user["email"],
|
| 136 |
+
username=user["username"],
|
| 137 |
+
full_name=user.get("full_name"),
|
| 138 |
+
created_at=user["created_at"],
|
| 139 |
+
is_active=user.get("is_active", True)
|
| 140 |
+
)
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@router.get("/me", response_model=UserResponse)
|
| 145 |
+
async def get_current_user_info(current_user: dict = Depends(get_current_user)):
|
| 146 |
+
"""
|
| 147 |
+
Get current authenticated user's information.
|
| 148 |
+
Requires valid JWT token in Authorization header.
|
| 149 |
+
"""
|
| 150 |
+
return UserResponse(
|
| 151 |
+
id=str(current_user["_id"]),
|
| 152 |
+
email=current_user["email"],
|
| 153 |
+
username=current_user["username"],
|
| 154 |
+
full_name=current_user.get("full_name"),
|
| 155 |
+
created_at=current_user["created_at"],
|
| 156 |
+
is_active=current_user.get("is_active", True)
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@router.get("/history")
|
| 161 |
+
async def get_prediction_history(
|
| 162 |
+
limit: int = 20,
|
| 163 |
+
current_user: dict = Depends(get_current_user)
|
| 164 |
+
):
|
| 165 |
+
"""
|
| 166 |
+
Get user's prediction history.
|
| 167 |
+
Returns the last N predictions made by the user.
|
| 168 |
+
"""
|
| 169 |
+
predictions_collection = get_predictions_collection()
|
| 170 |
+
user_id = str(current_user["_id"])
|
| 171 |
+
|
| 172 |
+
cursor = predictions_collection.find(
|
| 173 |
+
{"user_id": user_id}
|
| 174 |
+
).sort("created_at", -1).limit(limit)
|
| 175 |
+
|
| 176 |
+
predictions = []
|
| 177 |
+
async for prediction in cursor:
|
| 178 |
+
prediction["_id"] = str(prediction["_id"])
|
| 179 |
+
predictions.append(prediction)
|
| 180 |
+
|
| 181 |
+
return {"predictions": predictions, "count": len(predictions)}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
@router.get("/stats")
|
| 185 |
+
async def get_user_stats(current_user: dict = Depends(get_current_user)):
|
| 186 |
+
"""
|
| 187 |
+
Get user's prediction statistics.
|
| 188 |
+
Returns total checks, real count, and fake count.
|
| 189 |
+
"""
|
| 190 |
+
predictions_collection = get_predictions_collection()
|
| 191 |
+
user_id = str(current_user["_id"])
|
| 192 |
+
|
| 193 |
+
# Count total predictions
|
| 194 |
+
total_checks = await predictions_collection.count_documents({"user_id": user_id})
|
| 195 |
+
|
| 196 |
+
# Count real predictions
|
| 197 |
+
real_count = await predictions_collection.count_documents({
|
| 198 |
+
"user_id": user_id,
|
| 199 |
+
"is_fake": False
|
| 200 |
+
})
|
| 201 |
+
|
| 202 |
+
# Count fake predictions
|
| 203 |
+
fake_count = await predictions_collection.count_documents({
|
| 204 |
+
"user_id": user_id,
|
| 205 |
+
"is_fake": True
|
| 206 |
+
})
|
| 207 |
+
|
| 208 |
+
return {
|
| 209 |
+
"total_checks": total_checks,
|
| 210 |
+
"real_count": real_count,
|
| 211 |
+
"fake_count": fake_count
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
@router.post("/logout")
|
| 216 |
+
async def logout(current_user: dict = Depends(get_current_user)):
|
| 217 |
+
"""
|
| 218 |
+
Logout current user (client should discard the token).
|
| 219 |
+
"""
|
| 220 |
+
logger.info("[logout] user=%s | username=%s", str(current_user["_id"]), current_user.get("username"))
|
| 221 |
+
return {"message": "Successfully logged out"}
|
Backend/app/api/routes.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from app.schemas.prediction import PredictionRequest, PredictionResponse, ImagePredictionRequest, ImageExtractionResponse
|
| 4 |
+
from app.models.bert_model import get_model, predict_fake_news
|
| 5 |
+
from app.utils.ai_verification import ai_checker
|
| 6 |
+
from app.utils.news_validator import news_validator
|
| 7 |
+
from app.utils.image_ocr import image_ocr
|
| 8 |
+
from app.auth import get_current_user
|
| 9 |
+
from app.database import get_predictions_collection
|
| 10 |
+
from app.limiter import limiter
|
| 11 |
+
from app.utils.logger import get_logger
|
| 12 |
+
|
| 13 |
+
logger = get_logger(__name__)
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
@router.post("/predict", response_model=PredictionResponse)
|
| 17 |
+
@limiter.limit("30/minute")
|
| 18 |
+
async def predict(
|
| 19 |
+
request: Request,
|
| 20 |
+
body: PredictionRequest,
|
| 21 |
+
current_user: dict = Depends(get_current_user)
|
| 22 |
+
):
|
| 23 |
+
"""
|
| 24 |
+
Predict whether a news article is fake or real.
|
| 25 |
+
Flow: NewsAPI (real-world evidence) → Gemini AI (primary) → BERT (fallback only)
|
| 26 |
+
Requires authentication.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
request: PredictionRequest containing the news title and optional text
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
PredictionResponse with prediction label, confidence, and probabilities
|
| 33 |
+
"""
|
| 34 |
+
try:
|
| 35 |
+
user_id = str(current_user["_id"])
|
| 36 |
+
logger.info("[predict] user=%s | title='%.80s'", user_id, body.title)
|
| 37 |
+
|
| 38 |
+
# ── STEP 1: NewsAPI / Google News search (real-world evidence) ────────
|
| 39 |
+
news_validation = news_validator.validate_claim(body.title)
|
| 40 |
+
logger.info(
|
| 41 |
+
"[predict] news_validation status=%s relevant=%d",
|
| 42 |
+
news_validation.get("verification_status", "n/a") if news_validation else "n/a",
|
| 43 |
+
news_validation.get("relevant_articles", 0) if news_validation else 0,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# ── STEP 2: Gemini AI — PRIMARY predictor (with news context) ─────────
|
| 47 |
+
# Pass the fetched articles so Gemini reads actual content, not just headlines
|
| 48 |
+
news_articles = news_validation.get("articles", []) if news_validation else []
|
| 49 |
+
ai_result = ai_checker.predict_with_context(body.title, news_articles=news_articles)
|
| 50 |
+
|
| 51 |
+
if ai_result:
|
| 52 |
+
# Gemini succeeded → use it as the primary result
|
| 53 |
+
final_result = {
|
| 54 |
+
"text": body.title,
|
| 55 |
+
"prediction": ai_result["prediction"],
|
| 56 |
+
"confidence": ai_result["confidence"],
|
| 57 |
+
"probabilities": ai_result["probabilities"],
|
| 58 |
+
"is_fake": ai_result["is_fake"],
|
| 59 |
+
"prediction_source": "gemini_ai",
|
| 60 |
+
"classification_type": "binary",
|
| 61 |
+
"reasoning": ai_result.get("reasoning", "No reasoning available."),
|
| 62 |
+
"context_articles_used": ai_result.get("context_articles_used", 0),
|
| 63 |
+
}
|
| 64 |
+
logger.info(
|
| 65 |
+
"[predict] Gemini primary answer=%s conf=%.2f context_articles=%d",
|
| 66 |
+
ai_result["prediction"].upper(),
|
| 67 |
+
ai_result["confidence"],
|
| 68 |
+
ai_result.get("context_articles_used", 0),
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
else:
|
| 72 |
+
# ── STEP 3: BERT — FALLBACK (only when Gemini is unavailable) ────
|
| 73 |
+
logger.info("[predict] Gemini unavailable — falling back to BERT")
|
| 74 |
+
|
| 75 |
+
if body.text:
|
| 76 |
+
formatted_input = f"{body.title} [SEP] {body.text}"
|
| 77 |
+
else:
|
| 78 |
+
formatted_input = f"{body.title} [SEP] {body.title}"
|
| 79 |
+
|
| 80 |
+
model, tokenizer, checkpoint = get_model()
|
| 81 |
+
bert_result = predict_fake_news(formatted_input, model, tokenizer, checkpoint)
|
| 82 |
+
bert_result["text"] = body.title
|
| 83 |
+
|
| 84 |
+
final_result = {
|
| 85 |
+
**bert_result,
|
| 86 |
+
"is_fake": bert_result["prediction"] == "fake",
|
| 87 |
+
"prediction_source": "bert_model_fallback",
|
| 88 |
+
"reasoning": f"BERT model classified this as {bert_result['prediction']} with {bert_result['confidence']*100:.1f}% confidence based on linguistic pattern analysis.",
|
| 89 |
+
}
|
| 90 |
+
logger.info(
|
| 91 |
+
"[predict] BERT fallback answer=%s conf=%.2f",
|
| 92 |
+
bert_result["prediction"].upper(),
|
| 93 |
+
bert_result["confidence"],
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# ── Apply news validation insights to final result ────────────────────
|
| 97 |
+
final_result = news_validator.enhance_prediction(final_result, ai_result, news_validation)
|
| 98 |
+
|
| 99 |
+
# Save prediction to history
|
| 100 |
+
predictions_collection = get_predictions_collection()
|
| 101 |
+
prediction_record = {
|
| 102 |
+
"user_id": str(current_user["_id"]),
|
| 103 |
+
"text": body.title[:500], # Store title
|
| 104 |
+
"prediction": final_result["prediction"],
|
| 105 |
+
"confidence": final_result["confidence"],
|
| 106 |
+
"is_fake": final_result["is_fake"],
|
| 107 |
+
"created_at": datetime.utcnow()
|
| 108 |
+
}
|
| 109 |
+
await predictions_collection.insert_one(prediction_record)
|
| 110 |
+
|
| 111 |
+
logger.info(
|
| 112 |
+
"[predict] DONE user=%s | result=%s | confidence=%.2f | source=%s",
|
| 113 |
+
user_id,
|
| 114 |
+
final_result["prediction"].upper(),
|
| 115 |
+
final_result["confidence"],
|
| 116 |
+
final_result.get("prediction_source", "unknown"),
|
| 117 |
+
)
|
| 118 |
+
return final_result
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error("[predict] ERROR user=%s | %s", str(current_user.get("_id", "?")), e, exc_info=True)
|
| 121 |
+
raise HTTPException(status_code=500, detail=f"Prediction error: {str(e)}")
|
| 122 |
+
|
| 123 |
+
@router.post("/batch-predict")
|
| 124 |
+
@limiter.limit("5/minute")
|
| 125 |
+
async def batch_predict(
|
| 126 |
+
request: Request,
|
| 127 |
+
texts: list[str],
|
| 128 |
+
current_user: dict = Depends(get_current_user)
|
| 129 |
+
):
|
| 130 |
+
"""
|
| 131 |
+
Predict multiple news articles at once.
|
| 132 |
+
Flow: NewsAPI (real-world evidence) → Gemini AI (primary) → BERT (fallback only)
|
| 133 |
+
Requires authentication.
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
texts: List of news article texts
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
List of predictions
|
| 140 |
+
"""
|
| 141 |
+
try:
|
| 142 |
+
if len(texts) > 10:
|
| 143 |
+
raise HTTPException(status_code=422, detail="Max 10 items per batch request.")
|
| 144 |
+
user_id = str(current_user["_id"])
|
| 145 |
+
logger.info("[batch-predict] user=%s | count=%d", user_id, len(texts))
|
| 146 |
+
model, tokenizer, checkpoint = get_model()
|
| 147 |
+
results = []
|
| 148 |
+
predictions_collection = get_predictions_collection()
|
| 149 |
+
|
| 150 |
+
for text in texts:
|
| 151 |
+
# ── STEP 1: NewsAPI / Google News ─────────────────────────────────
|
| 152 |
+
news_validation = news_validator.validate_claim(text)
|
| 153 |
+
|
| 154 |
+
# ── STEP 2: Gemini AI — PRIMARY (with news context) ───────────────
|
| 155 |
+
news_articles = news_validation.get("articles", []) if news_validation else []
|
| 156 |
+
ai_result = ai_checker.predict_with_context(text, news_articles=news_articles)
|
| 157 |
+
|
| 158 |
+
if ai_result:
|
| 159 |
+
final_result = {
|
| 160 |
+
"text": text,
|
| 161 |
+
"prediction": ai_result["prediction"],
|
| 162 |
+
"confidence": ai_result["confidence"],
|
| 163 |
+
"probabilities": ai_result["probabilities"],
|
| 164 |
+
"is_fake": ai_result["is_fake"],
|
| 165 |
+
"prediction_source": "gemini_ai",
|
| 166 |
+
"classification_type": "binary",
|
| 167 |
+
"reasoning": ai_result.get("reasoning", "No reasoning available."),
|
| 168 |
+
}
|
| 169 |
+
if news_validation and news_validation.get("relevant_articles", 0) >= 2:
|
| 170 |
+
final_result["confidence"] = min(0.98, final_result["confidence"] + 0.05)
|
| 171 |
+
else:
|
| 172 |
+
# ── STEP 3: BERT — FALLBACK ────────────────────────────────────
|
| 173 |
+
formatted_input = f"{text} [SEP] {text}"
|
| 174 |
+
bert_result = predict_fake_news(formatted_input, model, tokenizer, checkpoint)
|
| 175 |
+
bert_result["text"] = text
|
| 176 |
+
final_result = {
|
| 177 |
+
**bert_result,
|
| 178 |
+
"is_fake": bert_result["prediction"] == "fake",
|
| 179 |
+
"prediction_source": "bert_model_fallback",
|
| 180 |
+
"reasoning": f"BERT model classified this as {bert_result['prediction']} with {bert_result['confidence']*100:.1f}% confidence based on linguistic pattern analysis.",
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
# ── Apply news validation insights ─────────────────────────────────
|
| 184 |
+
final_result = news_validator.enhance_prediction(final_result, ai_result, news_validation)
|
| 185 |
+
results.append(final_result)
|
| 186 |
+
|
| 187 |
+
# Save to history
|
| 188 |
+
prediction_record = {
|
| 189 |
+
"user_id": str(current_user["_id"]),
|
| 190 |
+
"text": text[:500],
|
| 191 |
+
"prediction": final_result["prediction"],
|
| 192 |
+
"confidence": final_result["confidence"],
|
| 193 |
+
"is_fake": final_result["is_fake"],
|
| 194 |
+
"created_at": datetime.utcnow()
|
| 195 |
+
}
|
| 196 |
+
await predictions_collection.insert_one(prediction_record)
|
| 197 |
+
|
| 198 |
+
logger.info("[batch-predict] DONE user=%s | processed=%d", user_id, len(results))
|
| 199 |
+
return {"predictions": results}
|
| 200 |
+
except Exception as e:
|
| 201 |
+
logger.error("[batch-predict] ERROR user=%s | %s", str(current_user.get("_id", "?")), e, exc_info=True)
|
| 202 |
+
raise HTTPException(status_code=500, detail=f"Batch prediction error: {str(e)}")
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
@router.post("/image-predict")
|
| 206 |
+
@limiter.limit("10/minute")
|
| 207 |
+
async def image_predict(
|
| 208 |
+
request: Request,
|
| 209 |
+
body: ImagePredictionRequest,
|
| 210 |
+
current_user: dict = Depends(get_current_user)
|
| 211 |
+
):
|
| 212 |
+
"""
|
| 213 |
+
Extract text from a news image and predict if it's fake or real.
|
| 214 |
+
Uses OCR to extract title and text, then runs through the prediction pipeline.
|
| 215 |
+
|
| 216 |
+
Args:
|
| 217 |
+
request: ImagePredictionRequest containing base64 encoded image
|
| 218 |
+
|
| 219 |
+
Returns:
|
| 220 |
+
Dict with extracted text and prediction results
|
| 221 |
+
"""
|
| 222 |
+
try:
|
| 223 |
+
user_id = str(current_user["_id"])
|
| 224 |
+
logger.info("[image-predict] user=%s | mime=%s", user_id, body.mime_type)
|
| 225 |
+
# Step 1: Extract text from image using OCR
|
| 226 |
+
if not image_ocr.enabled:
|
| 227 |
+
raise HTTPException(status_code=503, detail="Image OCR service not available. Check AI API key.")
|
| 228 |
+
|
| 229 |
+
extraction_result = image_ocr.extract_from_base64(body.image, body.mime_type)
|
| 230 |
+
|
| 231 |
+
if not extraction_result or not extraction_result.get("extraction_success"):
|
| 232 |
+
raise HTTPException(
|
| 233 |
+
status_code=400,
|
| 234 |
+
detail="Could not extract text from image. Please ensure the image contains readable news text."
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
title = extraction_result.get("title", "")
|
| 238 |
+
text = extraction_result.get("text", "")
|
| 239 |
+
|
| 240 |
+
# Use title if found, otherwise use first part of text
|
| 241 |
+
if title == "NOT_FOUND" or not title:
|
| 242 |
+
if text and text != "NOT_FOUND":
|
| 243 |
+
title = text[:200] # Use first 200 chars of text as title
|
| 244 |
+
else:
|
| 245 |
+
raise HTTPException(status_code=400, detail="No readable text found in image.")
|
| 246 |
+
|
| 247 |
+
# Step 2: News search (same as text pipeline — Gemini needs this context)
|
| 248 |
+
news_validation = news_validator.validate_claim(title)
|
| 249 |
+
logger.info(
|
| 250 |
+
"[image-predict] news_validation status=%s relevant=%d",
|
| 251 |
+
news_validation.get("verification_status", "n/a") if news_validation else "n/a",
|
| 252 |
+
news_validation.get("relevant_articles", 0) if news_validation else 0,
|
| 253 |
+
)
|
| 254 |
+
news_articles = news_validation.get("articles", []) if news_validation else []
|
| 255 |
+
|
| 256 |
+
# Step 3: Gemini AI — PRIMARY (with news context, identical to text pipeline)
|
| 257 |
+
ai_result = ai_checker.predict_with_context(title, news_articles=news_articles)
|
| 258 |
+
|
| 259 |
+
if ai_result:
|
| 260 |
+
final_result = {
|
| 261 |
+
"text": title,
|
| 262 |
+
"prediction": ai_result["prediction"],
|
| 263 |
+
"confidence": ai_result["confidence"],
|
| 264 |
+
"probabilities": ai_result["probabilities"],
|
| 265 |
+
"is_fake": ai_result["is_fake"],
|
| 266 |
+
"prediction_source": "gemini_ai",
|
| 267 |
+
"classification_type": "binary",
|
| 268 |
+
"extracted_from_image": True,
|
| 269 |
+
"reasoning": ai_result.get("reasoning", "No reasoning available."),
|
| 270 |
+
"context_articles_used": ai_result.get("context_articles_used", 0),
|
| 271 |
+
}
|
| 272 |
+
logger.info(
|
| 273 |
+
"[image-predict] Gemini answer=%s conf=%.2f context_articles=%d",
|
| 274 |
+
ai_result["prediction"].upper(),
|
| 275 |
+
ai_result["confidence"],
|
| 276 |
+
ai_result.get("context_articles_used", 0),
|
| 277 |
+
)
|
| 278 |
+
else:
|
| 279 |
+
# Step 4: BERT — FALLBACK (only when Gemini is unavailable)
|
| 280 |
+
logger.info("[image-predict] Gemini unavailable — falling back to BERT")
|
| 281 |
+
if text and text != "NOT_FOUND":
|
| 282 |
+
formatted_input = f"{title} [SEP] {text}"
|
| 283 |
+
else:
|
| 284 |
+
formatted_input = f"{title} [SEP] {title}"
|
| 285 |
+
model, tokenizer, checkpoint = get_model()
|
| 286 |
+
bert_result = predict_fake_news(formatted_input, model, tokenizer, checkpoint)
|
| 287 |
+
bert_result["text"] = title
|
| 288 |
+
final_result = {
|
| 289 |
+
**bert_result,
|
| 290 |
+
"is_fake": bert_result["prediction"] == "fake",
|
| 291 |
+
"prediction_source": "bert_model_fallback",
|
| 292 |
+
"extracted_from_image": True,
|
| 293 |
+
"reasoning": f"BERT model classified this as {bert_result['prediction']} with {bert_result['confidence']*100:.1f}% confidence based on linguistic pattern analysis.",
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
# Step 5: Apply news validation insights
|
| 297 |
+
final_result = news_validator.enhance_prediction(final_result, ai_result, news_validation)
|
| 298 |
+
|
| 299 |
+
# Add extraction metadata
|
| 300 |
+
final_result["image_extraction"] = {
|
| 301 |
+
"title": title,
|
| 302 |
+
"text": text if text != "NOT_FOUND" else None,
|
| 303 |
+
"source": extraction_result.get("source") if extraction_result.get("source") != "NOT_FOUND" else None,
|
| 304 |
+
"date": extraction_result.get("date") if extraction_result.get("date") != "NOT_FOUND" else None
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
# Save to history
|
| 308 |
+
predictions_collection = get_predictions_collection()
|
| 309 |
+
prediction_record = {
|
| 310 |
+
"user_id": str(current_user["_id"]),
|
| 311 |
+
"text": title[:500],
|
| 312 |
+
"prediction": final_result["prediction"],
|
| 313 |
+
"confidence": final_result["confidence"],
|
| 314 |
+
"is_fake": final_result["is_fake"],
|
| 315 |
+
"from_image": True,
|
| 316 |
+
"created_at": datetime.utcnow()
|
| 317 |
+
}
|
| 318 |
+
await predictions_collection.insert_one(prediction_record)
|
| 319 |
+
|
| 320 |
+
logger.info(
|
| 321 |
+
"[image-predict] DONE user=%s | title='%.60s' | result=%s | confidence=%.2f",
|
| 322 |
+
user_id, title,
|
| 323 |
+
final_result["prediction"].upper(),
|
| 324 |
+
final_result["confidence"],
|
| 325 |
+
)
|
| 326 |
+
return final_result
|
| 327 |
+
|
| 328 |
+
except HTTPException:
|
| 329 |
+
raise
|
| 330 |
+
except Exception as e:
|
| 331 |
+
logger.error("[image-predict] ERROR user=%s | %s", str(current_user.get("_id", "?")), e, exc_info=True)
|
| 332 |
+
raise HTTPException(status_code=500, detail=f"Image prediction error: {str(e)}")
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
@router.post("/extract-image-text", response_model=ImageExtractionResponse)
|
| 336 |
+
@limiter.limit("10/minute")
|
| 337 |
+
async def extract_image_text(
|
| 338 |
+
request: Request,
|
| 339 |
+
body: ImagePredictionRequest,
|
| 340 |
+
current_user: dict = Depends(get_current_user)
|
| 341 |
+
):
|
| 342 |
+
"""
|
| 343 |
+
Extract text from an image without making a prediction.
|
| 344 |
+
Useful for previewing extracted content before verification.
|
| 345 |
+
|
| 346 |
+
Args:
|
| 347 |
+
request: ImagePredictionRequest containing base64 encoded image
|
| 348 |
+
|
| 349 |
+
Returns:
|
| 350 |
+
ImageExtractionResponse with extracted title and text
|
| 351 |
+
"""
|
| 352 |
+
try:
|
| 353 |
+
if not image_ocr.enabled:
|
| 354 |
+
raise HTTPException(status_code=503, detail="Image OCR service not available.")
|
| 355 |
+
|
| 356 |
+
result = image_ocr.extract_from_base64(body.image, body.mime_type)
|
| 357 |
+
|
| 358 |
+
if not result:
|
| 359 |
+
raise HTTPException(status_code=400, detail="Failed to process image.")
|
| 360 |
+
|
| 361 |
+
return ImageExtractionResponse(
|
| 362 |
+
title=result.get("title", "NOT_FOUND"),
|
| 363 |
+
text=result.get("text", "NOT_FOUND"),
|
| 364 |
+
source=result.get("source") if result.get("source") != "NOT_FOUND" else None,
|
| 365 |
+
date=result.get("date") if result.get("date") != "NOT_FOUND" else None,
|
| 366 |
+
extraction_success=result.get("extraction_success", False)
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
except HTTPException:
|
| 370 |
+
raise
|
| 371 |
+
except Exception as e:
|
| 372 |
+
raise HTTPException(status_code=500, detail=f"Text extraction error: {str(e)}")
|
Backend/app/auth.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import bcrypt
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from jose import JWTError, jwt
|
| 6 |
+
from fastapi import Depends, HTTPException, status
|
| 7 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
from app.database import get_users_collection
|
| 10 |
+
from app.schemas.auth import TokenData
|
| 11 |
+
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
# JWT Configuration
|
| 15 |
+
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-super-secret-jwt-key-change-in-production")
|
| 16 |
+
ALGORITHM = "HS256"
|
| 17 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours
|
| 18 |
+
|
| 19 |
+
# Security scheme
|
| 20 |
+
security = HTTPBearer()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 24 |
+
"""Verify a password against its hash"""
|
| 25 |
+
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def get_password_hash(password: str) -> str:
|
| 29 |
+
"""Hash a password"""
|
| 30 |
+
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 34 |
+
"""Create a JWT access token"""
|
| 35 |
+
to_encode = data.copy()
|
| 36 |
+
if expires_delta:
|
| 37 |
+
expire = datetime.utcnow() + expires_delta
|
| 38 |
+
else:
|
| 39 |
+
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 40 |
+
to_encode.update({"exp": expire})
|
| 41 |
+
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 42 |
+
return encoded_jwt
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def decode_token(token: str) -> Optional[TokenData]:
|
| 46 |
+
"""Decode and validate a JWT token"""
|
| 47 |
+
try:
|
| 48 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 49 |
+
user_id: str = payload.get("sub")
|
| 50 |
+
email: str = payload.get("email")
|
| 51 |
+
if user_id is None:
|
| 52 |
+
return None
|
| 53 |
+
return TokenData(user_id=user_id, email=email)
|
| 54 |
+
except JWTError:
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
| 59 |
+
"""Get current authenticated user from JWT token"""
|
| 60 |
+
credentials_exception = HTTPException(
|
| 61 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 62 |
+
detail="Could not validate credentials",
|
| 63 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
token = credentials.credentials
|
| 67 |
+
token_data = decode_token(token)
|
| 68 |
+
|
| 69 |
+
if token_data is None:
|
| 70 |
+
raise credentials_exception
|
| 71 |
+
|
| 72 |
+
# Get user from database
|
| 73 |
+
users_collection = get_users_collection()
|
| 74 |
+
from bson import ObjectId
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
user = await users_collection.find_one({"_id": ObjectId(token_data.user_id)})
|
| 78 |
+
except:
|
| 79 |
+
raise credentials_exception
|
| 80 |
+
|
| 81 |
+
if user is None:
|
| 82 |
+
raise credentials_exception
|
| 83 |
+
|
| 84 |
+
if not user.get("is_active", True):
|
| 85 |
+
raise HTTPException(
|
| 86 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 87 |
+
detail="User account is disabled"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
return user
|
Backend/app/database.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
# MongoDB connection settings
|
| 8 |
+
MONGODB_URL = os.getenv("MONGODB_URL", "mongodb://localhost:27017")
|
| 9 |
+
DATABASE_NAME = os.getenv("DATABASE_NAME", "fake_news_detector")
|
| 10 |
+
|
| 11 |
+
# Global database client
|
| 12 |
+
client: AsyncIOMotorClient = None
|
| 13 |
+
db = None
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def connect_to_mongodb():
|
| 17 |
+
"""Connect to MongoDB database"""
|
| 18 |
+
global client, db
|
| 19 |
+
try:
|
| 20 |
+
client = AsyncIOMotorClient(MONGODB_URL)
|
| 21 |
+
db = client[DATABASE_NAME]
|
| 22 |
+
# Verify connection
|
| 23 |
+
await client.admin.command('ping')
|
| 24 |
+
print(f"✅ Connected to MongoDB: {DATABASE_NAME}")
|
| 25 |
+
except Exception as e:
|
| 26 |
+
print(f"❌ Failed to connect to MongoDB: {e}")
|
| 27 |
+
raise e
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
async def close_mongodb_connection():
|
| 31 |
+
"""Close MongoDB connection"""
|
| 32 |
+
global client
|
| 33 |
+
if client:
|
| 34 |
+
client.close()
|
| 35 |
+
print("MongoDB connection closed")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def get_database():
|
| 39 |
+
"""Get database instance"""
|
| 40 |
+
return db
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def get_users_collection():
|
| 44 |
+
"""Get users collection"""
|
| 45 |
+
return db["users"]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def get_predictions_collection():
|
| 49 |
+
"""Get predictions history collection"""
|
| 50 |
+
return db["predictions"]
|
Backend/app/limiter.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from slowapi import Limiter
|
| 2 |
+
from slowapi.util import get_remote_address
|
| 3 |
+
|
| 4 |
+
# Shared rate limiter — uses client IP as the key.
|
| 5 |
+
# Import this instance in main.py and all route files.
|
| 6 |
+
limiter = Limiter(key_func=get_remote_address)
|
Backend/app/main.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, Request
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from contextlib import asynccontextmanager
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
from slowapi import _rate_limit_exceeded_handler
|
| 7 |
+
from slowapi.errors import RateLimitExceeded
|
| 8 |
+
from app.api import routes, auth_routes
|
| 9 |
+
from app.database import connect_to_mongodb, close_mongodb_connection
|
| 10 |
+
from app.limiter import limiter
|
| 11 |
+
from app.utils.logger import get_logger
|
| 12 |
+
|
| 13 |
+
logger = get_logger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@asynccontextmanager
|
| 17 |
+
async def lifespan(app: FastAPI):
|
| 18 |
+
"""Manage application lifecycle - connect/disconnect from MongoDB"""
|
| 19 |
+
logger.info("Starting up TruthLens API...")
|
| 20 |
+
await connect_to_mongodb()
|
| 21 |
+
logger.info("MongoDB connected. API is ready.")
|
| 22 |
+
yield
|
| 23 |
+
logger.info("Shutting down TruthLens API...")
|
| 24 |
+
await close_mongodb_connection()
|
| 25 |
+
logger.info("MongoDB disconnected. Goodbye.")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
app = FastAPI(
|
| 29 |
+
title="Fake News Detection API",
|
| 30 |
+
description="API for detecting fake news using fine-tuned BERT model with user authentication",
|
| 31 |
+
version="2.0.0",
|
| 32 |
+
lifespan=lifespan
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Attach rate limiter
|
| 36 |
+
app.state.limiter = limiter
|
| 37 |
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 38 |
+
|
| 39 |
+
# Configure CORS – reads ALLOWED_ORIGINS from env (comma-separated) for production
|
| 40 |
+
_raw_origins = os.getenv(
|
| 41 |
+
"ALLOWED_ORIGINS",
|
| 42 |
+
"http://localhost,http://localhost:80,http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000,http://127.0.0.1:5173"
|
| 43 |
+
)
|
| 44 |
+
_allowed_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()]
|
| 45 |
+
|
| 46 |
+
app.add_middleware(
|
| 47 |
+
CORSMiddleware,
|
| 48 |
+
allow_origins=_allowed_origins,
|
| 49 |
+
allow_credentials=True,
|
| 50 |
+
allow_methods=["*"],
|
| 51 |
+
allow_headers=["*"],
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# ── Request/response logging middleware ──────────────────────────────────────
|
| 55 |
+
@app.middleware("http")
|
| 56 |
+
async def log_requests(request: Request, call_next):
|
| 57 |
+
start = time.time()
|
| 58 |
+
response = await call_next(request)
|
| 59 |
+
duration_ms = (time.time() - start) * 1000
|
| 60 |
+
logger.info(
|
| 61 |
+
"%s %s | status=%d | %.1fms",
|
| 62 |
+
request.method,
|
| 63 |
+
request.url.path,
|
| 64 |
+
response.status_code,
|
| 65 |
+
duration_ms,
|
| 66 |
+
)
|
| 67 |
+
return response
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# Include API routes
|
| 71 |
+
app.include_router(auth_routes.router, prefix="/api", tags=["authentication"])
|
| 72 |
+
app.include_router(routes.router, prefix="/api", tags=["predictions"])
|
| 73 |
+
|
| 74 |
+
@app.get("/")
|
| 75 |
+
async def root():
|
| 76 |
+
return {
|
| 77 |
+
"message": "Fake News Detection API",
|
| 78 |
+
"version": "1.0.0",
|
| 79 |
+
"docs": "/docs"
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
@app.get("/health")
|
| 83 |
+
async def health_check():
|
| 84 |
+
return {"status": "healthy"}
|
Backend/app/models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Models package
|
Backend/app/models/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (135 Bytes). View file
|
|
|
Backend/app/models/__pycache__/bert_model.cpython-310.pyc
ADDED
|
Binary file (4.62 kB). View file
|
|
|
Backend/app/models/bert_model.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
from transformers import BertTokenizer, BertModel
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from functools import lru_cache
|
| 6 |
+
|
| 7 |
+
class EnhancedBertForSequenceClassification(nn.Module):
|
| 8 |
+
def __init__(self, model_name='bert-base-uncased', num_classes=2, dropout=0.3):
|
| 9 |
+
super().__init__()
|
| 10 |
+
self.num_classes = num_classes
|
| 11 |
+
self.bert = BertModel.from_pretrained(model_name)
|
| 12 |
+
self.dropout = nn.Dropout(dropout)
|
| 13 |
+
|
| 14 |
+
# Additional layers for better performance
|
| 15 |
+
self.lstm = nn.LSTM(
|
| 16 |
+
input_size=self.bert.config.hidden_size,
|
| 17 |
+
hidden_size=256,
|
| 18 |
+
num_layers=2,
|
| 19 |
+
batch_first=True,
|
| 20 |
+
dropout=0.2,
|
| 21 |
+
bidirectional=True
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# Attention mechanism
|
| 25 |
+
self.attention = nn.MultiheadAttention(
|
| 26 |
+
embed_dim=512, # bidirectional LSTM output
|
| 27 |
+
num_heads=8,
|
| 28 |
+
dropout=0.1
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Classification layers
|
| 32 |
+
self.classifier = nn.Sequential(
|
| 33 |
+
nn.Linear(512, 256),
|
| 34 |
+
nn.ReLU(),
|
| 35 |
+
nn.Dropout(dropout),
|
| 36 |
+
nn.Linear(256, 128),
|
| 37 |
+
nn.ReLU(),
|
| 38 |
+
nn.Dropout(dropout),
|
| 39 |
+
nn.Linear(128, num_classes)
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# Layer normalization
|
| 43 |
+
self.layer_norm = nn.LayerNorm(512)
|
| 44 |
+
|
| 45 |
+
def forward(self, input_ids, attention_mask):
|
| 46 |
+
# BERT encoding
|
| 47 |
+
bert_output = self.bert(
|
| 48 |
+
input_ids=input_ids,
|
| 49 |
+
attention_mask=attention_mask
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Get sequence output (all tokens)
|
| 53 |
+
sequence_output = bert_output.last_hidden_state
|
| 54 |
+
sequence_output = self.dropout(sequence_output)
|
| 55 |
+
|
| 56 |
+
# LSTM layer
|
| 57 |
+
lstm_output, _ = self.lstm(sequence_output)
|
| 58 |
+
lstm_output = self.layer_norm(lstm_output)
|
| 59 |
+
|
| 60 |
+
# Self-attention
|
| 61 |
+
lstm_output_transposed = lstm_output.transpose(0, 1)
|
| 62 |
+
attn_output, _ = self.attention(
|
| 63 |
+
lstm_output_transposed,
|
| 64 |
+
lstm_output_transposed,
|
| 65 |
+
lstm_output_transposed
|
| 66 |
+
)
|
| 67 |
+
attn_output = attn_output.transpose(0, 1)
|
| 68 |
+
|
| 69 |
+
# Global max pooling
|
| 70 |
+
pooled_output = torch.max(attn_output, dim=1)[0]
|
| 71 |
+
|
| 72 |
+
# Classification
|
| 73 |
+
logits = self.classifier(pooled_output)
|
| 74 |
+
|
| 75 |
+
return logits
|
| 76 |
+
|
| 77 |
+
@lru_cache(maxsize=1)
|
| 78 |
+
def get_model():
|
| 79 |
+
"""
|
| 80 |
+
Load the fine-tuned BERT model and tokenizer.
|
| 81 |
+
Uses caching to load only once.
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
tuple: (model, tokenizer, checkpoint_info)
|
| 85 |
+
"""
|
| 86 |
+
model_path = Path(__file__).parent.parent.parent / "enhanced_bert_welfake_model"
|
| 87 |
+
|
| 88 |
+
# Load tokenizer
|
| 89 |
+
tokenizer = BertTokenizer.from_pretrained(str(model_path))
|
| 90 |
+
|
| 91 |
+
# Load checkpoint
|
| 92 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 93 |
+
checkpoint = torch.load(
|
| 94 |
+
model_path / "model.pth",
|
| 95 |
+
map_location=device
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Get model configuration from checkpoint
|
| 99 |
+
num_classes = checkpoint.get('num_classes', 2)
|
| 100 |
+
classification_type = checkpoint.get('classification_type', 'binary')
|
| 101 |
+
model_config = checkpoint.get('config', {})
|
| 102 |
+
dropout = model_config.get('dropout', 0.3)
|
| 103 |
+
model_name = model_config.get('model_name', 'bert-base-uncased')
|
| 104 |
+
|
| 105 |
+
# Create model with correct architecture
|
| 106 |
+
model = EnhancedBertForSequenceClassification(
|
| 107 |
+
model_name=model_name,
|
| 108 |
+
num_classes=num_classes,
|
| 109 |
+
dropout=dropout
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# Load state dict
|
| 113 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 114 |
+
model.to(device)
|
| 115 |
+
model.eval()
|
| 116 |
+
|
| 117 |
+
return model, tokenizer, checkpoint
|
| 118 |
+
|
| 119 |
+
def predict_fake_news(text: str, model=None, tokenizer=None, checkpoint=None):
|
| 120 |
+
"""
|
| 121 |
+
Predict whether a news article is fake or real.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
text: News article text (can be title only, or title [SEP] text format)
|
| 125 |
+
model: Pre-loaded model (optional)
|
| 126 |
+
tokenizer: Pre-loaded tokenizer (optional)
|
| 127 |
+
checkpoint: Model checkpoint with metadata (optional)
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
dict: Prediction results with label, confidence, and probabilities
|
| 131 |
+
"""
|
| 132 |
+
if model is None or tokenizer is None:
|
| 133 |
+
model, tokenizer, checkpoint = get_model()
|
| 134 |
+
|
| 135 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 136 |
+
|
| 137 |
+
# Format input to match training format: title [SEP] text
|
| 138 |
+
# If input doesn't have [SEP], treat the whole input as title + duplicate as text
|
| 139 |
+
if '[SEP]' not in text:
|
| 140 |
+
# User passed only headline/claim - format it like training data
|
| 141 |
+
# Use the text as both title and content for better model understanding
|
| 142 |
+
formatted_text = f"{text} [SEP] {text}"
|
| 143 |
+
else:
|
| 144 |
+
formatted_text = text
|
| 145 |
+
|
| 146 |
+
# Determine classification type from checkpoint
|
| 147 |
+
num_classes = checkpoint.get('num_classes', 2) if checkpoint else 2
|
| 148 |
+
classification_type = checkpoint.get('classification_type', 'binary') if checkpoint else 'binary'
|
| 149 |
+
|
| 150 |
+
# Label mapping based on classification type
|
| 151 |
+
# NOTE: WELFake dataset uses:
|
| 152 |
+
# 0 = real (legitimate news)
|
| 153 |
+
# 1 = fake (fake/misleading news)
|
| 154 |
+
if classification_type == 'binary' and num_classes == 2:
|
| 155 |
+
labels = {
|
| 156 |
+
0: "real",
|
| 157 |
+
1: "fake"
|
| 158 |
+
}
|
| 159 |
+
elif num_classes == 6:
|
| 160 |
+
labels = {
|
| 161 |
+
0: "pants-fire",
|
| 162 |
+
1: "false",
|
| 163 |
+
2: "barely-true",
|
| 164 |
+
3: "half-true",
|
| 165 |
+
4: "mostly-true",
|
| 166 |
+
5: "true"
|
| 167 |
+
}
|
| 168 |
+
else:
|
| 169 |
+
labels = {i: f"class_{i}" for i in range(num_classes)}
|
| 170 |
+
|
| 171 |
+
# Tokenize input (use formatted text)
|
| 172 |
+
encoding = tokenizer(
|
| 173 |
+
formatted_text,
|
| 174 |
+
add_special_tokens=True,
|
| 175 |
+
max_length=512,
|
| 176 |
+
padding='max_length',
|
| 177 |
+
truncation=True,
|
| 178 |
+
return_tensors='pt'
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
input_ids = encoding['input_ids'].to(device)
|
| 182 |
+
attention_mask = encoding['attention_mask'].to(device)
|
| 183 |
+
|
| 184 |
+
# Make prediction
|
| 185 |
+
with torch.no_grad():
|
| 186 |
+
logits = model(input_ids, attention_mask)
|
| 187 |
+
probabilities = torch.softmax(logits, dim=1)
|
| 188 |
+
predicted_class = torch.argmax(probabilities, dim=1).item()
|
| 189 |
+
confidence = probabilities[0][predicted_class].item()
|
| 190 |
+
|
| 191 |
+
# Convert probabilities to dict
|
| 192 |
+
prob_dict = {labels[i]: float(probabilities[0][i].item()) for i in range(num_classes)}
|
| 193 |
+
|
| 194 |
+
# Determine if fake based on classification type
|
| 195 |
+
if classification_type == 'binary':
|
| 196 |
+
is_fake = predicted_class == 1 # class 1 is "fake" in WELFake dataset
|
| 197 |
+
else:
|
| 198 |
+
is_fake = predicted_class < 3 # pants-fire, false, barely-true are considered fake
|
| 199 |
+
|
| 200 |
+
return {
|
| 201 |
+
"text": text, # Return original text, not formatted
|
| 202 |
+
"prediction": labels[predicted_class],
|
| 203 |
+
"confidence": float(confidence),
|
| 204 |
+
"probabilities": prob_dict,
|
| 205 |
+
"is_fake": is_fake,
|
| 206 |
+
"classification_type": classification_type
|
| 207 |
+
}
|
Backend/app/schemas/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Schemas package
|
Backend/app/schemas/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (136 Bytes). View file
|
|
|
Backend/app/schemas/__pycache__/auth.cpython-310.pyc
ADDED
|
Binary file (3.29 kB). View file
|
|
|
Backend/app/schemas/__pycache__/prediction.cpython-310.pyc
ADDED
|
Binary file (3.76 kB). View file
|
|
|
Backend/app/schemas/auth.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr, Field
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class UserCreate(BaseModel):
|
| 7 |
+
"""Schema for user registration"""
|
| 8 |
+
email: EmailStr = Field(..., description="User email address")
|
| 9 |
+
username: str = Field(..., min_length=3, max_length=50, description="Username")
|
| 10 |
+
password: str = Field(..., min_length=6, description="Password (min 6 characters)")
|
| 11 |
+
full_name: Optional[str] = Field(None, description="Full name")
|
| 12 |
+
|
| 13 |
+
class Config:
|
| 14 |
+
json_schema_extra = {
|
| 15 |
+
"example": {
|
| 16 |
+
"email": "user@example.com",
|
| 17 |
+
"username": "johndoe",
|
| 18 |
+
"password": "securepassword123",
|
| 19 |
+
"full_name": "John Doe"
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class UserLogin(BaseModel):
|
| 25 |
+
"""Schema for user login"""
|
| 26 |
+
email: EmailStr = Field(..., description="User email address")
|
| 27 |
+
password: str = Field(..., description="Password")
|
| 28 |
+
|
| 29 |
+
class Config:
|
| 30 |
+
json_schema_extra = {
|
| 31 |
+
"example": {
|
| 32 |
+
"email": "user@example.com",
|
| 33 |
+
"password": "securepassword123"
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class UserResponse(BaseModel):
|
| 39 |
+
"""Schema for user response (without password)"""
|
| 40 |
+
id: str = Field(..., description="User ID")
|
| 41 |
+
email: str = Field(..., description="User email")
|
| 42 |
+
username: str = Field(..., description="Username")
|
| 43 |
+
full_name: Optional[str] = Field(None, description="Full name")
|
| 44 |
+
created_at: datetime = Field(..., description="Account creation date")
|
| 45 |
+
is_active: bool = Field(default=True, description="Whether user is active")
|
| 46 |
+
|
| 47 |
+
class Config:
|
| 48 |
+
json_schema_extra = {
|
| 49 |
+
"example": {
|
| 50 |
+
"id": "507f1f77bcf86cd799439011",
|
| 51 |
+
"email": "user@example.com",
|
| 52 |
+
"username": "johndoe",
|
| 53 |
+
"full_name": "John Doe",
|
| 54 |
+
"created_at": "2024-01-15T10:30:00Z",
|
| 55 |
+
"is_active": True
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class Token(BaseModel):
|
| 61 |
+
"""Schema for JWT token response"""
|
| 62 |
+
access_token: str = Field(..., description="JWT access token")
|
| 63 |
+
token_type: str = Field(default="bearer", description="Token type")
|
| 64 |
+
user: UserResponse = Field(..., description="User information")
|
| 65 |
+
|
| 66 |
+
class Config:
|
| 67 |
+
json_schema_extra = {
|
| 68 |
+
"example": {
|
| 69 |
+
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
| 70 |
+
"token_type": "bearer",
|
| 71 |
+
"user": {
|
| 72 |
+
"id": "507f1f77bcf86cd799439011",
|
| 73 |
+
"email": "user@example.com",
|
| 74 |
+
"username": "johndoe",
|
| 75 |
+
"full_name": "John Doe",
|
| 76 |
+
"created_at": "2024-01-15T10:30:00Z",
|
| 77 |
+
"is_active": True
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class TokenData(BaseModel):
|
| 84 |
+
"""Schema for decoded token data"""
|
| 85 |
+
user_id: Optional[str] = None
|
| 86 |
+
email: Optional[str] = None
|
Backend/app/schemas/prediction.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
class PredictionRequest(BaseModel):
|
| 5 |
+
title: str = Field(..., description="News headline/title to analyze", min_length=5)
|
| 6 |
+
text: Optional[str] = Field(default=None, description="Full article text (optional, improves accuracy)")
|
| 7 |
+
|
| 8 |
+
class Config:
|
| 9 |
+
json_schema_extra = {
|
| 10 |
+
"example": {
|
| 11 |
+
"title": "Scientists Discover New Treatment for Cancer",
|
| 12 |
+
"text": "Researchers at Johns Hopkins University have announced a breakthrough in cancer treatment."
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
class ImagePredictionRequest(BaseModel):
|
| 17 |
+
image: str = Field(..., description="Base64 encoded image data")
|
| 18 |
+
mime_type: str = Field(default="image/jpeg", description="Image MIME type (image/jpeg, image/png)")
|
| 19 |
+
|
| 20 |
+
class Config:
|
| 21 |
+
json_schema_extra = {
|
| 22 |
+
"example": {
|
| 23 |
+
"image": "base64_encoded_image_data_here...",
|
| 24 |
+
"mime_type": "image/jpeg"
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
class ImageExtractionResponse(BaseModel):
|
| 29 |
+
title: str = Field(..., description="Extracted news title from image")
|
| 30 |
+
text: str = Field(..., description="Extracted text content from image")
|
| 31 |
+
source: Optional[str] = Field(default=None, description="News source if detected")
|
| 32 |
+
date: Optional[str] = Field(default=None, description="Date if detected")
|
| 33 |
+
extraction_success: bool = Field(..., description="Whether text extraction was successful")
|
| 34 |
+
|
| 35 |
+
class PredictionResponse(BaseModel):
|
| 36 |
+
text: str = Field(..., description="Original input text")
|
| 37 |
+
prediction: str = Field(..., description="Predicted label (e.g., fake, real)")
|
| 38 |
+
confidence: float = Field(..., description="Confidence score for the prediction", ge=0, le=1)
|
| 39 |
+
probabilities: dict[str, float] = Field(..., description="Probabilities for all labels")
|
| 40 |
+
is_fake: bool = Field(..., description="Whether the news is considered fake")
|
| 41 |
+
classification_type: str = Field(default="binary", description="Type of classification (binary or multi-class)")
|
| 42 |
+
|
| 43 |
+
# Prediction source tracking
|
| 44 |
+
prediction_source: str | None = Field(default=None, description="Source of prediction (bert_model, gemini_ai, or bert_model+gemini_ai)")
|
| 45 |
+
override_reason: str | None = Field(default=None, description="Reason if prediction was overridden by news sources")
|
| 46 |
+
|
| 47 |
+
# Reasoning
|
| 48 |
+
reasoning: str | None = Field(default=None, description="Explanation of why the article was classified as real or fake")
|
| 49 |
+
|
| 50 |
+
# News validation fields
|
| 51 |
+
news_validation: dict | None = Field(default=None, description="News source validation results")
|
| 52 |
+
news_insight: str | None = Field(default=None, description="Insight from news validation")
|
| 53 |
+
verification_boost: float | None = Field(default=None, description="Confidence adjustment from news validation")
|
| 54 |
+
|
| 55 |
+
class Config:
|
| 56 |
+
json_schema_extra = {
|
| 57 |
+
"example": {
|
| 58 |
+
"text": "Breaking news: Scientists have discovered a new planet in our solar system.",
|
| 59 |
+
"prediction": "fake",
|
| 60 |
+
"confidence": 0.85,
|
| 61 |
+
"probabilities": {
|
| 62 |
+
"real": 0.15,
|
| 63 |
+
"fake": 0.85
|
| 64 |
+
},
|
| 65 |
+
"is_fake": True,
|
| 66 |
+
"classification_type": "binary",
|
| 67 |
+
"news_validation": {
|
| 68 |
+
"verification_status": "not_found",
|
| 69 |
+
"total_articles_found": 0
|
| 70 |
+
},
|
| 71 |
+
"news_insight": "No news coverage found"
|
| 72 |
+
}
|
| 73 |
+
}
|
Backend/app/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Utils package
|
Backend/app/utils/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (134 Bytes). View file
|
|
|
Backend/app/utils/__pycache__/ai_verification.cpython-310.pyc
ADDED
|
Binary file (9.86 kB). View file
|
|
|
Backend/app/utils/__pycache__/image_ocr.cpython-310.pyc
ADDED
|
Binary file (6.61 kB). View file
|
|
|
Backend/app/utils/__pycache__/logger.cpython-310.pyc
ADDED
|
Binary file (1.26 kB). View file
|
|
|
Backend/app/utils/__pycache__/news_validator.cpython-310.pyc
ADDED
|
Binary file (12.8 kB). View file
|
|
|
Backend/app/utils/ai_verification.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
import time
|
| 4 |
+
from google import genai
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
from typing import Optional, Dict, List
|
| 7 |
+
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
# Models to try in order (current stable models per docs.ai.google.dev/gemini-api/docs/models)
|
| 11 |
+
_CANDIDATE_MODELS = [
|
| 12 |
+
"gemini-2.5-flash-lite", # most budget-friendly + fastest, separate quota pool
|
| 13 |
+
"gemini-2.5-flash", # best price-performance fallback
|
| 14 |
+
"gemini-2.0-flash", # deprecated but still available as last resort
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
_MAX_RETRIES = 3
|
| 18 |
+
_RETRY_DELAY = 30 # seconds to wait on quota error
|
| 19 |
+
|
| 20 |
+
class AIFactChecker:
|
| 21 |
+
def __init__(self):
|
| 22 |
+
api_key = os.getenv('AI_API_KEY')
|
| 23 |
+
self.enabled = os.getenv('ENABLE_AI_CHECK', 'true').lower() == 'true'
|
| 24 |
+
self._client = None
|
| 25 |
+
self._model_id = None
|
| 26 |
+
|
| 27 |
+
if api_key and api_key != 'your_api_key_here' and len(api_key) > 10:
|
| 28 |
+
try:
|
| 29 |
+
# New google-genai SDK uses a Client object
|
| 30 |
+
self._client = genai.Client(api_key=api_key)
|
| 31 |
+
# Pick the first model that doesn't raise on a list call
|
| 32 |
+
self._model_id = _CANDIDATE_MODELS[0] # will be confirmed on first call
|
| 33 |
+
self.enabled = True
|
| 34 |
+
print(f"✓ Gemini AI (google-genai SDK) ready — primary model: {self._model_id}")
|
| 35 |
+
except Exception as e:
|
| 36 |
+
print(f"⚠ Failed to initialise Gemini AI: {e}")
|
| 37 |
+
self._client = None
|
| 38 |
+
self._model_id = None
|
| 39 |
+
self.enabled = False
|
| 40 |
+
else:
|
| 41 |
+
self._client = None
|
| 42 |
+
self._model_id = None
|
| 43 |
+
self.enabled = False
|
| 44 |
+
print("⚠ AI API key not configured, using BERT model only")
|
| 45 |
+
|
| 46 |
+
# ── robust response parser ─────────────────────────────────────────────────
|
| 47 |
+
def _parse_response(self, text_response: str) -> Optional[Dict]:
|
| 48 |
+
"""
|
| 49 |
+
Parse Gemini's structured response.
|
| 50 |
+
Uses strict regex to avoid false-fake from lines like 'REAL (not FAKE)'.
|
| 51 |
+
"""
|
| 52 |
+
print(f"[Gemini raw response]:\n{text_response}\n---")
|
| 53 |
+
|
| 54 |
+
# Strict match: look for CLASSIFICATION line
|
| 55 |
+
# Valid values: REAL, FAKE, UNVERIFIED
|
| 56 |
+
classification = "fake" # default — for claims that can't be confirmed, lean fake
|
| 57 |
+
unverified = False
|
| 58 |
+
for line in text_response.split('\n'):
|
| 59 |
+
if 'CLASSIFICATION' in line.upper():
|
| 60 |
+
after_colon = line.split(':', 1)[-1].strip().upper()
|
| 61 |
+
if re.search(r'\bFAKE\b', after_colon) and not re.search(r'\bNOT\s+FAKE\b', after_colon):
|
| 62 |
+
classification = "fake"
|
| 63 |
+
elif re.search(r'\b(UNVERIFIED|UNCERTAIN|UNCLEAR|MISLEADING)\b', after_colon):
|
| 64 |
+
# UNVERIFIED = we cannot confirm the claim → treat as fake (safer default)
|
| 65 |
+
classification = "fake"
|
| 66 |
+
unverified = True
|
| 67 |
+
elif re.search(r'\bREAL\b', after_colon):
|
| 68 |
+
classification = "real"
|
| 69 |
+
break
|
| 70 |
+
|
| 71 |
+
# UNVERIFIED gets a lower base confidence (0.60) instead of 0.75
|
| 72 |
+
confidence = 0.60 if unverified else 0.75
|
| 73 |
+
for line in text_response.split('\n'):
|
| 74 |
+
if 'CONFIDENCE' in line.upper():
|
| 75 |
+
match = re.search(r'(\d+(?:\.\d+)?)', line)
|
| 76 |
+
if match:
|
| 77 |
+
confidence = float(match.group(1)) / 100.0
|
| 78 |
+
if unverified:
|
| 79 |
+
confidence = min(confidence, 0.68) # cap UNVERIFIED so it stays low-confidence
|
| 80 |
+
confidence = min(max(confidence, 0.50), 0.99)
|
| 81 |
+
break
|
| 82 |
+
|
| 83 |
+
# Extract the REASONING line for user-facing display
|
| 84 |
+
reasoning = ""
|
| 85 |
+
capture = False
|
| 86 |
+
for line in text_response.split('\n'):
|
| 87 |
+
if 'REASONING' in line.upper():
|
| 88 |
+
reasoning = line.split(':', 1)[-1].strip() if ':' in line else ""
|
| 89 |
+
capture = True
|
| 90 |
+
continue
|
| 91 |
+
if capture and line.strip():
|
| 92 |
+
reasoning += " " + line.strip()
|
| 93 |
+
reasoning = reasoning.strip() or "No detailed reasoning provided."
|
| 94 |
+
|
| 95 |
+
return {
|
| 96 |
+
"prediction": classification,
|
| 97 |
+
"confidence": confidence,
|
| 98 |
+
"probabilities": {
|
| 99 |
+
"fake": confidence if classification == "fake" else round(1 - confidence, 4),
|
| 100 |
+
"real": confidence if classification == "real" else round(1 - confidence, 4),
|
| 101 |
+
},
|
| 102 |
+
"is_fake": classification == "fake",
|
| 103 |
+
"ai_reasoning": text_response,
|
| 104 |
+
"reasoning": reasoning,
|
| 105 |
+
"ai_enabled": True,
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
# ── context-aware primary prediction ──────────────────────────────────────
|
| 109 |
+
def predict_with_context(
|
| 110 |
+
self,
|
| 111 |
+
text: str,
|
| 112 |
+
news_articles: Optional[List[Dict]] = None,
|
| 113 |
+
) -> Optional[Dict]:
|
| 114 |
+
"""
|
| 115 |
+
PRIMARY predictor. Gemini receives:
|
| 116 |
+
- The user's claim / headline
|
| 117 |
+
- Real news articles (title + description + fetched body snippet + URL)
|
| 118 |
+
Uses evidence to decide REAL vs FAKE.
|
| 119 |
+
"""
|
| 120 |
+
if not self.enabled or not self._client:
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
try:
|
| 124 |
+
# ── Build evidence block ──────────────────────────────────────────
|
| 125 |
+
evidence_block = ""
|
| 126 |
+
usable_articles = [a for a in (news_articles or []) if a.get("title")]
|
| 127 |
+
if usable_articles: # noqa: SIM102
|
| 128 |
+
lines = []
|
| 129 |
+
for i, art in enumerate(usable_articles[:5], 1):
|
| 130 |
+
title = art.get("title", "").strip()
|
| 131 |
+
source = art.get("source", "Unknown")
|
| 132 |
+
url = art.get("url", "")
|
| 133 |
+
pub_date = (art.get("published_at") or "").strip()
|
| 134 |
+
desc = (art.get("description") or art.get("snippet") or "").strip()[:300]
|
| 135 |
+
snippet = (art.get("fetched_snippet") or "").strip()[:500]
|
| 136 |
+
|
| 137 |
+
entry = f"[Article {i}] {source}\n"
|
| 138 |
+
if pub_date:
|
| 139 |
+
entry += f" Published: {pub_date}\n"
|
| 140 |
+
entry += f" Headline : {title}\n"
|
| 141 |
+
if desc:
|
| 142 |
+
entry += f" Summary : {desc}\n"
|
| 143 |
+
if snippet:
|
| 144 |
+
entry += f" Body text: {snippet}\n"
|
| 145 |
+
if url:
|
| 146 |
+
entry += f" URL : {url}\n"
|
| 147 |
+
lines.append(entry)
|
| 148 |
+
|
| 149 |
+
evidence_block = (
|
| 150 |
+
"\n=== LIVE NEWS ARTICLES RETRIEVED FROM THE WEB ===\n"
|
| 151 |
+
+ "\n".join(lines)
|
| 152 |
+
+ "=== END OF RETRIEVED ARTICLES ===\n"
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
# ── Prompt ────────────────────────────────────────────────────────
|
| 156 |
+
if evidence_block:
|
| 157 |
+
prompt = f"""You are an expert fact-checker. Assess whether the following claim is TRUE, FALSE, or UNVERIFIED.
|
| 158 |
+
|
| 159 |
+
CLAIM TO VERIFY: "{text}"
|
| 160 |
+
|
| 161 |
+
REAL NEWS ARTICLES RETRIEVED FROM THE WEB:
|
| 162 |
+
{evidence_block}
|
| 163 |
+
|
| 164 |
+
CLASSIFICATION RULES — read carefully before deciding:
|
| 165 |
+
|
| 166 |
+
• REAL — Use this when:
|
| 167 |
+
- The retrieved articles SPECIFICALLY confirm the core factual event described in the claim (who, what, where) actually happened.
|
| 168 |
+
- The claim's key facts are directly supported by the articles — not just topically related.
|
| 169 |
+
- Sensationalist phrasing of a CONFIRMED real event is NOT fake news.
|
| 170 |
+
- Do NOT choose REAL merely because the articles cover a related topic without confirming the specific claim.
|
| 171 |
+
|
| 172 |
+
• FAKE — Use this when:
|
| 173 |
+
- The retrieved articles DIRECTLY CONTRADICT the specific factual assertion (e.g. the event did not happen, the wrong person is named, the statistic is fabricated).
|
| 174 |
+
- The claim describes a HIGH-PROFILE EXTRAORDINARY EVENT (e.g. assassination of a sitting world leader, nuclear exchange, military attack on a capital city, declaration of world war) that would generate massive global breaking news coverage, yet NONE of the retrieved articles mention it occurring.
|
| 175 |
+
- There is clear evidence of fabrication or misinformation.
|
| 176 |
+
- Do NOT choose FAKE simply because the claim uses strong language or covers a sensitive topic — only when the specific facts are contradicted or clearly absent from worldwide coverage.
|
| 177 |
+
|
| 178 |
+
• UNVERIFIED — Use this when:
|
| 179 |
+
- The retrieved articles cover a related topic but do NOT specifically confirm or deny the claim.
|
| 180 |
+
- The claim is about an ordinary or minor event in 2025–2026 that may not be fully reported yet.
|
| 181 |
+
- You cannot determine truth or falsehood from the available evidence.
|
| 182 |
+
- When in doubt between FAKE and UNVERIFIED for ORDINARY claims, choose UNVERIFIED.
|
| 183 |
+
- EXCEPTION: For extraordinary high-profile claims (world leader death, nuclear attack, etc.), if no article confirms it, choose FAKE — not UNVERIFIED.
|
| 184 |
+
|
| 185 |
+
IMPORTANT: Finding articles about a RELATED TOPIC (e.g. Iran missile attacks) does NOT confirm a SPECIFIC claim (e.g. US President was killed). Check whether the articles confirm the exact claim, not just the general subject area.
|
| 186 |
+
|
| 187 |
+
YOU MUST RESPOND IN EXACTLY THIS FORMAT — no preamble, no extra lines:
|
| 188 |
+
CLASSIFICATION: REAL
|
| 189 |
+
CONFIDENCE: 85%
|
| 190 |
+
REASONING: Brief explanation referencing the articles.
|
| 191 |
+
|
| 192 |
+
Valid classifications: REAL, FAKE, UNVERIFIED"""
|
| 193 |
+
else:
|
| 194 |
+
prompt = f"""You are an expert fact-checker.
|
| 195 |
+
|
| 196 |
+
CLAIM TO VERIFY: "{text}"
|
| 197 |
+
|
| 198 |
+
No live news articles were retrieved for this claim.
|
| 199 |
+
|
| 200 |
+
INSTRUCTIONS:
|
| 201 |
+
- For ordinary events in 2024–2026, default to UNVERIFIED — they may simply be outside your training data.
|
| 202 |
+
- EXCEPTION: For extraordinary high-profile claims (e.g. assassination of a sitting world leader, nuclear war, major capital city attacked, declaration of world war between superpowers), the ABSENCE of news coverage is itself strong evidence the event did not happen. Such events would generate instant worldwide breaking news. If you have no knowledge of the event occurring AND no articles confirm it, classify as FAKE.
|
| 203 |
+
- Choose FAKE for claims with clearly impossible statistics, demonstrably established hoaxes, direct logical impossibilities, classic misinformation patterns, or extraordinary world-headline events for which no confirmation exists anywhere.
|
| 204 |
+
- Choose REAL only if you have strong, specific knowledge confirming this exact claim.
|
| 205 |
+
- When uncertain about ordinary claims: UNVERIFIED is safer than FAKE.
|
| 206 |
+
|
| 207 |
+
YOU MUST RESPOND IN EXACTLY THIS FORMAT — no preamble:
|
| 208 |
+
CLASSIFICATION: UNVERIFIED
|
| 209 |
+
CONFIDENCE: 60%
|
| 210 |
+
REASONING: Brief explanation here.
|
| 211 |
+
|
| 212 |
+
Valid classifications: REAL, FAKE, UNVERIFIED"""
|
| 213 |
+
|
| 214 |
+
# ── Call Gemini — try each model, fall to next on quota error ────
|
| 215 |
+
last_error = None
|
| 216 |
+
for attempt in range(_MAX_RETRIES):
|
| 217 |
+
all_quota = True
|
| 218 |
+
for model_id in _CANDIDATE_MODELS:
|
| 219 |
+
try:
|
| 220 |
+
response = self._client.models.generate_content(
|
| 221 |
+
model=model_id,
|
| 222 |
+
contents=prompt,
|
| 223 |
+
)
|
| 224 |
+
self._model_id = model_id
|
| 225 |
+
result = self._parse_response(response.text)
|
| 226 |
+
if result:
|
| 227 |
+
result["context_articles_used"] = len(usable_articles)
|
| 228 |
+
return result
|
| 229 |
+
except Exception as model_err:
|
| 230 |
+
err_str = str(model_err)
|
| 231 |
+
last_error = model_err
|
| 232 |
+
is_quota = (
|
| 233 |
+
"quota" in err_str.lower()
|
| 234 |
+
or "429" in err_str
|
| 235 |
+
or "resource_exhausted" in err_str.lower()
|
| 236 |
+
)
|
| 237 |
+
if is_quota:
|
| 238 |
+
# Parse suggested retry delay from error body
|
| 239 |
+
delay_match = re.search(r'retry in (\d+(?:\.\d+)?)s', err_str.lower())
|
| 240 |
+
suggested = int(float(delay_match.group(1))) + 2 if delay_match else _RETRY_DELAY
|
| 241 |
+
print(f"⚠ Quota on {model_id} (attempt {attempt+1}), trying next model…")
|
| 242 |
+
# DO NOT sleep here — try next model first
|
| 243 |
+
continue # ← try next model immediately
|
| 244 |
+
else:
|
| 245 |
+
all_quota = False
|
| 246 |
+
print(f"⚠ Model {model_id} error: {err_str[:120]}")
|
| 247 |
+
continue # try next model
|
| 248 |
+
|
| 249 |
+
# All models failed this attempt
|
| 250 |
+
if all_quota and attempt < _MAX_RETRIES - 1:
|
| 251 |
+
wait = suggested if 'suggested' in dir() else _RETRY_DELAY
|
| 252 |
+
print(f"⚠ All models quota-exhausted. Waiting {wait}s before retry {attempt+2}/{_MAX_RETRIES}…")
|
| 253 |
+
time.sleep(wait)
|
| 254 |
+
|
| 255 |
+
print(f"Gemini: all {len(_CANDIDATE_MODELS)} models failed after {_MAX_RETRIES} attempts. Last error: {str(last_error)[:200]}")
|
| 256 |
+
return None
|
| 257 |
+
|
| 258 |
+
except Exception as e:
|
| 259 |
+
print(f"Gemini unexpected error: {e}")
|
| 260 |
+
return None
|
| 261 |
+
|
| 262 |
+
# ── backwards-compat wrappers ──────────────────────────────────────────────
|
| 263 |
+
def predict(self, text: str) -> Optional[Dict]:
|
| 264 |
+
return self.predict_with_context(text, news_articles=None)
|
| 265 |
+
|
| 266 |
+
def check_claim(self, text: str) -> Optional[Dict]:
|
| 267 |
+
return self.predict(text)
|
| 268 |
+
|
| 269 |
+
def reconcile_predictions(self, bert_prediction: Dict, ai_result: Optional[Dict]) -> Dict:
|
| 270 |
+
if ai_result and self.enabled:
|
| 271 |
+
return {
|
| 272 |
+
"text": bert_prediction.get("text", ""),
|
| 273 |
+
"prediction": ai_result["prediction"],
|
| 274 |
+
"confidence": ai_result["confidence"],
|
| 275 |
+
"probabilities": ai_result["probabilities"],
|
| 276 |
+
"is_fake": ai_result["is_fake"],
|
| 277 |
+
"classification_type": "binary",
|
| 278 |
+
}
|
| 279 |
+
return {**bert_prediction, "is_fake": bert_prediction["prediction"] == "fake"}
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
# Global instance
|
| 283 |
+
ai_checker = AIFactChecker()
|
| 284 |
+
|
Backend/app/utils/image_ocr.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import base64
|
| 3 |
+
import requests
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
from typing import Optional, Dict
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
# mistralai>=1.x
|
| 9 |
+
from mistralai import Mistral
|
| 10 |
+
except Exception:
|
| 11 |
+
try:
|
| 12 |
+
# mistralai<1.x
|
| 13 |
+
from mistralai.client import MistralClient as Mistral
|
| 14 |
+
except Exception:
|
| 15 |
+
Mistral = None
|
| 16 |
+
|
| 17 |
+
load_dotenv()
|
| 18 |
+
|
| 19 |
+
class ImageOCR:
|
| 20 |
+
"""
|
| 21 |
+
Extract text from news images using Mistral OCR API.
|
| 22 |
+
Useful for analyzing screenshots of news shared on social media.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
def __init__(self):
|
| 26 |
+
self.api_key = os.getenv('MISTRAL_API_KEY')
|
| 27 |
+
self.enabled = False
|
| 28 |
+
self.client = None
|
| 29 |
+
self.use_http_fallback = False
|
| 30 |
+
self.model = "mistral-ocr-latest" # Mistral's OCR model
|
| 31 |
+
|
| 32 |
+
if not (self.api_key and self.api_key != 'your_api_key_here' and len(self.api_key) > 10):
|
| 33 |
+
print("⚠ MISTRAL_API_KEY not configured, image OCR disabled")
|
| 34 |
+
return
|
| 35 |
+
|
| 36 |
+
if Mistral is not None:
|
| 37 |
+
try:
|
| 38 |
+
self.client = Mistral(api_key=self.api_key)
|
| 39 |
+
self.enabled = True
|
| 40 |
+
print("✓ Image OCR (Mistral OCR SDK) initialized successfully")
|
| 41 |
+
return
|
| 42 |
+
except Exception as e:
|
| 43 |
+
print(f"⚠ Failed to initialize Mistral OCR SDK: {e}")
|
| 44 |
+
|
| 45 |
+
# SDK import/init can fail in some cloud images; use direct HTTP API fallback.
|
| 46 |
+
self.use_http_fallback = True
|
| 47 |
+
self.enabled = True
|
| 48 |
+
print("⚠ Mistral SDK unavailable, using direct HTTP OCR fallback")
|
| 49 |
+
|
| 50 |
+
def extract_text_from_image(self, image_data: bytes, mime_type: str = "image/jpeg") -> Optional[Dict]:
|
| 51 |
+
"""
|
| 52 |
+
Extract news title and text from an image using Mistral OCR.
|
| 53 |
+
|
| 54 |
+
Args:
|
| 55 |
+
image_data: Raw image bytes
|
| 56 |
+
mime_type: Image MIME type (image/jpeg, image/png, etc.)
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
Dict with extracted title, text, and metadata
|
| 60 |
+
"""
|
| 61 |
+
if not self.enabled:
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
# Convert to base64
|
| 66 |
+
base64_image = base64.b64encode(image_data).decode('utf-8')
|
| 67 |
+
return self._call_mistral_ocr(base64_image, mime_type)
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
print(f"Image OCR error: {e}")
|
| 71 |
+
return {
|
| 72 |
+
"title": "NOT_FOUND",
|
| 73 |
+
"text": "NOT_FOUND",
|
| 74 |
+
"source": "NOT_FOUND",
|
| 75 |
+
"date": "NOT_FOUND",
|
| 76 |
+
"error": str(e),
|
| 77 |
+
"extraction_success": False
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
def _call_mistral_ocr(self, base64_image: str, mime_type: str) -> Dict:
|
| 81 |
+
"""Call Mistral OCR API for text extraction."""
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
# Use Mistral OCR API with base64 image
|
| 85 |
+
image_data_url = f"data:{mime_type};base64,{base64_image}"
|
| 86 |
+
if self.client and not self.use_http_fallback:
|
| 87 |
+
ocr_response = self.client.ocr.process(
|
| 88 |
+
model=self.model,
|
| 89 |
+
document={
|
| 90 |
+
"type": "image_url",
|
| 91 |
+
"image_url": image_data_url
|
| 92 |
+
}
|
| 93 |
+
)
|
| 94 |
+
else:
|
| 95 |
+
ocr_response = self._call_mistral_ocr_http(image_data_url)
|
| 96 |
+
|
| 97 |
+
extracted_text = self._extract_text_from_ocr_response(ocr_response)
|
| 98 |
+
|
| 99 |
+
extracted_text = extracted_text.strip()
|
| 100 |
+
|
| 101 |
+
if not extracted_text:
|
| 102 |
+
return {
|
| 103 |
+
"title": "NOT_FOUND",
|
| 104 |
+
"text": "NOT_FOUND",
|
| 105 |
+
"source": "NOT_FOUND",
|
| 106 |
+
"date": "NOT_FOUND",
|
| 107 |
+
"extraction_success": False
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
# Parse the extracted text to find title and content
|
| 111 |
+
return self._parse_extracted_text(extracted_text)
|
| 112 |
+
|
| 113 |
+
except Exception as e:
|
| 114 |
+
print(f"Mistral OCR API error: {e}")
|
| 115 |
+
return {
|
| 116 |
+
"title": "NOT_FOUND",
|
| 117 |
+
"text": "NOT_FOUND",
|
| 118 |
+
"source": "NOT_FOUND",
|
| 119 |
+
"date": "NOT_FOUND",
|
| 120 |
+
"error": str(e),
|
| 121 |
+
"extraction_success": False
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
def _call_mistral_ocr_http(self, image_data_url: str) -> Dict:
|
| 125 |
+
"""Fallback to Mistral OCR REST API if SDK is unavailable."""
|
| 126 |
+
if not self.api_key:
|
| 127 |
+
raise RuntimeError("MISTRAL_API_KEY is missing")
|
| 128 |
+
|
| 129 |
+
response = requests.post(
|
| 130 |
+
"https://api.mistral.ai/v1/ocr",
|
| 131 |
+
headers={
|
| 132 |
+
"Authorization": f"Bearer {self.api_key}",
|
| 133 |
+
"Content-Type": "application/json",
|
| 134 |
+
},
|
| 135 |
+
json={
|
| 136 |
+
"model": self.model,
|
| 137 |
+
"document": {
|
| 138 |
+
"type": "image_url",
|
| 139 |
+
"image_url": image_data_url,
|
| 140 |
+
},
|
| 141 |
+
},
|
| 142 |
+
timeout=60,
|
| 143 |
+
)
|
| 144 |
+
response.raise_for_status()
|
| 145 |
+
return response.json()
|
| 146 |
+
|
| 147 |
+
def _extract_text_from_ocr_response(self, ocr_response) -> str:
|
| 148 |
+
"""Extract page text from both SDK objects and HTTP JSON responses."""
|
| 149 |
+
extracted_text = ""
|
| 150 |
+
|
| 151 |
+
if isinstance(ocr_response, dict):
|
| 152 |
+
pages = ocr_response.get("pages", [])
|
| 153 |
+
for page in pages:
|
| 154 |
+
markdown = page.get("markdown") if isinstance(page, dict) else None
|
| 155 |
+
text = page.get("text") if isinstance(page, dict) else None
|
| 156 |
+
if markdown:
|
| 157 |
+
extracted_text += markdown + "\n"
|
| 158 |
+
elif text:
|
| 159 |
+
extracted_text += text + "\n"
|
| 160 |
+
return extracted_text.strip()
|
| 161 |
+
|
| 162 |
+
if ocr_response and hasattr(ocr_response, 'pages'):
|
| 163 |
+
for page in ocr_response.pages:
|
| 164 |
+
if hasattr(page, 'markdown') and page.markdown:
|
| 165 |
+
extracted_text += page.markdown + "\n"
|
| 166 |
+
elif hasattr(page, 'text') and page.text:
|
| 167 |
+
extracted_text += page.text + "\n"
|
| 168 |
+
|
| 169 |
+
return extracted_text.strip()
|
| 170 |
+
|
| 171 |
+
def _parse_extracted_text(self, text: str) -> Dict:
|
| 172 |
+
"""Parse OCR extracted text to identify title, content, source, and date."""
|
| 173 |
+
|
| 174 |
+
lines = [line.strip() for line in text.split('\n') if line.strip()]
|
| 175 |
+
|
| 176 |
+
if not lines:
|
| 177 |
+
return {
|
| 178 |
+
"title": "NOT_FOUND",
|
| 179 |
+
"text": "NOT_FOUND",
|
| 180 |
+
"source": "NOT_FOUND",
|
| 181 |
+
"date": "NOT_FOUND",
|
| 182 |
+
"extraction_success": False
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
# Heuristic: First substantial line is likely the title
|
| 186 |
+
title = "NOT_FOUND"
|
| 187 |
+
text_content = "NOT_FOUND"
|
| 188 |
+
source = "NOT_FOUND"
|
| 189 |
+
date = "NOT_FOUND"
|
| 190 |
+
|
| 191 |
+
# Find title (first line with significant content)
|
| 192 |
+
for i, line in enumerate(lines):
|
| 193 |
+
# Skip very short lines or common UI elements
|
| 194 |
+
if len(line) > 15 and not any(x in line.lower() for x in ['follow', 'share', 'comment', 'like', 'reply', 'retweet']):
|
| 195 |
+
title = line[:300] # Limit title length
|
| 196 |
+
# Rest is the text content
|
| 197 |
+
remaining_lines = lines[i+1:] if i+1 < len(lines) else []
|
| 198 |
+
if remaining_lines:
|
| 199 |
+
text_content = ' '.join(remaining_lines)[:2000] # Limit text length
|
| 200 |
+
break
|
| 201 |
+
|
| 202 |
+
# If no title found, use first line
|
| 203 |
+
if title == "NOT_FOUND" and lines:
|
| 204 |
+
title = lines[0][:300]
|
| 205 |
+
if len(lines) > 1:
|
| 206 |
+
text_content = ' '.join(lines[1:])[:2000]
|
| 207 |
+
|
| 208 |
+
# Try to detect source (common news sources)
|
| 209 |
+
source_keywords = ['reuters', 'bbc', 'cnn', 'fox', 'nbc', 'abc', 'times', 'post', 'guardian', 'india today', 'ndtv', 'hindu', 'express', 'twitter', 'x.com', 'facebook', 'instagram']
|
| 210 |
+
for line in lines:
|
| 211 |
+
line_lower = line.lower()
|
| 212 |
+
for keyword in source_keywords:
|
| 213 |
+
if keyword in line_lower:
|
| 214 |
+
source = line[:100]
|
| 215 |
+
break
|
| 216 |
+
if source != "NOT_FOUND":
|
| 217 |
+
break
|
| 218 |
+
|
| 219 |
+
# Try to detect date patterns
|
| 220 |
+
import re
|
| 221 |
+
date_patterns = [
|
| 222 |
+
r'\d{1,2}[/-]\d{1,2}[/-]\d{2,4}', # DD/MM/YYYY or similar
|
| 223 |
+
r'\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4}', # 26 Feb 2026
|
| 224 |
+
r'(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{1,2},?\s+\d{4}', # Feb 26, 2026
|
| 225 |
+
]
|
| 226 |
+
|
| 227 |
+
for pattern in date_patterns:
|
| 228 |
+
match = re.search(pattern, text, re.IGNORECASE)
|
| 229 |
+
if match:
|
| 230 |
+
date = match.group()
|
| 231 |
+
break
|
| 232 |
+
|
| 233 |
+
return {
|
| 234 |
+
"title": title,
|
| 235 |
+
"text": text_content if text_content != "NOT_FOUND" else title,
|
| 236 |
+
"source": source,
|
| 237 |
+
"date": date,
|
| 238 |
+
"raw_text": text[:3000],
|
| 239 |
+
"extraction_success": True
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
def extract_from_base64(self, base64_string: str, mime_type: str = "image/jpeg") -> Optional[Dict]:
|
| 243 |
+
"""
|
| 244 |
+
Extract text from a base64-encoded image.
|
| 245 |
+
|
| 246 |
+
Args:
|
| 247 |
+
base64_string: Base64 encoded image string
|
| 248 |
+
mime_type: Image MIME type
|
| 249 |
+
|
| 250 |
+
Returns:
|
| 251 |
+
Dict with extracted text
|
| 252 |
+
"""
|
| 253 |
+
if not self.enabled:
|
| 254 |
+
return None
|
| 255 |
+
|
| 256 |
+
try:
|
| 257 |
+
# Remove data URL prefix if present
|
| 258 |
+
if ',' in base64_string:
|
| 259 |
+
base64_string = base64_string.split(',')[1]
|
| 260 |
+
|
| 261 |
+
return self._call_mistral_ocr(base64_string, mime_type)
|
| 262 |
+
except Exception as e:
|
| 263 |
+
print(f"Image OCR error: {e}")
|
| 264 |
+
return {
|
| 265 |
+
"title": "NOT_FOUND",
|
| 266 |
+
"text": "NOT_FOUND",
|
| 267 |
+
"source": "NOT_FOUND",
|
| 268 |
+
"date": "NOT_FOUND",
|
| 269 |
+
"error": str(e),
|
| 270 |
+
"extraction_success": False
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# Global instance
|
| 275 |
+
image_ocr = ImageOCR()
|
Backend/app/utils/logger.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
from logging.handlers import RotatingFileHandler
|
| 4 |
+
|
| 5 |
+
# ── Log directory ────────────────────────────────────────────────────────────
|
| 6 |
+
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs")
|
| 7 |
+
os.makedirs(LOG_DIR, exist_ok=True)
|
| 8 |
+
|
| 9 |
+
LOG_FILE = os.path.join(LOG_DIR, "app.log")
|
| 10 |
+
|
| 11 |
+
# ── Formatter ────────────────────────────────────────────────────────────────
|
| 12 |
+
LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
|
| 13 |
+
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
| 14 |
+
|
| 15 |
+
formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT)
|
| 16 |
+
|
| 17 |
+
# ── Handlers ─────────────────────────────────────────────────────────────────
|
| 18 |
+
# Rotating file: max 10 MB per file, keep 5 backups
|
| 19 |
+
file_handler = RotatingFileHandler(
|
| 20 |
+
LOG_FILE,
|
| 21 |
+
maxBytes=10 * 1024 * 1024, # 10 MB
|
| 22 |
+
backupCount=5,
|
| 23 |
+
encoding="utf-8"
|
| 24 |
+
)
|
| 25 |
+
file_handler.setFormatter(formatter)
|
| 26 |
+
file_handler.setLevel(logging.DEBUG)
|
| 27 |
+
|
| 28 |
+
console_handler = logging.StreamHandler()
|
| 29 |
+
console_handler.setFormatter(formatter)
|
| 30 |
+
console_handler.setLevel(logging.INFO)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def get_logger(name: str) -> logging.Logger:
|
| 34 |
+
"""
|
| 35 |
+
Get a named logger that writes to both console and logs/app.log.
|
| 36 |
+
|
| 37 |
+
Usage:
|
| 38 |
+
from app.utils.logger import get_logger
|
| 39 |
+
logger = get_logger(__name__)
|
| 40 |
+
logger.info("Hello")
|
| 41 |
+
"""
|
| 42 |
+
logger = logging.getLogger(name)
|
| 43 |
+
|
| 44 |
+
if not logger.handlers:
|
| 45 |
+
logger.setLevel(logging.DEBUG)
|
| 46 |
+
logger.addHandler(file_handler)
|
| 47 |
+
logger.addHandler(console_handler)
|
| 48 |
+
logger.propagate = False
|
| 49 |
+
|
| 50 |
+
return logger
|
Backend/app/utils/news_validator.py
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import requests
|
| 3 |
+
from typing import Optional, Dict, List
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
class NewsValidator:
|
| 11 |
+
"""
|
| 12 |
+
Validates claims against real news sources using multiple APIs.
|
| 13 |
+
Supports: Google News RSS (free), NewsAPI, SerpAPI
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.newsapi_key = os.getenv('NEWSAPI_KEY')
|
| 18 |
+
self.serpapi_key = os.getenv('SERPAPI_KEY')
|
| 19 |
+
# Always enabled because Google News RSS is free and doesn't need API key
|
| 20 |
+
self.enabled = True
|
| 21 |
+
|
| 22 |
+
def extract_keywords(self, text: str) -> List[str]:
|
| 23 |
+
"""Extract important keywords from text for searching"""
|
| 24 |
+
# Common stop words to filter out
|
| 25 |
+
stop_words = {'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
| 26 |
+
'of', 'with', 'is', 'are', 'was', 'were', 'been', 'be', 'have', 'has',
|
| 27 |
+
'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may',
|
| 28 |
+
'might', 'can', 'said', 'says', 'that', 'this', 'they', 'their', 'them',
|
| 29 |
+
'there', 'these', 'those', 'what', 'which', 'when', 'where', 'who', 'whom',
|
| 30 |
+
'how', 'why', 'just', 'only', 'even', 'also', 'very', 'most', 'some',
|
| 31 |
+
'many', 'much', 'more', 'other', 'than', 'then', 'now', 'here', 'such',
|
| 32 |
+
'like', 'into', 'over', 'after', 'before', 'between', 'under', 'again',
|
| 33 |
+
'about', 'being', 'once', 'during', 'each', 'because', 'through', 'while',
|
| 34 |
+
'news', 'breaking', 'report', 'says', 'according', 'announced', 'claims',
|
| 35 |
+
'article', 'story', 'sources', 'officials', 'people', 'percent', 'years'}
|
| 36 |
+
|
| 37 |
+
# Extract words (including proper nouns with capitals)
|
| 38 |
+
words = re.findall(r'\b[A-Za-z]{3,}\b', text)
|
| 39 |
+
|
| 40 |
+
# Prioritize capitalized words (likely proper nouns - names, places, organizations)
|
| 41 |
+
proper_nouns = [w for w in words if w[0].isupper() and w.lower() not in stop_words]
|
| 42 |
+
|
| 43 |
+
# Get other important words
|
| 44 |
+
other_words = [w.lower() for w in words if w.lower() not in stop_words and not w[0].isupper()]
|
| 45 |
+
|
| 46 |
+
# Combine: proper nouns first, then other words
|
| 47 |
+
keywords = proper_nouns[:4] + other_words[:3]
|
| 48 |
+
|
| 49 |
+
# Remove duplicates while preserving order
|
| 50 |
+
seen = set()
|
| 51 |
+
unique_keywords = []
|
| 52 |
+
for k in keywords:
|
| 53 |
+
k_lower = k.lower()
|
| 54 |
+
if k_lower not in seen:
|
| 55 |
+
seen.add(k_lower)
|
| 56 |
+
unique_keywords.append(k)
|
| 57 |
+
|
| 58 |
+
return unique_keywords[:6]
|
| 59 |
+
|
| 60 |
+
def build_search_query(self, text: str) -> str:
|
| 61 |
+
"""Build an effective search query from the text"""
|
| 62 |
+
# Clean the text - remove extra whitespace
|
| 63 |
+
clean_text = ' '.join(text.split())
|
| 64 |
+
|
| 65 |
+
# If text is short enough, use it directly (best for relevance)
|
| 66 |
+
if len(clean_text) <= 150:
|
| 67 |
+
return clean_text
|
| 68 |
+
|
| 69 |
+
# For longer text, extract the most important parts
|
| 70 |
+
keywords = self.extract_keywords(text)
|
| 71 |
+
|
| 72 |
+
if not keywords:
|
| 73 |
+
# Fallback: use first 100 chars
|
| 74 |
+
return clean_text[:100].strip()
|
| 75 |
+
|
| 76 |
+
# Build query with proper nouns quoted for exact matching
|
| 77 |
+
query_parts = []
|
| 78 |
+
for kw in keywords[:5]:
|
| 79 |
+
if kw[0].isupper():
|
| 80 |
+
query_parts.append(f'"{kw}"')
|
| 81 |
+
else:
|
| 82 |
+
query_parts.append(kw)
|
| 83 |
+
|
| 84 |
+
return ' '.join(query_parts)
|
| 85 |
+
|
| 86 |
+
def search_google_news_rss(self, query: str) -> Optional[Dict]:
|
| 87 |
+
"""Search Google News RSS feed for free, real-time results"""
|
| 88 |
+
try:
|
| 89 |
+
import urllib.parse
|
| 90 |
+
|
| 91 |
+
# Google News RSS - free and always up-to-date
|
| 92 |
+
encoded_query = urllib.parse.quote(query)
|
| 93 |
+
url = f"https://news.google.com/rss/search?q={encoded_query}&hl=en-IN&gl=IN&ceid=IN:en"
|
| 94 |
+
|
| 95 |
+
response = requests.get(url, timeout=10, headers={
|
| 96 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
| 97 |
+
})
|
| 98 |
+
|
| 99 |
+
if response.status_code == 200:
|
| 100 |
+
import xml.etree.ElementTree as ET
|
| 101 |
+
root = ET.fromstring(response.content)
|
| 102 |
+
|
| 103 |
+
articles = []
|
| 104 |
+
for item in root.findall('.//item')[:10]:
|
| 105 |
+
title = item.find('title')
|
| 106 |
+
link = item.find('link')
|
| 107 |
+
pub_date = item.find('pubDate')
|
| 108 |
+
source = item.find('source')
|
| 109 |
+
|
| 110 |
+
if title is not None and link is not None:
|
| 111 |
+
# Extract and clean the RSS <description> element (contains HTML snippet)
|
| 112 |
+
description_elem = item.find('description')
|
| 113 |
+
desc_text = ""
|
| 114 |
+
if description_elem is not None and description_elem.text:
|
| 115 |
+
desc_text = re.sub(r'<[^>]+>', '', description_elem.text).strip()[:300]
|
| 116 |
+
articles.append({
|
| 117 |
+
'title': title.text,
|
| 118 |
+
'url': link.text,
|
| 119 |
+
'source': source.text if source is not None else 'Google News',
|
| 120 |
+
'published_at': pub_date.text if pub_date is not None else None,
|
| 121 |
+
'description': desc_text or (title.text[:200] if title.text else '')
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
return {
|
| 125 |
+
'total_results': len(articles),
|
| 126 |
+
'articles': articles[:5]
|
| 127 |
+
}
|
| 128 |
+
return None
|
| 129 |
+
except Exception as e:
|
| 130 |
+
print(f"Google News RSS error: {e}")
|
| 131 |
+
return None
|
| 132 |
+
|
| 133 |
+
def search_newsapi(self, query: str, days: int = 7) -> Optional[Dict]:
|
| 134 |
+
"""Search NewsAPI for relevant articles"""
|
| 135 |
+
if not self.newsapi_key or self.newsapi_key == 'your_newsapi_key_here':
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
from_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
| 140 |
+
url = 'https://newsapi.org/v2/everything'
|
| 141 |
+
|
| 142 |
+
params = {
|
| 143 |
+
'q': query,
|
| 144 |
+
'from': from_date,
|
| 145 |
+
'sortBy': 'publishedAt', # Sort by date for recent news
|
| 146 |
+
'language': 'en',
|
| 147 |
+
'pageSize': 10,
|
| 148 |
+
'apiKey': self.newsapi_key
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
response = requests.get(url, params=params, timeout=10)
|
| 152 |
+
|
| 153 |
+
if response.status_code == 200:
|
| 154 |
+
data = response.json()
|
| 155 |
+
articles = data.get('articles', [])
|
| 156 |
+
|
| 157 |
+
return {
|
| 158 |
+
'total_results': data.get('totalResults', 0),
|
| 159 |
+
'articles': [
|
| 160 |
+
{
|
| 161 |
+
'title': article.get('title'),
|
| 162 |
+
'source': article.get('source', {}).get('name'),
|
| 163 |
+
'url': article.get('url'),
|
| 164 |
+
'published_at': article.get('publishedAt'),
|
| 165 |
+
'description': article.get('description', '')[:200] if article.get('description') else ''
|
| 166 |
+
}
|
| 167 |
+
for article in articles[:5]
|
| 168 |
+
]
|
| 169 |
+
}
|
| 170 |
+
else:
|
| 171 |
+
print(f"NewsAPI response: {response.status_code} - {response.text[:200]}")
|
| 172 |
+
return None
|
| 173 |
+
except Exception as e:
|
| 174 |
+
print(f"NewsAPI error: {e}")
|
| 175 |
+
return None
|
| 176 |
+
|
| 177 |
+
def search_serpapi(self, query: str) -> Optional[Dict]:
|
| 178 |
+
"""Search Google News using SerpAPI"""
|
| 179 |
+
if not self.serpapi_key or self.serpapi_key == 'your_serpapi_key_here':
|
| 180 |
+
return None
|
| 181 |
+
|
| 182 |
+
try:
|
| 183 |
+
import serpapi
|
| 184 |
+
|
| 185 |
+
params = {
|
| 186 |
+
"q": query,
|
| 187 |
+
"tbm": "nws", # News search
|
| 188 |
+
"api_key": self.serpapi_key,
|
| 189 |
+
"num": 10
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
results = serpapi.search(params)
|
| 193 |
+
|
| 194 |
+
news_results = results.get('news_results', [])
|
| 195 |
+
|
| 196 |
+
return {
|
| 197 |
+
'total_results': len(news_results),
|
| 198 |
+
'articles': [
|
| 199 |
+
{
|
| 200 |
+
'title': item.get('title'),
|
| 201 |
+
'source': item.get('source', {}).get('name') if isinstance(item.get('source'), dict) else item.get('source'),
|
| 202 |
+
'url': item.get('link'),
|
| 203 |
+
'published_at': item.get('date'),
|
| 204 |
+
'description': item.get('snippet', '')[:200]
|
| 205 |
+
}
|
| 206 |
+
for item in news_results[:5]
|
| 207 |
+
]
|
| 208 |
+
}
|
| 209 |
+
except Exception as e:
|
| 210 |
+
print(f"SerpAPI error: {e}")
|
| 211 |
+
return None
|
| 212 |
+
|
| 213 |
+
def fetch_article_snippet(self, url: str, max_chars: int = 600) -> str:
|
| 214 |
+
"""
|
| 215 |
+
Fetch the opening paragraphs of an article URL so Gemini gets
|
| 216 |
+
real content, not just the headline.
|
| 217 |
+
|
| 218 |
+
Handles Google News redirect URLs (news.google.com/rss/articles/...)
|
| 219 |
+
by resolving the redirect with a HEAD request first.
|
| 220 |
+
Returns an empty string on any error (non-blocking).
|
| 221 |
+
"""
|
| 222 |
+
if not url or not url.startswith("http"):
|
| 223 |
+
return ""
|
| 224 |
+
try:
|
| 225 |
+
from bs4 import BeautifulSoup
|
| 226 |
+
|
| 227 |
+
headers = {
|
| 228 |
+
"User-Agent": (
|
| 229 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 230 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 231 |
+
"Chrome/122.0.0.0 Safari/537.36"
|
| 232 |
+
),
|
| 233 |
+
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
|
| 234 |
+
"Accept-Language": "en-US,en;q=0.9",
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
# Google News RSS returns redirect URLs — resolve them first
|
| 238 |
+
if "news.google.com" in url:
|
| 239 |
+
try:
|
| 240 |
+
head = requests.head(url, headers=headers, timeout=5, allow_redirects=True)
|
| 241 |
+
resolved = head.url
|
| 242 |
+
if resolved and resolved != url and "news.google.com" not in resolved:
|
| 243 |
+
url = resolved
|
| 244 |
+
except Exception:
|
| 245 |
+
pass # keep original URL and attempt anyway
|
| 246 |
+
|
| 247 |
+
resp = requests.get(url, headers=headers, timeout=7, allow_redirects=True)
|
| 248 |
+
if resp.status_code != 200:
|
| 249 |
+
return ""
|
| 250 |
+
|
| 251 |
+
# Many paywalled sites return thin HTML but some text still leaks
|
| 252 |
+
soup = BeautifulSoup(resp.text, "html.parser")
|
| 253 |
+
for tag in soup(["script", "style", "nav", "header", "footer", "aside", "figure"]):
|
| 254 |
+
tag.decompose()
|
| 255 |
+
paragraphs = [
|
| 256 |
+
p.get_text(" ", strip=True)
|
| 257 |
+
for p in soup.find_all("p")
|
| 258 |
+
if len(p.get_text(strip=True)) > 60
|
| 259 |
+
]
|
| 260 |
+
snippet = " ".join(paragraphs[:6])
|
| 261 |
+
return snippet[:max_chars].strip()
|
| 262 |
+
|
| 263 |
+
except Exception:
|
| 264 |
+
return ""
|
| 265 |
+
|
| 266 |
+
def validate_claim(self, text: str) -> Optional[Dict]:
|
| 267 |
+
"""
|
| 268 |
+
Validate a claim against real news sources
|
| 269 |
+
|
| 270 |
+
Args:
|
| 271 |
+
text: The claim to validate
|
| 272 |
+
|
| 273 |
+
Returns:
|
| 274 |
+
Dict with validation results or None if no API available
|
| 275 |
+
"""
|
| 276 |
+
if not self.enabled:
|
| 277 |
+
return None
|
| 278 |
+
|
| 279 |
+
# Build search query from user's full input
|
| 280 |
+
query = self.build_search_query(text)
|
| 281 |
+
keywords = self.extract_keywords(text)
|
| 282 |
+
|
| 283 |
+
print(f"🔍 Searching news with query: {query[:100]}...")
|
| 284 |
+
|
| 285 |
+
# Try Google News RSS first (free, real-time, most relevant)
|
| 286 |
+
print(f"🔍 Searching Google News with query: {query[:80]}...")
|
| 287 |
+
news_results = self.search_google_news_rss(query)
|
| 288 |
+
|
| 289 |
+
# If no results, try with keywords only
|
| 290 |
+
if not news_results or news_results.get('total_results', 0) == 0:
|
| 291 |
+
keyword_query = ' '.join(keywords[:4]) if keywords else query[:50]
|
| 292 |
+
print(f"🔍 Retry Google News with keywords: {keyword_query}")
|
| 293 |
+
news_results = self.search_google_news_rss(keyword_query)
|
| 294 |
+
|
| 295 |
+
# If Google News fails, try NewsAPI
|
| 296 |
+
if not news_results or news_results.get('total_results', 0) == 0:
|
| 297 |
+
print("🔍 Trying NewsAPI...")
|
| 298 |
+
news_results = self.search_newsapi(query, days=7)
|
| 299 |
+
|
| 300 |
+
# If NewsAPI no results, try with keywords
|
| 301 |
+
if not news_results or news_results.get('total_results', 0) == 0:
|
| 302 |
+
keyword_query = ' '.join(keywords[:4]) if keywords else query[:50]
|
| 303 |
+
news_results = self.search_newsapi(keyword_query, days=7)
|
| 304 |
+
|
| 305 |
+
# If NewsAPI fails, try SerpAPI
|
| 306 |
+
if not news_results or news_results.get('total_results', 0) == 0:
|
| 307 |
+
print("🔍 Trying SerpAPI...")
|
| 308 |
+
news_results = self.search_serpapi(query)
|
| 309 |
+
|
| 310 |
+
# If still no results, try SerpAPI with keywords
|
| 311 |
+
if not news_results or news_results.get('total_results', 0) == 0:
|
| 312 |
+
keyword_query = ' '.join(keywords[:4]) if keywords else query[:50]
|
| 313 |
+
news_results = self.search_serpapi(keyword_query)
|
| 314 |
+
|
| 315 |
+
if not news_results:
|
| 316 |
+
return {
|
| 317 |
+
'news_validation_enabled': True,
|
| 318 |
+
'verification_status': 'unavailable',
|
| 319 |
+
'confidence': 0.5,
|
| 320 |
+
'message': 'News validation service unavailable.',
|
| 321 |
+
'total_results': 0,
|
| 322 |
+
'relevant_articles': 0,
|
| 323 |
+
'articles': [],
|
| 324 |
+
'search_keywords': keywords[:3],
|
| 325 |
+
'search_query': query[:100]
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
# Analyze results
|
| 329 |
+
total_articles = news_results.get('total_results', 0)
|
| 330 |
+
articles = news_results.get('articles', [])
|
| 331 |
+
|
| 332 |
+
# Check if any articles are relevant by matching keywords or text fragments
|
| 333 |
+
relevant_count = 0
|
| 334 |
+
relevant_articles = []
|
| 335 |
+
|
| 336 |
+
# Also check for words from the original text
|
| 337 |
+
text_words = set(word.lower() for word in text.split() if len(word) > 4)
|
| 338 |
+
|
| 339 |
+
for article in articles:
|
| 340 |
+
title = article.get('title', '').lower()
|
| 341 |
+
desc = article.get('description', '').lower() if article.get('description') else ''
|
| 342 |
+
article_text = title + ' ' + desc
|
| 343 |
+
|
| 344 |
+
# Check if keywords appear in article
|
| 345 |
+
keyword_matches = 0
|
| 346 |
+
for keyword in keywords[:5]:
|
| 347 |
+
kw_lower = keyword.lower()
|
| 348 |
+
if kw_lower in article_text:
|
| 349 |
+
keyword_matches += 1
|
| 350 |
+
|
| 351 |
+
# Also check for common words from original text
|
| 352 |
+
text_matches = sum(1 for w in text_words if w in article_text)
|
| 353 |
+
|
| 354 |
+
# Consider relevant if keyword match or significant text overlap
|
| 355 |
+
if keyword_matches >= 1 or text_matches >= 3:
|
| 356 |
+
relevant_count += 1
|
| 357 |
+
article['relevance_score'] = keyword_matches + (text_matches * 0.5)
|
| 358 |
+
relevant_articles.append(article)
|
| 359 |
+
|
| 360 |
+
# Sort by relevance score and prioritize
|
| 361 |
+
relevant_articles.sort(key=lambda x: x.get('relevance_score', 0), reverse=True)
|
| 362 |
+
|
| 363 |
+
# Combine: relevant first, then others
|
| 364 |
+
if relevant_articles:
|
| 365 |
+
other_articles = [a for a in articles if a not in relevant_articles]
|
| 366 |
+
articles = relevant_articles + other_articles
|
| 367 |
+
|
| 368 |
+
# ── Fetch real article body snippets for Gemini context ──────────────
|
| 369 |
+
# Only fetch for the top 3 to keep response time reasonable
|
| 370 |
+
print(f"📰 Fetching article snippets for top {min(3, len(articles))} articles...")
|
| 371 |
+
for art in articles[:3]:
|
| 372 |
+
url = art.get('url', '')
|
| 373 |
+
if url:
|
| 374 |
+
art['fetched_snippet'] = self.fetch_article_snippet(url)
|
| 375 |
+
if art['fetched_snippet']:
|
| 376 |
+
print(f" ✓ Fetched snippet from {art.get('source','?')} ({len(art['fetched_snippet'])} chars)")
|
| 377 |
+
else:
|
| 378 |
+
print(f" ✗ Could not fetch snippet from {url[:60]}")
|
| 379 |
+
|
| 380 |
+
# Calculate confidence based on findings
|
| 381 |
+
if total_articles == 0:
|
| 382 |
+
verification_status = "unverified"
|
| 383 |
+
confidence = 0.3
|
| 384 |
+
message = "No recent news articles found matching this claim."
|
| 385 |
+
elif relevant_count >= 2:
|
| 386 |
+
verification_status = "found"
|
| 387 |
+
confidence = min(0.9, 0.5 + (relevant_count * 0.1))
|
| 388 |
+
message = f"Found {relevant_count} relevant news articles discussing this topic."
|
| 389 |
+
elif relevant_count == 1:
|
| 390 |
+
verification_status = "limited"
|
| 391 |
+
confidence = 0.6
|
| 392 |
+
message = "Found limited news coverage of this topic."
|
| 393 |
+
else:
|
| 394 |
+
verification_status = "not_found"
|
| 395 |
+
confidence = 0.4
|
| 396 |
+
message = "No relevant news articles found. This may be unverified information."
|
| 397 |
+
|
| 398 |
+
return {
|
| 399 |
+
'news_validation_enabled': True,
|
| 400 |
+
'verification_status': verification_status,
|
| 401 |
+
'confidence': confidence,
|
| 402 |
+
'message': message,
|
| 403 |
+
'total_results': total_articles,
|
| 404 |
+
'relevant_articles': relevant_count,
|
| 405 |
+
'articles': articles,
|
| 406 |
+
'search_keywords': keywords[:4],
|
| 407 |
+
'search_query': query[:100]
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
def enhance_prediction(self, bert_result: Dict, ai_result: Optional[Dict],
|
| 411 |
+
news_validation: Optional[Dict]) -> Dict:
|
| 412 |
+
"""
|
| 413 |
+
Enhance the final prediction with news validation data.
|
| 414 |
+
NEWS SOURCES CAN OVERRIDE AI/MODEL PREDICTIONS when strong evidence exists.
|
| 415 |
+
|
| 416 |
+
Args:
|
| 417 |
+
bert_result: Model prediction (already corrected by AI silently)
|
| 418 |
+
ai_result: AI prediction (can be None)
|
| 419 |
+
news_validation: News validation results (can be None)
|
| 420 |
+
|
| 421 |
+
Returns:
|
| 422 |
+
Enhanced prediction with news validation insights
|
| 423 |
+
"""
|
| 424 |
+
result = bert_result.copy()
|
| 425 |
+
|
| 426 |
+
# Add news validation data
|
| 427 |
+
if news_validation:
|
| 428 |
+
result['news_validation'] = news_validation
|
| 429 |
+
|
| 430 |
+
# Adjust final prediction based on news validation
|
| 431 |
+
verification = news_validation['verification_status']
|
| 432 |
+
relevant_count = news_validation.get('relevant_articles', 0)
|
| 433 |
+
|
| 434 |
+
if verification == 'not_found':
|
| 435 |
+
# No news found - slightly reduce confidence in a "real" prediction
|
| 436 |
+
result['news_insight'] = "⚠️ No recent news coverage found. Treat with caution."
|
| 437 |
+
result['verification_boost'] = -0.1
|
| 438 |
+
|
| 439 |
+
if result.get('prediction') == 'real':
|
| 440 |
+
result['confidence'] = max(0.3, result.get('confidence', 0.5) - 0.1)
|
| 441 |
+
result['probabilities'] = {'real': result['confidence'], 'fake': 1 - result['confidence']}
|
| 442 |
+
|
| 443 |
+
elif verification == 'found':
|
| 444 |
+
# Related articles found - informational only, does NOT verify the claim
|
| 445 |
+
result['news_insight'] = f"ℹ️ Found {relevant_count} related news article(s) on this topic."
|
| 446 |
+
result['verification_boost'] = 0.05
|
| 447 |
+
# NOTE: Finding related articles does not mean the specific claim is true.
|
| 448 |
+
# The model prediction is preserved as-is.
|
| 449 |
+
|
| 450 |
+
elif verification == 'limited':
|
| 451 |
+
result['news_insight'] = "ℹ️ Limited related news coverage found."
|
| 452 |
+
result['verification_boost'] = 0.0
|
| 453 |
+
# NOTE: Finding related articles does not mean the specific claim is true.
|
| 454 |
+
# The model prediction is preserved as-is.
|
| 455 |
+
|
| 456 |
+
else: # unverified
|
| 457 |
+
result['news_insight'] = "Unable to verify against news sources."
|
| 458 |
+
result['verification_boost'] = 0.0
|
| 459 |
+
else:
|
| 460 |
+
result['news_validation'] = None
|
| 461 |
+
result['news_insight'] = "News validation not available."
|
| 462 |
+
result['verification_boost'] = 0.0
|
| 463 |
+
|
| 464 |
+
# Update is_fake based on final prediction
|
| 465 |
+
if result.get('prediction'):
|
| 466 |
+
result['is_fake'] = result['prediction'] == 'fake'
|
| 467 |
+
|
| 468 |
+
return result
|
| 469 |
+
|
| 470 |
+
# Global instance
|
| 471 |
+
news_validator = NewsValidator()
|
Backend/enhanced_bert_liar_model/special_tokens_map.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cls_token": "[CLS]",
|
| 3 |
+
"mask_token": "[MASK]",
|
| 4 |
+
"pad_token": "[PAD]",
|
| 5 |
+
"sep_token": "[SEP]",
|
| 6 |
+
"unk_token": "[UNK]"
|
| 7 |
+
}
|
Backend/enhanced_bert_liar_model/tokenizer.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
Backend/enhanced_bert_liar_model/tokenizer_config.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"added_tokens_decoder": {
|
| 3 |
+
"0": {
|
| 4 |
+
"content": "[PAD]",
|
| 5 |
+
"lstrip": false,
|
| 6 |
+
"normalized": false,
|
| 7 |
+
"rstrip": false,
|
| 8 |
+
"single_word": false,
|
| 9 |
+
"special": true
|
| 10 |
+
},
|
| 11 |
+
"100": {
|
| 12 |
+
"content": "[UNK]",
|
| 13 |
+
"lstrip": false,
|
| 14 |
+
"normalized": false,
|
| 15 |
+
"rstrip": false,
|
| 16 |
+
"single_word": false,
|
| 17 |
+
"special": true
|
| 18 |
+
},
|
| 19 |
+
"101": {
|
| 20 |
+
"content": "[CLS]",
|
| 21 |
+
"lstrip": false,
|
| 22 |
+
"normalized": false,
|
| 23 |
+
"rstrip": false,
|
| 24 |
+
"single_word": false,
|
| 25 |
+
"special": true
|
| 26 |
+
},
|
| 27 |
+
"102": {
|
| 28 |
+
"content": "[SEP]",
|
| 29 |
+
"lstrip": false,
|
| 30 |
+
"normalized": false,
|
| 31 |
+
"rstrip": false,
|
| 32 |
+
"single_word": false,
|
| 33 |
+
"special": true
|
| 34 |
+
},
|
| 35 |
+
"103": {
|
| 36 |
+
"content": "[MASK]",
|
| 37 |
+
"lstrip": false,
|
| 38 |
+
"normalized": false,
|
| 39 |
+
"rstrip": false,
|
| 40 |
+
"single_word": false,
|
| 41 |
+
"special": true
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
"clean_up_tokenization_spaces": false,
|
| 45 |
+
"cls_token": "[CLS]",
|
| 46 |
+
"do_lower_case": true,
|
| 47 |
+
"extra_special_tokens": {},
|
| 48 |
+
"mask_token": "[MASK]",
|
| 49 |
+
"model_max_length": 512,
|
| 50 |
+
"pad_token": "[PAD]",
|
| 51 |
+
"sep_token": "[SEP]",
|
| 52 |
+
"strip_accents": null,
|
| 53 |
+
"tokenize_chinese_chars": true,
|
| 54 |
+
"tokenizer_class": "BertTokenizer",
|
| 55 |
+
"unk_token": "[UNK]"
|
| 56 |
+
}
|
Backend/enhanced_bert_liar_model/vocab.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
Backend/enhanced_bert_welfake_model/tokenizer.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
Backend/enhanced_bert_welfake_model/tokenizer_config.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"backend": "tokenizers",
|
| 3 |
+
"cls_token": "[CLS]",
|
| 4 |
+
"do_lower_case": true,
|
| 5 |
+
"is_local": false,
|
| 6 |
+
"mask_token": "[MASK]",
|
| 7 |
+
"model_max_length": 512,
|
| 8 |
+
"pad_token": "[PAD]",
|
| 9 |
+
"sep_token": "[SEP]",
|
| 10 |
+
"strip_accents": null,
|
| 11 |
+
"tokenize_chinese_chars": true,
|
| 12 |
+
"tokenizer_class": "BertTokenizer",
|
| 13 |
+
"unk_token": "[UNK]"
|
| 14 |
+
}
|
Backend/enhanced_bert_welfake_model/vocab.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
Backend/run_api.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uvicorn
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
+
from logging.handlers import RotatingFileHandler
|
| 5 |
+
|
| 6 |
+
LOG_DIR = os.path.join(os.path.dirname(__file__), "logs")
|
| 7 |
+
os.makedirs(LOG_DIR, exist_ok=True)
|
| 8 |
+
|
| 9 |
+
# Configure uvicorn to also write its own logs to the logs/ folder
|
| 10 |
+
LOG_CONFIG = {
|
| 11 |
+
"version": 1,
|
| 12 |
+
"disable_existing_loggers": False,
|
| 13 |
+
"formatters": {
|
| 14 |
+
"default": {
|
| 15 |
+
"format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
| 16 |
+
"datefmt": "%Y-%m-%d %H:%M:%S",
|
| 17 |
+
}
|
| 18 |
+
},
|
| 19 |
+
"handlers": {
|
| 20 |
+
"console": {
|
| 21 |
+
"class": "logging.StreamHandler",
|
| 22 |
+
"formatter": "default",
|
| 23 |
+
"stream": "ext://sys.stdout",
|
| 24 |
+
},
|
| 25 |
+
"file": {
|
| 26 |
+
"class": "logging.handlers.RotatingFileHandler",
|
| 27 |
+
"formatter": "default",
|
| 28 |
+
"filename": os.path.join(LOG_DIR, "app.log"),
|
| 29 |
+
"maxBytes": 10 * 1024 * 1024, # 10 MB
|
| 30 |
+
"backupCount": 5,
|
| 31 |
+
"encoding": "utf-8",
|
| 32 |
+
},
|
| 33 |
+
},
|
| 34 |
+
"loggers": {
|
| 35 |
+
"uvicorn": {"handlers": ["console", "file"], "level": "INFO", "propagate": False},
|
| 36 |
+
"uvicorn.error": {"handlers": ["console", "file"], "level": "INFO", "propagate": False},
|
| 37 |
+
"uvicorn.access": {"handlers": ["console", "file"], "level": "INFO", "propagate": False},
|
| 38 |
+
},
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
if __name__ == "__main__":
|
| 42 |
+
uvicorn.run(
|
| 43 |
+
"app.main:app",
|
| 44 |
+
host="0.0.0.0",
|
| 45 |
+
port=8000,
|
| 46 |
+
reload=True,
|
| 47 |
+
log_level="info",
|
| 48 |
+
log_config=LOG_CONFIG,
|
| 49 |
+
)
|