-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathflask_app.py
More file actions
3767 lines (3146 loc) · 144 KB
/
Copy pathflask_app.py
File metadata and controls
3767 lines (3146 loc) · 144 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from flask import Flask, request, render_template, send_file, Response, jsonify, redirect, url_for
from dotenv import load_dotenv
import os
import requests
import io
import json
from urllib.parse import quote_plus
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.platypus import Paragraph, Table, TableStyle
from reportlab.lib.units import inch
from reportlab.lib import colors
import logging
import re
import uuid
import time
from bs4 import BeautifulSoup
import psycopg2
import psycopg2.extras
from datetime import datetime, timedelta
import base64
from PIL import Image
import pytesseract
from moviepy.editor import VideoFileClip
from moviepy.audio.io.AudioFileClip import AudioFileClip
import yt_dlp
import unicodedata
import hashlib
from flask_cors import CORS, cross_origin
import subprocess
from auth_module import auth_bp, get_current_user, require_user
from reportlab.lib.colors import HexColor
from reportlab.lib.enums import TA_JUSTIFY, TA_LEFT
from admin_routes import admin_bp
import random
from reportlab.lib.utils import ImageReader
import fcntl
from whitenoise import WhiteNoise
import math
# ✅ NEW: Google GenAI Import
import google.generativeai as genai
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Load environment variables
load_dotenv()
app = Flask(__name__, static_folder='templates/static')
# ✅ FIX: Use absolute path for WhiteNoise
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
static_root = os.path.join(BASE_DIR, 'templates', 'static')
# Only enable WhiteNoise if the directory actually exists (prevents crash)
if os.path.exists(static_root):
app.wsgi_app = WhiteNoise(app.wsgi_app, root=static_root, prefix='static/')
else:
logging.warning(f"Static folder not found at {static_root}")
app.secret_key = os.getenv("FLASK_SECRET_KEY")
if not app.secret_key:
app.secret_key = os.urandom(24)
app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp, url_prefix='/api/admin')
CORS(app, supports_credentials=True, origins=["https://epistemiq.vercel.app", "http://localhost:8080"])
# ----------------------------------------------------------------
# ✅ AI CONFIGURATION & MODEL LISTS
# ----------------------------------------------------------------
# 1. Google AI Studio (Native)
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
if GOOGLE_API_KEY:
genai.configure(api_key=GOOGLE_API_KEY)
else:
logging.error("GOOGLE_API_KEY is missing.")
# 2. OpenRouter
OR_URL = "https://openrouter.ai/api/v1/chat/completions"
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
# --- Model Lists ---
# A. Google Native Rotation (High throughput, large context)
GOOGLE_ROTATION_MODELS = [
"gemini-3-flash-preview",
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-preview-09-2025",
"gemini-2.5-flash-lite",
"gemini-2.5-flash-lite-preview-09-2025",
"gemini-2.0-flash",
"gemini-2.0-flash-lite-preview-02-05",
"gemini-2.0-pro-exp-02-05",
]
# B. OpenRouter Community Rotation
# Fallback models if Google fails or for user preference.
OPENROUTER_ROTATION_MODELS = [
"openai/gpt-oss-20b:free",
"meta-llama/llama-3.3-70b-instruct:free",
"allenai/olmo-3-32b-think:free",
"mistralai/mistral-7b-instruct:free",
"amazon/nova-2-lite-v1:free",
"nvidia/nemotron-nano-12b-v2-vl:free",
"tngtech/deepseek-r1t2-chimera:free",
]
# Extraction Default (Google Native ID)
# ✅ Updated to Gemma 3 27B Instruction Tuned
EXTRACTION_DEFAULT_MODEL = "gemma-3-27b-it"
# ----------------------------------------------------------------
# LLM WRAPPERS & HYBRID CONTROLLER
# ----------------------------------------------------------------
class UnifiedResponse:
"""Standardizes responses from Google Native and OpenRouter."""
def __init__(self, content=None, stream_generator=None, model_used="unknown"):
self.content = content
self.stream_generator = stream_generator
self.model_used = model_used
def json(self):
return {"choices": [{"message": {"content": self.content}}]}
def iter_content(self, chunk_size=None, decode_unicode=True):
if self.stream_generator:
yield from self.stream_generator
def call_google_native(prompt, model_name, stream=False, temperature=0.0, json_mode=False):
"""Native Google AI Studio Call"""
try:
generation_config = genai.GenerationConfig(
temperature=temperature,
response_mime_type="application/json" if json_mode else "text/plain"
)
model = genai.GenerativeModel(model_name)
response = model.generate_content(prompt, stream=stream, generation_config=generation_config)
if stream:
def google_stream_adapter():
for chunk in response:
if chunk.text:
data = json.dumps({"choices": [{"delta": {"content": chunk.text}}]})
yield f"data: {data}\n\n"
yield "data: [DONE]\n\n"
return UnifiedResponse(stream_generator=google_stream_adapter(), model_used=model_name)
else:
return UnifiedResponse(content=response.text, model_used=model_name)
except Exception as e:
raise Exception(f"Google Native Error ({model_name}): {str(e)}")
def call_openrouter(prompt, model_name, stream=False, temperature=0.0, json_mode=False, timeout=60):
"""OpenRouter API Call"""
if not OPENROUTER_API_KEY:
raise Exception("OPENROUTER_API_KEY not set")
headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "http://epistemiq.pythonanywhere.com/",
"X-Title": "Epistemiq"
}
if "70b" in model_name or "pro" in model_name:
timeout = max(timeout, 90)
payload = {
"model": model_name,
"messages": [{"role": "user", "content": prompt}],
"stream": stream,
"temperature": temperature
}
if json_mode:
payload["response_format"] = {"type": "json_object"}
try:
response = requests.post(OR_URL, headers=headers, json=payload, stream=stream, timeout=timeout)
if response.status_code in [429, 502, 503, 504]:
raise Exception(f"OpenRouter Provider Error {response.status_code}")
if response.status_code != 200:
raise Exception(f"OpenRouter Status {response.status_code}: {response.text[:200]}")
if stream:
return UnifiedResponse(stream_generator=response.iter_content(chunk_size=1024, decode_unicode=True), model_used=model_name)
else:
return UnifiedResponse(content=response.json()['choices'][0]['message']['content'], model_used=model_name)
except Exception as e:
raise Exception(f"OpenRouter Error ({model_name}): {str(e)}")
def call_hybrid_flow(prompt, stream=False, json_mode=False, temperature=0.0, preferred_model=None, task_type="verification", timeout=60):
"""
Iterates through models based on strategy.
task_type="extraction":
1. Gemma-3-27b (Google Native) -> Google Rotation -> OpenRouter Fallback
task_type="verification" / "report":
1. User Preferred Model (if set)
2. Google Native Rotation (Default)
3. OpenRouter Fallback
"""
queue = []
# 1. Build the Execution Queue
if task_type == "extraction":
# Force Gemma 3 (Native) first
queue.append(EXTRACTION_DEFAULT_MODEL)
queue.extend(GOOGLE_ROTATION_MODELS)
queue.extend(OPENROUTER_ROTATION_MODELS)
else: # verification or report
if preferred_model and preferred_model.strip():
# User wants a specific OpenRouter model
queue.append(preferred_model)
queue.extend(GOOGLE_ROTATION_MODELS)
queue.extend(OPENROUTER_ROTATION_MODELS)
else:
# User wants Google Default
queue.extend(GOOGLE_ROTATION_MODELS)
queue.extend(OPENROUTER_ROTATION_MODELS)
# 2. Deduplicate Queue
seen = set()
final_queue = [x for x in queue if not (x in seen or seen.add(x))]
last_error = None
# 3. Execute Rotation
for model_name in final_queue:
try:
# Determine provider:
# Google Native models don't have slashes (e.g. "gemma-3-27b-it", "gemini-2.0-flash")
# OpenRouter models have slashes (e.g. "google/gemma-3-27b-it:free")
is_google_native = "/" not in model_name
logging.info(f"🔄 Attempting model: {model_name} ({'Google Native' if is_google_native else 'OpenRouter'})")
if is_google_native:
return call_google_native(prompt, model_name, stream, temperature, json_mode), model_name
else:
return call_openrouter(prompt, model_name, stream, temperature, json_mode, timeout), model_name
except Exception as e:
logging.warning(f"❌ Model {model_name} failed: {e}")
last_error = e
continue
logging.error("All models in rotation failed.")
raise last_error or Exception("All hybrid models failed.")
# ==============================================================
# Utility functions
# ==============================================================
def json_dumps(obj) -> str:
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
def json_loads(s: str, fallback):
try:
return json.loads(s) if s else fallback
except Exception:
return fallback
def new_analysis_id() -> str:
return str(uuid.uuid4())
# ==============================================================
# DB HELPERS (PostgreSQL)
# ==============================================================
def get_conn():
"""Connect to PostgreSQL using DATABASE_URL env var."""
db_url = os.getenv("DATABASE_URL")
if not db_url:
raise Exception("DATABASE_URL environment variable is not set")
conn = psycopg2.connect(db_url)
return conn
def with_retry_db(fn):
"""Retry decorator adapted for Postgres."""
def wrapper(*args, **kwargs):
attempts = 0
while True:
try:
return fn(*args, **kwargs)
except psycopg2.OperationalError as e:
if attempts < 3:
attempts += 1
time.sleep(0.2 * attempts)
else:
raise e
except Exception as e:
raise e
return wrapper
# ==============================================================
# QUOTA MANAGEMENT
# ==============================================================
QUOTA_LIMITS = {
"analysis": 1, # Extractions per day
"verification": 1, # Claims verified per day (Strict cost saving)
"report": 1 # Deep dives per day
}
def check_and_increment_quota(user_id, quota_type):
"""
Checks if user has quota left.
Admin (epistemiq.ai@gmail.com) is EXEMPT.
"""
limit = QUOTA_LIMITS.get(quota_type, 0)
col_name = f"{quota_type}_count"
conn = get_conn()
try:
with conn.cursor() as c:
# 1. CHECK FOR ADMIN EXEMPTION
c.execute("SELECT email FROM users WHERE id = %s", (user_id,))
user_row = c.fetchone()
if user_row and user_row[0] == "epistemiq.ai@gmail.com":
return True, 0, 99999
# 2. Standard Logic
today = datetime.now().date()
# Ensure record exists
c.execute("""
INSERT INTO user_quotas (user_id, usage_date, analysis_count, verification_count, report_count)
VALUES (%s, %s, 0, 0, 0)
ON CONFLICT (user_id, usage_date) DO NOTHING
""", (user_id, today))
# Check usage
c.execute(f"SELECT {col_name} FROM user_quotas WHERE user_id=%s AND usage_date=%s", (user_id, today))
current_count = c.fetchone()[0]
if current_count >= limit:
return False, current_count, limit
# Increment
c.execute(f"UPDATE user_quotas SET {col_name} = {col_name} + 1 WHERE user_id=%s AND usage_date=%s", (user_id, today))
conn.commit()
return True, current_count + 1, limit
finally:
conn.close()
def get_todays_spotlight_id():
"""Calculates the Analysis ID for the Daily Spotlight based on the date."""
conn = get_conn()
try:
with conn.cursor() as c:
# Pick a "random" one based on the date (stable for 24h) from recent 50
# Filtering for ordinal=0 ensures we get unique analysis IDs
c.execute("""
SELECT a.analysis_id
FROM analyses a
JOIN claims c ON a.analysis_id = c.analysis_id
WHERE c.ordinal = 0
ORDER BY a.created_at DESC
LIMIT 50
""")
rows = c.fetchall()
if not rows: return None
today_int = int(datetime.now().strftime("%Y%m%d"))
selected_index = today_int % len(rows)
return rows[selected_index][0]
except Exception as e:
logging.error(f"Spotlight ID error: {e}")
return None
finally:
conn.close()
# ==============================================================
# EMBEDDING FUNCTION (Google AI)
# ==============================================================
def get_google_embedding(text):
"""Generates a 768-dimensional embedding using Google Gemini."""
if not text: return None
try:
# text-embedding-004 returns 768 dimensions
result = genai.embed_content(
model="models/text-embedding-004",
content=text[:9000], # Google limit approx 10k chars
task_type="SEMANTIC_SIMILARITY"
)
return result['embedding']
except Exception as e:
logging.error(f"Embedding failed: {e}")
return None
# ==============================================================
# Initialization: POSTGRES SCHEMA
# ==============================================================
def init_db():
"""Initialize Database Tables (PostgreSQL Syntax)"""
conn = get_conn()
try:
with conn.cursor() as c:
# 1. Enable Vector Extension
try:
c.execute("CREATE EXTENSION IF NOT EXISTS vector")
except Exception as e:
conn.rollback()
logging.warning(f"Vector extension creation failed (might already exist): {e}")
# Users & Auth
c.execute("""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP
)""")
# Quota System
c.execute("""
CREATE TABLE IF NOT EXISTS user_quotas (
user_id INTEGER REFERENCES users(id),
usage_date DATE DEFAULT CURRENT_DATE,
analysis_count INTEGER DEFAULT 0,
verification_count INTEGER DEFAULT 0,
report_count INTEGER DEFAULT 0,
PRIMARY KEY (user_id, usage_date)
)""")
c.execute("""
CREATE TABLE IF NOT EXISTS magic_links (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
token_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP
)""")
c.execute("""
CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
session_token TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
user_agent TEXT,
ip_hash TEXT
)""")
c.execute("CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)")
c.execute("CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(session_token)")
# Core Analyses (Updated for 768 dim)
c.execute("""
CREATE TABLE IF NOT EXISTS analyses (
analysis_id TEXT PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
text_hash TEXT,
canonical_text TEXT,
mode TEXT,
source_type TEXT,
source_meta TEXT,
embedding vector(768),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_accessed TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_published BOOLEAN DEFAULT FALSE,
published_slug TEXT UNIQUE,
published_title TEXT,
published_at TIMESTAMP,
published_summary TEXT,
published_image_url TEXT
)""")
c.execute("""
CREATE INDEX IF NOT EXISTS idx_analyses_embedding
ON analyses USING hnsw (embedding vector_cosine_ops)
""")
c.execute("CREATE INDEX IF NOT EXISTS idx_analyses_hash_mode ON analyses(text_hash, mode)")
# User Analyses Mapping
c.execute("""
CREATE TABLE IF NOT EXISTS user_analyses (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
analysis_id TEXT REFERENCES analyses(analysis_id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, analysis_id)
)""")
# Input Caches
c.execute("""
CREATE TABLE IF NOT EXISTS pasted_texts (
text_hash TEXT PRIMARY KEY,
text_content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""")
c.execute("""
CREATE TABLE IF NOT EXISTS article_cache (
url_hash TEXT PRIMARY KEY,
url TEXT,
raw_html TEXT,
article_text TEXT,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""")
c.execute("CREATE INDEX IF NOT EXISTS idx_article_cache_url ON article_cache(url)")
c.execute("""
CREATE TABLE IF NOT EXISTS media_cache (
file_hash TEXT PRIMARY KEY,
media_type TEXT NOT NULL,
extracted_text TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""")
# Claims Table
c.execute("""
CREATE TABLE IF NOT EXISTS claims (
claim_id TEXT PRIMARY KEY,
analysis_id TEXT REFERENCES analyses(analysis_id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
claim_text TEXT NOT NULL,
claim_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
final_verdict TEXT,
synthesis_summary TEXT,
category TEXT,
tags TEXT,
search_keywords TEXT
)""")
c.execute("CREATE INDEX IF NOT EXISTS idx_claims_analysis ON claims(analysis_id, ordinal)")
# Verdict Caches (For the 'Deep Dive' Accordion - 768 dim for Google embeddings)
c.execute("""
CREATE TABLE IF NOT EXISTS model_cache (
claim_hash TEXT PRIMARY KEY,
verdict TEXT,
questions_json TEXT,
keywords_json TEXT,
used_model TEXT,
claim_embedding vector(768),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""")
c.execute("""
CREATE TABLE IF NOT EXISTS external_cache (
claim_hash TEXT PRIMARY KEY,
verdict TEXT,
sources_json TEXT,
used_model TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""")
c.execute("""
CREATE TABLE IF NOT EXISTS report_cache (
rq_hash TEXT PRIMARY KEY,
question_text TEXT,
report_text TEXT,
used_model TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""")
# ✅ PUBLISHING ARCHIVE (Snapshot Table)
c.execute("""
CREATE TABLE IF NOT EXISTS published_articles (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
summary TEXT,
image_url TEXT,
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
tags JSONB,
categories JSONB,
content_snapshot JSONB NOT NULL
)""")
# ✅ COMMENTS (Linked to Archive)
c.execute("""
CREATE TABLE IF NOT EXISTS comments (
id SERIAL PRIMARY KEY,
article_id INTEGER REFERENCES published_articles(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id),
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""")
c.execute("CREATE INDEX IF NOT EXISTS idx_comments_article ON comments(article_id)")
conn.commit()
except Exception as e:
print(f"❌ Database Init Failed: {e}")
finally:
conn.close()
# ==============================================================
# CACHE + CLAIM HELPERS (UPDATED FOR UNIFIED FLOW)
# ==============================================================
def sha256_str(s: str):
return hashlib.sha256(s.encode('utf-8')).hexdigest()
def canonicalize_text(text: str) -> str:
if not text: return ""
txt = text.replace("\r\n", "\n").replace("\r", "\n")
txt = " ".join(txt.split())
return txt.strip()
def text_hash(text: str) -> str:
canon = canonicalize_text(text)
return hashlib.sha256(canon.encode("utf-8")).hexdigest()
@with_retry_db
def save_claims_for_analysis(analysis_id: str, claims_data_list: list):
conn = get_conn()
try:
with conn.cursor() as c:
c.execute("DELETE FROM claims WHERE analysis_id=%s", (analysis_id,))
for idx, item in enumerate(claims_data_list):
claim_text = item.get("claim", "").strip()
if not claim_text: continue
keywords_raw = item.get("keywords", [])
keywords_json = json_dumps(keywords_raw) if keywords_raw else None
claim_text_clean = claim_text
claim_hash = sha256_str(claim_text_clean.lower())
claim_id = sha256_str(f"{analysis_id}|{idx}|{claim_text_clean}")
c.execute("""
INSERT INTO claims (
claim_id, analysis_id, ordinal, claim_text,
claim_hash, search_keywords, created_at
)
VALUES (%s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (claim_id) DO UPDATE SET
claim_text = EXCLUDED.claim_text,
claim_hash = EXCLUDED.claim_hash,
search_keywords = EXCLUDED.search_keywords,
created_at = CURRENT_TIMESTAMP
""", (claim_id, analysis_id, idx, claim_text_clean, claim_hash, keywords_json))
conn.commit()
finally:
conn.close()
def get_claims_for_analysis(analysis_id: str):
conn = get_conn()
try:
with conn.cursor() as c:
c.execute("SELECT claim_text FROM claims WHERE analysis_id=%s ORDER BY ordinal", (analysis_id,))
rows = c.fetchall()
return [row[0] for row in rows]
finally:
conn.close()
@with_retry_db
def save_pasted_text_to_db(text_content):
t_hash = text_hash(text_content)
conn = get_conn()
try:
with conn.cursor() as c:
c.execute("""
INSERT INTO pasted_texts (text_hash, text_content, created_at)
VALUES (%s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (text_hash) DO NOTHING
""", (t_hash, text_content))
conn.commit()
finally:
conn.close()
@with_retry_db
def save_article_to_cache_db(url, text):
url_hash = sha256_str(url)
conn = get_conn()
try:
with conn.cursor() as c:
c.execute("""
INSERT INTO article_cache (url_hash, url, raw_html, article_text, fetched_at)
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (url_hash) DO UPDATE SET
article_text = EXCLUDED.article_text,
fetched_at = CURRENT_TIMESTAMP
""", (url_hash, url, "", text))
conn.commit()
finally:
conn.close()
# ==============================================================
# MEDIA CACHE HELPERS (POSTGRESQL)
# ==============================================================
def compute_file_hash(file_path):
hash_sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_sha256.update(chunk)
return hash_sha256.hexdigest()
def get_cached_media(file_hash):
conn = get_conn()
try:
with conn.cursor() as c:
c.execute('SELECT extracted_text FROM media_cache WHERE file_hash = %s', (file_hash,))
result = c.fetchone()
return result[0] if result else None
finally:
conn.close()
@with_retry_db
def store_media_cache(file_hash, media_type, extracted_text):
conn = get_conn()
try:
with conn.cursor() as c:
c.execute("""
INSERT INTO media_cache (file_hash, media_type, extracted_text, created_at)
VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (file_hash) DO UPDATE SET
extracted_text = EXCLUDED.extracted_text
""", (file_hash, media_type, extracted_text))
conn.commit()
finally:
conn.close()
def save_uploaded_file(file, upload_folder=None):
"""Save uploaded file and return path"""
if upload_folder is None:
if os.getenv("DB_PATH"): # Docker
upload_folder = "/app/uploads"
else: # Local
base = os.path.dirname(os.path.abspath(__file__))
upload_folder = os.path.join(base, "uploads")
try:
os.makedirs(upload_folder, exist_ok=True)
filename = str(uuid.uuid4()) + "_" + file.filename
filepath = os.path.join(upload_folder, filename)
file.save(filepath)
return filepath
except Exception as e:
logging.error(f"Error saving uploaded file: {e}")
return None
# ==============================================================
# CLEANUP (POSTGRESQL)
# ==============================================================
@with_retry_db
def cleanup_old_cache():
conn = get_conn()
try:
with conn.cursor() as c:
cutoff = datetime.now() - timedelta(days=30)
# 1. DELETE UNUSED MEDIA
c.execute('DELETE FROM media_cache WHERE created_at < %s', (cutoff,))
media_deleted = c.rowcount
# 2. DELETE OLD ANALYSES (Aggressive cleanup - Published content is safe in Archive)
c.execute("""
DELETE FROM analyses
WHERE last_accessed < %s
""", (cutoff,))
analyses_deleted = c.rowcount
# 3. DELETE INPUT CACHES
c.execute("""
DELETE FROM pasted_texts
WHERE created_at < %s
AND text_hash NOT IN (
SELECT text_hash FROM analyses
)
""", (cutoff,))
texts_deleted = c.rowcount
# 4. DELETE ORPHANED VERDICTS
# Only keep verdicts that are linked to currently active claims
active_claims_query = "SELECT claim_hash FROM claims"
c.execute(f"""
DELETE FROM model_cache
WHERE updated_at < %s
AND claim_hash NOT IN ({active_claims_query})
""", (cutoff,))
model_deleted = c.rowcount
c.execute(f"""
DELETE FROM external_cache
WHERE updated_at < %s
AND claim_hash NOT IN ({active_claims_query})
""", (cutoff,))
external_deleted = c.rowcount
# 5. DELETE ORPHANED REPORTS
# Reports are harder to join directly, so we delete based on time for now
# (Or implement a strict join if rq_hash logic allows)
c.execute('DELETE FROM report_cache WHERE updated_at < %s', (cutoff,))
report_deleted = c.rowcount
# 6. DELETE ARTICLE CACHE
c.execute('DELETE FROM article_cache WHERE fetched_at < %s', (cutoff,))
articles_deleted = c.rowcount
conn.commit()
logging.info(
f"Cache cleanup: {media_deleted} media, {analyses_deleted} analyses, "
f"{texts_deleted} texts, {articles_deleted} articles, "
f"{model_deleted} model, {external_deleted} external, "
f"{report_deleted} reports removed."
)
except Exception as e:
logging.error(f"Cleanup error: {e}")
conn.rollback()
raise
finally:
conn.close()
# ==============================================================
# UNIFIED PROMPT CONFIGURATION (STRICT & ORIGINAL LOGIC)
# ==============================================================
# 1. EXTRACTION CONFIGURATION (Robust Text Mode)
# We use the pipe format '|' because it is reliable for free models.
BASE_TEXT_INSTRUCTION = '''
**Strict rules:**
- ONLY include claims that appear EXPLICITLY in the text.
- Each claim must be explicitly stated.
- If no explicit, complete, testable claims exist, output exactly: "No explicit claims found."
- Absolutely DO NOT infer, paraphrase, generalize, or introduce external knowledge.
- NEVER include incomplete sentences, headings, summaries, conclusions, speculations, questions, or introductory remarks.
- **FORMAT:** Use a numbered list. Separate the claim and keywords with a pipe symbol "|".
- **Keywords:** 3-5 specific search phrases (2-4 words each). Include specific chemical names, physical laws, project names, or specific authors.
**STRICT OUTPUT FORMAT:**
1. The claim text goes here | Keywords: keyword1, keyword2, keyword3
2. Another claim text here | Keywords: keyword1, keyword2, keyword3
'''
UNIFIED_EXTRACTION_TEMPLATES = {
"General Analysis of Testable Claims": f'''
You will be given a text. Extract a **numbered list** of the **top up to 7** most scientifically significant and testable claims.
Prioritize controversial, specific, or verifiable assertions over general statements.
{{text}}
{BASE_TEXT_INSTRUCTION}
''',
"Specific Focus on Scientific Claims": f'''
You will be given a text. Extract a **numbered list** of the **top up to 7** most significant, scientifically testable claims related to science.
Prioritize controversial, data-driven or specific experimental assertions.
{{text}}
{BASE_TEXT_INSTRUCTION}
''',
"Technology-Focused Extraction": f'''
You will be given a text. Extract a **numbered list** of the **top up to 7** most significant, testable claims related to technology.
Prioritize specific capabilities, benchmarks, or innovation claims.
{{text}}
{BASE_TEXT_INSTRUCTION}
'''
}
# 2. VERIFICATION MODES (Exact Original Logic Tables)
VERIFICATION_MODES = {
"General Analysis of Testable Claims": '''
**ROLE:** You are a rigorous scientific fact-checker.
**VERDICT LOGIC TABLE (Follow strictly):**
- If the claim is a known scientific fact -> **VERIFIED**
- If the claim is plausible but lacks proof -> **POSSIBLE_BUT_UNPROVEN**
- If the claim contradicts known science (e.g. "Earth is flat", "CERN opened portal") -> **NONSENSE**
- If the claim is from a fictional/viral story and not real science -> **NONSENSE**
- If the claim is nowhere to be found in real science -> **NOT_SUPPORTED**
**CRITICAL RULES:**
- Use the Source Text ONLY for definition. If the claim says "The team", check the Source Text to know it refers to CERN.
- Do NOT treat the Source Text as evidence. The Source Text is the material we are questioning.
- **REALITY CHECK:** Does the claim entity (event, fact, phenomenon, state, discovery, breakthrough) exist in the real world outside of this text? If not, the verdict is NOT_SUPPORTED or NONSENSE.
''',
"Specific Focus on Scientific Claims": '''
**ROLE:** You are a rigorous scientific fact-checker.
**VERDICT LOGIC TABLE (Follow strictly):**
- If the claim is a known scientific fact -> **VERIFIED**
- If the claim contradicts standard models (e.g. "CERN simulation became conscious") -> **NONSENSE**
- If the claim is a misinterpretation of real science -> **UNLIKELY**
- If the claim exists only in viral posts -> **NOT_SUPPORTED**
**CRITICAL RULES:**
- Use the Source Text ONLY for definition.
- **Consensus:** Judge validity against established mathematics, chemistry, physics, and biology.
- **REALITY CHECK:** Does the claim entity exist in the real world?
''',
"Technology-Focused Extraction": '''
**ROLE:** You are a technology fact-checker.
**VERDICT LOGIC TABLE (Follow strictly):**
- If technology exists and works -> **VERIFIED**
- If technology is theoretical -> **FEASIBLE**
- If technology is scientifically impossible -> **NONSENSE**
- If claim is a hoax -> **NOT_SUPPORTED**
**CRITICAL RULES:**
- **Identify Entities:** Use the Source Text to define vague terms.
- **Reality Check:** Do not blindly believe the Source Text. Evaluate technical feasibility based on real-world engineering standards.
'''
}
# 3. THE MASTER UNIFIED PROMPT
UNIFIED_VERIFICATION_PROMPT = '''
You are a rigorous scientific analyst and the Executive Editor of "The Epistemiq Compass".
Your goal is to write a **Definitive Editorial Verdict** that synthesizes both your internal AI analysis and the external scientific literature.
---
### INPUT DATA
1. **Context of Claim:** "{short_context}"
2. **The Claim:** "{claim_text}"
3. **Scientific Papers Found:**
"""{paper_abstracts}"""
---
### STEP 1: INTERNAL ANALYSIS (Blind Test)
**CRITICAL RULE:** Do NOT look at the "Scientific Papers Found" section yet.
Use ONLY your internal pre-trained knowledge to evaluate the scientific validity of the claim.
- If the claim refers to specific data (e.g. "recorded by Perseverance"), verify if this is a known event/discovery/phenomenon in your training data.
- {mode_specific_instructions}
- **Mandatory:** You MUST provide a detailed explanation (approx 300 words) justifying your verdict. Do NOT just output the verdict label.
---
### STEP 2: EXTERNAL ANALYSIS (Paper Review)
Now, look *only* at the "Scientific Papers Found".
1. **Relevance Check:** Do these papers explicitly confirm the specific event/discovery/phenomenon described in the claim?
2. **Verdict:** Analyze the papers. Do they Support, Refute, or are they Irrelevant?
3. If the papers discuss similar topics but do NOT mention the specific breakthrough claimed, point that out.
3. **Citation:** You MUST cite specific papers from the list by Title/Year in your text.
- **Mandatory:** You MUST provide a detailed explanation (approx 300 words) justifying your verdict. Do NOT just output the verdict label.
---
### STEP 3: FUTURE RESEARCH
Generate 3 specific, open-ended research questions that would help a user validate this claim further (e.g., "What is the specific mechanism...?" or "Has this been replicated in humans?").
---
### STEP 4: THE SYNTHESIS (CRITICAL THINKING PROCESS)
**CRITICAL THINKING PROCESS:**
- **Compare Sources:** Does the External Search (Papers) support the Internal AI's theory?
- If they agree, reinforce the conclusion.
- If they DISAGREE, **you must reconcile them.**
- Prioritize External Verification if it cites papers.
- Explain *why* there is a mismatch (e.g. "External search likely failed due to keywords").
- **Detect Gaps:** If the Internal AI says "Possible" but External Search says "False/Unsupported", check the External Sources. Are they relevant? Or did they miss the topic?
- **Judge the Discrepancy:**
- If papers exist but disprove the claim -> "Explicitly Debunked".
- If no relevant papers were found -> "Theoretical but Unproven".
- If papers confirm it -> "Scientific Consensus".
- **Weigh Evidence:** Prioritize peer-reviewed studies over preprints, and larger studies over smaller ones.
- **Nuance:** If evidence is mixed, highlight the complexity rather than oversimplifying.
**CATEGORY SELECTION GUIDE (CRITICAL):**
- **"The Hype Check"**: Use when a claim exaggerates a real scientific finding (e.g., "New study cures cancer" when it was just mice).
- **"The Scam Bust"**: Use when the claim is pseudoscience, a hoax, or scientifically impossible (e.g., "Perpetual motion machine", "Aliens confirmed").
- **"The Nuance"**: Use when the claim is partially true but lacks context, or if scientific consensus is mixed/debated.
- **"Fact Check"**: Use ONLY for dry, binary historical or data verification (e.g., "Apollo 11 landed in 1969"). **Avoid using this if others apply.**
---
### OUTPUT FORMAT (Strict JSON)
{{
"compass": {{
"category": "The Hype Check" | "The Scam Bust" | "The Nuance" | "Fact Check",
"verdict_label": "Short Badge (e.g. Feasible but Unproven, Debunked, Widely Accepted)",
"summary": "The Editorial (max 150 words). **The 'Report Style' Reconciliation:** If the AI said 'True' but Papers said 'Unsupported', your summary must start by identifying this gap. Example: 'Initial AI analysis suggests [Consensus], yet external verification found no direct experimental support in the provided literature. This mismatch likely stems from [Keyword failure / Narrow search / Theoretical nature of the claim]. Consequently, the verdict is [Verdict].'",
"tags": ["#tag1", "#tag2", "#tag3"]
}},
"evidence": {{
"model_verdict": "Verdict: **[VERDICT]**\\n\\n[Detailed Internal Analysis Paragraph - REQUIRED. Start with the **VERDICT** from the Logic Table (e.g., 'Verdict: **POSSIBLE**'). Then provide a detailed internal assessment based on the Logic Table defined above in up to 300 words. **CITATION RULE:** Explicitly cite the sources that you internally have acces to and that you used (provide a numbered list of the sources you used with url links if available), and name the scientific principles, laws, or consensus you are using (e.g., 'According to the Standard Model...', 'Based on known thermodynamics...').",
"external_verdict": "Verdict: **[VERDICT]**\\n\\n[Detailed External Analysis Paragraph - REQUIRED. Start with a **VERDICT** based strictly on the papers (e.g., 'Verdict: **SUPPORTED**'). Then provide a detailed analysis of the retrieved papers in up to 300 words. **CITATION RULE:** Explicitly cite the papers by title and authors (this is especially important if the authors are mentioned in the source text/context of the claim), and explain how they support or refute the claim.",
"questions": ["Question 1", "Question 2", "Question 3"]
}}