Spaces:
Sleeping
Sleeping
Initial API app, Dockerfile, and requirements for Space deployment
Browse files- Dockerfile +32 -0
- cve_factchecker/README.md +66 -0
- cve_factchecker/__init__.py +1 -0
- cve_factchecker/__main__.py +4 -0
- cve_factchecker/__pycache__/__init__.cpython-311.pyc +0 -0
- cve_factchecker/__pycache__/__init__.cpython-313.pyc +0 -0
- cve_factchecker/__pycache__/analyzer.cpython-311.pyc +0 -0
- cve_factchecker/__pycache__/analyzer.cpython-313.pyc +0 -0
- cve_factchecker/__pycache__/app.cpython-311.pyc +0 -0
- cve_factchecker/__pycache__/app.cpython-313.pyc +0 -0
- cve_factchecker/__pycache__/config.cpython-311.pyc +0 -0
- cve_factchecker/__pycache__/config.cpython-313.pyc +0 -0
- cve_factchecker/__pycache__/embeddings.cpython-311.pyc +0 -0
- cve_factchecker/__pycache__/embeddings.cpython-313.pyc +0 -0
- cve_factchecker/__pycache__/firebase_loader.cpython-311.pyc +0 -0
- cve_factchecker/__pycache__/firebase_loader.cpython-313.pyc +0 -0
- cve_factchecker/__pycache__/firebase_service.cpython-311.pyc +0 -0
- cve_factchecker/__pycache__/firebase_service.cpython-313.pyc +0 -0
- cve_factchecker/__pycache__/llm.cpython-311.pyc +0 -0
- cve_factchecker/__pycache__/llm.cpython-313.pyc +0 -0
- cve_factchecker/__pycache__/models.cpython-311.pyc +0 -0
- cve_factchecker/__pycache__/models.cpython-313.pyc +0 -0
- cve_factchecker/__pycache__/orchestrator.cpython-311.pyc +0 -0
- cve_factchecker/__pycache__/orchestrator.cpython-313.pyc +0 -0
- cve_factchecker/__pycache__/retriever.cpython-311.pyc +0 -0
- cve_factchecker/__pycache__/retriever.cpython-313.pyc +0 -0
- cve_factchecker/__pycache__/wsgi.cpython-313.pyc +0 -0
- cve_factchecker/analyzer.py +70 -0
- cve_factchecker/app.py +106 -0
- cve_factchecker/config.py +26 -0
- cve_factchecker/embeddings.py +39 -0
- cve_factchecker/firebase_loader.py +146 -0
- cve_factchecker/firebase_service.py +29 -0
- cve_factchecker/llm.py +19 -0
- cve_factchecker/models.py +47 -0
- cve_factchecker/orchestrator.py +35 -0
- cve_factchecker/retriever.py +68 -0
- cve_factchecker/wsgi.py +5 -0
- 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
|