Spaces:
Sleeping
Sleeping
| import os | |
| import re | |
| import json | |
| import time | |
| import datetime | |
| import numpy as np | |
| import joblib | |
| import pandas as pd | |
| import traceback | |
| import logging | |
| from collections import defaultdict | |
| from flask import Flask, request, jsonify | |
| from flask_cors import CORS | |
| from werkzeug.security import generate_password_hash, check_password_hash | |
| import requests | |
| import jwt | |
| from feature_extraction import FeatureExtractor | |
| from source_reputation import ReputationEngine | |
| from init_db import init_db | |
| from db import get_conn, PH, IntegrityError | |
| # βββ Logging ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| logging.basicConfig(level=logging.INFO, | |
| format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') | |
| logger = logging.getLogger(__name__) | |
| # βββ App Setup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = Flask(__name__) | |
| # Allow requests from the Vercel frontend (set FRONTEND_ORIGIN env var in production) | |
| _frontend_origin = os.environ.get('FRONTEND_ORIGIN', '*') | |
| CORS(app, origins=_frontend_origin, supports_credentials=True) | |
| SECRET_KEY = os.environ.get('TRUTH_SECRET_KEY', 'truth-detector-jwt-secret-2025') | |
| # βββ Rate Limiter (in-memory, per IP) ββββββββββββββββββββββββββββββββββββββββ | |
| _login_attempts = defaultdict(list) # ip -> [timestamp, ...] | |
| RATE_LIMIT_MAX = 5 # max attempts | |
| RATE_LIMIT_WINDOW = 300 # seconds (5 minutes) | |
| def _check_rate_limit(ip: str) -> bool: | |
| """Returns True if the IP is allowed, False if rate-limited.""" | |
| now = time.time() | |
| attempts = [t for t in _login_attempts[ip] if now - t < RATE_LIMIT_WINDOW] | |
| _login_attempts[ip] = attempts | |
| if len(attempts) >= RATE_LIMIT_MAX: | |
| return False | |
| _login_attempts[ip].append(now) | |
| return True | |
| # βββ Input Validators ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| USERNAME_RE = re.compile(r'^[A-Za-z0-9_]{3,30}$') | |
| EMAIL_RE = re.compile(r'^[^\s@]+@[^\s@]+\.[^\s@]+$') | |
| def validate_username(u: str): | |
| if not u: | |
| return "Username is required." | |
| if not USERNAME_RE.match(u): | |
| return "Username must be 3β30 characters: letters, digits, or underscore only." | |
| return None | |
| def validate_password(p: str): | |
| if not p: | |
| return "Password is required." | |
| if len(p) < 8: | |
| return "Password must be at least 8 characters." | |
| if not re.search(r'[A-Za-z]', p): | |
| return "Password must contain at least one letter." | |
| if not re.search(r'[0-9]', p): | |
| return "Password must contain at least one number." | |
| return None | |
| def validate_email(e: str): | |
| if e and not EMAIL_RE.match(e): | |
| return "Invalid email format." | |
| return None | |
| # βββ JWT Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _issue_token(username: str) -> str: | |
| payload = { | |
| 'sub': username, | |
| 'iat': datetime.datetime.utcnow(), | |
| 'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=24) | |
| } | |
| return jwt.encode(payload, SECRET_KEY, algorithm='HS256') | |
| def _decode_token(token: str): | |
| """Returns username on success, or raises jwt.PyJWTError.""" | |
| payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256']) | |
| return payload['sub'] | |
| def _get_request_token() -> str | None: | |
| auth = request.headers.get('Authorization', '') | |
| if auth.startswith('Bearer '): | |
| return auth[7:] | |
| # Fallback: let legacy localStorage pass username directly (for predict route) | |
| return request.headers.get('X-Username') | |
| # βββ Global Models & Extractor ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| models = {} | |
| metrics = {} | |
| thresholds = {} | |
| # βββ Model & Asset Loading ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| extractor = FeatureExtractor() | |
| reputation_engine = ReputationEngine() | |
| def load_models(): | |
| global models, metrics, thresholds | |
| base_dir = os.path.dirname(os.path.abspath(__file__)) | |
| models_dir = os.path.join(base_dir, 'models') | |
| try: | |
| model_names = ['nb', 'lr', 'svm', 'rf', 'dl', 'ensemble'] | |
| for name in model_names: | |
| path = os.path.join(models_dir, f'{name}_model.pkl') | |
| if os.path.exists(path): | |
| models[name] = joblib.load(path) | |
| logger.info(f"Loaded model: {name}") | |
| metrics_path = os.path.join(models_dir, 'metrics.json') | |
| if os.path.exists(metrics_path): | |
| with open(metrics_path, 'r') as f: | |
| metrics = json.load(f) | |
| thresholds_path = os.path.join(models_dir, 'thresholds.json') | |
| if os.path.exists(thresholds_path): | |
| with open(thresholds_path, 'r') as f: | |
| thresholds = json.load(f) | |
| logger.info(f"Loaded thresholds: {list(thresholds.keys())}") | |
| else: | |
| logger.warning("No thresholds.json found, using default 0.5") | |
| except Exception as e: | |
| logger.error(f"Error loading models: {e}") | |
| # βββ Global Error Handler ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def handle_exception(e): | |
| if hasattr(e, 'code') and isinstance(e.code, int): | |
| return jsonify({'error': str(e)}), e.code | |
| logger.error(f"Unhandled Exception: {traceback.format_exc()}") | |
| return jsonify({'error': 'Internal Server Error', 'message': str(e)}), 500 | |
| # βββ HEALTH ROUTE βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def health(): | |
| return jsonify({ | |
| 'status': 'ok', | |
| 'models_loaded': list(models.keys()), | |
| 'extractor_ready': extractor is not None, | |
| 'timestamp': datetime.datetime.utcnow().isoformat() | |
| }) | |
| # βββ PREDICTION ROUTE βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def predict(): | |
| try: | |
| data = request.json | |
| if not data: | |
| return jsonify({'error': 'No data provided'}), 400 | |
| statement = data.get('statement', '').strip() | |
| model_type = data.get('model', 'ensemble') | |
| if not statement: | |
| return jsonify({'error': 'No statement provided'}), 400 | |
| if len(statement) < 5: | |
| return jsonify({'error': 'Statement is too short (min 5 characters)'}), 400 | |
| logger.info(f"Prediction request [{model_type}]: {statement[:60]}...") | |
| if extractor is None: | |
| return jsonify({'error': 'Feature extractor failed to initialize'}), 500 | |
| # ββ Β§3.3 Hybrid Prediction ββββββββββββββββββββββββββββββββββββββββββββ | |
| input_data = {**raw_features, 'statement': statement} | |
| input_df = pd.DataFrame([input_data]) | |
| if model_type == 'ensemble' and 'ensemble' not in models and len(models) > 0: | |
| probs = [] | |
| for m_name, m_obj in models.items(): | |
| try: | |
| p = m_obj.predict_proba(input_df)[0].tolist()[1] | |
| probs.append(p) | |
| except Exception: | |
| pass | |
| real_prob = float(np.mean(probs)) if probs else 0.5 | |
| prob = [1.0 - real_prob, real_prob] | |
| else: | |
| model = models.get(model_type) or (list(models.values())[0] if models else None) | |
| if not model: | |
| return jsonify({'error': 'No model available.'}), 500 | |
| try: | |
| prob = model.predict_proba(input_df)[0].tolist() | |
| except Exception: | |
| prob = [0.5, 0.5] | |
| real_prob = prob[1] # P(Real) | |
| # Calibrated threshold | |
| t_data = thresholds.get(model_type, {}) | |
| threshold = t_data.get('threshold', 0.5) if t_data else 0.5 | |
| prediction = 1 if real_prob >= threshold else 0 | |
| # Confidence: distance from decision boundary | |
| confidence = real_prob * 100 if prediction == 1 else (1 - real_prob) * 100 | |
| # ββ Β§3.3 Logic Guardrail (fixed β bounded probability shift only) βββββ | |
| is_official = raw_features.get('official_marker', 0) > 0 | |
| formal_cadence = raw_features.get('formal_cadence', 0) | |
| sensationalism_score = raw_features.get('sensationalism_score', 0) | |
| sensational_hit_count = raw_features.get('sensational_hit_count', 0) | |
| # Credibility boost ONLY for verified official sources with strong formal cadence | |
| if is_official and formal_cadence > 0.08: | |
| logger.info("Guardrail: Official source detected β applying credibility boost.") | |
| # Safe bounded shift: at most +5% to real_prob | |
| boost = min(0.05, (1.0 - real_prob) * 0.15) | |
| real_prob = real_prob + boost | |
| if real_prob >= threshold: | |
| prediction = 1 | |
| confidence = real_prob * 100 if prediction == 1 else (1 - real_prob) * 100 | |
| # Sensationalism penalty β reduce real_prob for fake-news signature patterns | |
| if sensational_hit_count > 0: | |
| penalty = min(0.50, sensational_hit_count * 0.15) | |
| real_prob = max(0.0, real_prob - penalty) | |
| if real_prob < threshold: | |
| prediction = 0 | |
| confidence = real_prob * 100 if prediction == 1 else (1 - real_prob) * 100 | |
| logger.info(f"Sensationalism penalty: -{penalty:.2f} (hits={sensational_hit_count})") | |
| # ββ Final label βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| final_label = 'Real' if prediction == 1 else 'Fake' | |
| if confidence < 50.1: | |
| final_label = 'Uncertain' | |
| logger.info("Confidence below 50.1% β labeling as 'Uncertain'") | |
| logger.info(f"Result: {final_label} | Confidence: {confidence:.2f}%") | |
| # ββ Β§3 Paper Taxonomy Breakdown βββββββββββββββββββββββββββββββββββββββ | |
| paper_taxonomy = { | |
| # Β§3.2.1 News Content Features | |
| 'content_features': { | |
| 'lexical_density': round(raw_features.get('lexical_density', 0), 4), | |
| 'sentiment_score': round(raw_features.get('sentiment_score', 0), 4), | |
| 'complexity_score': round(raw_features.get('complexity_score', 0), 4), | |
| 'subjectivity_score': round(raw_features.get('subjectivity_score', 0), 4), | |
| 'emotional_intensity': round(raw_features.get('emotional_intensity', 0), 4), | |
| }, | |
| # Β§3.2.2 Social Context Features | |
| 'social_context': { | |
| 'speaker_reliability': round(raw_features.get('speaker_reliability', 0.5), 4), | |
| 'false_history_ratio': round(raw_features.get('false_history_ratio', 0), 4), | |
| 'true_history_ratio': round(raw_features.get('true_history_ratio', 0), 4), | |
| 'history_volume': round(raw_features.get('history_volume', 0), 4), | |
| 'is_republican': int(raw_features.get('is_republican', 0)), | |
| 'is_democrat': int(raw_features.get('is_democrat', 0)), | |
| }, | |
| # Β§3.3 Knowledge-Guided Signals | |
| 'knowledge_signals': { | |
| 'official_marker': int(raw_features.get('official_marker', 0)), | |
| 'formal_cadence': round(raw_features.get('formal_cadence', 0), 4), | |
| 'attribution_ratio': round(raw_features.get('attribution_ratio', 0), 4), | |
| 'has_source_citation': int(raw_features.get('has_source_citation', 0)), | |
| 'entity_density': round(raw_features.get('entity_density', 0), 4), | |
| }, | |
| # Β§3.3.2 Deep Module Signals | |
| 'deep_module': { | |
| 'hedge_ratio': round(raw_features.get('hedge_ratio', 0), 4), | |
| 'certainty_ratio': round(raw_features.get('certainty_ratio', 0), 4), | |
| 'negation_ratio': round(raw_features.get('negation_ratio', 0), 4), | |
| 'caps_word_ratio': round(raw_features.get('caps_word_ratio', 0), 4), | |
| 'number_density': round(raw_features.get('number_density', 0), 4), | |
| }, | |
| # Deception & Sensationalism Signals | |
| 'deception_signals': { | |
| 'sensationalism_score': round(raw_features.get('sensationalism_score', 0), 4), | |
| 'conspiracy_score': round(raw_features.get('conspiracy_score', 0), 4), | |
| 'health_misinfo_score': round(raw_features.get('health_misinfo_score', 0), 4), | |
| 'sensational_hit_count': int(raw_features.get('sensational_hit_count', 0)), | |
| 'absolutist_ratio': round(raw_features.get('absolutist_ratio', 0), 4), | |
| } | |
| } | |
| # Legacy taxonomy breakdown kept for backward compatibility | |
| taxonomy_breakdown = { | |
| 'lexical_density': raw_features.get('lexical_density', 0), | |
| 'syntactic_noun_ratio': raw_features.get('noun_ratio', 0), | |
| 'style_capital_ratio': raw_features.get('capital_ratio', 0), | |
| 'social_reliability_proxy': raw_features.get('speaker_reliability', 0.5) | |
| } | |
| # Source Reputation Analysis | |
| speaker = data.get('speaker') | |
| context_str = data.get('context') | |
| source_rep = reputation_engine.analyze_source(statement, speaker=speaker, context=context_str) | |
| return jsonify({ | |
| 'prediction': final_label, | |
| 'confidence': round(confidence, 2), | |
| 'probabilities': {'fake': round(prob[0], 4), 'real': round(prob[1], 4)}, | |
| 'taxonomy_breakdown': taxonomy_breakdown, | |
| 'paper_taxonomy': paper_taxonomy, | |
| 'model_used': model_type, | |
| 'source_reputation': source_rep | |
| }) | |
| except Exception as e: | |
| logger.error(f"Predict Error: {traceback.format_exc()}") | |
| return jsonify({'error': 'Prediction failed', 'details': str(e)}), 500 | |
| # βββ METRICS ROUTE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_metrics(): | |
| return jsonify(metrics) | |
| # βββ MODEL RECOMMENDATION ROUTE βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def recommend_model(): | |
| """Analyze statement and recommend the best model (Β§3 taxonomy heuristic).""" | |
| try: | |
| data = request.json | |
| statement = data.get('statement', '').strip() | |
| if not statement or len(statement) < 10: | |
| return jsonify({'recommended': 'ensemble', 'reason': 'Enter more text.', | |
| 'scores': {}, 'reasons': {}, 'model_info': {}}) | |
| if extractor is None: | |
| return jsonify({'recommended': 'ensemble', 'reason': 'Extractor unavailable.', | |
| 'scores': {}, 'reasons': {}, 'model_info': {}}) | |
| metadata = {'party': 'independent', 'barely_true_counts': 0, 'false_counts': 0, | |
| 'half_true_counts': 0, 'mostly_true_counts': 0, 'pants_on_fire_counts': 0} | |
| feats = extractor.get_combined_features(statement, metadata) | |
| scores, reasons = {}, {} | |
| wc = feats.get('total_words', 0) | |
| sx = abs(feats.get('sentiment_score', 0)) | |
| em = feats.get('emotional_intensity', 0) | |
| fm = feats.get('formal_cadence', 0) | |
| cx = feats.get('complexity_score', 0) | |
| nr = feats.get('noun_ratio', 0) | |
| nd = feats.get('number_density', 0) | |
| hs = feats.get('has_source_citation', 0) | |
| hg = feats.get('hedge_ratio', 0) | |
| cp = feats.get('caps_word_ratio', 0) | |
| sl = feats.get('avg_sentence_length', 0) | |
| # NB: emotional, short, keyword-heavy text | |
| s, r = 0.5, [] | |
| if wc < 30: s += 0.2; r.append('short text') | |
| if em > 0.02: s += 0.2; r.append('high emotional language') | |
| if sx > 0.5: s += 0.15; r.append('strong sentiment') | |
| if cp > 0.05: s += 0.1; r.append('urgency markers') | |
| scores['nb'] = round(s, 3) | |
| reasons['nb'] = 'Strong for ' + ', '.join(r) if r else 'General keyword analysis' | |
| # LR: formal, noun-heavy, source-cited - HIGHLY RECOMMENDED FOR COMPLEXITY | |
| s, r = 0.5, [] | |
| if nr > 0.15: s += 0.2; r.append('high noun density') | |
| if fm > 0.02: s += 0.15; r.append('formal structure') | |
| if hs > 0: s += 0.15; r.append('source citations') | |
| if nd > 0.05: s += 0.1; r.append('statistical content') | |
| if cx < 40 or sl > 15: s += 0.3; r.append('structural complexity') | |
| if 40 < cx < 70: s += 0.1; r.append('balanced readability') | |
| scores['lr'] = round(s, 3) | |
| reasons['lr'] = 'Highly reliable for ' + ', '.join(r) if r else 'Balanced statistical analysis' | |
| # SVM: complex structure, hedging | |
| s, r = 0.5, [] | |
| if sl > 20: s += 0.2; r.append('long complex sentences') | |
| if cx < 30: s += 0.15; r.append('complex language') | |
| if hg > 0.02: s += 0.15; r.append('hedging language') | |
| if wc > 40: s += 0.1; r.append('detailed statement') | |
| scores['svm'] = round(s, 3) | |
| reasons['svm'] = 'Detects ' + ', '.join(r) if r else 'Linguistic pattern analysis' | |
| # DL: medium-length with emotive+structural mix (Β§3.3.2) | |
| s, r = 0.5, [] | |
| if 20 < wc < 60: s += 0.2; r.append('medium-length text') | |
| if sx > 0.3: s += 0.15; r.append('moderate sentiment') | |
| if em > 0.01: s += 0.15; r.append('emotive language detected') | |
| if hg > 0.01: s += 0.1; r.append('hedging cues') | |
| scores['dl'] = round(s, 3) | |
| reasons['dl'] = 'Deep encoder strength: ' + ', '.join(r) if r else 'Dual text encoder analysis' | |
| # RF: diverse features | |
| s, r = 0.55, [] | |
| if wc > 25: s += 0.1; r.append('sufficient text length') | |
| if nd > 0.03: s += 0.1; r.append('numerical features') | |
| if 0.01 < em < 0.05: s += 0.1; r.append('moderate emotion') | |
| s += 0.05 | |
| scores['rf'] = round(s, 3) | |
| reasons['rf'] = 'Robust with ' + ', '.join(r) if r else 'Robust with diverse text patterns' | |
| # Ensemble: always safest | |
| s, r = 0.6, [] | |
| ms = list(scores.values()) | |
| if ms and (max(ms) - min(ms) < 0.2): | |
| s += 0.2; r.append('models agree') | |
| if wc > 50: s += 0.1; r.append('complex statement') | |
| s += 0.05 | |
| scores['ensemble'] = round(s, 3) | |
| reasons['ensemble'] = 'Safest: ' + ', '.join(r) if r else 'Combines all model perspectives' | |
| rec = max(scores, key=scores.get) | |
| mi = { | |
| 'nb': {'name': 'Naive Bayes', 'icon': 'π', 'strength': 'Keyword Spotting'}, | |
| 'lr': {'name': 'Logistic Regression','icon': 'βοΈ', 'strength': 'Statistical Balance'}, | |
| 'svm': {'name': 'Linear SVC', 'icon': 'π¬', 'strength': 'Pattern Recognition'}, | |
| 'dl': {'name': 'Deep Learning', 'icon': 'π§ ', 'strength': 'Dual Text Encoding (Β§3.3.2)'}, | |
| 'rf': {'name': 'Random Forest', 'icon': 'π²', 'strength': 'Feature Diversity'}, | |
| 'ensemble': {'name': 'Hybrid Ensemble', 'icon': 'π', 'strength': 'Combined Intelligence'}, | |
| } | |
| # Sorted scores array for frontend consumption | |
| scores_array = sorted([{'model': k, 'score': v} for k, v in scores.items()], key=lambda x: x['score'], reverse=True) | |
| return jsonify({'recommended': rec, 'recommended_model': rec, | |
| 'reason': reasons.get(rec, ''), | |
| 'scores': scores_array, 'scores_map': scores, | |
| 'reasons': reasons, 'model_info': mi}) | |
| except Exception as e: | |
| logger.error(f"Recommend error: {traceback.format_exc()}") | |
| return jsonify({'recommended': 'ensemble', 'reason': 'Analysis unavailable.', | |
| 'scores': {}, 'reasons': {}, 'model_info': {}}) | |
| # βββ AUTHENTICATION ROUTES ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def signup(): | |
| data = request.json or {} | |
| username = (data.get('username') or '').strip() | |
| password = (data.get('password') or '').strip() | |
| email = (data.get('email') or '').strip() | |
| # Validate inputs | |
| err = validate_username(username) | |
| if err: | |
| return jsonify({'error': err}), 400 | |
| err = validate_password(password) | |
| if err: | |
| return jsonify({'error': err}), 400 | |
| err = validate_email(email) | |
| if err: | |
| return jsonify({'error': err}), 400 | |
| try: | |
| conn = get_conn() | |
| cursor = conn.cursor() | |
| hashed_pw = generate_password_hash(password) | |
| cursor.execute( | |
| f'INSERT INTO users (username, email, password) VALUES ({PH}, {PH}, {PH})', | |
| (username, email, hashed_pw) | |
| ) | |
| conn.commit() | |
| return jsonify({'message': 'Account created successfully.'}), 201 | |
| except IntegrityError: | |
| return jsonify({'error': 'Username already exists.'}), 409 | |
| except Exception as e: | |
| return jsonify({'error': str(e)}), 500 | |
| finally: | |
| try: | |
| conn.close() | |
| except Exception: | |
| pass | |
| def login(): | |
| ip = request.remote_addr or '0.0.0.0' | |
| if not _check_rate_limit(ip): | |
| return jsonify({'error': 'Too many login attempts. Please wait 5 minutes.'}), 429 | |
| data = request.json or {} | |
| username = (data.get('username') or '').strip() | |
| password = (data.get('password') or '').strip() | |
| if not username or not password: | |
| return jsonify({'error': 'Username and password are required.'}), 400 | |
| conn = None | |
| try: | |
| conn = get_conn() | |
| cursor = conn.cursor() | |
| cursor.execute(f'SELECT password FROM users WHERE username = {PH}', (username,)) | |
| user = cursor.fetchone() | |
| if user and check_password_hash(user[0], password): | |
| token = _issue_token(username) | |
| return jsonify({ | |
| 'message': 'Login successful', | |
| 'username': username, | |
| 'token': token | |
| }), 200 | |
| return jsonify({'error': 'Invalid username or password.'}), 401 | |
| except Exception as e: | |
| return jsonify({'error': str(e)}), 500 | |
| finally: | |
| try: | |
| if conn: | |
| conn.close() | |
| except Exception: | |
| pass | |
| def verify_token(): | |
| """Validates a JWT token. Returns username if valid.""" | |
| token = None | |
| auth = request.headers.get('Authorization', '') | |
| if auth.startswith('Bearer '): | |
| token = auth[7:] | |
| elif request.json: | |
| token = request.json.get('token') | |
| if not token: | |
| return jsonify({'error': 'No token provided.'}), 401 | |
| try: | |
| username = _decode_token(token) | |
| return jsonify({'valid': True, 'username': username}), 200 | |
| except jwt.ExpiredSignatureError: | |
| return jsonify({'valid': False, 'error': 'Token expired. Please log in again.'}), 401 | |
| except jwt.PyJWTError: | |
| return jsonify({'valid': False, 'error': 'Invalid token.'}), 401 | |
| def change_password(): | |
| data = request.json or {} | |
| username = (data.get('username') or '').strip() | |
| current_password = (data.get('currentPassword') or '').strip() | |
| new_password = (data.get('newPassword') or '').strip() | |
| if not username or not current_password or not new_password: | |
| return jsonify({'error': 'All fields required.'}), 400 | |
| err = validate_password(new_password) | |
| if err: | |
| return jsonify({'error': err}), 400 | |
| conn = None | |
| try: | |
| conn = get_conn() | |
| cursor = conn.cursor() | |
| cursor.execute(f'SELECT password FROM users WHERE username = {PH}', (username,)) | |
| user = cursor.fetchone() | |
| if user and check_password_hash(user[0], current_password): | |
| hashed_pw = generate_password_hash(new_password) | |
| cursor.execute(f'UPDATE users SET password = {PH} WHERE username = {PH}', | |
| (hashed_pw, username)) | |
| conn.commit() | |
| return jsonify({'message': 'Password changed successfully.'}), 200 | |
| return jsonify({'error': 'Incorrect current password.'}), 401 | |
| except Exception as e: | |
| return jsonify({'error': str(e)}), 500 | |
| finally: | |
| try: | |
| if conn: | |
| conn.close() | |
| except Exception: | |
| pass | |
| # βββ ROOT ROUTE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def index(): | |
| return jsonify({'status': 'Truth Detector API is running. Frontend is on Vercel.'}) | |
| # βββ Fact Check API Proxy βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def verify_claim(): | |
| """ | |
| Proxies the request to Google Fact Check Tools API. | |
| """ | |
| token = _get_request_token() | |
| if not token: | |
| return jsonify({"error": "Unauthorized"}), 401 | |
| data = request.get_json() | |
| query = data.get('statement') | |
| if not query: | |
| return jsonify({"error": "Statement is required"}), 400 | |
| api_key = os.environ.get('GOOGLE_FACT_CHECK_API_KEY') | |
| if not api_key: | |
| logger.warning("GOOGLE_FACT_CHECK_API_KEY not set. Using dry-run/mock behavior.") | |
| # Return a helpful mock response pointing to real documentation if key is missing | |
| return jsonify({ | |
| "status": "mock", | |
| "message": "Fact-Check API Key not configured on server.", | |
| "results": [ | |
| { | |
| "claimReview": [ | |
| { | |
| "publisher": {"name": "Veracity System"}, | |
| "textualRating": "API Key Required", | |
| "title": "How to enable live fact-checking" | |
| } | |
| ], | |
| "text": f"Search for: '{query}'" | |
| } | |
| ] | |
| }) | |
| try: | |
| url = "https://factchecktools.googleapis.com/v1alpha1/claims:search" | |
| params = { | |
| "query": query, | |
| "key": api_key, | |
| "languageCode": "en" | |
| } | |
| resp = requests.get(url, params=params, timeout=10) | |
| resp.raise_for_status() | |
| results = resp.json() | |
| return jsonify({ | |
| "status": "success", | |
| "results": results.get('claims', []) | |
| }) | |
| except Exception as e: | |
| logger.error(f"Fact Check API Error: {str(e)}") | |
| return jsonify({"error": str(e)}), 500 | |
| # βββ Startup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| init_db() # Ensure DB exists before accepting requests | |
| load_models() | |
| # βββ Gradio + Flask Integration via FastAPI mount ββββββββββββββββββββββββββββββ | |
| # The official way to combine Gradio and custom APIs in HF Spaces is to create | |
| # a FastAPI app, mount the custom API, and then mount Gradio on top. | |
| try: | |
| import gradio as gr | |
| import spaces | |
| from fastapi import FastAPI | |
| from fastapi.middleware.wsgi import WSGIMiddleware | |
| # ββ 1. Gradio UI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def predict_gradio(statement, model_type): | |
| if not statement or len(statement) < 5: | |
| return "Please enter a statement with at least 5 characters." | |
| if not extractor: | |
| return "Extractor unavailable." | |
| metadata = {'party': 'independent', 'barely_true_counts': 0, 'false_counts': 0, | |
| 'half_true_counts': 0, 'mostly_true_counts': 0, 'pants_on_fire_counts': 0} | |
| raw_feats = extractor.get_combined_features(statement, metadata) | |
| input_data = {**raw_feats, 'statement': statement} | |
| input_df = pd.DataFrame([input_data]) | |
| if model_type == 'ensemble' and 'ensemble' not in models and len(models) > 0: | |
| probs = [m.predict_proba(input_df)[0].tolist()[1] for m in models.values() if hasattr(m, 'predict_proba')] | |
| real_prob = float(np.mean(probs)) if probs else 0.5 | |
| else: | |
| m = models.get(model_type) or (list(models.values())[0] if models else None) | |
| if not m: | |
| return "Model unavailable." | |
| try: | |
| real_prob = m.predict_proba(input_df)[0].tolist()[1] | |
| except Exception: | |
| real_prob = 0.5 | |
| t_data = thresholds.get(model_type, {}) | |
| threshold = t_data.get('threshold', 0.5) if t_data else 0.5 | |
| pred = 1 if real_prob >= threshold else 0 | |
| label = 'Real' if pred == 1 else 'Fake' | |
| conf = real_prob * 100 if pred == 1 else (1 - real_prob) * 100 | |
| return f"Prediction: {label} ({conf:.2f}% confidence)\nReal Prob: {real_prob*100:.2f}%\nFake Prob: {(1-real_prob)*100:.2f}%" | |
| demo = gr.Interface( | |
| fn=predict_gradio, | |
| inputs=[ | |
| gr.Textbox(lines=4, placeholder="Enter statement to verify...", label="News Statement"), | |
| gr.Dropdown(choices=['ensemble', 'lr', 'nb', 'svm', 'rf', 'dl'], value='ensemble', label="Model") | |
| ], | |
| outputs="text", | |
| title="Truth Detector API & Interactive Demo", | |
| description="Backend API for Fake News Detection. REST API available at /api/*" | |
| ) | |
| demo.app.mount("/flask", WSGIMiddleware(app)) | |
| _GRADIO_AVAILABLE = True | |
| except ImportError as _ie: | |
| _GRADIO_AVAILABLE = False | |
| logger.info(f"Gradio not available ({_ie}) β running in Flask-only (Docker) mode.") | |
| if __name__ == '__main__': | |
| port = int(os.environ.get('PORT', 7860)) | |
| if _GRADIO_AVAILABLE: | |
| demo.launch(server_name="0.0.0.0", server_port=port) | |
| else: | |
| app.run(host='0.0.0.0', port=port, debug=False) | |