NLPGenius commited on
Commit
89fd50e
ยท
1 Parent(s): 1e472de

Initial API app, Dockerfile, and requirements for Space deployment

Browse files
Files changed (39) hide show
  1. Dockerfile +32 -0
  2. cve_factchecker/README.md +66 -0
  3. cve_factchecker/__init__.py +1 -0
  4. cve_factchecker/__main__.py +4 -0
  5. cve_factchecker/__pycache__/__init__.cpython-311.pyc +0 -0
  6. cve_factchecker/__pycache__/__init__.cpython-313.pyc +0 -0
  7. cve_factchecker/__pycache__/analyzer.cpython-311.pyc +0 -0
  8. cve_factchecker/__pycache__/analyzer.cpython-313.pyc +0 -0
  9. cve_factchecker/__pycache__/app.cpython-311.pyc +0 -0
  10. cve_factchecker/__pycache__/app.cpython-313.pyc +0 -0
  11. cve_factchecker/__pycache__/config.cpython-311.pyc +0 -0
  12. cve_factchecker/__pycache__/config.cpython-313.pyc +0 -0
  13. cve_factchecker/__pycache__/embeddings.cpython-311.pyc +0 -0
  14. cve_factchecker/__pycache__/embeddings.cpython-313.pyc +0 -0
  15. cve_factchecker/__pycache__/firebase_loader.cpython-311.pyc +0 -0
  16. cve_factchecker/__pycache__/firebase_loader.cpython-313.pyc +0 -0
  17. cve_factchecker/__pycache__/firebase_service.cpython-311.pyc +0 -0
  18. cve_factchecker/__pycache__/firebase_service.cpython-313.pyc +0 -0
  19. cve_factchecker/__pycache__/llm.cpython-311.pyc +0 -0
  20. cve_factchecker/__pycache__/llm.cpython-313.pyc +0 -0
  21. cve_factchecker/__pycache__/models.cpython-311.pyc +0 -0
  22. cve_factchecker/__pycache__/models.cpython-313.pyc +0 -0
  23. cve_factchecker/__pycache__/orchestrator.cpython-311.pyc +0 -0
  24. cve_factchecker/__pycache__/orchestrator.cpython-313.pyc +0 -0
  25. cve_factchecker/__pycache__/retriever.cpython-311.pyc +0 -0
  26. cve_factchecker/__pycache__/retriever.cpython-313.pyc +0 -0
  27. cve_factchecker/__pycache__/wsgi.cpython-313.pyc +0 -0
  28. cve_factchecker/analyzer.py +70 -0
  29. cve_factchecker/app.py +106 -0
  30. cve_factchecker/config.py +26 -0
  31. cve_factchecker/embeddings.py +39 -0
  32. cve_factchecker/firebase_loader.py +146 -0
  33. cve_factchecker/firebase_service.py +29 -0
  34. cve_factchecker/llm.py +19 -0
  35. cve_factchecker/models.py +47 -0
  36. cve_factchecker/orchestrator.py +35 -0
  37. cve_factchecker/retriever.py +68 -0
  38. cve_factchecker/wsgi.py +5 -0
  39. requirements.txt +28 -0
Dockerfile ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces - Docker runtime for Flask API
2
+ # Using a slim Python base to keep image small
3
+ FROM python:3.11-slim
4
+
5
+ # Prevent Python from buffering stdout/stderr
6
+ ENV PYTHONDONTWRITEBYTECODE=1 \
7
+ PYTHONUNBUFFERED=1 \
8
+ PIP_NO_CACHE_DIR=1 \
9
+ PORT=7860
10
+
11
+ # System deps for chromadb and sentence-transformers
12
+ RUN apt-get update && apt-get install -y --no-install-recommends \
13
+ build-essential \
14
+ git \
15
+ curl \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ WORKDIR /app
19
+
20
+ # Install Python deps early for better layer caching
21
+ COPY requirements.txt ./
22
+ RUN pip install --upgrade pip && pip install -r requirements.txt && pip install gunicorn
23
+
24
+ # Copy application code
25
+ COPY . .
26
+
27
+ # Expose the port used by Hugging Face Spaces
28
+ EXPOSE 7860
29
+
30
+ # Run the Flask app with gunicorn for production-grade serving
31
+ # Bind to 0.0.0.0:7860 and point to the WSGI entry point
32
+ CMD ["gunicorn", "-w", "2", "-k", "gthread", "--threads", "8", "-b", "0.0.0.0:7860", "cve_factchecker.wsgi:application"]
cve_factchecker/README.md ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CVE Fact Checker Flask API
2
+
3
+ Production-oriented API layer exposing fact checking, Firebase ingestion, and semantic search.
4
+
5
+ ## Features
6
+ - Firebase article ingestion -> vector store (Chroma)
7
+ - Semantic similarity search
8
+ - Claim fact-check endpoint (LLM backed if OPENROUTER_API_KEY present; graceful fallback otherwise)
9
+ - Schema inspection & collection listing
10
+ - Lightweight dummy embeddings fallback (no GPU / HF model required)
11
+
12
+ ## Environment Variables
13
+ ```
14
+ OPENROUTER_API_KEY=your_key # Optional (improves analysis)
15
+ APP_DOMAIN=https://yourdomain.tld # For OpenRouter headers (optional)
16
+ APP_TITLE="Your App Title" # For OpenRouter headers (optional)
17
+ ```
18
+
19
+ ## Install
20
+ ```
21
+ python -m venv .venv
22
+ .venv\Scripts\activate # Windows
23
+ pip install -r requirements.txt
24
+ pip install flask
25
+ ```
26
+
27
+ ## Run
28
+ ```
29
+ python -m cve_factchecker.app
30
+ ```
31
+ Server listens on `http://0.0.0.0:8000`.
32
+
33
+ ## Endpoints
34
+ | Method | Path | Description |
35
+ | ------ | ---- | ----------- |
36
+ | GET | / | Index + endpoint list |
37
+ | GET | /health | Health & uptime |
38
+ | POST | /ingest/firebase | Ingest from Firebase (paged) `{collection?, limit?}` |
39
+ | POST | /ingest/firebase/full | Ingest ALL documents (pagination) `{collection?}` |
40
+ | GET | /schema | Inspect collection schema `?collection=Articles` |
41
+ | GET | /collections | List probable collections |
42
+ | GET | /search | Semantic search `?q=term&k=5` |
43
+ | POST | /fact-check | Fact check `{claim, auto_ingest?, ingest_limit?}` |
44
+
45
+ ## Example Requests (PowerShell)
46
+ ```
47
+ # Health
48
+ curl http://localhost:8000/health
49
+
50
+ # Ingest sample (limited)
51
+ curl -Method POST -Uri http://localhost:8000/ingest/firebase -Headers @{"Content-Type"="application/json"} -Body '{"limit":50}'
52
+
53
+ # Full ingest (all)
54
+ curl -Method POST -Uri http://localhost:8000/ingest/firebase/full -Headers @{"Content-Type"="application/json"} -Body '{}'
55
+
56
+ # Search
57
+ curl "http://localhost:8000/search?q=Pakistan"
58
+
59
+ # Fact Check (with auto ingest)
60
+ curl -Method POST -Uri http://localhost:8000/fact-check -Headers @{"Content-Type"="application/json"} -Body '{"claim":"Pakistan signed a new mineral deal","auto_ingest":true,"ingest_limit":80}'
61
+ ```
62
+
63
+ ## Notes
64
+ - If you have no API key configured, verdicts will be `UNVERIFIED`; retrieval still works.
65
+ - Adjust vector DB path by editing `FactCheckSystem` init in `orchestrator.py`.
66
+ - Set `AUTO_INGEST_FIREBASE=true` (env var) to automatically ingest all articles on startup.
cve_factchecker/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Production-ready Flask integration package for the CVE Fact Checker."""
cve_factchecker/__main__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .app import app
2
+
3
+ if __name__ == '__main__':
4
+ app.run(host='0.0.0.0', port=8000)
cve_factchecker/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (238 Bytes). View file
 
cve_factchecker/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (227 Bytes). View file
 
cve_factchecker/__pycache__/analyzer.cpython-311.pyc ADDED
Binary file (7.62 kB). View file
 
cve_factchecker/__pycache__/analyzer.cpython-313.pyc ADDED
Binary file (6.55 kB). View file
 
cve_factchecker/__pycache__/app.cpython-311.pyc ADDED
Binary file (6.12 kB). View file
 
cve_factchecker/__pycache__/app.cpython-313.pyc ADDED
Binary file (6.25 kB). View file
 
cve_factchecker/__pycache__/config.cpython-311.pyc ADDED
Binary file (2.06 kB). View file
 
cve_factchecker/__pycache__/config.cpython-313.pyc ADDED
Binary file (1.92 kB). View file
 
cve_factchecker/__pycache__/embeddings.cpython-311.pyc ADDED
Binary file (3.42 kB). View file
 
cve_factchecker/__pycache__/embeddings.cpython-313.pyc ADDED
Binary file (2.73 kB). View file
 
cve_factchecker/__pycache__/firebase_loader.cpython-311.pyc ADDED
Binary file (9.91 kB). View file
 
cve_factchecker/__pycache__/firebase_loader.cpython-313.pyc ADDED
Binary file (9.43 kB). View file
 
cve_factchecker/__pycache__/firebase_service.cpython-311.pyc ADDED
Binary file (3.13 kB). View file
 
cve_factchecker/__pycache__/firebase_service.cpython-313.pyc ADDED
Binary file (2.81 kB). View file
 
cve_factchecker/__pycache__/llm.cpython-311.pyc ADDED
Binary file (1.82 kB). View file
 
cve_factchecker/__pycache__/llm.cpython-313.pyc ADDED
Binary file (1.63 kB). View file
 
cve_factchecker/__pycache__/models.cpython-311.pyc ADDED
Binary file (2.67 kB). View file
 
cve_factchecker/__pycache__/models.cpython-313.pyc ADDED
Binary file (2.34 kB). View file
 
cve_factchecker/__pycache__/orchestrator.cpython-311.pyc ADDED
Binary file (3.76 kB). View file
 
cve_factchecker/__pycache__/orchestrator.cpython-313.pyc ADDED
Binary file (3.06 kB). View file
 
cve_factchecker/__pycache__/retriever.cpython-311.pyc ADDED
Binary file (6.49 kB). View file
 
cve_factchecker/__pycache__/retriever.cpython-313.pyc ADDED
Binary file (5.78 kB). View file
 
cve_factchecker/__pycache__/wsgi.cpython-313.pyc ADDED
Binary file (234 Bytes). View file
 
cve_factchecker/analyzer.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import json, re
3
+ from typing import List, Dict, Any
4
+ from .config import OpenRouterConfig
5
+ from .llm import build_openrouter_client, chat_complete
6
+ from .models import normalize_result
7
+
8
+ class QueryRewriter:
9
+ def __init__(self, cfg: OpenRouterConfig):
10
+ self.cfg = cfg
11
+ self.client = build_openrouter_client(cfg)
12
+ def rewrite(self, query: str) -> List[str]:
13
+ if not self.client:
14
+ return list({query, f"{query} Pakistan", f"{query} Urdu"})
15
+ prompt = ("Generate 3 diverse search queries for Pakistani news related to the user's query. "
16
+ "Consider Urdu-English variations and synonyms. Return only the queries, one per line without numbering.\n\n"
17
+ f"User query: {query}")
18
+ try:
19
+ out = chat_complete(self.client, self.cfg.model, prompt, temperature=self.cfg.temperature, max_tokens=min(400, self.cfg.max_tokens))
20
+ lines = [ln.strip(" -โ€ข\t").strip() for ln in out.splitlines()]
21
+ queries = [ln for ln in lines if ln]
22
+ return queries[:3] if queries else [query]
23
+ except Exception as e:
24
+ print(f"โŒ Query rewriting error: {e}")
25
+ return [query]
26
+
27
+ class ClaimAnalyzer:
28
+ def __init__(self, cfg: OpenRouterConfig):
29
+ self.cfg = cfg
30
+ self.client = build_openrouter_client(cfg)
31
+ def analyze(self, claim: str, articles: List[Dict[str, Any]]) -> Dict[str, Any]:
32
+ if not self.client:
33
+ # Heuristic fallback: simple keyword overlap scoring.
34
+ claim_lc = claim.lower()
35
+ keywords = {w for w in claim_lc.split() if len(w) > 4}
36
+ supporting: List[str] = []
37
+ score = 0
38
+ for a in articles:
39
+ text = (a.get('content','') or '').lower()
40
+ overlap = sum(1 for k in keywords if k in text)
41
+ if overlap:
42
+ supporting.append(f"Match ({overlap}) in {a.get('url','')}")
43
+ score += overlap
44
+ confidence = min(0.6, 0.1 * score) if supporting else 0.05
45
+ verdict = "POSSIBLY TRUE" if confidence > 0.3 else "UNVERIFIED"
46
+ return {
47
+ "verdict": verdict,
48
+ "confidence": confidence,
49
+ "reasoning": "Heuristic fallback (no LLM). Confidence based on keyword overlap in retrieved articles.",
50
+ "supporting_evidence": supporting[:5],
51
+ "contradicting_evidence": [],
52
+ "context_quality": "medium" if supporting else "low",
53
+ }
54
+ context = "\n\n".join([f"Article {i+1}:\nTitle: {a.get('title','Unknown')}\nSource: {a.get('source','Unknown')}\nURL: {a.get('url','')}\nContent: {a.get('content','')[:500]}..." for i,a in enumerate(articles)])
55
+ prompt = ("You are an expert Pakistani fact-checker. Analyze the claim against the retrieved context and return JSON only.\n\n"
56
+ f"NEWS CLAIM: {claim}\n\nRETRIEVED CONTEXT:\n{context}\n\n"
57
+ "Return strictly valid JSON with keys: verdict, confidence, reasoning, supporting_evidence, contradicting_evidence, context_quality.")
58
+ try:
59
+ content = chat_complete(self.client, self.cfg.model, prompt, temperature=self.cfg.temperature, max_tokens=self.cfg.max_tokens).strip()
60
+ if content.startswith("```"):
61
+ content = content.strip("`")
62
+ if "\n" in content:
63
+ content = "\n".join(content.split("\n")[1:])
64
+ m = re.search(r"\{[\s\S]*\}", content)
65
+ if m:
66
+ content = m.group(0)
67
+ data = json.loads(content)
68
+ except Exception as e:
69
+ return {"verdict": "ERROR", "confidence": 0.0, "reasoning": f"Analysis failed: {e}", "supporting_evidence": [], "contradicting_evidence": [], "context_quality": "low"}
70
+ return normalize_result(data)
cve_factchecker/app.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from flask import Flask, jsonify, request
3
+ from typing import Any, Dict
4
+ import time
5
+ import threading
6
+ from .orchestrator import FactCheckSystem
7
+ from .firebase_service import FirebaseVectorSync
8
+ try:
9
+ from flask_cors import CORS # type: ignore
10
+ except Exception: # pragma: no cover
11
+ CORS = None # type: ignore
12
+
13
+ system = FactCheckSystem()
14
+ firebase_sync = FirebaseVectorSync()
15
+
16
+ import os
17
+ AUTO_INGEST = True # Always ingest on startup for seamless experience
18
+ INGEST_STATUS: Dict[str, Any] = {"started": time.time(), "finished": False, "synced": 0}
19
+
20
+ def _background_ingest() -> None:
21
+ try:
22
+ print("๐Ÿš€ Performing full Firebase ingestion (background, startup)...")
23
+ ingest_res = firebase_sync.full_sync()
24
+ INGEST_STATUS.update({"finished": True, **ingest_res})
25
+ if not ingest_res.get("success"):
26
+ print("โš ๏ธ Startup ingestion did not succeed:", ingest_res.get("error"))
27
+ else:
28
+ print(f"โœ… Startup ingestion complete: {ingest_res.get('synced')} articles")
29
+ # Log LLM availability
30
+ if system.analyzer.client:
31
+ print(f"๐Ÿค– LLM active: model={system.cfg.model} max_tokens={system.cfg.max_tokens}")
32
+ else:
33
+ print("โš ๏ธ No LLM API key detected. Using heuristic fallback.")
34
+ except Exception as e:
35
+ INGEST_STATUS.update({"finished": True, "error": str(e)})
36
+ print(f"โŒ Startup ingestion failed: {e}")
37
+
38
+ def _start_ingest_thread() -> None:
39
+ if not AUTO_INGEST:
40
+ return
41
+ t = threading.Thread(target=_background_ingest, name="firebase-ingest", daemon=True)
42
+ t.start()
43
+
44
+ app = Flask(__name__)
45
+ if CORS:
46
+ CORS(app, resources={r"/*": {"origins": "*"}})
47
+ start_time = time.time()
48
+
49
+ # Start ingestion in background as soon as the module is imported / app is created
50
+ _start_ingest_thread()
51
+
52
+ @app.route('/health')
53
+ def health() -> Any:
54
+ return jsonify({"status": "ok", "uptime_sec": round(time.time()-start_time,2)})
55
+
56
+ ## Simplified API: only /health and /fact-check provided. Data ingestion occurs automatically on startup.
57
+
58
+ def _run_fact_check(claim: str): # internal helper
59
+ if not INGEST_STATUS.get("finished"):
60
+ return {"verdict": "INITIALIZING", "reasoning": "Ingestion still in progress. Try again soon.", "confidence": 0.0}, 503
61
+ result = system.fact_check(claim)
62
+ if result.get('verdict') == 'ERROR' and '402' in result.get('reasoning',''):
63
+ result['verdict'] = 'UNVERIFIED'
64
+ result['reasoning'] = 'LLM quota/credits insufficient. Retrieval performed; provide API key to enable full analysis.'
65
+ return result, 200
66
+
67
+ @app.route('/fact-check', methods=['POST','GET'])
68
+ def fact_check() -> Any:
69
+ claim: Any = None
70
+ if request.method == 'GET':
71
+ claim = request.args.get('claim') or request.args.get('text')
72
+ else: # POST
73
+ payload = request.get_json(silent=True) or {}
74
+ # support form or query fallback
75
+ claim = (payload.get('claim') or payload.get('text') or
76
+ request.form.get('claim') or request.args.get('claim'))
77
+ if not claim:
78
+ return jsonify({"error": "claim parameter or JSON field 'claim' required"}), 400
79
+ result, code = _run_fact_check(claim)
80
+ return jsonify(result), code
81
+
82
+ @app.route('/')
83
+ def index():
84
+ # Convenience: allow GET /?claim=... for Postman users
85
+ q_claim = request.args.get('claim')
86
+ if q_claim:
87
+ result, code = _run_fact_check(q_claim)
88
+ return jsonify(result), code
89
+ return jsonify({
90
+ "name": "CVE Fact Checker API (Simplified)",
91
+ "status": {
92
+ "ingestion_finished": INGEST_STATUS.get("finished"),
93
+ "synced_articles": INGEST_STATUS.get("synced"),
94
+ "ingestion_error": INGEST_STATUS.get("error")
95
+ },
96
+ "endpoints": [
97
+ "GET /health",
98
+ "GET /fact-check?claim=...",
99
+ "POST /fact-check {claim}" ,
100
+ "GET /?claim=... (alias)"
101
+ ]
102
+ })
103
+
104
+ if __name__ == '__main__':
105
+ port = int(os.environ.get('PORT', '7860'))
106
+ app.run(host='0.0.0.0', port=port)
cve_factchecker/config.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import Optional, Dict
4
+ import os
5
+
6
+ @dataclass
7
+ class OpenRouterConfig:
8
+ api_key: Optional[str]
9
+ model: str = "deepseek/deepseek-r1-0528"
10
+ base_url: str = "https://openrouter.ai/api/v1"
11
+ temperature: float = 0.2
12
+ max_tokens: int = 800
13
+ headers: Optional[Dict[str, str]] = None
14
+
15
+ def load_openrouter_config(api_key: Optional[str] = None) -> OpenRouterConfig:
16
+ key = api_key or os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY")
17
+ model = os.getenv("FACTCHECK_MODEL") or "deepseek/deepseek-r1-0528"
18
+ try:
19
+ max_tokens = int(os.getenv("FACTCHECK_MAX_TOKENS", "800"))
20
+ except ValueError:
21
+ max_tokens = 800
22
+ headers = {
23
+ "HTTP-Referer": os.getenv("APP_DOMAIN", "http://localhost"),
24
+ "X-Title": os.getenv("APP_TITLE", "CVE Fact Checker"),
25
+ }
26
+ return OpenRouterConfig(api_key=key, headers=headers, model=model, max_tokens=max_tokens)
cve_factchecker/embeddings.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import List, Any
3
+ import os
4
+
5
+ class SimpleDummyEmbeddings:
6
+ def __init__(self, dim: int = 384):
7
+ self.dimension = dim
8
+ def embed_documents(self, texts: List[str]) -> List[List[float]]:
9
+ vecs: List[List[float]] = []
10
+ for t in texts:
11
+ h = abs(hash(t.lower()))
12
+ v = [(float((h >> i) & 1)) for i in range(self.dimension)]
13
+ norm = sum(x * x for x in v) ** 0.5 or 1.0
14
+ vecs.append([x / norm for x in v])
15
+ return vecs
16
+ def embed_query(self, text: str) -> List[float]:
17
+ return self.embed_documents([text])[0]
18
+
19
+ def build_embeddings() -> Any:
20
+ # Allow forcing lightweight embeddings to speed up cold starts (e.g., on Spaces)
21
+ if os.environ.get("USE_DUMMY_EMBEDDINGS", "").lower() in ("1", "true", "yes"): # pragma: no cover
22
+ return SimpleDummyEmbeddings()
23
+ try:
24
+ from langchain_huggingface import HuggingFaceEmbeddings # type: ignore
25
+ except Exception:
26
+ try:
27
+ from langchain_community.embeddings import HuggingFaceEmbeddings # type: ignore
28
+ except Exception:
29
+ HuggingFaceEmbeddings = None # type: ignore
30
+ if "HuggingFaceEmbeddings" in locals() and HuggingFaceEmbeddings is not None: # type: ignore
31
+ try:
32
+ return HuggingFaceEmbeddings(
33
+ model_name="sentence-transformers/all-MiniLM-L6-v2",
34
+ model_kwargs={"device": "cpu"},
35
+ encode_kwargs={"normalize_embeddings": True},
36
+ )
37
+ except Exception:
38
+ pass
39
+ return SimpleDummyEmbeddings()
cve_factchecker/firebase_loader.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import requests
3
+ from typing import List, Dict, Any, Optional
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from .models import NewsArticle
7
+
8
+ @dataclass
9
+ class FirebaseConfig:
10
+ api_key: str
11
+ auth_domain: str
12
+ project_id: str
13
+ storage_bucket: str
14
+ messaging_sender_id: str
15
+ app_id: str
16
+
17
+ FIREBASE_CONFIG = FirebaseConfig(
18
+ api_key=os.environ.get("FIREBASE_API_KEY", "AIzaSyAX2ZBIB5lkBEEgXydi__Qlb0WBpUmntCk"),
19
+ auth_domain=os.environ.get("FIREBASE_AUTH_DOMAIN", "cve-articles-b4f4f.firebaseapp.com"),
20
+ project_id=os.environ.get("FIREBASE_PROJECT_ID", "cve-articles-b4f4f"),
21
+ storage_bucket=os.environ.get("FIREBASE_STORAGE_BUCKET", "cve-articles-b4f4f.firebasestorage.app"),
22
+ messaging_sender_id=os.environ.get("FIREBASE_MESSAGING_SENDER_ID", "682945772298"),
23
+ app_id=os.environ.get("FIREBASE_APP_ID", "1:682945772298:web:b0d1dab0c7e07f83fad8f3")
24
+ )
25
+
26
+ class FirebaseNewsLoader:
27
+ def __init__(self, config: Optional[FirebaseConfig] = None):
28
+ self.config = config or FIREBASE_CONFIG
29
+ self.project_id = self.config.project_id
30
+ self.api_key = self.config.api_key
31
+
32
+ def fetch_articles(self, collection_name: str = "Articles", limit: Optional[int] = 100) -> List[NewsArticle]:
33
+ """Fetch articles with optional limit. If limit is None or <=0, fetch ALL via pagination."""
34
+ try:
35
+ base_url = f"https://firestore.googleapis.com/v1/projects/{self.project_id}/databases/(default)/documents/{collection_name}"
36
+ remaining = None if (limit is None or (isinstance(limit, int) and limit <= 0)) else int(limit)
37
+ page_token: Optional[str] = None
38
+ batch_size = 300 # Firestore max pageSize
39
+ articles: List[NewsArticle] = []
40
+ while True:
41
+ if remaining is not None and remaining <= 0:
42
+ break
43
+ page_size = batch_size if remaining is None else min(batch_size, remaining)
44
+ params = {"key": self.api_key, "pageSize": page_size}
45
+ if page_token:
46
+ params["pageToken"] = page_token
47
+ resp = requests.get(base_url, params=params, timeout=30)
48
+ if resp.status_code != 200:
49
+ print(f"โŒ Firebase API failed: {resp.status_code}")
50
+ break
51
+ data = resp.json()
52
+ docs = data.get("documents", [])
53
+ if not docs:
54
+ break
55
+ for d in docs:
56
+ art = self._convert_doc(d)
57
+ if art:
58
+ articles.append(art)
59
+ if remaining is not None:
60
+ remaining -= len(docs)
61
+ page_token = data.get("nextPageToken")
62
+ if not page_token:
63
+ break
64
+ return articles
65
+ except Exception as e:
66
+ print(f"โŒ Firebase error: {e}")
67
+ return []
68
+
69
+ def _convert_doc(self, doc: Dict[str, Any]) -> Optional[NewsArticle]:
70
+ try:
71
+ doc_name = doc.get("name", "")
72
+ doc_id = doc_name.split("/")[-1] if doc_name else "unknown"
73
+ fields = doc.get("fields", {})
74
+ data: Dict[str, Any] = {}
75
+ for fname, fval in fields.items():
76
+ if fval and isinstance(fval, dict):
77
+ ftype = list(fval.keys())[0]
78
+ data[fname] = fval[ftype]
79
+ return NewsArticle(
80
+ title=data.get("Title", data.get("title", "Untitled")),
81
+ content=data.get("Article_text", data.get("content", "")),
82
+ url=data.get("URL", data.get("url", f"firebase://doc/{doc_id}")),
83
+ source=data.get("source", "Firebase"),
84
+ published_date=data.get("Date", data.get("createdAt", datetime.now().isoformat())),
85
+ scraped_date=data.get("scrapedAt", data.get("createdAt", datetime.now().isoformat())),
86
+ article_id=doc_id,
87
+ )
88
+ except Exception as e:
89
+ print(f"โš ๏ธ Conversion error: {e}")
90
+ return None
91
+
92
+ def load_news_articles(self, collection_name: str = "Articles", limit: int = 100) -> List[NewsArticle]:
93
+ return self.fetch_articles(collection_name, limit)
94
+
95
+ def analyze_schema(self, collection_name: str = "Articles") -> Dict[str, Any]:
96
+ try:
97
+ url = f"https://firestore.googleapis.com/v1/projects/{self.project_id}/databases/(default)/documents/{collection_name}"
98
+ params = {"key": self.api_key, "pageSize": 5}
99
+ response = requests.get(url, params=params, timeout=30)
100
+ if response.status_code == 200:
101
+ data = response.json()
102
+ documents = data.get("documents", [])
103
+ if not documents:
104
+ return {"error": "empty", "collection": collection_name}
105
+ all_fields = set()
106
+ sample_data = []
107
+ for doc in documents:
108
+ fields = doc.get("fields", {})
109
+ field_names = list(fields.keys())
110
+ all_fields.update(field_names)
111
+ sample_values: Dict[str, Any] = {}
112
+ for fname, fdata in fields.items():
113
+ if fdata and isinstance(fdata, dict):
114
+ ftype = list(fdata.keys())[0]
115
+ sample_values[fname] = str(fdata[ftype])[:100]
116
+ doc_id = doc.get("name", "").split("/")[-1]
117
+ sample_data.append({"id": doc_id, "fields": field_names, "sample": sample_values})
118
+ return {
119
+ "collection": collection_name,
120
+ "document_count": len(documents),
121
+ "unique_fields": sorted(list(all_fields)),
122
+ "field_count": len(all_fields),
123
+ "sample_documents": sample_data,
124
+ }
125
+ return {"error": f"status {response.status_code}", "collection": collection_name}
126
+ except Exception as e:
127
+ return {"error": str(e), "collection": collection_name}
128
+
129
+ def get_collections_info(self) -> List[Dict[str, Any]]:
130
+ possible = ["Articles", "articles"]
131
+ results: List[Dict[str, Any]] = []
132
+ seen = set()
133
+ for name in possible:
134
+ if name in seen:
135
+ continue
136
+ arts = self.fetch_articles(name, limit=5)
137
+ if arts:
138
+ results.append({
139
+ "name": name,
140
+ "document_count": "โ‰ฅ" + str(len(arts)),
141
+ "sample_titles": [a.title for a in arts[:3]],
142
+ })
143
+ seen.add(name)
144
+ if not results:
145
+ results.append({"name": "Articles", "document_count": 0})
146
+ return results
cve_factchecker/firebase_service.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import Dict, Any, Optional, List
3
+ from .firebase_loader import FirebaseNewsLoader
4
+ from .retriever import VectorNewsRetriever
5
+
6
+ class FirebaseVectorSync:
7
+ def __init__(self, vector_persist_dir: str = "./vector_db"):
8
+ self.firebase_loader = FirebaseNewsLoader()
9
+ self.vector_retriever = VectorNewsRetriever(persist_directory=vector_persist_dir)
10
+ def analyze_firebase_schema(self, collection: str = "Articles") -> Dict[str, Any]:
11
+ schema = self.firebase_loader.analyze_schema(collection)
12
+ return schema
13
+ def sync_from_firebase(self, collection_name: str = "Articles", limit: Optional[int] = None) -> Dict[str, Any]:
14
+ # If limit is None => fetch ALL via pagination logic in loader.
15
+ arts = self.firebase_loader.load_news_articles(collection_name, limit if limit is not None else None)
16
+ if not arts and collection_name != "Articles":
17
+ arts = self.firebase_loader.load_news_articles("Articles", limit if limit is not None else None)
18
+ if arts:
19
+ collection_name = "Articles"
20
+ if not arts:
21
+ return {"error": "No articles found", "synced": 0, "success": False}
22
+ self.vector_retriever.store_articles_in_vector_db(arts)
23
+ return {"synced": len(arts), "collection": collection_name, "success": True}
24
+ def quick_sync(self, limit: int = 100) -> Dict[str, Any]:
25
+ return self.sync_from_firebase(limit=limit)
26
+ def full_sync(self) -> Dict[str, Any]:
27
+ return self.sync_from_firebase(limit=None)
28
+ def list_firebase_collections(self) -> List[Dict[str, Any]]:
29
+ return self.firebase_loader.get_collections_info()
cve_factchecker/llm.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import Optional
3
+ from openai import OpenAI
4
+ from .config import OpenRouterConfig
5
+
6
+ def build_openrouter_client(cfg: OpenRouterConfig) -> Optional[OpenAI]:
7
+ if not cfg.api_key:
8
+ return None
9
+ try:
10
+ client = OpenAI(api_key=cfg.api_key, base_url=cfg.base_url, default_headers=cfg.headers or None)
11
+ return client
12
+ except Exception as e:
13
+ print(f"โš ๏ธ LLM initialization failed: {e}")
14
+ return None
15
+
16
+ def chat_complete(client: OpenAI, model: str, prompt: str, temperature: float = 0.0, max_tokens: int = 800) -> str:
17
+ resp = client.chat.completions.create(model=model, messages=[{"role": "user", "content": prompt}], temperature=temperature, max_tokens=max_tokens)
18
+ choice = resp.choices[0]
19
+ return getattr(getattr(choice, "message", None), "content", None) or getattr(choice, "text", "") or ""
cve_factchecker/models.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import List, Dict, Any
5
+
6
+
7
+ @dataclass
8
+ class NewsArticle:
9
+ title: str
10
+ content: str
11
+ url: str
12
+ source: str
13
+ published_date: str
14
+ scraped_date: str
15
+ article_id: str
16
+
17
+
18
+ def normalize_result(result: Dict[str, Any]) -> Dict[str, Any]:
19
+ out = {
20
+ "verdict": result.get("verdict", "UNVERIFIED"),
21
+ "confidence": result.get("confidence", 0.0),
22
+ "reasoning": result.get("reasoning", ""),
23
+ "supporting_evidence": result.get("supporting_evidence", []) or [],
24
+ "contradicting_evidence": result.get("contradicting_evidence", []) or [],
25
+ "context_quality": result.get("context_quality", "unknown"),
26
+ }
27
+ c = out["confidence"]
28
+ try:
29
+ if isinstance(c, str):
30
+ c = float(c.strip().replace("%", ""))
31
+ c = float(c)
32
+ if c > 1.0:
33
+ c = c / 100.0
34
+ if c < 0:
35
+ c = 0.0
36
+ if c > 1:
37
+ c = 1.0
38
+ except Exception:
39
+ c = 0.0
40
+ out["confidence"] = c
41
+ if not isinstance(out["supporting_evidence"], list):
42
+ out["supporting_evidence"] = [str(out["supporting_evidence"])]
43
+ if not isinstance(out["contradicting_evidence"], list):
44
+ out["contradicting_evidence"] = [str(out["contradicting_evidence"])]
45
+ if isinstance(out["verdict"], str):
46
+ out["verdict"] = out["verdict"].upper()
47
+ return out
cve_factchecker/orchestrator.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import Dict, Any, Optional
3
+ from .config import load_openrouter_config
4
+ from .retriever import VectorNewsRetriever
5
+ from .analyzer import QueryRewriter, ClaimAnalyzer
6
+ from .firebase_loader import FirebaseNewsLoader
7
+
8
+ class FactCheckSystem:
9
+ def __init__(self, api_key: Optional[str] = None, vector_dir: str = "./vector_db"):
10
+ cfg = load_openrouter_config(api_key)
11
+ self.cfg = cfg
12
+ self.retriever = VectorNewsRetriever(persist_directory=vector_dir)
13
+ self.rewriter = QueryRewriter(cfg)
14
+ self.analyzer = ClaimAnalyzer(cfg)
15
+ self.firebase = FirebaseNewsLoader()
16
+ def ingest_firebase(self, collection: str = "Articles", limit: int = 200) -> Dict[str, Any]:
17
+ arts = self.firebase.load_news_articles(collection, limit)
18
+ if not arts:
19
+ return {"synced": 0, "collection": collection, "success": False}
20
+ self.retriever.store_articles_in_vector_db(arts)
21
+ return {"synced": len(arts), "collection": collection, "success": True}
22
+ def fact_check(self, claim: str, k: int = 5) -> Dict[str, Any]:
23
+ base = self.retriever.semantic_search(claim, k=k)
24
+ urls = {a.get("url", "") for a in base}
25
+ for q in self.rewriter.rewrite(claim):
26
+ more = self.retriever.semantic_search(q, k=3)
27
+ for m in more:
28
+ u = m.get("url", "")
29
+ if u and u not in urls:
30
+ base.append(m)
31
+ urls.add(u)
32
+ result = self.analyzer.analyze(claim, base[:8])
33
+ result["sources_used"] = len(base[:8])
34
+ result["retrieved_articles"] = [a.get("url", "") for a in base[:8]]
35
+ return result
cve_factchecker/retriever.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import os
3
+ from typing import List, Dict, Any
4
+ from langchain.text_splitter import RecursiveCharacterTextSplitter
5
+ from langchain.schema import Document
6
+ try:
7
+ from langchain_chroma import Chroma # type: ignore
8
+ except Exception:
9
+ from langchain_community.vectorstores import Chroma # type: ignore
10
+ from .embeddings import build_embeddings
11
+ from .models import NewsArticle
12
+
13
+ class VectorNewsRetriever:
14
+ def __init__(self, persist_directory: str = "./vector_db"):
15
+ # Allow overriding persist directory via env (e.g., /data on Hugging Face Spaces)
16
+ env_dir = os.environ.get("VECTOR_PERSIST_DIR")
17
+ self.persist_directory = env_dir or persist_directory
18
+ self.embeddings = build_embeddings()
19
+ self.vector_store = self._initialize_vector_store()
20
+ def _initialize_vector_store(self) -> Chroma:
21
+ try:
22
+ os.makedirs(self.persist_directory, exist_ok=True)
23
+ vs = Chroma(persist_directory=self.persist_directory, embedding_function=self.embeddings, collection_name="news_articles")
24
+ try:
25
+ count = vs._collection.count()
26
+ print(f"โœ… Loaded vector database with {count} documents")
27
+ except Exception:
28
+ print("โœ… Vector database loaded")
29
+ return vs
30
+ except Exception as e:
31
+ print(f"โŒ Error initializing vector store: {e}")
32
+ print("๐Ÿ”„ Using in-memory store")
33
+ return Chroma(embedding_function=self.embeddings, collection_name="news_articles_memory")
34
+ def store_articles_in_vector_db(self, articles: List[NewsArticle]) -> None:
35
+ if not articles:
36
+ print("โ„น๏ธ No new articles to store")
37
+ return
38
+ splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
39
+ docs: List[Document] = []
40
+ for art in articles:
41
+ chunks = splitter.split_text(art.content or "")
42
+ for chunk in chunks:
43
+ docs.append(Document(page_content=f"Title: {art.title}\n\n{chunk}", metadata={"url": art.url, "source": art.source, "published_date": art.published_date, "scraped_date": art.scraped_date, "id": art.article_id}))
44
+ if hasattr(self.vector_store, "add_documents"):
45
+ self.vector_store.add_documents(docs)
46
+ else:
47
+ self.vector_store.add_texts([d.page_content for d in docs], metadatas=[d.metadata for d in docs])
48
+ try:
49
+ self.vector_store.persist()
50
+ except Exception:
51
+ pass
52
+ print(f"๐Ÿ’พ Stored {len(docs)} chunks from {len(articles)} articles")
53
+ def semantic_search(self, query: str, k: int = 5) -> List[Dict[str, Any]]:
54
+ try:
55
+ docs = self.vector_store.similarity_search(query, k=k)
56
+ except Exception as e:
57
+ print(f"โŒ Vector search failed: {e}")
58
+ return []
59
+ results: List[Dict[str, Any]] = []
60
+ for d in docs:
61
+ meta = getattr(d, "metadata", {}) or {}
62
+ content = getattr(d, "page_content", "") or ""
63
+ title = "Unknown"
64
+ if content.startswith("Title: "):
65
+ line = content.splitlines()[0]
66
+ title = line.replace("Title: ", "").strip() or title
67
+ results.append({"title": title, "content": content, "url": meta.get("url", ""), "source": meta.get("source", "Unknown"), "metadata": meta})
68
+ return results
cve_factchecker/wsgi.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from .app import app as application # WSGI entry point
3
+
4
+ # This file allows running with a production server like gunicorn/waitress:
5
+ # gunicorn -w 4 -b 0.0.0.0:8000 cve_factchecker.wsgi:application
requirements.txt ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ requests==2.31.0
2
+ beautifulsoup4==4.12.2
3
+ openai==1.35.0
4
+ lxml==4.9.3
5
+ flask==2.3.3
6
+ werkzeug==2.3.7
7
+ flask-cors==4.0.0
8
+
9
+ # LangChain and Vector Database dependencies
10
+ langchain==0.1.20
11
+ langchain-community==0.2.10
12
+ langchain-openai==0.1.7
13
+ langchain-chroma==0.1.2
14
+ langchain-huggingface==0.0.3
15
+ chromadb==0.4.18
16
+ tiktoken==0.5.2
17
+ sentence-transformers==2.2.2
18
+
19
+ # Firebase integration
20
+ firebase-admin
21
+
22
+ # Additional dependencies for robust retrieval
23
+ numpy
24
+ pandas
25
+ scikit-learn
26
+
27
+ # Production WSGI server
28
+ gunicorn==21.2.0