Ravi1212 commited on
Commit
bf5067d
·
verified ·
1 Parent(s): 5105397

uploaded the all the dependencies

Browse files
Files changed (45) hide show
  1. Backend/Dockerfile +52 -0
  2. Backend/app/__init__.py +1 -0
  3. Backend/app/__pycache__/__init__.cpython-310.pyc +0 -0
  4. Backend/app/__pycache__/auth.cpython-310.pyc +0 -0
  5. Backend/app/__pycache__/database.cpython-310.pyc +0 -0
  6. Backend/app/__pycache__/limiter.cpython-310.pyc +0 -0
  7. Backend/app/__pycache__/main.cpython-310.pyc +0 -0
  8. Backend/app/api/__init__.py +1 -0
  9. Backend/app/api/__pycache__/__init__.cpython-310.pyc +0 -0
  10. Backend/app/api/__pycache__/auth_routes.cpython-310.pyc +0 -0
  11. Backend/app/api/__pycache__/routes.cpython-310.pyc +0 -0
  12. Backend/app/api/auth_routes.py +221 -0
  13. Backend/app/api/routes.py +372 -0
  14. Backend/app/auth.py +90 -0
  15. Backend/app/database.py +50 -0
  16. Backend/app/limiter.py +6 -0
  17. Backend/app/main.py +84 -0
  18. Backend/app/models/__init__.py +1 -0
  19. Backend/app/models/__pycache__/__init__.cpython-310.pyc +0 -0
  20. Backend/app/models/__pycache__/bert_model.cpython-310.pyc +0 -0
  21. Backend/app/models/bert_model.py +207 -0
  22. Backend/app/schemas/__init__.py +1 -0
  23. Backend/app/schemas/__pycache__/__init__.cpython-310.pyc +0 -0
  24. Backend/app/schemas/__pycache__/auth.cpython-310.pyc +0 -0
  25. Backend/app/schemas/__pycache__/prediction.cpython-310.pyc +0 -0
  26. Backend/app/schemas/auth.py +86 -0
  27. Backend/app/schemas/prediction.py +73 -0
  28. Backend/app/utils/__init__.py +1 -0
  29. Backend/app/utils/__pycache__/__init__.cpython-310.pyc +0 -0
  30. Backend/app/utils/__pycache__/ai_verification.cpython-310.pyc +0 -0
  31. Backend/app/utils/__pycache__/image_ocr.cpython-310.pyc +0 -0
  32. Backend/app/utils/__pycache__/logger.cpython-310.pyc +0 -0
  33. Backend/app/utils/__pycache__/news_validator.cpython-310.pyc +0 -0
  34. Backend/app/utils/ai_verification.py +284 -0
  35. Backend/app/utils/image_ocr.py +275 -0
  36. Backend/app/utils/logger.py +50 -0
  37. Backend/app/utils/news_validator.py +471 -0
  38. Backend/enhanced_bert_liar_model/special_tokens_map.json +7 -0
  39. Backend/enhanced_bert_liar_model/tokenizer.json +0 -0
  40. Backend/enhanced_bert_liar_model/tokenizer_config.json +56 -0
  41. Backend/enhanced_bert_liar_model/vocab.txt +0 -0
  42. Backend/enhanced_bert_welfake_model/tokenizer.json +0 -0
  43. Backend/enhanced_bert_welfake_model/tokenizer_config.json +14 -0
  44. Backend/enhanced_bert_welfake_model/vocab.txt +0 -0
  45. 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
+ )