Ryan Christian D. Deniega commited on
Commit
b1c84b5
Β·
1 Parent(s): b1a8265

fix: cold start 502, favicon, verify state persistence

Browse files

- Dockerfile: pre-download HuggingFace models to eliminate cold start 502s
- scoring/engine.py: cache NLP instances per-process (_nlp_cache)
- Cloud Run: set min-instances=1 in deploy.sh
- frontend: Lucide Radar icon favicon (matches navbar)
- frontend/VerifyPage: sessionStorage persistence + Verify Again button
- .gitignore: exclude ml/models/xlmr_model/ and *.safetensors

This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .dockerignore +47 -0
  2. .firebase/hosting.ZnJvbnRlbmQvZGlzdA.cache +5 -0
  3. .firebaserc +13 -3
  4. .gcloudignore +50 -0
  5. .gitignore +11 -1
  6. Dockerfile +67 -0
  7. api/routes/history.py +111 -6
  8. api/routes/preview.py +179 -0
  9. api/routes/trends.py +64 -4
  10. api/routes/verify.py +4 -0
  11. api/schemas.py +15 -0
  12. deploy.sh +78 -0
  13. evidence/domain_credibility.py +150 -0
  14. evidence/news_fetcher.py +236 -21
  15. evidence/similarity.py +80 -0
  16. evidence/stance_detector.py +194 -0
  17. extension/background.js +171 -0
  18. extension/content.css +190 -0
  19. extension/content.js +390 -0
  20. extension/generate_icons.py +61 -0
  21. extension/icons/icon128.png +0 -0
  22. extension/icons/icon16.png +0 -0
  23. extension/icons/icon32.png +0 -0
  24. extension/icons/icon48.png +0 -0
  25. extension/manifest.json +55 -0
  26. extension/popup.html +446 -0
  27. extension/popup.js +238 -0
  28. firebase.json +0 -1
  29. firebase_client.py +24 -4
  30. firestore.indexes.json +11 -49
  31. frontend/index.html +6 -2
  32. frontend/package-lock.json +15 -0
  33. frontend/package.json +3 -1
  34. frontend/public/logo.svg +13 -0
  35. frontend/src/App.jsx +33 -2
  36. frontend/src/api.js +23 -4
  37. frontend/src/api.ts +84 -0
  38. frontend/src/components/Navbar.jsx +59 -44
  39. frontend/src/components/SkeletonCard.jsx +46 -0
  40. frontend/src/components/WordHighlighter.jsx +115 -0
  41. frontend/src/firebase.js +19 -5
  42. frontend/src/index.css +28 -1
  43. frontend/src/pages/HistoryPage.jsx +530 -64
  44. frontend/src/pages/TrendsPage.jsx +187 -63
  45. frontend/src/pages/VerifyPage.jsx +553 -80
  46. frontend/src/types.ts +133 -0
  47. frontend/tsconfig.json +39 -0
  48. frontend/vite.config.js +1 -1
  49. inputs/url_scraper.py +265 -27
  50. main.py +13 -4
.dockerignore ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ **/__pycache__/
4
+ *.py[cod]
5
+ *.pyo
6
+ .pytest_cache/
7
+ .cache/
8
+ venv/
9
+ .venv/
10
+ *.egg-info/
11
+
12
+ # Environment & secrets
13
+ .env
14
+ .env.*
15
+ serviceAccountKey.json
16
+
17
+ # Local data / logs
18
+ data/history.json
19
+ inputs/*.log
20
+ inputs/__pycache__/
21
+
22
+ # ML training artefacts (keep ml/models/ β€” needed at runtime)
23
+ ml/data/
24
+ ml/*.log
25
+
26
+ # Frontend source (only dist goes to Firebase Hosting, not Cloud Run)
27
+ frontend/
28
+
29
+ # Git / editor
30
+ .git/
31
+ .gitignore
32
+ .gitattributes
33
+ .vscode/
34
+ .idea/
35
+
36
+ # Docs
37
+ docs/
38
+ *.md
39
+ README*
40
+
41
+ # Tests
42
+ tests/
43
+ pytest.ini
44
+
45
+ # Docker itself
46
+ Dockerfile
47
+ .dockerignore
.firebase/hosting.ZnJvbnRlbmQvZGlzdA.cache ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ vite.svg,1771983804434,d3bbbc44b3ea71906a72bf2ec1a4716903e2e3d9f85a5007205a65d1f12e2923
2
+ index.html,1771983804629,b6c877b7fe830ae6270dfb77cd1d205222591249325fa88601f51f6e2ed57653
3
+ logo.svg,1771983804434,c1ca19989c26d83c632b01609dc4514e16bef7418284c6df88b29ac34ca035ec
4
+ assets/index-DE8XF5VL.css,1771983804629,941148112bdd25f98beea529b6ad97209f2f777e70671d0f5b96f919c8472699
5
+ assets/index-BCcoqzYM.js,1771983804629,60632c706af44a3486a56a8364e32bdce3c7a8cb388f69de2fe9c21876d55942
.firebaserc CHANGED
@@ -1,5 +1,15 @@
1
  {
2
- "projects": {},
3
- "targets": {},
 
 
 
 
 
 
 
 
 
 
4
  "etags": {}
5
- }
 
1
  {
2
+ "projects": {
3
+ "default": "philverify"
4
+ },
5
+ "targets": {
6
+ "philverify": {
7
+ "hosting": {
8
+ "philverify": [
9
+ "philverify"
10
+ ]
11
+ }
12
+ }
13
+ },
14
  "etags": {}
15
+ }
.gcloudignore ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # .gcloudignore β€” Cloud Build source upload exclusions
2
+ # gcloud builds submit uses this before creating the source tarball.
3
+ # Patterns follow .gitignore syntax.
4
+
5
+ # ── Heavy runtimes / caches ───────────────────────────────────────────────────
6
+ venv/
7
+ .venv/
8
+ __pycache__/
9
+ **/__pycache__/
10
+ *.py[cod]
11
+ .cache/
12
+ .pytest_cache/
13
+
14
+ # ── Secrets (never upload) ────────────────────────────────────────────────────
15
+ .env
16
+ .env.*
17
+ serviceAccountKey.json
18
+ *.json.key
19
+
20
+ # ── ML artefacts (large β€” Docker downloads from HuggingFace at build time) ───
21
+ ml/models/
22
+ ml/data/raw/
23
+ ml/data/processed/
24
+ ml/data/combined/
25
+
26
+ # ── Frontend source & deps (built separately, not needed in Cloud Run) ───────
27
+ frontend/node_modules/
28
+ frontend/dist/
29
+
30
+ # ── Dataset pipeline scripts (not needed at runtime) ─────────────────────────
31
+ ml/data_sources/
32
+ ml/train_*.py
33
+ ml/dataset_builder.py
34
+ ml/combined_dataset.py
35
+ ml/_smoke_test.py
36
+
37
+ # ── Tests & docs ──────────────────────────────────────────────────────────────
38
+ tests/
39
+ docs/
40
+
41
+ # ── OS / editor ───────────────────────────────────────────────────────────────
42
+ .DS_Store
43
+ .vscode/
44
+ .idea/
45
+ *.swp
46
+
47
+ # ── Git ───────────────────────────────────────────────────────────────────────
48
+ .git/
49
+ .gitignore
50
+ .gitattributes
.gitignore CHANGED
@@ -22,10 +22,20 @@ build/
22
  # OS
23
  .DS_Store
24
 
25
- # ML models (too large for git)
26
  ml/models/*.pkl
27
  ml/models/*.bin
28
  ml/models/*.pt
 
 
29
  serviceAccountKey.json
30
  *.json.key
31
  docs/*.json
 
 
 
 
 
 
 
 
 
22
  # OS
23
  .DS_Store
24
 
25
+ # ML models (too large for git β€” use DVC or download separately)
26
  ml/models/*.pkl
27
  ml/models/*.bin
28
  ml/models/*.pt
29
+ ml/models/*.safetensors
30
+ ml/models/xlmr_model/
31
  serviceAccountKey.json
32
  *.json.key
33
  docs/*.json
34
+
35
+ # Dataset pipeline β€” raw downloads & processed parquet (regenerate via dataset_builder.py)
36
+ ml/data/raw/
37
+ ml/data/processed/
38
+ ml/_smoke_test.py
39
+
40
+ # Local history persistence (user data β€” do not commit)
41
+ data/history.json
Dockerfile ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── PhilVerify API β€” Cloud Run Dockerfile ─────────────────────────────────────
2
+ # Build: docker build -t philverify-api .
3
+ # Run: docker run -p 8080:8080 --env-file .env philverify-api
4
+
5
+ FROM python:3.12-slim
6
+
7
+ # ── System dependencies ───────────────────────────────────────────────────────
8
+ # tesseract: OCR for image verification
9
+ # ffmpeg: audio decoding for Whisper (video/audio input)
10
+ RUN apt-get update && apt-get install -y --no-install-recommends \
11
+ tesseract-ocr \
12
+ tesseract-ocr-fil \
13
+ tesseract-ocr-eng \
14
+ ffmpeg \
15
+ libgl1 \
16
+ libglib2.0-0 \
17
+ curl \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ WORKDIR /app
21
+
22
+ # ── Python dependencies ───────────────────────────────────────────────────────
23
+ # Upgrade pip + add setuptools (required by openai-whisper's setup.py on 3.12-slim)
24
+ COPY requirements.txt .
25
+ RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
26
+ pip install --no-cache-dir -r requirements.txt
27
+
28
+ # Download spaCy English model (small, ~12 MB)
29
+ RUN python -m spacy download en_core_web_sm || true
30
+
31
+ # Download NLTK data used by the NLP pipeline
32
+ RUN python -c "import nltk; nltk.download('punkt', quiet=True); nltk.download('stopwords', quiet=True); nltk.download('punkt_tab', quiet=True)" || true
33
+
34
+ # ── Application code ──────────────────────────────────────────────────────────
35
+ COPY . .
36
+
37
+ # Remove local secrets β€” Cloud Run uses its own service account (ADC)
38
+ # The serviceAccountKey.json is NOT needed inside the container.
39
+ RUN rm -f serviceAccountKey.json .env
40
+
41
+ # Pre-download Whisper base model so cold starts are faster
42
+ RUN python -c "import whisper; whisper.load_model('base')" || true
43
+
44
+ # Pre-download HuggingFace transformer models used by the NLP pipeline so that
45
+ # cold starts don't hit the network β€” these would otherwise be fetched on the
46
+ # first /verify request and cause a Firebase Hosting 502 timeout (~1.2 GB total).
47
+ RUN python -c "\
48
+ from transformers import pipeline; \
49
+ print('Downloading twitter-roberta-base-sentiment...'); \
50
+ pipeline('text-classification', model='cardiffnlp/twitter-roberta-base-sentiment-latest'); \
51
+ print('Downloading emotion-english-distilroberta...'); \
52
+ pipeline('text-classification', model='j-hartmann/emotion-english-distilroberta-base'); \
53
+ print('Downloading distilbart-cnn-6-6 (claim extractor)...'); \
54
+ pipeline('summarization', model='sshleifer/distilbart-cnn-6-6'); \
55
+ print('All HuggingFace models cached.'); \
56
+ " || true
57
+
58
+ # ── Runtime ───────────────────────────────────────────────────────────────────
59
+ # Cloud Run sets PORT automatically; default to 8080 for local runs.
60
+ ENV PORT=8080
61
+ ENV APP_ENV=production
62
+ ENV DEBUG=false
63
+
64
+ EXPOSE 8080
65
+
66
+ # Use exec form so signals (SIGTERM) reach uvicorn directly
67
+ CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT} --workers 1 --timeout-keep-alive 75"]
api/routes/history.py CHANGED
@@ -1,21 +1,101 @@
1
  """
2
  PhilVerify β€” History Route
3
  GET /history β€” Returns past verification logs with pagination.
 
 
 
 
 
4
  """
 
5
  import logging
6
- from fastapi import APIRouter, Query
 
 
7
  from api.schemas import HistoryResponse, HistoryEntry, Verdict
8
 
9
  logger = logging.getLogger(__name__)
10
  router = APIRouter(prefix="/history", tags=["History"])
11
 
12
- # In-memory store for development. Will be replaced by DB queries in Phase 7.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  _HISTORY: list[dict] = []
14
 
15
 
16
  def record_verification(entry: dict) -> None:
17
- """Called by the scoring engine to persist each verification result."""
 
 
 
 
18
  _HISTORY.append(entry)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
 
21
  @router.get(
@@ -31,7 +111,7 @@ async def get_history(
31
  ) -> HistoryResponse:
32
  logger.info("GET /history | page=%d limit=%d", page, limit)
33
 
34
- # Try Firestore first
35
  try:
36
  from firebase_client import get_verifications, get_verification_count
37
  vf = verdict_filter.value if verdict_filter else None
@@ -55,9 +135,34 @@ async def get_history(
55
  ],
56
  )
57
  except Exception as e:
58
- logger.debug("Firestore history read failed (%s) β€” using in-memory store", e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
- # In-memory fallback
61
  entries = list(reversed(_HISTORY))
62
  if verdict_filter:
63
  entries = [e for e in entries if e.get("verdict") == verdict_filter.value]
 
1
  """
2
  PhilVerify β€” History Route
3
  GET /history β€” Returns past verification logs with pagination.
4
+
5
+ Persistence tier order (best to worst):
6
+ 1. Firestore β€” requires Cloud Firestore API to be enabled in GCP console
7
+ 2. Local JSON file β€” data/history.json, survives server restarts, no setup needed
8
+ 3. In-memory list β€” last resort, resets on every restart
9
  """
10
+ import json
11
  import logging
12
+ import threading
13
+ from pathlib import Path
14
+ from fastapi import APIRouter, Query, HTTPException
15
  from api.schemas import HistoryResponse, HistoryEntry, Verdict
16
 
17
  logger = logging.getLogger(__name__)
18
  router = APIRouter(prefix="/history", tags=["History"])
19
 
20
+ # ── Local JSON file store ─────────────────────────────────────────────────────
21
+ # Survives server restarts. Used when Firestore is unavailable (e.g. API disabled).
22
+ _HISTORY_FILE = Path(__file__).parent.parent.parent / "data" / "history.json"
23
+ _HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
24
+ _file_lock = threading.Lock() # Guard concurrent writes
25
+
26
+
27
+ def _load_history_file() -> list[dict]:
28
+ """Read all records from the local JSON history file."""
29
+ try:
30
+ if _HISTORY_FILE.exists():
31
+ return json.loads(_HISTORY_FILE.read_text(encoding="utf-8"))
32
+ except Exception as e:
33
+ logger.warning("Could not read history file: %s", e)
34
+ return []
35
+
36
+
37
+ def _append_history_file(entry: dict) -> None:
38
+ """Atomically append one entry to the local JSON history file."""
39
+ with _file_lock:
40
+ records = _load_history_file()
41
+ records.append(entry)
42
+ try:
43
+ _HISTORY_FILE.write_text(
44
+ json.dumps(records, ensure_ascii=False, indent=2),
45
+ encoding="utf-8",
46
+ )
47
+ except Exception as e:
48
+ logger.warning("Could not write history file: %s", e)
49
+
50
+
51
+ # In-memory fallback (last resort β€” loses data on restart)
52
  _HISTORY: list[dict] = []
53
 
54
 
55
  def record_verification(entry: dict) -> None:
56
+ """
57
+ Called by the scoring engine after every verification.
58
+ Writes to the local JSON file so history persists even without Firestore.
59
+ Also keeps the in-memory list in sync for the current process lifetime.
60
+ """
61
  _HISTORY.append(entry)
62
+ _append_history_file(entry)
63
+
64
+
65
+ @router.get(
66
+ "/{entry_id}",
67
+ summary="Get single verification by ID",
68
+ description="Returns the full raw record for a single verification, including layer scores, entities, sentiment.",
69
+ )
70
+ async def get_history_entry(entry_id: str) -> dict:
71
+ logger.info("GET /history/%s", entry_id)
72
+
73
+ # Tier 1: Firestore
74
+ try:
75
+ from firebase_client import get_firestore
76
+ db = get_firestore()
77
+ if db:
78
+ doc = db.collection("verifications").document(entry_id).get()
79
+ if doc.exists:
80
+ return doc.to_dict()
81
+ except Exception as e:
82
+ logger.debug("Firestore detail unavailable (%s) β€” trying local file", e)
83
+
84
+ # Tier 2: Local JSON file
85
+ try:
86
+ records = _load_history_file()
87
+ for r in records:
88
+ if r.get("id") == entry_id:
89
+ return r
90
+ except Exception:
91
+ pass
92
+
93
+ # Tier 3: In-memory
94
+ for r in _HISTORY:
95
+ if r.get("id") == entry_id:
96
+ return r
97
+
98
+ raise HTTPException(status_code=404, detail="Verification not found")
99
 
100
 
101
  @router.get(
 
111
  ) -> HistoryResponse:
112
  logger.info("GET /history | page=%d limit=%d", page, limit)
113
 
114
+ # ── Tier 1: Firestore ─────────────────────────────────────────────────────
115
  try:
116
  from firebase_client import get_verifications, get_verification_count
117
  vf = verdict_filter.value if verdict_filter else None
 
135
  ],
136
  )
137
  except Exception as e:
138
+ logger.debug("Firestore history unavailable (%s) β€” trying local file", e)
139
+
140
+ # ── Tier 2: Local JSON file ───────────────────────────────────────────────
141
+ # Load from file rather than in-memory list so data survives restarts.
142
+ file_entries = list(reversed(_load_history_file()))
143
+ if file_entries:
144
+ if verdict_filter:
145
+ file_entries = [e for e in file_entries if e.get("verdict") == verdict_filter.value]
146
+ total = len(file_entries)
147
+ start = (page - 1) * limit
148
+ paginated = file_entries[start : start + limit]
149
+ return HistoryResponse(
150
+ total=total,
151
+ entries=[
152
+ HistoryEntry(
153
+ id=e["id"],
154
+ timestamp=e["timestamp"],
155
+ input_type=e.get("input_type", "text"),
156
+ text_preview=e.get("text_preview", "")[:120],
157
+ verdict=Verdict(e["verdict"]),
158
+ confidence=e["confidence"],
159
+ final_score=e["final_score"],
160
+ )
161
+ for e in paginated
162
+ ],
163
+ )
164
 
165
+ # ── Tier 3: In-memory (last resort β€” resets on restart) ───────────────────
166
  entries = list(reversed(_HISTORY))
167
  if verdict_filter:
168
  entries = [e for e in entries if e.get("verdict") == verdict_filter.value]
api/routes/preview.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PhilVerify β€” URL Preview Route
3
+ GET /preview?url=<encoded_url>
4
+
5
+ Fetches Open Graph / meta tags from the given URL and returns a lightweight
6
+ article card payload: title, description, image, site name, favicon, and domain.
7
+ Used by the frontend to show a "link unfurl" preview before/after verification.
8
+ """
9
+ import logging
10
+ import re
11
+ from urllib.parse import urlparse
12
+
13
+ from fastapi import APIRouter, Query, HTTPException
14
+ from pydantic import BaseModel
15
+ from typing import Optional
16
+
17
+ logger = logging.getLogger(__name__)
18
+ router = APIRouter(prefix="/preview", tags=["Preview"])
19
+
20
+
21
+ class URLPreview(BaseModel):
22
+ title: Optional[str] = None
23
+ description: Optional[str] = None
24
+ image: Optional[str] = None
25
+ site_name: Optional[str] = None
26
+ favicon: Optional[str] = None
27
+ domain: Optional[str] = None
28
+
29
+
30
+ def _slug_to_title(url: str) -> Optional[str]:
31
+ """Convert URL path slug to a readable title.
32
+ e.g. 'remulla-chides-bulacan-guv-for-alleged-road-abuse-dont-act-like-a-king' β†’
33
+ 'Remulla Chides Bulacan Guv For Alleged Road Abuse Dont Act Like A King'
34
+ """
35
+ parsed = urlparse(url)
36
+ segments = [s for s in parsed.path.split("/") if s and not s.isdigit() and len(s) > 4]
37
+ if segments:
38
+ slug = segments[-1]
39
+ # Remove common file extensions
40
+ slug = re.sub(r'\.(html?|php|aspx?)$', '', slug, flags=re.IGNORECASE)
41
+ # Strip UTM / query artifacts that leaked into path
42
+ slug = slug.split('?')[0]
43
+ return ' '.join(w.capitalize() for w in slug.replace('-', ' ').replace('_', ' ').split())
44
+ return None
45
+
46
+
47
+ def _extract_preview(html: str, base_url: str, original_url: str = "") -> URLPreview:
48
+ """Parse OG / meta tags from raw HTML."""
49
+ from bs4 import BeautifulSoup
50
+
51
+ parsed_base = urlparse(base_url)
52
+ domain = parsed_base.netloc.replace("www.", "")
53
+ origin = f"{parsed_base.scheme}://{parsed_base.netloc}"
54
+
55
+ # Parse head first for speed, then fall back to full doc if needed
56
+ head_end = html.find("</head>")
57
+ head_html = html[:head_end + 7] if head_end != -1 else html[:8000]
58
+ soup_head = BeautifulSoup(head_html, "lxml")
59
+ # Also keep full soup for body-level og: tags some CDNs inject
60
+ soup_full = BeautifulSoup(html[:60_000], "lxml") if head_end == -1 or head_end > 60_000 else soup_head
61
+
62
+ def meta(soup, prop=None, name=None):
63
+ if prop:
64
+ el = soup.find("meta", property=prop) or soup.find("meta", attrs={"property": prop})
65
+ else:
66
+ el = soup.find("meta", attrs={"name": name})
67
+ return (el.get("content") or "").strip() if el else None
68
+
69
+ def m(prop=None, name=None):
70
+ return meta(soup_head, prop=prop, name=name) or meta(soup_full, prop=prop, name=name)
71
+
72
+ title = (
73
+ m(prop="og:title")
74
+ or m(name="twitter:title")
75
+ or (soup_head.title.get_text(strip=True) if soup_head.title else None)
76
+ or _slug_to_title(original_url or base_url)
77
+ )
78
+ description = (
79
+ m(prop="og:description")
80
+ or m(name="twitter:description")
81
+ or m(name="description")
82
+ )
83
+ image = (
84
+ m(prop="og:image")
85
+ or m(name="twitter:image")
86
+ or m(name="twitter:image:src")
87
+ )
88
+ site_name = m(prop="og:site_name") or domain
89
+
90
+ # Resolve relative image URLs
91
+ if image and image.startswith("//"):
92
+ image = f"{parsed_base.scheme}:{image}"
93
+ elif image and image.startswith("/"):
94
+ image = f"{origin}{image}"
95
+
96
+ # Favicon: try link[rel=icon], fallback to /favicon.ico
97
+ favicon = None
98
+ icon_el = (
99
+ soup_head.find("link", rel="icon")
100
+ or soup_head.find("link", rel="shortcut icon")
101
+ or soup_head.find("link", rel=lambda v: v and "icon" in v)
102
+ )
103
+ if icon_el and icon_el.get("href"):
104
+ href = icon_el["href"].strip()
105
+ if href.startswith("//"):
106
+ favicon = f"{parsed_base.scheme}:{href}"
107
+ elif href.startswith("/"):
108
+ favicon = f"{origin}{href}"
109
+ else:
110
+ favicon = href
111
+ else:
112
+ favicon = f"{origin}/favicon.ico"
113
+
114
+ return URLPreview(
115
+ title=title or None,
116
+ description=description or None,
117
+ image=image or None,
118
+ site_name=site_name or None,
119
+ favicon=favicon,
120
+ domain=domain,
121
+ )
122
+
123
+
124
+ _BOT_TITLES = {
125
+ "just a moment", "attention required", "access denied", "please wait",
126
+ "checking your browser", "ddos-guard", "enable javascript", "403 forbidden",
127
+ "404 not found", "503 service unavailable",
128
+ }
129
+
130
+
131
+ @router.get("", response_model=URLPreview, summary="Fetch article preview (OG meta)")
132
+ async def get_preview(url: str = Query(..., description="Article URL to preview")) -> URLPreview:
133
+ try:
134
+ import httpx
135
+ except ImportError:
136
+ raise HTTPException(status_code=500, detail="httpx not installed")
137
+
138
+ headers = {
139
+ "User-Agent": (
140
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
141
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
142
+ "Chrome/122.0.0.0 Safari/537.36"
143
+ ),
144
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
145
+ "Accept-Language": "en-US,en;q=0.5",
146
+ }
147
+
148
+ parsed = urlparse(url)
149
+ domain = parsed.netloc.replace("www.", "")
150
+ origin = f"{parsed.scheme}://{parsed.netloc}"
151
+ slug_title = _slug_to_title(url)
152
+
153
+ try:
154
+ async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
155
+ resp = await client.get(url, headers=headers)
156
+ if resp.status_code >= 400:
157
+ logger.warning("Preview fetch returned %d for %s", resp.status_code, url)
158
+ return URLPreview(
159
+ domain=domain,
160
+ site_name=domain,
161
+ title=slug_title,
162
+ favicon=f"{origin}/favicon.ico",
163
+ )
164
+ preview = _extract_preview(resp.text, str(resp.url), original_url=url)
165
+ # If OG parsing returned no title, or got a bot-challenge page title, fall back to slug
166
+ if not preview.title or preview.title.lower().strip() in _BOT_TITLES:
167
+ preview.title = slug_title
168
+ # Don't keep description/image from a bot-challenge page
169
+ preview.description = None
170
+ preview.image = None
171
+ return preview
172
+ except Exception as exc:
173
+ logger.warning("Preview fetch failed for %s: %s", url, exc)
174
+ return URLPreview(
175
+ domain=domain,
176
+ site_name=domain,
177
+ title=slug_title,
178
+ favicon=f"{origin}/favicon.ico",
179
+ )
api/routes/trends.py CHANGED
@@ -10,8 +10,33 @@ from api.schemas import TrendsResponse, TrendingEntity, TrendingTopic, Verdict
10
  logger = logging.getLogger(__name__)
11
  router = APIRouter(prefix="/trends", tags=["Trends"])
12
 
13
- # Reads from the same in-memory store as history (Phase 7 β†’ DB aggregation).
14
- from api.routes.history import _HISTORY
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
 
17
  @router.get(
@@ -26,13 +51,15 @@ async def get_trends(
26
  ) -> TrendsResponse:
27
  logger.info("GET /trends | days=%d", days)
28
 
 
 
29
  entity_counter: Counter = Counter()
30
  entity_type_map: dict[str, str] = {}
31
  entity_fake_counter: Counter = Counter()
32
  topic_counter: Counter = Counter()
33
  topic_verdict_map: dict[str, list[str]] = {}
34
 
35
- for entry in _HISTORY:
36
  is_fake = entry.get("verdict") in (Verdict.LIKELY_FAKE.value, Verdict.UNVERIFIED.value)
37
  entities = entry.get("entities", {})
38
 
@@ -81,4 +108,37 @@ async def get_trends(
81
  for topic, count in topic_counter.most_common(limit)
82
  ]
83
 
84
- return TrendsResponse(top_entities=top_entities, top_topics=top_topics)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  logger = logging.getLogger(__name__)
11
  router = APIRouter(prefix="/trends", tags=["Trends"])
12
 
13
+
14
+ def _load_all_history() -> list[dict]:
15
+ """
16
+ Return all history records from the best available source:
17
+ 1. Firestore 2. Local JSON file 3. In-memory list (fallback)
18
+ """
19
+ # Tier 1: Firestore
20
+ try:
21
+ from firebase_client import get_all_verifications_sync
22
+ records = get_all_verifications_sync()
23
+ if records:
24
+ return records
25
+ except Exception:
26
+ pass
27
+
28
+ # Tier 2: Local JSON file (persists across restarts)
29
+ try:
30
+ from api.routes.history import _load_history_file
31
+ records = _load_history_file()
32
+ if records:
33
+ return records
34
+ except Exception:
35
+ pass
36
+
37
+ # Tier 3: In-memory (empty after restart, but keeps current session data)
38
+ from api.routes.history import _HISTORY
39
+ return list(_HISTORY)
40
 
41
 
42
  @router.get(
 
51
  ) -> TrendsResponse:
52
  logger.info("GET /trends | days=%d", days)
53
 
54
+ all_history = _load_all_history()
55
+
56
  entity_counter: Counter = Counter()
57
  entity_type_map: dict[str, str] = {}
58
  entity_fake_counter: Counter = Counter()
59
  topic_counter: Counter = Counter()
60
  topic_verdict_map: dict[str, list[str]] = {}
61
 
62
+ for entry in all_history:
63
  is_fake = entry.get("verdict") in (Verdict.LIKELY_FAKE.value, Verdict.UNVERIFIED.value)
64
  entities = entry.get("entities", {})
65
 
 
108
  for topic, count in topic_counter.most_common(limit)
109
  ]
110
 
111
+ # ── Verdict distribution totals ───────────────────────────────────────────────
112
+ verdict_dist: dict[str, int] = {"Credible": 0, "Unverified": 0, "Likely Fake": 0}
113
+ day_map: dict[str, dict[str, int]] = {} # date β†’ {Credible, Unverified, Likely Fake}
114
+
115
+ for entry in all_history:
116
+ v = entry.get("verdict", "Unverified")
117
+ if v in verdict_dist:
118
+ verdict_dist[v] += 1
119
+
120
+ ts = entry.get("timestamp", "")
121
+ date_key = ts[:10] if ts else "" # YYYY-MM-DD prefix
122
+ if date_key:
123
+ bucket = day_map.setdefault(date_key, {"Credible": 0, "Unverified": 0, "Likely Fake": 0})
124
+ if v in bucket:
125
+ bucket[v] += 1
126
+
127
+ from api.schemas import VerdictDayPoint
128
+ verdict_by_day = [
129
+ VerdictDayPoint(
130
+ date=d,
131
+ credible=day_map[d]["Credible"],
132
+ unverified=day_map[d]["Unverified"],
133
+ fake=day_map[d]["Likely Fake"],
134
+ )
135
+ for d in sorted(day_map.keys())
136
+ ]
137
+
138
+ return TrendsResponse(
139
+ top_entities=top_entities,
140
+ top_topics=top_topics,
141
+ verdict_distribution=verdict_dist,
142
+ verdict_by_day=verdict_by_day,
143
+ )
144
+
api/routes/verify.py CHANGED
@@ -67,6 +67,10 @@ async def verify_url(body: URLVerifyRequest) -> VerificationResponse:
67
  return result
68
  except HTTPException:
69
  raise
 
 
 
 
70
  except Exception as exc:
71
  logger.exception("verify/url error: %s", exc)
72
  raise HTTPException(status_code=500, detail=f"URL verification failed: {exc}") from exc
 
67
  return result
68
  except HTTPException:
69
  raise
70
+ except ValueError as exc:
71
+ # Expected user-facing errors (e.g. robots.txt block, bad URL)
72
+ logger.warning("verify/url rejected: %s", exc)
73
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
74
  except Exception as exc:
75
  logger.exception("verify/url error: %s", exc)
76
  raise HTTPException(status_code=500, detail=f"URL verification failed: {exc}") from exc
api/schemas.py CHANGED
@@ -138,9 +138,24 @@ class TrendingTopic(BaseModel):
138
  dominant_verdict: Verdict
139
 
140
 
 
 
 
 
 
 
 
141
  class TrendsResponse(BaseModel):
142
  top_entities: list[TrendingEntity]
143
  top_topics: list[TrendingTopic]
 
 
 
 
 
 
 
 
144
 
145
 
146
  # ── Error ─────────────────────────────────────────────────────────────────────
 
138
  dominant_verdict: Verdict
139
 
140
 
141
+ class VerdictDayPoint(BaseModel):
142
+ date: str # YYYY-MM-DD
143
+ credible: int = 0
144
+ unverified: int = 0
145
+ fake: int = 0
146
+
147
+
148
  class TrendsResponse(BaseModel):
149
  top_entities: list[TrendingEntity]
150
  top_topics: list[TrendingTopic]
151
+ verdict_distribution: dict[str, int] = Field(
152
+ default_factory=dict,
153
+ description="Counts per verdict: Credible, Unverified, Likely Fake",
154
+ )
155
+ verdict_by_day: list[VerdictDayPoint] = Field(
156
+ default_factory=list,
157
+ description="Day-by-day verdict counts for the area chart (last N days)",
158
+ )
159
 
160
 
161
  # ── Error ─────────────────────────────────────────────────────────────────────
deploy.sh ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # ── PhilVerify β€” Firebase + Cloud Run Deployment Script ───────────────────────
3
+ # Usage:
4
+ # chmod +x deploy.sh
5
+ # ./deploy.sh YOUR_GCP_PROJECT_ID
6
+ #
7
+ # Prerequisites:
8
+ # brew install google-cloud-sdk firebase-cli
9
+ # gcloud auth login
10
+ # gcloud auth configure-docker
11
+ # firebase login
12
+
13
+ set -euo pipefail
14
+
15
+ PROJECT_ID="${1:-}"
16
+ REGION="asia-southeast1"
17
+ SERVICE_NAME="philverify-api"
18
+ IMAGE="gcr.io/${PROJECT_ID}/${SERVICE_NAME}"
19
+
20
+ if [[ -z "$PROJECT_ID" ]]; then
21
+ echo "Usage: ./deploy.sh YOUR_GCP_PROJECT_ID"
22
+ exit 1
23
+ fi
24
+
25
+ echo "β–Ά Project: $PROJECT_ID | Region: $REGION | Service: $SERVICE_NAME"
26
+
27
+ # ── 1. Set GCP project ────────────────────────────────────────────────────────
28
+ gcloud config set project "$PROJECT_ID"
29
+
30
+ # ── 2. Build + push Docker image to GCR ──────────────────────────────────────
31
+ echo ""
32
+ echo "β–Ά Building & pushing Docker image (this takes ~10 min first time)…"
33
+ gcloud builds submit \
34
+ --tag "$IMAGE" \
35
+ --timeout=30m \
36
+ .
37
+
38
+ # ── 3. Deploy to Cloud Run ────────────────────────────────────────────────────
39
+ echo ""
40
+ echo "β–Ά Deploying to Cloud Run…"
41
+ gcloud run deploy "$SERVICE_NAME" \
42
+ --image "$IMAGE" \
43
+ --region "$REGION" \
44
+ --platform managed \
45
+ --allow-unauthenticated \
46
+ --memory 4Gi \
47
+ --cpu 2 \
48
+ --concurrency 10 \
49
+ --timeout 300 \
50
+ --min-instances 1 \
51
+ --max-instances 3 \
52
+ --set-env-vars "APP_ENV=production,DEBUG=false,LOG_LEVEL=INFO" \
53
+ --set-env-vars "ALLOWED_ORIGINS=https://${PROJECT_ID}.web.app,https://${PROJECT_ID}.firebaseapp.com"
54
+ # Add secrets like NEWS_API_KEY via:
55
+ # --update-secrets NEWS_API_KEY=philverify-news-api-key:latest
56
+
57
+ # ── 4. Link Firebase project ──────────────────────────────────────────────────
58
+ echo ""
59
+ echo "β–Ά Setting Firebase project…"
60
+ firebase use "$PROJECT_ID"
61
+
62
+ # ── 5. Build React frontend ───────────────────────────────────────────────────
63
+ echo ""
64
+ echo "β–Ά Building React frontend…"
65
+ cd frontend
66
+ npm ci
67
+ npm run build
68
+ cd ..
69
+
70
+ # ── 6. Deploy to Firebase Hosting ────────────────────────────────────────────
71
+ echo ""
72
+ echo "β–Ά Deploying to Firebase Hosting…"
73
+ firebase deploy --only hosting,firestore
74
+
75
+ echo ""
76
+ echo "βœ… Deploy complete!"
77
+ echo " Frontend: https://${PROJECT_ID}.web.app"
78
+ echo " API: https://${PROJECT_ID}.web.app/api/health"
evidence/domain_credibility.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PhilVerify β€” Domain Credibility Module (Phase 5)
3
+ Wraps domain_credibility.json to provide structured tier lookups
4
+ for evidence source URLs and news article domains.
5
+
6
+ Tiers:
7
+ Tier 1 (CREDIBLE) β€” Established PH news orgs (Rappler, Inquirer, GMA, etc.)
8
+ Tier 2 (SATIRE_OPINION) β€” Satire, opinion blogs, entertainment
9
+ Tier 3 (SUSPICIOUS) β€” Unknown / newly registered / low authority
10
+ Tier 4 (KNOWN_FAKE) β€” Vera Files blacklisted fake news sites
11
+ """
12
+ import json
13
+ import logging
14
+ import re
15
+ from dataclasses import dataclass
16
+ from enum import IntEnum
17
+ from pathlib import Path
18
+ from urllib.parse import urlparse
19
+ import functools
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _DB_PATH = Path(__file__).parent.parent / "domain_credibility.json"
24
+
25
+ # Score adjustments per tier (applied in scoring engine)
26
+ TIER_SCORE_ADJUSTMENT: dict[int, float] = {
27
+ 1: +20.0, # Established PH news β€” credibility boost
28
+ 2: -5.0, # Satire/opinion β€” mild penalty
29
+ 3: -10.0, # Unknown β€” moderate penalty
30
+ 4: -35.0, # Known fake β€” heavy penalty
31
+ }
32
+
33
+ TIER_LABELS: dict[int, str] = {
34
+ 1: "Credible",
35
+ 2: "Satire/Opinion",
36
+ 3: "Suspicious",
37
+ 4: "Known Fake",
38
+ }
39
+
40
+
41
+ class DomainTier(IntEnum):
42
+ CREDIBLE = 1
43
+ SATIRE_OPINION = 2
44
+ SUSPICIOUS = 3
45
+ KNOWN_FAKE = 4
46
+
47
+
48
+ @dataclass
49
+ class DomainResult:
50
+ domain: str
51
+ tier: DomainTier
52
+ tier_label: str
53
+ score_adjustment: float
54
+ matched_entry: str | None = None # Which entry in the JSON matched
55
+
56
+
57
+ @functools.lru_cache(maxsize=1)
58
+ def _load_db() -> dict:
59
+ """Load and cache the domain_credibility.json file."""
60
+ try:
61
+ data = json.loads(_DB_PATH.read_text())
62
+ total = sum(len(v.get("domains", [])) for v in data.values())
63
+ logger.info("domain_credibility.json loaded β€” %d domains across %d tiers", total, len(data))
64
+ return data
65
+ except Exception as e:
66
+ logger.error("Failed to load domain_credibility.json: %s", e)
67
+ return {}
68
+
69
+
70
+ def extract_domain(url_or_domain: str) -> str:
71
+ """
72
+ Normalize a URL or raw domain string to a bare hostname.
73
+
74
+ Examples:
75
+ "https://www.rappler.com/news/..." β†’ "rappler.com"
76
+ "www.gmanetwork.com" β†’ "gmanetwork.com"
77
+ "inquirer.net" β†’ "inquirer.net"
78
+ """
79
+ if not url_or_domain:
80
+ return ""
81
+ raw = url_or_domain.strip().lower()
82
+ # Add scheme if missing so urlparse works correctly
83
+ if not raw.startswith(("http://", "https://")):
84
+ raw = "https://" + raw
85
+ try:
86
+ hostname = urlparse(raw).hostname or ""
87
+ # Strip leading www.
88
+ hostname = re.sub(r"^www\.", "", hostname)
89
+ return hostname
90
+ except Exception:
91
+ # Last resort β€” strip www. manually
92
+ return re.sub(r"^www\.", "", raw.split("/")[0])
93
+
94
+
95
+ def lookup_domain(url_or_domain: str) -> DomainResult:
96
+ """
97
+ Classify a domain/URL against the credibility tier database.
98
+
99
+ Args:
100
+ url_or_domain: Full URL or bare domain name.
101
+
102
+ Returns:
103
+ DomainResult β€” Tier 3 (Suspicious) by default for unknown domains.
104
+ """
105
+ domain = extract_domain(url_or_domain)
106
+ if not domain:
107
+ return _make_result("", DomainTier.SUSPICIOUS, None)
108
+
109
+ db = _load_db()
110
+
111
+ for tier_key, tier_data in db.items():
112
+ tier_num = int(tier_key[-1]) # "tier1" β†’ 1
113
+ for entry in tier_data.get("domains", []):
114
+ # Match exact domain or subdomain of listed domain
115
+ if domain == entry or domain.endswith(f".{entry}"):
116
+ return _make_result(domain, DomainTier(tier_num), entry)
117
+
118
+ # Not found β†’ Tier 3 (Suspicious/Unknown)
119
+ logger.debug("Domain '%s' not in credibility DB β€” defaulting to Tier 3 (Suspicious)", domain)
120
+ return _make_result(domain, DomainTier.SUSPICIOUS, None)
121
+
122
+
123
+ def _make_result(domain: str, tier: DomainTier, matched_entry: str | None) -> DomainResult:
124
+ return DomainResult(
125
+ domain=domain,
126
+ tier=tier,
127
+ tier_label=TIER_LABELS[tier.value],
128
+ score_adjustment=TIER_SCORE_ADJUSTMENT[tier.value],
129
+ matched_entry=matched_entry,
130
+ )
131
+
132
+
133
+ def get_tier_score(url_or_domain: str) -> float:
134
+ """
135
+ Convenience: return just the score adjustment for a domain.
136
+ Positive = credibility boost, negative = penalty.
137
+ """
138
+ return lookup_domain(url_or_domain).score_adjustment
139
+
140
+
141
+ def is_blacklisted(url_or_domain: str) -> bool:
142
+ """Return True if the domain is a known fake news / blacklisted site."""
143
+ return lookup_domain(url_or_domain).tier == DomainTier.KNOWN_FAKE
144
+
145
+
146
+ def describe_tier(tier: DomainTier) -> str:
147
+ """Human-readable tier description for API responses."""
148
+ db = _load_db()
149
+ key = f"tier{tier.value}"
150
+ return db.get(key, {}).get("description", TIER_LABELS[tier.value])
evidence/news_fetcher.py CHANGED
@@ -1,20 +1,46 @@
1
  """
2
  PhilVerify β€” Evidence Retrieval Module
3
- Fetches related articles from NewsAPI, computes cosine similarity,
4
- and produces an evidence score for Layer 2 of the scoring engine.
 
 
 
 
 
5
  """
 
6
  import logging
7
  import hashlib
 
 
8
  from dataclasses import dataclass, field
9
  from pathlib import Path
10
  import json
11
 
12
  logger = logging.getLogger(__name__)
13
 
14
- # Simple file-based cache to respect NewsAPI 100 req/day free tier limit
 
 
15
  _CACHE_DIR = Path(__file__).parent.parent / ".cache" / "newsapi"
16
  _CACHE_DIR.mkdir(parents=True, exist_ok=True)
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  @dataclass
20
  class ArticleResult:
@@ -36,8 +62,8 @@ class EvidenceResult:
36
  claim_used: str = ""
37
 
38
 
39
- def _cache_key(claim: str) -> str:
40
- return hashlib.md5(claim.lower().strip().encode()).hexdigest()
41
 
42
 
43
  def _load_cache(key: str) -> list[dict] | None:
@@ -52,41 +78,230 @@ def _load_cache(key: str) -> list[dict] | None:
52
 
53
  def _save_cache(key: str, data: list[dict]) -> None:
54
  path = _CACHE_DIR / f"{key}.json"
55
- path.write_text(json.dumps(data))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
 
 
 
 
 
 
 
 
57
 
58
- async def fetch_evidence(claim: str, api_key: str, max_results: int = 5) -> list[dict]:
59
- """Fetch top articles from NewsAPI for the given claim. Cached."""
60
- key = _cache_key(claim)
61
- cached = _load_cache(key)
62
- if cached is not None:
63
- logger.info("NewsAPI cache hit for claim hash %s", key[:8])
64
- return cached
65
 
66
- if not api_key:
67
- logger.warning("NEWS_API_KEY not set β€” returning empty evidence")
68
  return []
69
 
 
 
 
 
 
 
 
 
70
  try:
71
  from newsapi import NewsApiClient
72
  client = NewsApiClient(api_key=api_key)
73
- # Use first 100 chars of claim as query
74
- query = claim[:100]
75
  resp = client.get_everything(
76
  q=query,
 
77
  language="en",
78
  sort_by="relevancy",
79
  page_size=max_results,
80
  )
81
  articles = resp.get("articles", [])
82
- _save_cache(key, articles)
83
- logger.info("NewsAPI returned %d articles for query '%s...'", len(articles), query[:30])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  return articles
85
- except Exception as e:
86
- logger.warning("NewsAPI fetch error: %s", e)
87
  return []
88
 
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  def compute_similarity(claim: str, article_text: str) -> float:
91
  """
92
  Compute cosine similarity between claim and article using sentence-transformers.
 
1
  """
2
  PhilVerify β€” Evidence Retrieval Module
3
+ Fetches related articles from two sources and merges the results:
4
+ 1. Google News RSS (gl=PH) β€” free, no API key, PH-indexed, primary source
5
+ 2. NewsAPI /everything β€” broader English coverage, requires API key
6
+
7
+ Google News RSS is always attempted first since it covers local PH outlets
8
+ (GMA, Inquirer, Rappler, CNN Philippines, PhilStar, etc.) far better than
9
+ NewsAPI's free tier index.
10
  """
11
+ import asyncio
12
  import logging
13
  import hashlib
14
+ import xml.etree.ElementTree as ET
15
+ import urllib.parse
16
  from dataclasses import dataclass, field
17
  from pathlib import Path
18
  import json
19
 
20
  logger = logging.getLogger(__name__)
21
 
22
+ # ── Cache ─────────────────────────────────────────────────────────────────────
23
+ # Shared cache for both sources. NewsAPI free tier = 100 req/day.
24
+ # Google News RSS has no hard limit but we cache anyway to stay polite.
25
  _CACHE_DIR = Path(__file__).parent.parent / ".cache" / "newsapi"
26
  _CACHE_DIR.mkdir(parents=True, exist_ok=True)
27
 
28
+ # ── Philippine news domains (used to boost Google News RSS results) ───────────
29
+ _PH_DOMAINS = {
30
+ "rappler.com", "inquirer.net", "gmanetwork.com", "philstar.com",
31
+ "manilatimes.net", "mb.com.ph", "abs-cbn.com", "cnnphilippines.com",
32
+ "pna.gov.ph", "sunstar.com.ph", "businessmirror.com.ph",
33
+ "businessworld.com.ph", "malaya.com.ph", "marikina.gov.ph",
34
+ "verafiles.org", "pcij.org", "interaksyon.philstar.com",
35
+ }
36
+
37
+ # NewsAPI domains filter β€” restricts results to PH outlets when API key is set
38
+ _NEWSAPI_PH_DOMAINS = ",".join([
39
+ "rappler.com", "inquirer.net", "gmanetwork.com", "philstar.com",
40
+ "manilatimes.net", "mb.com.ph", "abs-cbn.com", "cnnphilippines.com",
41
+ "pna.gov.ph", "sunstar.com.ph", "businessmirror.com.ph",
42
+ ])
43
+
44
 
45
  @dataclass
46
  class ArticleResult:
 
62
  claim_used: str = ""
63
 
64
 
65
+ def _cache_key(prefix: str, claim: str) -> str:
66
+ return f"{prefix}_{hashlib.md5(claim.lower().strip().encode()).hexdigest()}"
67
 
68
 
69
  def _load_cache(key: str) -> list[dict] | None:
 
78
 
79
  def _save_cache(key: str, data: list[dict]) -> None:
80
  path = _CACHE_DIR / f"{key}.json"
81
+ try:
82
+ path.write_text(json.dumps(data))
83
+ except Exception:
84
+ pass
85
+
86
+
87
+ def _extract_domain(url: str) -> str:
88
+ """Return bare domain from a URL string."""
89
+ try:
90
+ from urllib.parse import urlparse
91
+ host = urlparse(url).hostname or ""
92
+ return host.removeprefix("www.")
93
+ except Exception:
94
+ return ""
95
+
96
+
97
+ def _is_ph_article(article: dict) -> bool:
98
+ """
99
+ Return True if the article appears to be from a Philippine outlet.
100
+ Checks the source name since Google News RSS links are redirect URLs.
101
+ """
102
+ src = (article.get("source", {}) or {}).get("name", "").lower()
103
+ url = article.get("url", "").lower()
104
+ # Direct domain match on URL (works for NewsAPI results)
105
+ if _extract_domain(url) in _PH_DOMAINS:
106
+ return True
107
+ # Source-name match (works for Google News RSS redirect URLs)
108
+ _PH_SOURCE_KEYWORDS = {
109
+ "rappler", "inquirer", "gma", "abs-cbn", "cnn philippines",
110
+ "philstar", "manila times", "manila bulletin", "sunstar",
111
+ "businessworld", "business mirror", "malaya", "philippine news agency",
112
+ "pna", "vera files", "pcij", "interaksyon",
113
+ }
114
+ return any(kw in src for kw in _PH_SOURCE_KEYWORDS)
115
+
116
+
117
+ def _build_query(claim: str, entities: list[str] | None) -> str:
118
+ """Build a concise search query from entities or the first words of the claim."""
119
+ if entities:
120
+ return " ".join(entities[:3])
121
+ words = claim.split()
122
+ return " ".join(words[:6])
123
+
124
+
125
+ # ── Google News RSS ───────────────────────────────────────────────────────────
126
+
127
+ def _fetch_gnews_rss(query: str, max_results: int = 5) -> list[dict]:
128
+ """
129
+ Fetch articles from Google News RSS scoped to the Philippines.
130
+ Returns a list of dicts in the same shape as NewsAPI articles so the
131
+ rest of the pipeline can treat both sources uniformly.
132
+ No API key required.
133
+ """
134
+ encoded = urllib.parse.quote(query)
135
+ url = (
136
+ f"https://news.google.com/rss/search"
137
+ f"?q={encoded}&gl=PH&hl=en-PH&ceid=PH:en"
138
+ )
139
+ try:
140
+ import requests as req_lib
141
+ resp = req_lib.get(url, headers={"User-Agent": "PhilVerify/1.0"}, timeout=10)
142
+ resp.raise_for_status()
143
+ raw = resp.content
144
+ root = ET.fromstring(raw)
145
+ channel = root.find("channel")
146
+ if channel is None:
147
+ return []
148
+
149
+ articles: list[dict] = []
150
+ for item in channel.findall("item")[:max_results]:
151
+ title_el = item.find("title")
152
+ link_el = item.find("link")
153
+ desc_el = item.find("description")
154
+ pub_el = item.find("pubDate")
155
+ src_el = item.find("source")
156
+
157
+ title = title_el.text if title_el is not None else ""
158
+ link = link_el.text if link_el is not None else ""
159
+ description = desc_el.text if desc_el is not None else ""
160
+ pub_date = pub_el.text if pub_el is not None else ""
161
+ src_name = src_el.text if src_el is not None else _extract_domain(link)
162
+
163
+ # Google News titles often include "- Source" suffix β€” strip it
164
+ if src_name and title.endswith(f" - {src_name}"):
165
+ title = title[: -(len(src_name) + 3)].strip()
166
 
167
+ articles.append({
168
+ "title": title,
169
+ "url": link,
170
+ "description": description or title,
171
+ "publishedAt": pub_date,
172
+ "source": {"name": src_name},
173
+ "_gnews": True, # Tag so we can log the origin
174
+ })
175
 
176
+ logger.info(
177
+ "Google News RSS (PH) returned %d articles for query '%s...'",
178
+ len(articles), query[:40],
179
+ )
180
+ return articles
 
 
181
 
182
+ except Exception as exc:
183
+ logger.warning("Google News RSS fetch failed: %s", exc)
184
  return []
185
 
186
+
187
+ # ── NewsAPI ───────────────────────────────────────────────────────────────────
188
+
189
+ def _fetch_newsapi(query: str, api_key: str, max_results: int = 5) -> list[dict]:
190
+ """
191
+ Fetch from NewsAPI /everything, restricted to PH domains.
192
+ Falls back to global search if the PH-domains query returns < 2 results.
193
+ """
194
  try:
195
  from newsapi import NewsApiClient
196
  client = NewsApiClient(api_key=api_key)
197
+
198
+ # Try Philippine outlets first
199
  resp = client.get_everything(
200
  q=query,
201
+ domains=_NEWSAPI_PH_DOMAINS,
202
  language="en",
203
  sort_by="relevancy",
204
  page_size=max_results,
205
  )
206
  articles = resp.get("articles", [])
207
+
208
+ # If PH domains yield nothing useful, fall back to global
209
+ if len(articles) < 2:
210
+ logger.debug("NewsAPI PH-domains sparse (%d) β€” retrying global", len(articles))
211
+ resp = client.get_everything(
212
+ q=query,
213
+ language="en",
214
+ sort_by="relevancy",
215
+ page_size=max_results,
216
+ )
217
+ articles = resp.get("articles", [])
218
+
219
+ logger.info(
220
+ "NewsAPI returned %d articles for query '%s...'",
221
+ len(articles), query[:40],
222
+ )
223
  return articles
224
+ except Exception as exc:
225
+ logger.warning("NewsAPI fetch error: %s", exc)
226
  return []
227
 
228
 
229
+ # ── Public API ────────────────────────────────────────────────────────────────
230
+
231
+ async def fetch_evidence(
232
+ claim: str,
233
+ api_key: str,
234
+ entities: list[str] = None,
235
+ max_results: int = 5,
236
+ ) -> list[dict]:
237
+ """
238
+ Fetch the most relevant articles for a claim by merging:
239
+ 1. Google News RSS (PH-scoped) β€” always attempted, no key needed
240
+ 2. NewsAPI β€” only when NEWS_API_KEY is configured
241
+
242
+ Results are deduplicated by domain and capped at max_results.
243
+ PH-domain articles are surfaced first so scoring reflects local coverage.
244
+ """
245
+ query = _build_query(claim, entities)
246
+
247
+ # ── Google News RSS (check cache) ─────────────────────────────────────────
248
+ gnews_key = _cache_key("gnews", query)
249
+ gnews_articles = _load_cache(gnews_key)
250
+ if gnews_articles is None:
251
+ # Run blocking RSS fetch in a thread so we don't block the event loop
252
+ gnews_articles = await asyncio.get_event_loop().run_in_executor(
253
+ None, _fetch_gnews_rss, query, max_results
254
+ )
255
+ _save_cache(gnews_key, gnews_articles)
256
+ else:
257
+ logger.info("Google News RSS cache hit for query hash %s", gnews_key[-8:])
258
+
259
+ # ── NewsAPI (check cache) ─────────────────────────────────────────────────
260
+ newsapi_articles: list[dict] = []
261
+ if api_key:
262
+ newsapi_key = _cache_key("newsapi", query)
263
+ newsapi_articles = _load_cache(newsapi_key)
264
+ if newsapi_articles is None:
265
+ newsapi_articles = await asyncio.get_event_loop().run_in_executor(
266
+ None, _fetch_newsapi, query, api_key, max_results
267
+ )
268
+ _save_cache(newsapi_key, newsapi_articles)
269
+ else:
270
+ logger.info("NewsAPI cache hit for query hash %s", newsapi_key[-8:])
271
+
272
+ # ── Merge: PH articles first, then global, deduplicated by domain ─────────
273
+ seen_domains: set[str] = set()
274
+ merged: list[dict] = []
275
+
276
+ def _add(articles: list[dict]) -> None:
277
+ for art in articles:
278
+ url = art.get("url", "")
279
+ domain = _extract_domain(url)
280
+ # For Google News redirect URLs, deduplicate by source name instead
281
+ dedup_key = domain if domain and "google.com" not in domain \
282
+ else (art.get("source", {}) or {}).get("name", url)
283
+ if dedup_key and dedup_key in seen_domains:
284
+ continue
285
+ if dedup_key:
286
+ seen_domains.add(dedup_key)
287
+ merged.append(art)
288
+
289
+ # PH-source Google News articles go first
290
+ ph_gnews = [a for a in gnews_articles if _is_ph_article(a)]
291
+ other_gnews = [a for a in gnews_articles if not _is_ph_article(a)]
292
+
293
+ _add(ph_gnews)
294
+ _add(newsapi_articles)
295
+ _add(other_gnews) # non-PH Google News last
296
+
297
+ result = merged[:max_results]
298
+ logger.info(
299
+ "Evidence merged: %d PH-gnews + %d newsapi + %d other β†’ %d final",
300
+ len(ph_gnews), len(newsapi_articles), len(other_gnews), len(result),
301
+ )
302
+ return result
303
+
304
+
305
  def compute_similarity(claim: str, article_text: str) -> float:
306
  """
307
  Compute cosine similarity between claim and article using sentence-transformers.
evidence/similarity.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PhilVerify β€” Similarity Module (Phase 5)
3
+ Computes semantic similarity between a claim and evidence article text.
4
+ Primary: sentence-transformers/all-MiniLM-L6-v2 (cosine similarity)
5
+ Fallback: Jaccard word-overlap similarity
6
+ """
7
+ import logging
8
+ import functools
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Lazy-load the model at first use β€” avoids blocking app startup
13
+ @functools.lru_cache(maxsize=1)
14
+ def _get_model():
15
+ """Load sentence-transformer model once and cache it."""
16
+ try:
17
+ from sentence_transformers import SentenceTransformer
18
+ model = SentenceTransformer("all-MiniLM-L6-v2")
19
+ logger.info("sentence-transformers model loaded: all-MiniLM-L6-v2")
20
+ return model
21
+ except Exception as e:
22
+ logger.warning("sentence-transformers unavailable (%s) β€” Jaccard fallback active", e)
23
+ return None
24
+
25
+
26
+ def compute_similarity(claim: str, article_text: str) -> float:
27
+ """
28
+ Compute semantic similarity between a fact-check claim and an article.
29
+
30
+ Args:
31
+ claim: The extracted falsifiable claim sentence.
32
+ article_text: Title + description of a retrieved news article.
33
+
34
+ Returns:
35
+ Float in [0.0, 1.0] β€” higher means more semantically related.
36
+ """
37
+ if not claim or not article_text:
38
+ return 0.0
39
+
40
+ model = _get_model()
41
+ if model is not None:
42
+ try:
43
+ from sentence_transformers import util
44
+ emb_claim = model.encode(claim, convert_to_tensor=True)
45
+ emb_article = model.encode(article_text[:512], convert_to_tensor=True)
46
+ score = float(util.cos_sim(emb_claim, emb_article)[0][0])
47
+ return round(max(0.0, min(1.0, score)), 4)
48
+ except Exception as e:
49
+ logger.warning("Embedding similarity failed (%s) β€” falling back to Jaccard", e)
50
+
51
+ # Jaccard token-overlap fallback
52
+ return _jaccard_similarity(claim, article_text)
53
+
54
+
55
+ def _jaccard_similarity(a: str, b: str) -> float:
56
+ """Simple set-based Jaccard similarity on word tokens."""
57
+ tokens_a = set(a.lower().split())
58
+ tokens_b = set(b.lower().split())
59
+ if not tokens_a or not tokens_b:
60
+ return 0.0
61
+ intersection = tokens_a & tokens_b
62
+ union = tokens_a | tokens_b
63
+ return round(len(intersection) / len(union), 4)
64
+
65
+
66
+ def rank_articles_by_similarity(claim: str, articles: list[dict]) -> list[dict]:
67
+ """
68
+ Annotate and sort a list of NewsAPI article dicts by similarity to the claim.
69
+
70
+ Each article dict gets a `similarity` key added.
71
+ Returns articles sorted descending by similarity.
72
+ """
73
+ scored = []
74
+ for article in articles:
75
+ article_text = f"{article.get('title', '')} {article.get('description', '')}"
76
+ sim = compute_similarity(claim, article_text)
77
+ scored.append({**article, "similarity": sim})
78
+
79
+ scored.sort(key=lambda x: x["similarity"], reverse=True)
80
+ return scored
evidence/stance_detector.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PhilVerify β€” Stance Detection Module (Phase 5)
3
+ Classifies the relationship between a claim and a retrieved evidence article.
4
+
5
+ Stance labels:
6
+ Supports β€” article content supports the claim
7
+ Refutes β€” article content contradicts / debunks the claim
8
+ Not Enough Info β€” article is related but not conclusive either way
9
+
10
+ Strategy (rule-based hybrid β€” no heavy model dependency):
11
+ 1. Keyword scan of title + description for refutation/support signals
12
+ 2. Similarity threshold guard β€” low similarity β†’ NEI
13
+ 3. Factuality keywords override similarity-based detection
14
+ """
15
+ import logging
16
+ import re
17
+ from dataclasses import dataclass
18
+ from enum import Enum
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class Stance(str, Enum):
24
+ SUPPORTS = "Supports"
25
+ REFUTES = "Refutes"
26
+ NOT_ENOUGH_INFO = "Not Enough Info"
27
+
28
+
29
+ # ── Keyword Lists ─────────────────────────────────────────────────────────────
30
+ # Ordered: check REFUTATION first (stronger signal), then SUPPORT
31
+ _REFUTATION_KEYWORDS = [
32
+ # Fact-check verdicts
33
+ r"\bfact.?check\b", r"\bfalse\b", r"\bfake\b", r"\bhoax\b",
34
+ r"\bdebunked\b", r"\bmisinformation\b", r"\bdisinformation\b",
35
+ r"\bnot true\b", r"\bno evidence\b", r"\bunverified\b",
36
+ r"\bcorrection\b", r"\bretract\b", r"\bwrong\b", r"\bdenied\b",
37
+ r"\bscam\b", r"\bsatire\b",
38
+ # Filipino equivalents
39
+ r"\bkasinungalingan\b", r"\bhindi totoo\b", r"\bpeke\b",
40
+ ]
41
+
42
+ _SUPPORT_KEYWORDS = [
43
+ r"\bconfirmed\b", r"\bverified\b", r"\bofficial\b", r"\bproven\b",
44
+ r"\btrue\b", r"\blegitimate\b", r"\baccurate\b", r"\bauthorized\b",
45
+ r"\breal\b", r"\bgenuine\b",
46
+ # Filipino equivalents
47
+ r"\btotoo\b", r"\bkumpirmado\b", r"\bopisyal\b",
48
+ ]
49
+
50
+ # Articles from these PH fact-check domains always β†’ Refutes regardless of content
51
+ _FACTCHECK_DOMAINS = {
52
+ "vera-files.org", "verafiles.org", "factcheck.afp.com",
53
+ "rappler.com/newsbreak/fact-check", "cnn.ph/fact-check",
54
+ }
55
+
56
+ # Similarity threshold: below this β†’ NEI even with support keywords
57
+ _SIMILARITY_NEI_THRESHOLD = 0.15
58
+ # Similarity above this + support keywords β†’ Supports
59
+ _SIMILARITY_SUPPORT_THRESHOLD = 0.35
60
+
61
+
62
+ @dataclass
63
+ class StanceResult:
64
+ stance: Stance
65
+ confidence: float # 0.0–1.0 β€” how confident we are in this label
66
+ matched_keywords: list[str]
67
+ reason: str
68
+
69
+
70
+ def detect_stance(
71
+ claim: str,
72
+ article_title: str,
73
+ article_description: str,
74
+ article_url: str = "",
75
+ similarity: float = 0.0,
76
+ ) -> StanceResult:
77
+ """
78
+ Detect the stance of an article relative to the claim.
79
+
80
+ Args:
81
+ claim: The extracted falsifiable claim.
82
+ article_title: NewsAPI article title.
83
+ article_description: NewsAPI article description.
84
+ article_url: Article URL (used for fact-check domain detection).
85
+ similarity: Pre-computed cosine similarity score (0–1).
86
+
87
+ Returns:
88
+ StanceResult with stance label, confidence, and reason.
89
+ """
90
+ # Combine article text for keyword search
91
+ article_text = f"{article_title} {article_description}".lower()
92
+
93
+ # ── Rule 0: Known fact-check domain β†’ always Refutes ──────────────────────
94
+ if article_url:
95
+ for fc_domain in _FACTCHECK_DOMAINS:
96
+ if fc_domain in article_url.lower():
97
+ return StanceResult(
98
+ stance=Stance.REFUTES,
99
+ confidence=0.90,
100
+ matched_keywords=[fc_domain],
101
+ reason="Known Philippine fact-check domain",
102
+ )
103
+
104
+ # ── Rule 1: Similarity floor β€” too low to make any claim ──────────────────
105
+ if similarity < _SIMILARITY_NEI_THRESHOLD:
106
+ return StanceResult(
107
+ stance=Stance.NOT_ENOUGH_INFO,
108
+ confidence=0.80,
109
+ matched_keywords=[],
110
+ reason=f"Low similarity ({similarity:.2f}) β€” article not related to claim",
111
+ )
112
+
113
+ # ── Rule 2: Scan for refutation keywords ──────────────────────────────────
114
+ refutation_hits = _scan_keywords(article_text, _REFUTATION_KEYWORDS)
115
+ if refutation_hits:
116
+ confidence = min(0.95, 0.65 + len(refutation_hits) * 0.10)
117
+ return StanceResult(
118
+ stance=Stance.REFUTES,
119
+ confidence=round(confidence, 2),
120
+ matched_keywords=refutation_hits,
121
+ reason=f"Refutation signal detected: {', '.join(refutation_hits[:3])}",
122
+ )
123
+
124
+ # ── Rule 3: Scan for support keywords + similarity threshold ──────────────
125
+ support_hits = _scan_keywords(article_text, _SUPPORT_KEYWORDS)
126
+ if support_hits and similarity >= _SIMILARITY_SUPPORT_THRESHOLD:
127
+ confidence = min(0.90, 0.50 + len(support_hits) * 0.10 + similarity * 0.20)
128
+ return StanceResult(
129
+ stance=Stance.SUPPORTS,
130
+ confidence=round(confidence, 2),
131
+ matched_keywords=support_hits,
132
+ reason=f"Support signal + similarity {similarity:.2f}: {', '.join(support_hits[:3])}",
133
+ )
134
+
135
+ # ── Default: Not Enough Info ───────────────────────────────────────────────
136
+ return StanceResult(
137
+ stance=Stance.NOT_ENOUGH_INFO,
138
+ confidence=0.70,
139
+ matched_keywords=[],
140
+ reason="No conclusive support or refutation signals found",
141
+ )
142
+
143
+
144
+ def _scan_keywords(text: str, patterns: list[str]) -> list[str]:
145
+ """Return list of matched keyword patterns found in text."""
146
+ hits = []
147
+ for pattern in patterns:
148
+ match = re.search(pattern, text, re.IGNORECASE)
149
+ if match:
150
+ hits.append(match.group(0))
151
+ return hits
152
+
153
+
154
+ def compute_evidence_score(
155
+ stances: list[StanceResult],
156
+ similarities: list[float],
157
+ ) -> tuple[float, str]:
158
+ """
159
+ Aggregate multiple article stances into a single evidence score (0–100)
160
+ and an overall Layer 2 verdict.
161
+
162
+ Scoring:
163
+ - Start at neutral 50
164
+ - Each Supports article: +10 Γ— similarity bonus
165
+ - Each Refutes article: -15 penalty (stronger signal)
166
+ - NEI articles: no effect
167
+
168
+ Returns:
169
+ (evidence_score, verdict_label)
170
+ """
171
+ if not stances:
172
+ return 50.0, "Unverified"
173
+
174
+ score = 50.0
175
+ supporting = [s for s in stances if s.stance == Stance.SUPPORTS]
176
+ refuting = [s for s in stances if s.stance == Stance.REFUTES]
177
+
178
+ for i, stance in enumerate(stances):
179
+ sim = similarities[i] if i < len(similarities) else 0.5
180
+ if stance.stance == Stance.SUPPORTS:
181
+ score += 10.0 * (0.5 + sim)
182
+ elif stance.stance == Stance.REFUTES:
183
+ score -= 15.0 * stance.confidence
184
+
185
+ score = round(max(0.0, min(100.0, score)), 1)
186
+
187
+ if len(refuting) > len(supporting):
188
+ verdict = "Likely Fake"
189
+ elif len(supporting) >= 2 and score >= 60:
190
+ verdict = "Credible"
191
+ else:
192
+ verdict = "Unverified"
193
+
194
+ return score, verdict
extension/background.js ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * PhilVerify β€” Background Service Worker (Manifest V3)
3
+ *
4
+ * Responsibilities:
5
+ * - Proxy API calls to the PhilVerify FastAPI backend
6
+ * - File-based cache via chrome.storage.local (24-hour TTL, max 50 entries)
7
+ * - Maintain personal verification history
8
+ * - Respond to messages from content.js and popup.js
9
+ *
10
+ * Message types handled:
11
+ * VERIFY_TEXT { text } β†’ VerificationResponse
12
+ * VERIFY_URL { url } β†’ VerificationResponse
13
+ * GET_HISTORY {} β†’ { history: HistoryEntry[] }
14
+ * GET_SETTINGS {} β†’ { apiBase, autoScan }
15
+ * SAVE_SETTINGS { apiBase, autoScan } β†’ {}
16
+ */
17
+
18
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
19
+ const MAX_HISTORY = 50
20
+
21
+ // ── Default settings ──────────────────────────────────────────────────────────
22
+ const DEFAULT_SETTINGS = {
23
+ apiBase: 'http://localhost:8000',
24
+ autoScan: true, // Automatically scan Facebook feed posts
25
+ }
26
+
27
+ // ── Utilities ─────────────────────────────────────────────────────────────────
28
+ /** Validate that a string is a safe http/https URL */
29
+ function isHttpUrl(str) {
30
+ if (!str || typeof str !== 'string') return false
31
+ try {
32
+ const u = new URL(str)
33
+ return u.protocol === 'http:' || u.protocol === 'https:'
34
+ } catch { return false }
35
+ }
36
+ async function sha256prefix(text, len = 16) {
37
+ const buf = await crypto.subtle.digest(
38
+ 'SHA-256',
39
+ new TextEncoder().encode(text.trim().toLowerCase()),
40
+ )
41
+ return Array.from(new Uint8Array(buf))
42
+ .map(b => b.toString(16).padStart(2, '0'))
43
+ .join('')
44
+ .slice(0, len)
45
+ }
46
+
47
+ async function getSettings() {
48
+ const stored = await chrome.storage.local.get('settings')
49
+ return { ...DEFAULT_SETTINGS, ...(stored.settings ?? {}) }
50
+ }
51
+
52
+ // ── Cache helpers ─────────────────────────────────────────────────────────────
53
+
54
+ async function getCached(key) {
55
+ const stored = await chrome.storage.local.get(key)
56
+ const entry = stored[key]
57
+ if (!entry) return null
58
+ if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
59
+ await chrome.storage.local.remove(key)
60
+ return null
61
+ }
62
+ return entry.result
63
+ }
64
+
65
+ async function setCached(key, result, preview) {
66
+ await chrome.storage.local.set({
67
+ [key]: { result, timestamp: Date.now() },
68
+ })
69
+
70
+ // Prepend to history list
71
+ const { history = [] } = await chrome.storage.local.get('history')
72
+ const entry = {
73
+ id: key,
74
+ timestamp: new Date().toISOString(),
75
+ text_preview: preview.slice(0, 80),
76
+ verdict: result.verdict,
77
+ final_score: result.final_score,
78
+ }
79
+ const updated = [entry, ...history.filter(h => h.id !== key)].slice(0, MAX_HISTORY)
80
+ await chrome.storage.local.set({ history: updated })
81
+ }
82
+
83
+ // ── API calls ─────────────────────────────────────────────────────────────────
84
+
85
+ async function verifyText(text) {
86
+ const key = 'txt_' + await sha256prefix(text)
87
+ const hit = await getCached(key)
88
+ if (hit) return { ...hit, _fromCache: true }
89
+
90
+ const { apiBase } = await getSettings()
91
+ const res = await fetch(`${apiBase}/verify/text`, {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ text }),
95
+ })
96
+ if (!res.ok) {
97
+ const body = await res.json().catch(() => ({}))
98
+ throw new Error(body.detail ?? `API error ${res.status}`)
99
+ }
100
+ const result = await res.json()
101
+ await setCached(key, result, text)
102
+ return result
103
+ }
104
+
105
+ async function verifyUrl(url) {
106
+ const key = 'url_' + await sha256prefix(url)
107
+ const hit = await getCached(key)
108
+ if (hit) return { ...hit, _fromCache: true }
109
+
110
+ const { apiBase } = await getSettings()
111
+ const res = await fetch(`${apiBase}/verify/url`, {
112
+ method: 'POST',
113
+ headers: { 'Content-Type': 'application/json' },
114
+ body: JSON.stringify({ url }),
115
+ })
116
+ if (!res.ok) {
117
+ const body = await res.json().catch(() => ({}))
118
+ throw new Error(body.detail ?? `API error ${res.status}`)
119
+ }
120
+ const result = await res.json()
121
+ await setCached(key, result, url)
122
+ return result
123
+ }
124
+
125
+ // ── Message handler ───────────────────────────────────────────────────────────
126
+
127
+ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
128
+ switch (msg.type) {
129
+
130
+ case 'VERIFY_TEXT':
131
+ verifyText(msg.text)
132
+ .then(r => sendResponse({ ok: true, result: r }))
133
+ .catch(e => sendResponse({ ok: false, error: e.message }))
134
+ return true // keep message channel open for async response
135
+
136
+ case 'VERIFY_URL':
137
+ if (!isHttpUrl(msg.url)) {
138
+ sendResponse({ ok: false, error: 'Invalid URL: only http/https allowed' })
139
+ return false
140
+ }
141
+ verifyUrl(msg.url)
142
+ .then(r => sendResponse({ ok: true, result: r }))
143
+ .catch(e => sendResponse({ ok: false, error: e.message }))
144
+ return true
145
+
146
+ case 'GET_HISTORY':
147
+ chrome.storage.local.get('history')
148
+ .then(({ history = [] }) => sendResponse({ history }))
149
+ return true
150
+
151
+ case 'GET_SETTINGS':
152
+ getSettings().then(s => sendResponse(s))
153
+ return true
154
+
155
+ case 'SAVE_SETTINGS': {
156
+ const incoming = msg.settings ?? {}
157
+ // Validate apiBase is a safe URL before persisting
158
+ if (incoming.apiBase && !isHttpUrl(incoming.apiBase)) {
159
+ sendResponse({ ok: false, error: 'Invalid API URL: only http/https allowed' })
160
+ return false
161
+ }
162
+ chrome.storage.local
163
+ .set({ settings: incoming })
164
+ .then(() => sendResponse({ ok: true }))
165
+ return true
166
+ }
167
+
168
+ default:
169
+ break
170
+ }
171
+ })
extension/content.css ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * PhilVerify β€” Content Script Styles
3
+ * Badge overlay injected into Facebook feed posts.
4
+ * All selectors are namespaced under .pv-* to avoid collisions.
5
+ */
6
+
7
+ /* ── Badge wrapper ───────────────────────────────────────────────────────── */
8
+ .pv-badge-wrap {
9
+ display: block;
10
+ margin: 6px 12px 2px;
11
+ }
12
+
13
+ .pv-badge {
14
+ display: inline-flex;
15
+ align-items: center;
16
+ gap: 6px;
17
+ padding: 4px 10px;
18
+ border-radius: 3px;
19
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
20
+ font-size: 11px;
21
+ font-weight: 600;
22
+ letter-spacing: 0.04em;
23
+ cursor: pointer;
24
+ touch-action: manipulation;
25
+ -webkit-tap-highlight-color: transparent;
26
+ }
27
+
28
+ .pv-badge:focus-visible {
29
+ outline: 2px solid #06b6d4;
30
+ outline-offset: 2px;
31
+ }
32
+
33
+ /* ── Loading state ───────────────────────────────────────────────────────── */
34
+ .pv-badge--loading {
35
+ color: #a89f94;
36
+ border: 1px solid rgba(168, 159, 148, 0.2);
37
+ background: rgba(168, 159, 148, 0.06);
38
+ cursor: default;
39
+ }
40
+
41
+ .pv-spinner {
42
+ display: inline-block;
43
+ width: 10px;
44
+ height: 10px;
45
+ border: 2px solid rgba(168, 159, 148, 0.3);
46
+ border-top-color: #a89f94;
47
+ border-radius: 50%;
48
+ animation: pv-spin 0.7s linear infinite;
49
+ }
50
+
51
+ @media (prefers-reduced-motion: reduce) {
52
+ .pv-spinner { animation: none; }
53
+ }
54
+
55
+ @keyframes pv-spin {
56
+ to { transform: rotate(360deg); }
57
+ }
58
+
59
+ /* ── Error state ─────────────────────────────────────────────────────────── */
60
+ .pv-badge--error {
61
+ color: #78716c;
62
+ border: 1px solid rgba(120, 113, 108, 0.2);
63
+ background: transparent;
64
+ cursor: default;
65
+ font-size: 10px;
66
+ }
67
+
68
+ /* ── Detail panel ────────────────────────────────────────────────────────── */
69
+ .pv-detail {
70
+ display: block;
71
+ margin: 4px 0 6px;
72
+ padding: 10px 12px;
73
+ background: #141414;
74
+ border: 1px solid rgba(245, 240, 232, 0.1);
75
+ border-radius: 4px;
76
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
77
+ font-size: 11px;
78
+ color: #f5f0e8;
79
+ max-width: 400px;
80
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
81
+ }
82
+
83
+ .pv-detail-header {
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: space-between;
87
+ margin-bottom: 8px;
88
+ padding-bottom: 6px;
89
+ border-bottom: 1px solid rgba(245, 240, 232, 0.07);
90
+ }
91
+
92
+ .pv-logo {
93
+ font-weight: 800;
94
+ font-size: 12px;
95
+ letter-spacing: 0.12em;
96
+ color: #f5f0e8;
97
+ }
98
+
99
+ .pv-close {
100
+ background: none;
101
+ border: none;
102
+ cursor: pointer;
103
+ color: #5c554e;
104
+ font-size: 12px;
105
+ padding: 2px 4px;
106
+ border-radius: 2px;
107
+ touch-action: manipulation;
108
+ }
109
+ .pv-close:hover { color: #f5f0e8; }
110
+ .pv-close:focus-visible { outline: 2px solid #06b6d4; }
111
+
112
+ .pv-row {
113
+ display: flex;
114
+ justify-content: space-between;
115
+ align-items: center;
116
+ padding: 4px 0;
117
+ border-bottom: 1px solid rgba(245, 240, 232, 0.05);
118
+ }
119
+
120
+ .pv-label {
121
+ font-size: 9px;
122
+ font-weight: 700;
123
+ letter-spacing: 0.12em;
124
+ color: #5c554e;
125
+ text-transform: uppercase;
126
+ }
127
+
128
+ .pv-val {
129
+ font-size: 11px;
130
+ font-weight: 600;
131
+ color: #a89f94;
132
+ }
133
+
134
+ .pv-signals {
135
+ padding: 6px 0 4px;
136
+ border-bottom: 1px solid rgba(245, 240, 232, 0.05);
137
+ }
138
+
139
+ .pv-tags {
140
+ display: flex;
141
+ flex-wrap: wrap;
142
+ gap: 4px;
143
+ margin-top: 4px;
144
+ }
145
+
146
+ .pv-tag {
147
+ padding: 2px 6px;
148
+ background: rgba(220, 38, 38, 0.12);
149
+ color: #f87171;
150
+ border: 1px solid rgba(220, 38, 38, 0.25);
151
+ border-radius: 2px;
152
+ font-size: 9px;
153
+ letter-spacing: 0.04em;
154
+ font-weight: 600;
155
+ }
156
+
157
+ .pv-source {
158
+ padding: 6px 0 4px;
159
+ border-bottom: 1px solid rgba(245, 240, 232, 0.05);
160
+ }
161
+
162
+ .pv-source-link {
163
+ display: block;
164
+ margin-top: 4px;
165
+ color: #06b6d4;
166
+ font-size: 10px;
167
+ text-decoration: none;
168
+ overflow: hidden;
169
+ text-overflow: ellipsis;
170
+ white-space: nowrap;
171
+ }
172
+ .pv-source-link:hover { text-decoration: underline; }
173
+
174
+ .pv-open-full {
175
+ display: block;
176
+ margin-top: 8px;
177
+ text-align: center;
178
+ color: #dc2626;
179
+ font-size: 10px;
180
+ font-weight: 700;
181
+ letter-spacing: 0.08em;
182
+ text-decoration: none;
183
+ text-transform: uppercase;
184
+ padding: 5px;
185
+ border: 1px solid rgba(220, 38, 38, 0.3);
186
+ border-radius: 2px;
187
+ }
188
+ .pv-open-full:hover {
189
+ background: rgba(220, 38, 38, 0.08);
190
+ }
extension/content.js ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * PhilVerify β€” Content Script (Facebook feed scanner)
3
+ *
4
+ * Watches the Facebook feed via MutationObserver.
5
+ * For each new post that appears:
6
+ * 1. Extracts the post text or shared URL
7
+ * 2. Sends to background.js for verification (with cache)
8
+ * 3. Injects a credibility badge overlay onto the post card
9
+ *
10
+ * Badge click β†’ opens an inline detail panel with verdict, score, and top source.
11
+ *
12
+ * Uses `data-philverify` attribute to mark already-processed posts.
13
+ */
14
+
15
+ ;(function philverifyContentScript() {
16
+ 'use strict'
17
+
18
+ // ── Config ────────────────────────────────────────────────────────────────
19
+
20
+ /** Minimum text length to send for verification (avoids verifying 1-word posts) */
21
+ const MIN_TEXT_LENGTH = 40
22
+
23
+ /**
24
+ * Facebook feed post selectors β€” ordered by reliability.
25
+ * Facebook's class names are obfuscated; structural role/data attributes are
26
+ * more stable across renames.
27
+ */
28
+ const POST_SELECTORS = [
29
+ '[data-pagelet^="FeedUnit"]',
30
+ '[data-pagelet^="GroupsFeedUnit"]',
31
+ '[role="article"]',
32
+ '[data-testid="post_message"]',
33
+ ]
34
+
35
+ const VERDICT_COLORS = {
36
+ 'Credible': '#16a34a',
37
+ 'Unverified': '#d97706',
38
+ 'Likely Fake': '#dc2626',
39
+ }
40
+ const VERDICT_LABELS = {
41
+ 'Credible': 'βœ“ Credible',
42
+ 'Unverified': '? Unverified',
43
+ 'Likely Fake': 'βœ— Likely Fake',
44
+ }
45
+
46
+ // ── Utilities ─────────────────────────────────────────────────────────────
47
+
48
+ /** Escape HTML special chars to prevent XSS in innerHTML templates */
49
+ function safeText(str) {
50
+ if (str == null) return ''
51
+ return String(str)
52
+ .replace(/&/g, '&amp;')
53
+ .replace(/</g, '&lt;')
54
+ .replace(/>/g, '&gt;')
55
+ .replace(/"/g, '&quot;')
56
+ .replace(/'/g, '&#39;')
57
+ }
58
+
59
+ /** Allow only http/https URLs; return '#' for anything else */
60
+ function safeUrl(url) {
61
+ if (!url) return '#'
62
+ try {
63
+ const u = new URL(url)
64
+ return (u.protocol === 'http:' || u.protocol === 'https:') ? u.href : '#'
65
+ } catch { return '#' }
66
+ }
67
+
68
+ function extractPostText(post) {
69
+ // Try common post message containers
70
+ const msgSelectors = [
71
+ '[data-ad-preview="message"]',
72
+ '[data-testid="post_message"]',
73
+ '[dir="auto"] > div > div > div > span',
74
+ 'div[style*="text-align"] span',
75
+ ]
76
+ for (const sel of msgSelectors) {
77
+ const el = post.querySelector(sel)
78
+ if (el?.innerText?.trim().length >= MIN_TEXT_LENGTH) {
79
+ return el.innerText.trim().slice(0, 2000)
80
+ }
81
+ }
82
+ // Fallback: gather all text spans β‰₯ MIN_TEXT_LENGTH chars
83
+ const spans = Array.from(post.querySelectorAll('span'))
84
+ for (const span of spans) {
85
+ const t = span.innerText?.trim()
86
+ if (t && t.length >= MIN_TEXT_LENGTH && !t.startsWith('http')) return t.slice(0, 2000)
87
+ }
88
+ return null
89
+ }
90
+
91
+ function extractPostUrl(post) {
92
+ // Shared article links
93
+ const linkSelectors = [
94
+ 'a[href*="l.facebook.com/l.php"]', // Facebook link wrapper
95
+ 'a[target="_blank"][href^="https"]', // Direct external links
96
+ 'a[aria-label][href*="facebook.com/watch"]', // Videos
97
+ ]
98
+ for (const sel of linkSelectors) {
99
+ const el = post.querySelector(sel)
100
+ if (el?.href) {
101
+ try {
102
+ const u = new URL(el.href)
103
+ const dest = u.searchParams.get('u') // Unwrap l.facebook.com redirect
104
+ return dest || el.href
105
+ } catch {
106
+ return el.href
107
+ }
108
+ }
109
+ }
110
+ return null
111
+ }
112
+
113
+ function genPostId(post) {
114
+ // Use aria-label prefix + UUID for stable, unique ID
115
+ // Avoids offsetTop which forces a synchronous layout read
116
+ const label = (post.getAttribute('aria-label') ?? '').replace(/\W/g, '').slice(0, 20)
117
+ return 'pv_' + label + crypto.randomUUID().replace(/-/g, '').slice(0, 12)
118
+ }
119
+
120
+ // ── Badge rendering ───────────────────────────────────────────────────────
121
+
122
+ function createBadge(verdict, score, result) {
123
+ const color = VERDICT_COLORS[verdict] ?? '#5c554e'
124
+ const label = VERDICT_LABELS[verdict] ?? verdict
125
+
126
+ const wrap = document.createElement('div')
127
+ wrap.className = 'pv-badge'
128
+ wrap.setAttribute('role', 'status')
129
+ wrap.setAttribute('aria-label', `PhilVerify: ${label} β€” ${Math.round(score)}% credibility score`)
130
+ wrap.style.cssText = `
131
+ display: inline-flex;
132
+ align-items: center;
133
+ gap: 6px;
134
+ padding: 4px 10px;
135
+ border-radius: 3px;
136
+ border: 1px solid ${color}4d;
137
+ background: ${color}14;
138
+ cursor: pointer;
139
+ font-family: system-ui, sans-serif;
140
+ font-size: 11px;
141
+ font-weight: 600;
142
+ letter-spacing: 0.04em;
143
+ color: ${color};
144
+ touch-action: manipulation;
145
+ -webkit-tap-highlight-color: transparent;
146
+ position: relative;
147
+ z-index: 10;
148
+ `
149
+
150
+ const dot = document.createElement('span')
151
+ dot.style.cssText = `
152
+ width: 7px; height: 7px;
153
+ border-radius: 50%;
154
+ background: ${color};
155
+ flex-shrink: 0;
156
+ `
157
+
158
+ const text = document.createElement('span')
159
+ text.textContent = `${label} ${Math.round(score)}%`
160
+
161
+ const cacheTag = result._fromCache
162
+ ? (() => { const t = document.createElement('span'); t.textContent = 'Β·cached'; t.style.cssText = `opacity:0.5;font-size:9px;`; return t })()
163
+ : null
164
+
165
+ wrap.appendChild(dot)
166
+ wrap.appendChild(text)
167
+ if (cacheTag) wrap.appendChild(cacheTag)
168
+
169
+ // Click β†’ toggle detail panel
170
+ wrap.addEventListener('click', (e) => {
171
+ e.stopPropagation()
172
+ toggleDetailPanel(wrap, result)
173
+ })
174
+
175
+ return wrap
176
+ }
177
+
178
+ function toggleDetailPanel(badge, result) {
179
+ const existing = badge.parentElement?.querySelector('.pv-detail')
180
+ if (existing) { existing.remove(); return }
181
+
182
+ const panel = document.createElement('div')
183
+ panel.className = 'pv-detail'
184
+ panel.setAttribute('role', 'dialog')
185
+ panel.setAttribute('aria-label', 'PhilVerify fact-check details')
186
+
187
+ const color = VERDICT_COLORS[result.verdict] ?? '#5c554e'
188
+ const topSource = result.layer2?.sources?.[0]
189
+
190
+ panel.innerHTML = `
191
+ <div class="pv-detail-header">
192
+ <span class="pv-logo">PHIL<span style="color:${color}">VERIFY</span></span>
193
+ <button class="pv-close" aria-label="Close fact-check panel">βœ•</button>
194
+ </div>
195
+ <div class="pv-row">
196
+ <span class="pv-label">VERDICT</span>
197
+ <span class="pv-val" style="color:${color};font-weight:700">${safeText(result.verdict)}</span>
198
+ </div>
199
+ <div class="pv-row">
200
+ <span class="pv-label">SCORE</span>
201
+ <span class="pv-val" style="color:${color}">${Math.round(result.final_score)}%</span>
202
+ </div>
203
+ <div class="pv-row">
204
+ <span class="pv-label">LANGUAGE</span>
205
+ <span class="pv-val">${safeText(result.language ?? 'β€”')}</span>
206
+ </div>
207
+ ${result.layer1?.triggered_features?.length ? `
208
+ <div class="pv-signals">
209
+ <span class="pv-label">SIGNALS</span>
210
+ <div class="pv-tags">
211
+ ${result.layer1.triggered_features.slice(0, 3).map(f =>
212
+ `<span class="pv-tag">${safeText(f)}</span>`
213
+ ).join('')}
214
+ </div>
215
+ </div>` : ''}
216
+ ${topSource ? `
217
+ <div class="pv-source">
218
+ <span class="pv-label">TOP SOURCE</span>
219
+ <a href="${safeUrl(topSource.url)}" target="_blank" rel="noreferrer" class="pv-source-link">
220
+ ${safeText(topSource.title?.slice(0, 60) ?? topSource.source_name ?? 'View source')} β†—
221
+ </a>
222
+ </div>` : ''}
223
+ <a href="http://localhost:5173" target="_blank" rel="noreferrer" class="pv-open-full">
224
+ Open full analysis β†—
225
+ </a>
226
+ `
227
+
228
+ panel.querySelector('.pv-close').addEventListener('click', (e) => {
229
+ e.stopPropagation()
230
+ panel.remove()
231
+ })
232
+
233
+ badge.insertAdjacentElement('afterend', panel)
234
+ }
235
+
236
+ function injectBadgeIntoPost(post, result) {
237
+ // Find a stable injection point near the post actions bar
238
+ const actionBar = post.querySelector('[data-testid="UFI2ReactionsCount/root"]')
239
+ ?? post.querySelector('[aria-label*="reaction"]')
240
+ ?? post.querySelector('[role="toolbar"]')
241
+ ?? post
242
+
243
+ const container = document.createElement('div')
244
+ container.className = 'pv-badge-wrap'
245
+ const badge = createBadge(result.verdict, result.final_score, result)
246
+ container.appendChild(badge)
247
+
248
+ // Insert before the action bar, or append inside the post
249
+ if (actionBar && actionBar !== post) {
250
+ actionBar.insertAdjacentElement('beforebegin', container)
251
+ } else {
252
+ post.appendChild(container)
253
+ }
254
+ }
255
+
256
+ // ── Loading state ─────────────────────────────────────────────────────────
257
+
258
+ function injectLoadingBadge(post) {
259
+ const container = document.createElement('div')
260
+ container.className = 'pv-badge-wrap pv-loading'
261
+ container.setAttribute('aria-label', 'PhilVerify: verifying…')
262
+ container.innerHTML = `
263
+ <div class="pv-badge pv-badge--loading">
264
+ <span class="pv-spinner" aria-hidden="true"></span>
265
+ <span>Verifying…</span>
266
+ </div>
267
+ `
268
+ post.appendChild(container)
269
+ return container
270
+ }
271
+
272
+ // ── Post processing ───────────────────────────────────────────────────────
273
+
274
+ async function processPost(post) {
275
+ if (post.dataset.philverify) return // already processed
276
+ const id = genPostId(post)
277
+ post.dataset.philverify = id
278
+
279
+ const text = extractPostText(post)
280
+ const url = extractPostUrl(post)
281
+
282
+ if (!text && !url) return // nothing to verify
283
+
284
+ const loader = injectLoadingBadge(post)
285
+
286
+ try {
287
+ const response = await new Promise((resolve, reject) => {
288
+ const msg = url
289
+ ? { type: 'VERIFY_URL', url }
290
+ : { type: 'VERIFY_TEXT', text }
291
+ chrome.runtime.sendMessage(msg, (resp) => {
292
+ if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message))
293
+ else if (!resp?.ok) reject(new Error(resp?.error ?? 'Unknown error'))
294
+ else resolve(resp.result)
295
+ })
296
+ })
297
+
298
+ loader.remove()
299
+ injectBadgeIntoPost(post, response)
300
+ } catch (err) {
301
+ loader.remove()
302
+ // Show a muted error indicator β€” don't block reading
303
+ const errBadge = document.createElement('div')
304
+ errBadge.className = 'pv-badge-wrap'
305
+ const errInner = document.createElement('div')
306
+ errInner.className = 'pv-badge pv-badge--error'
307
+ errInner.title = err.message // .title setter is XSS-safe
308
+ errInner.textContent = '⚠ PhilVerify offline'
309
+ errBadge.appendChild(errInner)
310
+ post.appendChild(errBadge)
311
+ }
312
+ }
313
+
314
+ // ── MutationObserver ──────────────────────────────────────────────────────
315
+
316
+ const pendingPosts = new Set()
317
+ let rafScheduled = false
318
+
319
+ function flushPosts() {
320
+ rafScheduled = false
321
+ for (const post of pendingPosts) processPost(post)
322
+ pendingPosts.clear()
323
+ }
324
+
325
+ function scheduleProcess(post) {
326
+ pendingPosts.add(post)
327
+ if (!rafScheduled) {
328
+ rafScheduled = true
329
+ requestAnimationFrame(flushPosts)
330
+ }
331
+ }
332
+
333
+ function findPosts(root) {
334
+ for (const sel of POST_SELECTORS) {
335
+ const found = root.querySelectorAll(sel)
336
+ if (found.length) return found
337
+ }
338
+ return []
339
+ }
340
+
341
+ const observer = new MutationObserver((mutations) => {
342
+ for (const mutation of mutations) {
343
+ for (const node of mutation.addedNodes) {
344
+ if (node.nodeType !== 1) continue // element nodes only
345
+ // Check if the node itself matches
346
+ for (const sel of POST_SELECTORS) {
347
+ if (node.matches?.(sel)) { scheduleProcess(node); break }
348
+ }
349
+ // Check descendants
350
+ const posts = findPosts(node)
351
+ for (const post of posts) scheduleProcess(post)
352
+ }
353
+ }
354
+ })
355
+
356
+ // ── Initialization ────────────────────────────────────────────────────────
357
+
358
+ async function init() {
359
+ // Check autoScan setting before activating
360
+ const response = await new Promise(resolve => {
361
+ chrome.runtime.sendMessage({ type: 'GET_SETTINGS' }, resolve)
362
+ }).catch(() => ({ autoScan: true }))
363
+
364
+ if (!response?.autoScan) return
365
+
366
+ // Process any posts already in the DOM
367
+ const existing = findPosts(document.body)
368
+ for (const post of existing) scheduleProcess(post)
369
+
370
+ // Watch for new posts (Facebook is a SPA β€” feed dynamically loads more)
371
+ observer.observe(document.body, { childList: true, subtree: true })
372
+ }
373
+
374
+ init()
375
+
376
+ // React to autoScan toggle without requiring page reload
377
+ chrome.storage.onChanged.addListener((changes, area) => {
378
+ if (area !== 'local' || !changes.settings) return
379
+ const autoScan = changes.settings.newValue?.autoScan
380
+ if (autoScan === false) {
381
+ observer.disconnect()
382
+ } else if (autoScan === true) {
383
+ observer.observe(document.body, { childList: true, subtree: true })
384
+ // Process any posts that appeared while scanning was paused
385
+ const existing = findPosts(document.body)
386
+ for (const post of existing) scheduleProcess(post)
387
+ }
388
+ })
389
+
390
+ })()
extension/generate_icons.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Generate PhilVerify extension icons (16Γ—16, 32Γ—32, 48Γ—48, 128Γ—128 PNG).
3
+ Requires Pillow: pip install Pillow
4
+ Run from the extension/ directory: python generate_icons.py
5
+ """
6
+ import os
7
+ from PIL import Image, ImageDraw, ImageFont
8
+
9
+ SIZES = [16, 32, 48, 128]
10
+ OUTPUT_DIR = os.path.join(os.path.dirname(__file__), 'icons')
11
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
12
+
13
+ BG_COLOR = (13, 13, 13, 255) # --bg-base
14
+ RED_COLOR = (220, 38, 38, 255) # --accent-red
15
+ TEXT_COLOR = (245, 240, 232, 255) # --text-primary
16
+
17
+
18
+ def make_icon(size: int) -> Image.Image:
19
+ img = Image.new('RGBA', (size, size), BG_COLOR)
20
+ draw = ImageDraw.Draw(img)
21
+
22
+ # Red left-edge accent bar (3px scaled)
23
+ bar_width = max(2, size // 10)
24
+ draw.rectangle([0, 0, bar_width - 1, size - 1], fill=RED_COLOR)
25
+
26
+ # 'PV' text label β€” only draw text on larger icons where it looks clean
27
+ font_size = max(6, int(size * 0.38))
28
+ font = None
29
+ for path in [
30
+ '/System/Library/Fonts/Helvetica.ttc',
31
+ '/System/Library/Fonts/SFNSDisplay.ttf',
32
+ '/System/Library/Fonts/ArialHB.ttc',
33
+ ]:
34
+ try:
35
+ font = ImageFont.truetype(path, font_size)
36
+ break
37
+ except OSError:
38
+ continue
39
+ if font is None:
40
+ font = ImageFont.load_default()
41
+
42
+ if size >= 32:
43
+ text = 'PV'
44
+ try:
45
+ bbox = draw.textbbox((0, 0), text, font=font)
46
+ tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
47
+ tx = bar_width + (size - bar_width - tw) // 2
48
+ ty = (size - th) // 2 - bbox[1]
49
+ draw.text((tx, ty), text, fill=TEXT_COLOR, font=font)
50
+ except Exception:
51
+ pass # Skip text on render error β€” icon still has the red bar
52
+
53
+ return img
54
+
55
+
56
+ for sz in SIZES:
57
+ icon_path = os.path.join(OUTPUT_DIR, f'icon{sz}.png')
58
+ make_icon(sz).save(icon_path, 'PNG')
59
+ print(f'βœ“ icons/icon{sz}.png')
60
+
61
+ print('Icons generated in extension/icons/')
extension/icons/icon128.png ADDED
extension/icons/icon16.png ADDED
extension/icons/icon32.png ADDED
extension/icons/icon48.png ADDED
extension/manifest.json ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "PhilVerify",
4
+ "version": "1.0.0",
5
+ "description": "AI-powered fact-checking for Philippine news and social media. Detects misinformation on Facebook in real time.",
6
+
7
+ "permissions": [
8
+ "storage",
9
+ "activeTab",
10
+ "scripting"
11
+ ],
12
+
13
+ "host_permissions": [
14
+ "https://www.facebook.com/*",
15
+ "https://facebook.com/*",
16
+ "http://localhost:8000/*",
17
+ "https://api.philverify.com/*"
18
+ ],
19
+
20
+ "background": {
21
+ "service_worker": "background.js",
22
+ "type": "module"
23
+ },
24
+
25
+ "content_scripts": [
26
+ {
27
+ "matches": ["https://www.facebook.com/*", "https://facebook.com/*"],
28
+ "js": ["content.js"],
29
+ "css": ["content.css"],
30
+ "run_at": "document_idle"
31
+ }
32
+ ],
33
+
34
+ "action": {
35
+ "default_popup": "popup.html",
36
+ "default_title": "PhilVerify β€” Fact Check",
37
+ "default_icon": {
38
+ "16": "icons/icon16.png",
39
+ "32": "icons/icon32.png",
40
+ "48": "icons/icon48.png",
41
+ "128": "icons/icon128.png"
42
+ }
43
+ },
44
+
45
+ "icons": {
46
+ "16": "icons/icon16.png",
47
+ "32": "icons/icon32.png",
48
+ "48": "icons/icon48.png",
49
+ "128": "icons/icon128.png"
50
+ },
51
+
52
+ "content_security_policy": {
53
+ "extension_pages": "script-src 'self'; object-src 'self'"
54
+ }
55
+ }
extension/popup.html ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>PhilVerify</title>
7
+ <style>
8
+ /* ── Reset ───────────────────────────────────────── */
9
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
10
+
11
+ /* ── Design tokens (mirrors dashboard) ──────────── */
12
+ :root {
13
+ --bg-base: #0d0d0d;
14
+ --bg-surface: #141414;
15
+ --bg-elevated: #1c1c1c;
16
+ --bg-hover: #222;
17
+ --border: rgba(245,240,232,0.07);
18
+ --border-light: rgba(245,240,232,0.14);
19
+ --accent-red: #dc2626;
20
+ --accent-gold: #d97706;
21
+ --accent-cyan: #06b6d4;
22
+ --credible: #16a34a;
23
+ --unverified: #d97706;
24
+ --fake: #dc2626;
25
+ --text-primary: #f5f0e8;
26
+ --text-secondary: #a89f94;
27
+ --text-muted: #5c554e;
28
+ --font-display: 'Syne', system-ui, sans-serif;
29
+ --font-mono: 'JetBrains Mono', monospace;
30
+ }
31
+
32
+ html { color-scheme: dark; }
33
+
34
+ body {
35
+ width: 340px;
36
+ min-height: 200px;
37
+ background: var(--bg-base);
38
+ color: var(--text-primary);
39
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
40
+ font-size: 12px;
41
+ -webkit-font-smoothing: antialiased;
42
+ }
43
+
44
+ /* ── Navbar ──────────────────────────────────────── */
45
+ .navbar {
46
+ display: flex;
47
+ align-items: center;
48
+ justify-content: space-between;
49
+ padding: 10px 14px;
50
+ background: var(--bg-surface);
51
+ border-bottom: 1px solid var(--border);
52
+ }
53
+ .logo {
54
+ font-weight: 800;
55
+ font-size: 13px;
56
+ letter-spacing: 0.06em;
57
+ }
58
+ .logo span { color: var(--accent-red); }
59
+
60
+ /* ── Tab bar ─────────────────────────────────────── */
61
+ .tabs {
62
+ display: flex;
63
+ border-bottom: 1px solid var(--border);
64
+ }
65
+ .tab {
66
+ flex: 1;
67
+ padding: 10px 0;
68
+ min-height: 40px;
69
+ background: none;
70
+ border: none;
71
+ color: var(--text-muted);
72
+ font-size: 10px;
73
+ font-weight: 700;
74
+ letter-spacing: 0.1em;
75
+ text-transform: uppercase;
76
+ cursor: pointer;
77
+ border-bottom: 2px solid transparent;
78
+ touch-action: manipulation;
79
+ }
80
+ .tab.active {
81
+ color: var(--text-primary);
82
+ border-bottom-color: var(--accent-red);
83
+ }
84
+ .tab:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; }
85
+
86
+ /* ── Panels ──────────────────────────────────────── */
87
+ .panel { display: none; padding: 12px 14px; }
88
+ .panel.active { display: block; }
89
+
90
+ /* ── Verify panel ────────────────────────────────── */
91
+ .current-url {
92
+ font-size: 10px;
93
+ color: var(--text-muted);
94
+ white-space: nowrap;
95
+ overflow: hidden;
96
+ text-overflow: ellipsis;
97
+ margin-bottom: 10px;
98
+ font-family: var(--font-mono);
99
+ }
100
+
101
+ textarea {
102
+ width: 100%;
103
+ resize: none;
104
+ padding: 8px 10px;
105
+ background: var(--bg-elevated);
106
+ border: 1px solid var(--border);
107
+ color: var(--text-primary);
108
+ font-size: 11px;
109
+ font-family: inherit;
110
+ border-radius: 2px;
111
+ line-height: 1.6;
112
+ margin-bottom: 8px;
113
+ field-sizing: content;
114
+ min-height: 60px;
115
+ }
116
+ textarea:focus-visible {
117
+ outline: none;
118
+ box-shadow: 0 0 0 2px var(--accent-red);
119
+ border-color: var(--accent-red);
120
+ }
121
+
122
+ .btn-verify {
123
+ width: 100%;
124
+ padding: 8px;
125
+ background: var(--accent-red);
126
+ color: #fff;
127
+ border: none;
128
+ font-size: 10px;
129
+ font-weight: 700;
130
+ letter-spacing: 0.1em;
131
+ text-transform: uppercase;
132
+ cursor: pointer;
133
+ border-radius: 2px;
134
+ min-height: 44px;
135
+ touch-action: manipulation;
136
+ }
137
+ .btn-verify:disabled {
138
+ background: var(--bg-elevated);
139
+ color: var(--text-muted);
140
+ cursor: not-allowed;
141
+ }
142
+ .btn-verify:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; }
143
+
144
+ /* ── Result card ─────────────────────────────────── */
145
+ .result {
146
+ margin-top: 10px;
147
+ padding: 10px 12px;
148
+ background: var(--bg-surface);
149
+ border: 1px solid var(--border);
150
+ border-radius: 3px;
151
+ }
152
+ .result-verdict {
153
+ font-size: 15px;
154
+ font-weight: 800;
155
+ letter-spacing: -0.01em;
156
+ margin-bottom: 4px;
157
+ }
158
+ .result-score {
159
+ font-size: 10px;
160
+ color: var(--text-muted);
161
+ font-family: var(--font-mono);
162
+ }
163
+ .result-row {
164
+ display: flex;
165
+ justify-content: space-between;
166
+ align-items: center;
167
+ padding: 4px 0;
168
+ border-top: 1px solid var(--border);
169
+ margin-top: 4px;
170
+ }
171
+ .result-label {
172
+ font-size: 9px;
173
+ color: var(--text-muted);
174
+ font-weight: 700;
175
+ letter-spacing: 0.1em;
176
+ text-transform: uppercase;
177
+ }
178
+ .result-val {
179
+ font-size: 10px;
180
+ color: var(--text-secondary);
181
+ font-family: var(--font-mono);
182
+ }
183
+ .result-source {
184
+ margin-top: 8px;
185
+ padding-top: 6px;
186
+ border-top: 1px solid var(--border);
187
+ }
188
+ .result-source a {
189
+ color: var(--accent-cyan);
190
+ font-size: 10px;
191
+ text-decoration: none;
192
+ display: block;
193
+ overflow: hidden;
194
+ text-overflow: ellipsis;
195
+ white-space: nowrap;
196
+ }
197
+ .result-source a:hover { text-decoration: underline; }
198
+ .open-full {
199
+ display: block;
200
+ text-align: center;
201
+ margin-top: 8px;
202
+ color: var(--accent-red);
203
+ font-size: 9px;
204
+ font-weight: 700;
205
+ letter-spacing: 0.1em;
206
+ text-transform: uppercase;
207
+ text-decoration: none;
208
+ padding: 4px;
209
+ border: 1px solid rgba(220,38,38,0.3);
210
+ border-radius: 2px;
211
+ }
212
+ .open-full:hover { background: rgba(220,38,38,0.08); }
213
+
214
+ /* ── Loading / error states ──────────────────────── */
215
+ .state-loading, .state-error, .state-empty {
216
+ padding: 20px;
217
+ text-align: center;
218
+ color: var(--text-muted);
219
+ font-size: 11px;
220
+ }
221
+ .state-error { color: #f87171; }
222
+ .spinner {
223
+ display: inline-block;
224
+ width: 16px; height: 16px;
225
+ border: 2px solid rgba(168,159,148,0.2);
226
+ border-top-color: var(--text-muted);
227
+ border-radius: 50%;
228
+ animation: spin 0.7s linear infinite;
229
+ margin-bottom: 8px;
230
+ }
231
+ @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
232
+ @keyframes spin { to { transform: rotate(360deg); } }
233
+
234
+ /* ── History panel ───────────────────────────────── */
235
+ .history-list { display: flex; flex-direction: column; gap: 6px; }
236
+ .history-item {
237
+ padding: 8px 10px;
238
+ background: var(--bg-surface);
239
+ border: 1px solid var(--border);
240
+ border-radius: 3px;
241
+ cursor: default;
242
+ }
243
+ .history-item-top {
244
+ display: flex;
245
+ justify-content: space-between;
246
+ align-items: center;
247
+ gap: 8px;
248
+ margin-bottom: 3px;
249
+ }
250
+ .history-verdict {
251
+ font-size: 9px;
252
+ font-weight: 700;
253
+ letter-spacing: 0.08em;
254
+ text-transform: uppercase;
255
+ padding: 2px 6px;
256
+ border-radius: 2px;
257
+ flex-shrink: 0;
258
+ }
259
+ .history-score {
260
+ font-size: 10px;
261
+ font-family: var(--font-mono);
262
+ color: var(--text-muted);
263
+ }
264
+ .history-preview {
265
+ font-size: 10px;
266
+ color: var(--text-secondary);
267
+ overflow: hidden;
268
+ text-overflow: ellipsis;
269
+ white-space: nowrap;
270
+ }
271
+ .history-time {
272
+ font-size: 9px;
273
+ color: var(--text-muted);
274
+ margin-top: 2px;
275
+ }
276
+
277
+ /* ── Settings panel ──────────────────────────────── */
278
+ .setting-row {
279
+ display: flex;
280
+ flex-direction: column;
281
+ gap: 4px;
282
+ margin-bottom: 12px;
283
+ }
284
+ .setting-label {
285
+ font-size: 9px;
286
+ font-weight: 700;
287
+ letter-spacing: 0.1em;
288
+ text-transform: uppercase;
289
+ color: var(--text-muted);
290
+ }
291
+ .setting-input {
292
+ width: 100%;
293
+ padding: 8px 10px;
294
+ min-height: 36px;
295
+ background: var(--bg-elevated);
296
+ border: 1px solid var(--border);
297
+ color: var(--text-primary);
298
+ font-size: 11px;
299
+ font-family: var(--font-mono);
300
+ border-radius: 2px;
301
+ }
302
+ .setting-input:focus-visible {
303
+ outline: none;
304
+ box-shadow: 0 0 0 2px var(--accent-cyan);
305
+ }
306
+ .toggle-row {
307
+ display: flex;
308
+ justify-content: space-between;
309
+ align-items: center;
310
+ margin-bottom: 12px;
311
+ }
312
+ .toggle-label {
313
+ font-size: 11px;
314
+ color: var(--text-secondary);
315
+ cursor: pointer;
316
+ }
317
+ .toggle {
318
+ position: relative;
319
+ width: 32px;
320
+ height: 18px;
321
+ }
322
+ .toggle input { opacity: 0; width: 0; height: 0; }
323
+ .toggle-track {
324
+ position: absolute;
325
+ inset: 0;
326
+ border-radius: 18px;
327
+ background: var(--bg-elevated);
328
+ border: 1px solid var(--border);
329
+ cursor: pointer;
330
+ transition: background 0.2s;
331
+ }
332
+ .toggle input:checked + .toggle-track { background: var(--accent-red); border-color: var(--accent-red); }
333
+ @media (prefers-reduced-motion: reduce) {
334
+ .toggle-track,
335
+ .toggle-track::after { transition: none; }
336
+ }
337
+ .toggle-track::after {
338
+ content: '';
339
+ position: absolute;
340
+ left: 2px; top: 2px;
341
+ width: 12px; height: 12px;
342
+ background: #fff;
343
+ border-radius: 50%;
344
+ transition: transform 0.2s;
345
+ }
346
+ .toggle input:checked + .toggle-track::after { transform: translateX(14px); }
347
+ .btn-save {
348
+ width: 100%;
349
+ padding: 7px;
350
+ min-height: 36px;
351
+ background: var(--bg-elevated);
352
+ border: 1px solid var(--border-light);
353
+ color: var(--text-secondary);
354
+ font-size: 10px;
355
+ font-weight: 700;
356
+ letter-spacing: 0.1em;
357
+ text-transform: uppercase;
358
+ cursor: pointer;
359
+ border-radius: 2px;
360
+ touch-action: manipulation;
361
+ }
362
+ .btn-save:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); }
363
+ .btn-save:focus-visible { outline: 2px solid var(--accent-cyan); }
364
+ .saved-flash {
365
+ text-align: center;
366
+ color: var(--credible);
367
+ font-size: 10px;
368
+ margin-top: 6px;
369
+ height: 14px;
370
+ }
371
+ </style>
372
+ </head>
373
+ <body>
374
+
375
+ <!-- Navbar -->
376
+ <nav class="navbar" role="banner">
377
+ <div class="logo">PHIL<span>VERIFY</span></div>
378
+ <div id="live-dot" style="display:flex;align-items:center;gap:5px;font-size:9px;color:var(--text-muted);font-weight:700;letter-spacing:0.1em;">
379
+ <span id="api-status-dot" style="width:6px;height:6px;border-radius:50%;background:var(--text-muted);" aria-hidden="true"></span>
380
+ <span id="api-status-label">CHECKING…</span>
381
+ </div>
382
+ </nav>
383
+
384
+ <!-- Tabs -->
385
+ <div class="tabs" role="tablist" aria-label="PhilVerify sections">
386
+ <button class="tab active" role="tab" aria-selected="true" aria-controls="panel-verify" id="tab-verify" data-tab="verify">Verify</button>
387
+ <button class="tab" role="tab" aria-selected="false" aria-controls="panel-history" id="tab-history" data-tab="history">History</button>
388
+ <button class="tab" role="tab" aria-selected="false" aria-controls="panel-settings" id="tab-settings" data-tab="settings">Settings</button>
389
+ </div>
390
+
391
+ <!-- ── Verify Panel ── -->
392
+ <div class="panel active" id="panel-verify" role="tabpanel" aria-labelledby="tab-verify">
393
+ <p class="current-url" id="current-url" title="">Loading current URL…</p>
394
+ <textarea
395
+ id="verify-input"
396
+ name="claim-text"
397
+ placeholder="Paste text or URL to fact-check…"
398
+ rows="3"
399
+ aria-label="Text or URL to verify"
400
+ autocomplete="off"
401
+ spellcheck="true"
402
+ ></textarea>
403
+ <button class="btn-verify" id="btn-verify" type="button" aria-busy="false">
404
+ Verify Claim
405
+ </button>
406
+ <div id="verify-result" aria-live="polite" aria-atomic="true"></div>
407
+ </div>
408
+
409
+ <!-- ── History Panel ── -->
410
+ <div class="panel" id="panel-history" role="tabpanel" aria-labelledby="tab-history">
411
+ <div id="history-container">
412
+ <div class="state-empty">No verifications yet β€” use the Verify tab or browse Facebook.</div>
413
+ </div>
414
+ </div>
415
+
416
+ <!-- ── Settings Panel ── -->
417
+ <div class="panel" id="panel-settings" role="tabpanel" aria-labelledby="tab-settings">
418
+ <div class="setting-row">
419
+ <label class="setting-label" for="api-base">Backend API URL</label>
420
+ <input
421
+ class="setting-input"
422
+ id="api-base"
423
+ name="api-base"
424
+ type="url"
425
+ autocomplete="url"
426
+ placeholder="http://localhost:8000"
427
+ aria-describedby="api-base-hint"
428
+ >
429
+ <span id="api-base-hint" style="font-size:9px;color:var(--text-muted);">
430
+ Default: http://localhost:8000 β€” change for production deployment.
431
+ </span>
432
+ </div>
433
+ <div class="toggle-row">
434
+ <label for="auto-scan" class="toggle-label">Auto-scan Facebook feed</label>
435
+ <label class="toggle">
436
+ <input type="checkbox" id="auto-scan" name="auto-scan" checked>
437
+ <span class="toggle-track" aria-hidden="true"></span>
438
+ </label>
439
+ </div>
440
+ <button class="btn-save" id="btn-save" type="button">Save Settings</button>
441
+ <div class="saved-flash" id="saved-flash" aria-live="polite"></div>
442
+ </div>
443
+
444
+ <script src="popup.js"></script>
445
+ </body>
446
+ </html>
extension/popup.js ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * PhilVerify β€” Popup Script
3
+ * Controls the extension popup: verify tab, history tab, settings tab.
4
+ */
5
+ 'use strict'
6
+
7
+ // ── Constants ─────────────────────────────────────────────────────────────────
8
+
9
+ const VERDICT_COLORS = {
10
+ 'Credible': '#16a34a',
11
+ 'Unverified': '#d97706',
12
+ 'Likely Fake': '#dc2626',
13
+ }
14
+
15
+ // ── Helpers ───────────────────────────────────────────────────────────────────
16
+ /** Escape HTML special chars to prevent XSS in innerHTML templates */
17
+ function safeText(str) {
18
+ if (str == null) return ''
19
+ return String(str)
20
+ .replace(/&/g, '&amp;')
21
+ .replace(/</g, '&lt;')
22
+ .replace(/>/g, '&gt;')
23
+ .replace(/"/g, '&quot;')
24
+ .replace(/'/g, '&#39;')
25
+ }
26
+
27
+ /** Allow only http/https URLs; return '#' for anything else */
28
+ function safeUrl(url) {
29
+ if (!url) return '#'
30
+ try {
31
+ const u = new URL(url)
32
+ return (u.protocol === 'http:' || u.protocol === 'https:') ? u.href : '#'
33
+ } catch { return '#' }
34
+ }
35
+ function msg(obj) {
36
+ return new Promise(resolve => {
37
+ chrome.runtime.sendMessage(obj, resolve)
38
+ })
39
+ }
40
+
41
+ function timeAgo(iso) {
42
+ const diff = Date.now() - new Date(iso).getTime()
43
+ if (diff < 60_000) return 'just now'
44
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
45
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
46
+ return `${Math.floor(diff / 86_400_000)}d ago`
47
+ }
48
+
49
+ function isUrl(s) {
50
+ try { new URL(s); return s.startsWith('http'); } catch { return false }
51
+ }
52
+
53
+ // ── Render helpers ────────────────────────────────────────────────────────────
54
+
55
+ function renderResult(result, container) {
56
+ const color = VERDICT_COLORS[result.verdict] ?? '#5c554e'
57
+ const topSource = result.layer2?.sources?.[0]
58
+
59
+ container.innerHTML = `
60
+ <div class="result" role="status" aria-live="polite">
61
+ <div class="result-verdict" style="color:${color}">${safeText(result.verdict)}</div>
62
+ <div class="result-score">${Math.round(result.final_score)}% credibility${result._fromCache ? ' (cached)' : ''}</div>
63
+ <div class="result-row">
64
+ <span class="result-label">Language</span>
65
+ <span class="result-val">${safeText(result.language ?? 'β€”')}</span>
66
+ </div>
67
+ <div class="result-row">
68
+ <span class="result-label">Confidence</span>
69
+ <span class="result-val" style="color:${color}">${result.confidence?.toFixed(1)}%</span>
70
+ </div>
71
+ ${result.layer1?.triggered_features?.length ? `
72
+ <div class="result-row">
73
+ <span class="result-label">Signals</span>
74
+ <span class="result-val">${result.layer1.triggered_features.slice(0, 3).map(safeText).join(', ')}</span>
75
+ </div>` : ''}
76
+ ${topSource ? `
77
+ <div class="result-source">
78
+ <div class="result-label" style="margin-bottom:4px;">Top Source</div>
79
+ <a href="${safeUrl(topSource.url)}" target="_blank" rel="noreferrer">${safeText(topSource.title?.slice(0, 55) ?? topSource.source_name ?? 'View')} β†—</a>
80
+ </div>` : ''}
81
+ <a class="open-full" href="http://localhost:5173" target="_blank" rel="noreferrer">
82
+ Open Full Dashboard β†—
83
+ </a>
84
+ </div>
85
+ `
86
+ }
87
+
88
+ function renderHistory(entries, container) {
89
+ if (!entries.length) {
90
+ container.innerHTML = '<div class="state-empty">No verifications yet.</div>'
91
+ return
92
+ }
93
+ container.innerHTML = `
94
+ <ul class="history-list" role="list" aria-label="Verification history">
95
+ ${entries.map(e => {
96
+ const color = VERDICT_COLORS[e.verdict] ?? '#5c554e'
97
+ return `
98
+ <li class="history-item" role="listitem">
99
+ <div class="history-item-top">
100
+ <span class="history-verdict" style="background:${color}22;color:${color};border:1px solid ${color}4d;">${safeText(e.verdict)}</span>
101
+ <span class="history-score">${Math.round(e.final_score)}%</span>
102
+ </div>
103
+ <div class="history-preview">${safeText(e.text_preview || 'β€”')}</div>
104
+ <div class="history-time">${timeAgo(e.timestamp)}</div>
105
+ </li>`
106
+ }).join('')}
107
+ </ul>
108
+ `
109
+ }
110
+
111
+ // ── Tab switching ─────────────────────────────────────────────────────────────
112
+
113
+ document.querySelectorAll('.tab').forEach(tab => {
114
+ tab.addEventListener('click', () => {
115
+ document.querySelectorAll('.tab').forEach(t => {
116
+ t.classList.remove('active')
117
+ t.setAttribute('aria-selected', 'false')
118
+ })
119
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'))
120
+ tab.classList.add('active')
121
+ tab.setAttribute('aria-selected', 'true')
122
+ document.getElementById(`panel-${tab.dataset.tab}`)?.classList.add('active')
123
+ if (tab.dataset.tab === 'history') loadHistory()
124
+ if (tab.dataset.tab === 'settings') loadSettings()
125
+ })
126
+ })
127
+
128
+ // ── Verify tab ────────────────────────────────────────────────────────────────
129
+
130
+ const verifyInput = document.getElementById('verify-input')
131
+ const btnVerify = document.getElementById('btn-verify')
132
+ const verifyResult = document.getElementById('verify-result')
133
+ const currentUrlEl = document.getElementById('current-url')
134
+
135
+ // Auto-populate input with current tab URL if it's a news article
136
+ chrome.tabs.query({ active: true, currentWindow: true }, ([tab]) => {
137
+ const url = tab?.url ?? ''
138
+ if (url && !url.startsWith('chrome') && !url.includes('facebook.com')) {
139
+ currentUrlEl.textContent = url
140
+ currentUrlEl.title = url
141
+ verifyInput.value = url
142
+ } else {
143
+ currentUrlEl.textContent = 'facebook.com β€” use text input below'
144
+ }
145
+ })
146
+
147
+ btnVerify.addEventListener('click', async () => {
148
+ const raw = verifyInput.value.trim()
149
+ if (!raw) return
150
+
151
+ btnVerify.disabled = true
152
+ btnVerify.setAttribute('aria-busy', 'true')
153
+ btnVerify.textContent = 'Verifying…'
154
+ verifyResult.innerHTML = `
155
+ <div class="state-loading" aria-live="polite">
156
+ <div class="spinner" aria-hidden="true"></div><br>Analyzing claim…
157
+ </div>`
158
+
159
+ const type = isUrl(raw) ? 'VERIFY_URL' : 'VERIFY_TEXT'
160
+ const payload = type === 'VERIFY_URL' ? { type, url: raw } : { type, text: raw }
161
+ const resp = await msg(payload)
162
+
163
+ btnVerify.disabled = false
164
+ btnVerify.setAttribute('aria-busy', 'false')
165
+ btnVerify.textContent = 'Verify Claim'
166
+
167
+ if (resp?.ok) {
168
+ renderResult(resp.result, verifyResult)
169
+ } else {
170
+ verifyResult.innerHTML = `
171
+ <div class="state-error" role="alert">
172
+ ${resp?.error ?? 'Could not reach PhilVerify backend.'}<br>
173
+ <span style="font-size:10px;color:var(--text-muted)">Is the backend running at your configured API URL?</span>
174
+ </div>`
175
+ }
176
+ })
177
+
178
+ // Allow Enter (single line) to trigger verify when text area is focused on Ctrl+Enter
179
+ verifyInput.addEventListener('keydown', e => {
180
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
181
+ e.preventDefault()
182
+ btnVerify.click()
183
+ }
184
+ })
185
+
186
+ // ── History tab ───────────────────────────────────────────────────────────────
187
+
188
+ async function loadHistory() {
189
+ const container = document.getElementById('history-container')
190
+ container.innerHTML = '<div class="state-loading"><div class="spinner"></div><br>Loading…</div>'
191
+ const resp = await msg({ type: 'GET_HISTORY' })
192
+ renderHistory(resp?.history ?? [], container)
193
+ }
194
+
195
+ // ── Settings tab ──────────────────────────────────────────────────────────────
196
+
197
+ async function loadSettings() {
198
+ const resp = await msg({ type: 'GET_SETTINGS' })
199
+ if (!resp) return
200
+ document.getElementById('api-base').value = resp.apiBase ?? 'http://localhost:8000'
201
+ document.getElementById('auto-scan').checked = resp.autoScan ?? true
202
+ }
203
+
204
+ document.getElementById('btn-save').addEventListener('click', async () => {
205
+ const settings = {
206
+ apiBase: document.getElementById('api-base').value.trim() || 'http://localhost:8000',
207
+ autoScan: document.getElementById('auto-scan').checked,
208
+ }
209
+ await msg({ type: 'SAVE_SETTINGS', settings })
210
+
211
+ const flash = document.getElementById('saved-flash')
212
+ flash.textContent = 'Saved βœ“'
213
+ setTimeout(() => { flash.textContent = '' }, 2000)
214
+ })
215
+
216
+ // ── API status check ──────────────────────────────────────────────────────────
217
+
218
+ async function checkApiStatus() {
219
+ const dot = document.getElementById('api-status-dot')
220
+ const label = document.getElementById('api-status-label')
221
+ try {
222
+ const { apiBase } = await msg({ type: 'GET_SETTINGS' })
223
+ const res = await fetch(`${apiBase ?? 'http://localhost:8000'}/health`, { signal: AbortSignal.timeout(3000) })
224
+ if (res.ok) {
225
+ dot.style.background = 'var(--credible)'
226
+ label.style.color = 'var(--credible)'
227
+ label.textContent = 'ONLINE'
228
+ } else {
229
+ throw new Error(`${res.status}`)
230
+ }
231
+ } catch {
232
+ dot.style.background = 'var(--fake)'
233
+ label.style.color = 'var(--fake)'
234
+ label.textContent = 'OFFLINE'
235
+ }
236
+ }
237
+
238
+ checkApiStatus()
firebase.json CHANGED
@@ -4,7 +4,6 @@
4
  "indexes": "firestore.indexes.json"
5
  },
6
  "hosting": {
7
- "site": "philverify",
8
  "public": "frontend/dist",
9
  "ignore": [
10
  "firebase.json",
 
4
  "indexes": "firestore.indexes.json"
5
  },
6
  "hosting": {
 
7
  "public": "frontend/dist",
8
  "ignore": [
9
  "firebase.json",
firebase_client.py CHANGED
@@ -42,8 +42,8 @@ def get_firestore():
42
  cred = credentials.Certificate(str(_SERVICEACCOUNT_PATH))
43
  firebase_admin.initialize_app(cred)
44
  logger.info("Firebase initialized via service account key")
45
- elif os.getenv("GOOGLE_APPLICATION_CREDENTIALS"):
46
- # Cloud Run / GCE default credentials
47
  cred = credentials.ApplicationDefault()
48
  firebase_admin.initialize_app(cred)
49
  logger.info("Firebase initialized via Application Default Credentials")
@@ -92,12 +92,13 @@ async def get_verifications(
92
  if db is None:
93
  return []
94
  try:
 
95
  query = (
96
  db.collection("verifications")
97
  .order_by("timestamp", direction="DESCENDING")
98
  )
99
  if verdict_filter:
100
- query = query.where("verdict", "==", verdict_filter)
101
  docs = query.limit(limit + offset).stream()
102
  results = [doc.to_dict() for doc in docs]
103
  return results[offset : offset + limit]
@@ -106,15 +107,34 @@ async def get_verifications(
106
  return []
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  async def get_verification_count(verdict_filter: str | None = None) -> int:
110
  """Return total count of verifications (with optional verdict filter)."""
111
  db = get_firestore()
112
  if db is None:
113
  return 0
114
  try:
 
115
  query = db.collection("verifications")
116
  if verdict_filter:
117
- query = query.where("verdict", "==", verdict_filter)
118
  # Use aggregation query (Firestore native count)
119
  result = query.count().get()
120
  return result[0][0].value
 
42
  cred = credentials.Certificate(str(_SERVICEACCOUNT_PATH))
43
  firebase_admin.initialize_app(cred)
44
  logger.info("Firebase initialized via service account key")
45
+ elif os.getenv("GOOGLE_APPLICATION_CREDENTIALS") or os.getenv("K_SERVICE"):
46
+ # Cloud Run (K_SERVICE is always set) or explicit ADC path
47
  cred = credentials.ApplicationDefault()
48
  firebase_admin.initialize_app(cred)
49
  logger.info("Firebase initialized via Application Default Credentials")
 
92
  if db is None:
93
  return []
94
  try:
95
+ from google.cloud.firestore_v1.base_query import FieldFilter
96
  query = (
97
  db.collection("verifications")
98
  .order_by("timestamp", direction="DESCENDING")
99
  )
100
  if verdict_filter:
101
+ query = query.where(filter=FieldFilter("verdict", "==", verdict_filter))
102
  docs = query.limit(limit + offset).stream()
103
  results = [doc.to_dict() for doc in docs]
104
  return results[offset : offset + limit]
 
107
  return []
108
 
109
 
110
+ def get_all_verifications_sync() -> list[dict]:
111
+ """Synchronously fetch ALL verification records from Firestore (used by trends aggregation)."""
112
+ db = get_firestore()
113
+ if db is None:
114
+ return []
115
+ try:
116
+ docs = (
117
+ db.collection("verifications")
118
+ .order_by("timestamp", direction="DESCENDING")
119
+ .limit(10_000) # hard cap β€” more than enough for trends analysis
120
+ .stream()
121
+ )
122
+ return [doc.to_dict() for doc in docs]
123
+ except Exception as e:
124
+ logger.error("Firestore get_all_verifications_sync error: %s", e)
125
+ return []
126
+
127
+
128
  async def get_verification_count(verdict_filter: str | None = None) -> int:
129
  """Return total count of verifications (with optional verdict filter)."""
130
  db = get_firestore()
131
  if db is None:
132
  return 0
133
  try:
134
+ from google.cloud.firestore_v1.base_query import FieldFilter
135
  query = db.collection("verifications")
136
  if verdict_filter:
137
+ query = query.where(filter=FieldFilter("verdict", "==", verdict_filter))
138
  # Use aggregation query (Firestore native count)
139
  result = query.count().get()
140
  return result[0][0].value
firestore.indexes.json CHANGED
@@ -1,51 +1,13 @@
1
  {
2
- // Example (Standard Edition):
3
- //
4
- // "indexes": [
5
- // {
6
- // "collectionGroup": "widgets",
7
- // "queryScope": "COLLECTION",
8
- // "fields": [
9
- // { "fieldPath": "foo", "arrayConfig": "CONTAINS" },
10
- // { "fieldPath": "bar", "mode": "DESCENDING" }
11
- // ]
12
- // },
13
- //
14
- // "fieldOverrides": [
15
- // {
16
- // "collectionGroup": "widgets",
17
- // "fieldPath": "baz",
18
- // "indexes": [
19
- // { "order": "ASCENDING", "queryScope": "COLLECTION" }
20
- // ]
21
- // },
22
- // ]
23
- // ]
24
- //
25
- // Example (Enterprise Edition):
26
- //
27
- // "indexes": [
28
- // {
29
- // "collectionGroup": "reviews",
30
- // "queryScope": "COLLECTION_GROUP",
31
- // "apiScope": "MONGODB_COMPATIBLE_API",
32
- // "density": "DENSE",
33
- // "multikey": false,
34
- // "fields": [
35
- // { "fieldPath": "baz", "mode": "ASCENDING" }
36
- // ]
37
- // },
38
- // {
39
- // "collectionGroup": "items",
40
- // "queryScope": "COLLECTION_GROUP",
41
- // "apiScope": "MONGODB_COMPATIBLE_API",
42
- // "density": "SPARSE_ANY",
43
- // "multikey": true,
44
- // "fields": [
45
- // { "fieldPath": "baz", "mode": "ASCENDING" }
46
- // ]
47
- // },
48
- // ]
49
- "indexes": [],
50
  "fieldOverrides": []
51
- }
 
1
  {
2
+ "indexes": [
3
+ {
4
+ "collectionGroup": "verifications",
5
+ "queryScope": "COLLECTION",
6
+ "fields": [
7
+ { "fieldPath": "verdict", "order": "ASCENDING" },
8
+ { "fieldPath": "timestamp", "order": "DESCENDING" }
9
+ ]
10
+ }
11
+ ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  "fieldOverrides": []
13
+ }
frontend/index.html CHANGED
@@ -2,9 +2,13 @@
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8" />
5
- <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>frontend</title>
 
 
 
 
8
  </head>
9
  <body>
10
  <div id="root"></div>
 
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/logo.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <meta name="theme-color" content="#0d0d0d" />
8
+ <meta name="description" content="PhilVerify β€” AI-powered fake news detection for Philippine content. Verify text, URLs, images, and video in Tagalog, English, and Taglish." />
9
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
+ <title>PhilVerify β€” Philippine Fake News Detector</title>
12
  </head>
13
  <body>
14
  <div id="root"></div>
frontend/package-lock.json CHANGED
@@ -27,6 +27,7 @@
27
  "eslint-plugin-react-hooks": "^7.0.1",
28
  "eslint-plugin-react-refresh": "^0.4.24",
29
  "globals": "^16.5.0",
 
30
  "vite": "^7.3.1"
31
  }
32
  },
@@ -4595,6 +4596,20 @@
4595
  "node": ">= 0.8.0"
4596
  }
4597
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4598
  "node_modules/undici-types": {
4599
  "version": "7.18.2",
4600
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
 
27
  "eslint-plugin-react-hooks": "^7.0.1",
28
  "eslint-plugin-react-refresh": "^0.4.24",
29
  "globals": "^16.5.0",
30
+ "typescript": "^5.9.3",
31
  "vite": "^7.3.1"
32
  }
33
  },
 
4596
  "node": ">= 0.8.0"
4597
  }
4598
  },
4599
+ "node_modules/typescript": {
4600
+ "version": "5.9.3",
4601
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
4602
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
4603
+ "dev": true,
4604
+ "license": "Apache-2.0",
4605
+ "bin": {
4606
+ "tsc": "bin/tsc",
4607
+ "tsserver": "bin/tsserver"
4608
+ },
4609
+ "engines": {
4610
+ "node": ">=14.17"
4611
+ }
4612
+ },
4613
  "node_modules/undici-types": {
4614
  "version": "7.18.2",
4615
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
frontend/package.json CHANGED
@@ -7,7 +7,8 @@
7
  "dev": "vite",
8
  "build": "vite build",
9
  "lint": "eslint .",
10
- "preview": "vite preview"
 
11
  },
12
  "dependencies": {
13
  "@tailwindcss/vite": "^4.2.1",
@@ -29,6 +30,7 @@
29
  "eslint-plugin-react-hooks": "^7.0.1",
30
  "eslint-plugin-react-refresh": "^0.4.24",
31
  "globals": "^16.5.0",
 
32
  "vite": "^7.3.1"
33
  }
34
  }
 
7
  "dev": "vite",
8
  "build": "vite build",
9
  "lint": "eslint .",
10
+ "preview": "vite preview",
11
+ "typecheck": "tsc --noEmit"
12
  },
13
  "dependencies": {
14
  "@tailwindcss/vite": "^4.2.1",
 
30
  "eslint-plugin-react-hooks": "^7.0.1",
31
  "eslint-plugin-react-refresh": "^0.4.24",
32
  "globals": "^16.5.0",
33
+ "typescript": "^5.9.3",
34
  "vite": "^7.3.1"
35
  }
36
  }
frontend/public/logo.svg ADDED
frontend/src/App.jsx CHANGED
@@ -4,18 +4,49 @@ import VerifyPage from './pages/VerifyPage.jsx'
4
  import HistoryPage from './pages/HistoryPage.jsx'
5
  import TrendsPage from './pages/TrendsPage.jsx'
6
 
 
 
 
 
 
 
 
 
 
7
  export default function App() {
8
  return (
9
  <BrowserRouter>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  <div style={{ minHeight: '100vh', background: 'var(--bg-base)' }}>
11
  <Navbar />
12
- <main>
13
  <Routes>
14
  <Route path="/" element={<VerifyPage />} />
15
  <Route path="/history" element={<HistoryPage />} />
16
  <Route path="/trends" element={<TrendsPage />} />
17
  </Routes>
18
- </main>
19
  </div>
20
  </BrowserRouter>
21
  )
 
4
  import HistoryPage from './pages/HistoryPage.jsx'
5
  import TrendsPage from './pages/TrendsPage.jsx'
6
 
7
+ /** Shared horizontal constraint β€” all pages + navbar use this */
8
+ export const PAGE_MAX_W = 960
9
+ export const PAGE_STYLE = {
10
+ maxWidth: PAGE_MAX_W,
11
+ width: '100%',
12
+ margin: '0 auto',
13
+ padding: '0 24px',
14
+ }
15
+
16
  export default function App() {
17
  return (
18
  <BrowserRouter>
19
+ {/* web-design-guidelines: skip link for keyboard/screen-reader users */}
20
+ <a
21
+ href="#main-content"
22
+ className="sr-only focus-visible:not-sr-only"
23
+ style={{
24
+ position: 'fixed',
25
+ top: 8,
26
+ left: 8,
27
+ zIndex: 9999,
28
+ background: 'var(--accent-red)',
29
+ color: '#fff',
30
+ padding: '8px 16px',
31
+ fontFamily: 'var(--font-display)',
32
+ fontSize: 12,
33
+ fontWeight: 700,
34
+ letterSpacing: '0.08em',
35
+ borderRadius: 2,
36
+ textDecoration: 'none',
37
+ }}
38
+ >
39
+ Skip to content
40
+ </a>
41
  <div style={{ minHeight: '100vh', background: 'var(--bg-base)' }}>
42
  <Navbar />
43
+ <div id="main-content">
44
  <Routes>
45
  <Route path="/" element={<VerifyPage />} />
46
  <Route path="/history" element={<HistoryPage />} />
47
  <Route path="/trends" element={<TrendsPage />} />
48
  </Routes>
49
+ </div>
50
  </div>
51
  </BrowserRouter>
52
  )
frontend/src/api.js CHANGED
@@ -1,6 +1,16 @@
1
  /** PhilVerify API client β€” proxied through Vite to http://localhost:8000 */
2
  const BASE = '/api'
3
 
 
 
 
 
 
 
 
 
 
 
4
  async function post(path, body) {
5
  const res = await fetch(`${BASE}${path}`, {
6
  method: 'POST',
@@ -9,7 +19,9 @@ async function post(path, body) {
9
  })
10
  if (!res.ok) {
11
  const err = await res.json().catch(() => ({}))
12
- throw new Error(err.detail || `HTTP ${res.status}`)
 
 
13
  }
14
  return res.json()
15
  }
@@ -18,7 +30,7 @@ async function postForm(path, formData) {
18
  const res = await fetch(`${BASE}${path}`, { method: 'POST', body: formData })
19
  if (!res.ok) {
20
  const err = await res.json().catch(() => ({}))
21
- throw new Error(err.detail || `HTTP ${res.status}`)
22
  }
23
  return res.json()
24
  }
@@ -26,8 +38,13 @@ async function postForm(path, formData) {
26
  async function get(path, params = {}) {
27
  const qs = new URLSearchParams(params).toString()
28
  const res = await fetch(`${BASE}${path}${qs ? '?' + qs : ''}`)
29
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
30
- return res.json()
 
 
 
 
 
31
  }
32
 
33
  export const api = {
@@ -36,6 +53,8 @@ export const api = {
36
  verifyImage: (file) => { const f = new FormData(); f.append('file', file); return postForm('/verify/image', f) },
37
  verifyVideo: (file) => { const f = new FormData(); f.append('file', file); return postForm('/verify/video', f) },
38
  history: (params) => get('/history', params),
 
39
  trends: () => get('/trends'),
40
  health: () => get('/health'),
 
41
  }
 
1
  /** PhilVerify API client β€” proxied through Vite to http://localhost:8000 */
2
  const BASE = '/api'
3
 
4
+ function _detailToString(detail, status) {
5
+ if (!detail) return `HTTP ${status}`
6
+ if (typeof detail === 'string') return detail
7
+ if (Array.isArray(detail)) {
8
+ // FastAPI validation errors: [{loc, msg, type}, ...]
9
+ return detail.map(d => d.msg || JSON.stringify(d)).join('; ')
10
+ }
11
+ return JSON.stringify(detail)
12
+ }
13
+
14
  async function post(path, body) {
15
  const res = await fetch(`${BASE}${path}`, {
16
  method: 'POST',
 
19
  })
20
  if (!res.ok) {
21
  const err = await res.json().catch(() => ({}))
22
+ const e = new Error(_detailToString(err.detail, res.status))
23
+ e.isBackendError = true // backend responded β€” not a connection failure
24
+ throw e
25
  }
26
  return res.json()
27
  }
 
30
  const res = await fetch(`${BASE}${path}`, { method: 'POST', body: formData })
31
  if (!res.ok) {
32
  const err = await res.json().catch(() => ({}))
33
+ throw new Error(_detailToString(err.detail, res.status))
34
  }
35
  return res.json()
36
  }
 
38
  async function get(path, params = {}) {
39
  const qs = new URLSearchParams(params).toString()
40
  const res = await fetch(`${BASE}${path}${qs ? '?' + qs : ''}`)
41
+ if (!res.ok) {
42
+ const err = await res.json().catch(() => ({}))
43
+ throw new Error(_detailToString(err.detail, res.status))
44
+ }
45
+ return res.json().catch(() => {
46
+ throw new Error('API returned an unexpected response β€” the server may be starting up. Please try again.')
47
+ })
48
  }
49
 
50
  export const api = {
 
53
  verifyImage: (file) => { const f = new FormData(); f.append('file', file); return postForm('/verify/image', f) },
54
  verifyVideo: (file) => { const f = new FormData(); f.append('file', file); return postForm('/verify/video', f) },
55
  history: (params) => get('/history', params),
56
+ historyDetail: (id) => get(`/history/${id}`),
57
  trends: () => get('/trends'),
58
  health: () => get('/health'),
59
+ preview: (url) => get('/preview', { url }),
60
  }
frontend/src/api.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * PhilVerify API client β€” proxied through Vite to http://localhost:8000
3
+ * Typed via src/types.ts which mirrors api/schemas.py
4
+ */
5
+ import type {
6
+ VerificationResponse,
7
+ HistoryParams,
8
+ HistoryResponse,
9
+ TrendsResponse,
10
+ HealthResponse,
11
+ ApiError as ApiErrorType,
12
+ } from './types'
13
+ import { ApiError } from './types'
14
+
15
+ const BASE = '/api'
16
+
17
+ // ── Internal fetch helpers ─────────────────────────────────────────────────────
18
+
19
+ async function post<T>(path: string, body: unknown): Promise<T> {
20
+ const res = await fetch(`${BASE}${path}`, {
21
+ method: 'POST',
22
+ headers: { 'Content-Type': 'application/json' },
23
+ body: JSON.stringify(body),
24
+ })
25
+ if (!res.ok) {
26
+ const err = await res.json().catch(() => ({})) as { detail?: string }
27
+ throw new ApiError(err.detail ?? `HTTP ${res.status}`, true)
28
+ }
29
+ return res.json() as Promise<T>
30
+ }
31
+
32
+ async function postForm<T>(path: string, formData: FormData): Promise<T> {
33
+ const res = await fetch(`${BASE}${path}`, { method: 'POST', body: formData })
34
+ if (!res.ok) {
35
+ const err = await res.json().catch(() => ({})) as { detail?: string }
36
+ throw new ApiError(err.detail ?? `HTTP ${res.status}`, true)
37
+ }
38
+ return res.json() as Promise<T>
39
+ }
40
+
41
+ async function get<T>(path: string, params: Record<string, string | number | undefined> = {}): Promise<T> {
42
+ const defined = Object.fromEntries(
43
+ Object.entries(params).filter(([, v]) => v !== undefined),
44
+ ) as Record<string, string>
45
+ const qs = new URLSearchParams(defined).toString()
46
+ const res = await fetch(`${BASE}${path}${qs ? '?' + qs : ''}`)
47
+ if (!res.ok) throw new ApiError(`HTTP ${res.status}`)
48
+ return res.json() as Promise<T>
49
+ }
50
+
51
+ // ── Public API surface ─────────────────────────────────────────────────────────
52
+
53
+ export const api = {
54
+ verifyText: (text: string): Promise<VerificationResponse> =>
55
+ post('/verify/text', { text }),
56
+
57
+ verifyUrl: (url: string): Promise<VerificationResponse> =>
58
+ post('/verify/url', { url }),
59
+
60
+ verifyImage: (file: File): Promise<VerificationResponse> => {
61
+ const f = new FormData()
62
+ f.append('file', file)
63
+ return postForm('/verify/image', f)
64
+ },
65
+
66
+ verifyVideo: (file: File): Promise<VerificationResponse> => {
67
+ const f = new FormData()
68
+ f.append('file', file)
69
+ return postForm('/verify/video', f)
70
+ },
71
+
72
+ history: (params?: HistoryParams): Promise<HistoryResponse> =>
73
+ get('/history', params as Record<string, string | number | undefined>),
74
+
75
+ trends: (): Promise<TrendsResponse> =>
76
+ get('/trends'),
77
+
78
+ health: (): Promise<HealthResponse> =>
79
+ get('/health'),
80
+ } as const
81
+
82
+ // Re-export error class for consumers
83
+ export { ApiError } from './types'
84
+ export type { ApiErrorType }
frontend/src/components/Navbar.jsx CHANGED
@@ -1,5 +1,6 @@
1
- import { NavLink } from 'react-router-dom'
2
  import { Radar, Clock, TrendingUp, ShieldCheck } from 'lucide-react'
 
3
 
4
  const NAV_LINKS = [
5
  { to: '/', icon: ShieldCheck, label: 'Verify' },
@@ -9,55 +10,69 @@ const NAV_LINKS = [
9
 
10
  export default function Navbar() {
11
  return (
12
- /* semantic <header> β€” web-design-guidelines: semantic HTML */
13
  <header
14
  role="banner"
15
  style={{ background: 'var(--bg-surface)', borderBottom: '1px solid var(--border)' }}
16
- className="sticky top-0 z-50 flex items-center justify-between px-6 h-14"
17
  >
18
- {/* Logo */}
19
- <div className="flex items-center gap-2" aria-label="PhilVerify home">
20
- <Radar size={18} style={{ color: 'var(--accent-red)' }} aria-hidden="true" />
21
- <span className="font-display font-bold text-sm tracking-wide"
22
- style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.05em' }}>
23
- PHIL<span style={{ color: 'var(--accent-red)' }}>VERIFY</span>
24
- </span>
25
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
- {/* Nav β€” web-design-guidelines: use <nav> for navigation */}
28
- <nav aria-label="Main navigation">
29
- <ul className="flex items-center gap-1" role="list">
30
- {NAV_LINKS.map(({ to, icon: Icon, label }) => (
31
- <li key={to}>
32
- <NavLink to={to} end={to === '/'}>
33
- {({ isActive }) => (
34
- <div
35
- className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold transition-colors"
36
- style={{
37
- fontFamily: 'var(--font-display)',
38
- letterSpacing: '0.08em',
39
- color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
40
- borderBottom: isActive ? '2px solid var(--accent-red)' : '2px solid transparent',
41
- }}
42
- >
43
- {/* aria-hidden on decorative icons β€” web-design-guidelines */}
44
- <Icon size={13} aria-hidden="true" />
45
- {label}
46
- </div>
47
- )}
48
- </NavLink>
49
- </li>
50
- ))}
51
- </ul>
52
- </nav>
 
 
53
 
54
- {/* Live indicator */}
55
- <div className="flex items-center gap-1.5 text-xs tabular"
56
- style={{ color: 'var(--text-muted)' }}
57
- aria-label="API status: live">
58
- <span className="w-1.5 h-1.5 rounded-full" aria-hidden="true"
59
- style={{ background: 'var(--accent-green)' }} />
60
- LIVE
 
61
  </div>
62
  </header>
63
  )
 
1
+ import { NavLink, Link } from 'react-router-dom'
2
  import { Radar, Clock, TrendingUp, ShieldCheck } from 'lucide-react'
3
+ import { PAGE_STYLE } from '../App.jsx'
4
 
5
  const NAV_LINKS = [
6
  { to: '/', icon: ShieldCheck, label: 'Verify' },
 
10
 
11
  export default function Navbar() {
12
  return (
 
13
  <header
14
  role="banner"
15
  style={{ background: 'var(--bg-surface)', borderBottom: '1px solid var(--border)' }}
16
+ className="sticky top-0 z-50 h-14"
17
  >
18
+ {/* Inner content aligned to same width as page content */}
19
+ <div style={{
20
+ ...PAGE_STYLE,
21
+ display: 'flex',
22
+ alignItems: 'center',
23
+ justifyContent: 'space-between',
24
+ height: '100%',
25
+ }}>
26
+ {/* Logo β€” Link to home */}
27
+ <Link
28
+ to="/"
29
+ className="flex items-center gap-2"
30
+ aria-label="PhilVerify home"
31
+ style={{ textDecoration: 'none' }}
32
+ >
33
+ <Radar size={18} style={{ color: 'var(--accent-red)' }} aria-hidden="true" />
34
+ <span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 13, letterSpacing: '0.05em', color: 'var(--text-primary)' }}>
35
+ PHIL<span style={{ color: 'var(--accent-red)' }}>VERIFY</span>
36
+ </span>
37
+ </Link>
38
 
39
+ {/* Nav */}
40
+ <nav aria-label="Main navigation">
41
+ <ul className="flex items-center gap-2" role="list">
42
+ {NAV_LINKS.map(({ to, icon: Icon, label }) => (
43
+ <li key={to}>
44
+ <NavLink to={to} end={to === '/'} className="nav-link-item">
45
+ {({ isActive }) => (
46
+ <div
47
+ className="flex items-center gap-2 px-4 py-2 text-xs font-semibold transition-colors"
48
+ style={{
49
+ fontFamily: 'var(--font-display)',
50
+ letterSpacing: '0.08em',
51
+ color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
52
+ borderBottom: isActive ? '2px solid var(--accent-red)' : '2px solid transparent',
53
+ minHeight: 44,
54
+ display: 'flex',
55
+ alignItems: 'center',
56
+ }}
57
+ >
58
+ <Icon size={13} aria-hidden="true" />
59
+ {label}
60
+ </div>
61
+ )}
62
+ </NavLink>
63
+ </li>
64
+ ))}
65
+ </ul>
66
+ </nav>
67
 
68
+ {/* Live indicator */}
69
+ <div className="flex items-center gap-1.5 text-xs tabular"
70
+ style={{ color: 'var(--text-muted)' }}
71
+ aria-label="API status: live">
72
+ <span className="w-1.5 h-1.5 rounded-full" aria-hidden="true"
73
+ style={{ background: 'var(--accent-green)' }} />
74
+ LIVE
75
+ </div>
76
  </div>
77
  </header>
78
  )
frontend/src/components/SkeletonCard.jsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * SkeletonCard β€” Phase 8: Loading state skeleton screens
3
+ * Used while the verification API call is in-flight.
4
+ * web-design-guidelines: content-jumping β€” reserve space for async content.
5
+ * web-design-guidelines: prefers-reduced-motion β€” skip animation if user prefers.
6
+ */
7
+ export default function SkeletonCard({ lines = 3, height = null, className = '' }) {
8
+ return (
9
+ <div className={`card p-5 ${className}`} aria-hidden="true">
10
+ {height ? (
11
+ <SkeletonBar style={{ height, borderRadius: 4 }} />
12
+ ) : (
13
+ <div className="space-y-3">
14
+ {Array.from({ length: lines }).map((_, i) => (
15
+ <SkeletonBar key={i}
16
+ style={{
17
+ height: i === 0 ? 12 : 10,
18
+ width: i === lines - 1 ? '60%' : '100%',
19
+ }}
20
+ />
21
+ ))}
22
+ </div>
23
+ )}
24
+ </div>
25
+ )
26
+ }
27
+
28
+ function SkeletonBar({ style = {} }) {
29
+ return (
30
+ <div
31
+ style={{
32
+ background: 'var(--bg-elevated)',
33
+ borderRadius: 3,
34
+ overflow: 'hidden',
35
+ ...style,
36
+ }}
37
+ >
38
+ <div style={{
39
+ width: '100%',
40
+ height: '100%',
41
+ background: 'linear-gradient(90deg, transparent 0%, rgba(245,240,232,0.05) 50%, transparent 100%)',
42
+ animation: 'shimmer 1.5s infinite',
43
+ }} />
44
+ </div>
45
+ )
46
+ }
frontend/src/components/WordHighlighter.jsx ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * WordHighlighter β€” Phase 8: Suspicious Word Highlighter
3
+ * Highlights suspicious / clickbait trigger words in the claim text.
4
+ * Uses triggered_features from Layer 1 as hint words.
5
+ *
6
+ * architect-review: pure presentational, no side-effects.
7
+ * web-design-guidelines: uses <mark> with visible styles, screen-reader friendly.
8
+ */
9
+
10
+ // Common suspicious/misinformation signal words to highlight
11
+ const SUSPICIOUS_PATTERNS = [
12
+ // English signals
13
+ /\b(shocking|exposed|revealed|secret|hoax|fake|false|confirmed|breaking|urgent|emergency|exclusive|banned|cover[\s-]?up|conspiracy|miracle|crisis|scandal|leaked|hidden|truth|they don't want you to know)\b/gi,
14
+ // Filipino signals
15
+ /\b(grabe|nakakagulat|totoo|peke|huwag maniwala|nagsisinungaling|lihim|inilabas|natuklasan|katotohanan|panlilinlang|kahirap-hirap|itinatago)\b/gi,
16
+ ]
17
+
18
+ function getHighlightedSegments(text, triggerWords = []) {
19
+ if (!text) return []
20
+
21
+ // Build a combined pattern from both static patterns + dynamic trigger words
22
+ const allPatterns = [...SUSPICIOUS_PATTERNS]
23
+
24
+ if (triggerWords.length > 0) {
25
+ const escaped = triggerWords.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
26
+ allPatterns.push(new RegExp(`\\b(${escaped.join('|')})\\b`, 'gi'))
27
+ }
28
+
29
+ // Find all match intervals
30
+ const matches = []
31
+ for (const pattern of allPatterns) {
32
+ pattern.lastIndex = 0
33
+ let m
34
+ while ((m = pattern.exec(text)) !== null) {
35
+ matches.push({ start: m.index, end: m.index + m[0].length, word: m[0] })
36
+ }
37
+ }
38
+
39
+ if (matches.length === 0) return [{ text, highlighted: false }]
40
+
41
+ // Sort + merge overlapping intervals
42
+ matches.sort((a, b) => a.start - b.start)
43
+ const merged = []
44
+ for (const m of matches) {
45
+ const last = merged[merged.length - 1]
46
+ if (last && m.start <= last.end) {
47
+ last.end = Math.max(last.end, m.end)
48
+ } else {
49
+ merged.push({ ...m })
50
+ }
51
+ }
52
+
53
+ // Build segments
54
+ const segments = []
55
+ let cursor = 0
56
+ for (const { start, end, word } of merged) {
57
+ if (cursor < start) segments.push({ text: text.slice(cursor, start), highlighted: false })
58
+ segments.push({ text: text.slice(start, end), highlighted: true, word })
59
+ cursor = end
60
+ }
61
+ if (cursor < text.length) segments.push({ text: text.slice(cursor), highlighted: false })
62
+
63
+ return segments
64
+ }
65
+
66
+ export default function WordHighlighter({ text = '', triggerWords = [], className = '' }) {
67
+ const segments = getHighlightedSegments(text, triggerWords)
68
+ const hitCount = segments.filter(s => s.highlighted).length
69
+
70
+ if (segments.length === 1 && !segments[0].highlighted) {
71
+ // No suspicious words found
72
+ return (
73
+ <p className={className}
74
+ style={{ fontFamily: 'var(--font-body)', lineHeight: 1.7, color: 'var(--text-primary)' }}>
75
+ {text}
76
+ </p>
77
+ )
78
+ }
79
+
80
+ return (
81
+ <div>
82
+ {hitCount > 0 && (
83
+ <p className="text-xs mb-2"
84
+ style={{
85
+ color: 'var(--accent-gold)',
86
+ fontFamily: 'var(--font-display)',
87
+ letterSpacing: '0.08em',
88
+ }}
89
+ aria-live="polite">
90
+ ⚠ {hitCount} suspicious signal{hitCount !== 1 ? 's' : ''} detected
91
+ </p>
92
+ )}
93
+ <p className={className} style={{ fontFamily: 'var(--font-body)', lineHeight: 1.7, color: 'var(--text-primary)' }}>
94
+ {segments.map((seg, i) =>
95
+ seg.highlighted ? (
96
+ <mark key={i}
97
+ title={`Suspicious signal: "${seg.word}"`}
98
+ style={{
99
+ background: 'rgba(220, 38, 38, 0.18)',
100
+ color: '#f87171',
101
+ borderRadius: 2,
102
+ padding: '0 2px',
103
+ fontWeight: 600,
104
+ outline: '1px solid rgba(220,38,38,0.3)',
105
+ }}>
106
+ {seg.text}
107
+ </mark>
108
+ ) : (
109
+ <span key={i}>{seg.text}</span>
110
+ )
111
+ )}
112
+ </p>
113
+ </div>
114
+ )
115
+ }
frontend/src/firebase.js CHANGED
@@ -13,14 +13,28 @@ const firebaseConfig = {
13
  const app = initializeApp(firebaseConfig)
14
  export const db = getFirestore(app)
15
 
16
- /** Subscribe to the 20 most recent verifications in real-time. */
17
- export function subscribeToHistory(callback) {
 
 
 
 
 
18
  const q = query(
19
  collection(db, 'verifications'),
20
  orderBy('timestamp', 'desc'),
21
  limit(20)
22
  )
23
- return onSnapshot(q, (snap) => {
24
- callback(snap.docs.map(d => ({ id: d.id, ...d.data() })))
25
- })
 
 
 
 
 
 
 
 
 
26
  }
 
13
  const app = initializeApp(firebaseConfig)
14
  export const db = getFirestore(app)
15
 
16
+ /**
17
+ * Subscribe to the 20 most recent verifications in real-time.
18
+ * @param {Function} callback - called with array of docs on each update
19
+ * @param {Function} [onError] - called with Error when Firestore is unreachable (e.g. ad blocker)
20
+ * @returns unsubscribe function
21
+ */
22
+ export function subscribeToHistory(callback, onError) {
23
  const q = query(
24
  collection(db, 'verifications'),
25
  orderBy('timestamp', 'desc'),
26
  limit(20)
27
  )
28
+ return onSnapshot(
29
+ q,
30
+ (snap) => {
31
+ callback(snap.docs.map(d => ({ id: d.id, ...d.data() })))
32
+ },
33
+ (error) => {
34
+ // Firestore blocked (ERR_BLOCKED_BY_CLIENT from ad blockers) or
35
+ // permission denied β€” fail fast and let caller fall back to REST.
36
+ console.warn('[PhilVerify] Firestore unavailable:', error.code || error.message)
37
+ if (onError) onError(error)
38
+ }
39
+ )
40
  }
frontend/src/index.css CHANGED
@@ -97,6 +97,13 @@ h4 {
97
  border-radius: 4px;
98
  }
99
 
 
 
 
 
 
 
 
100
  /* ── Touch (web-design-guidelines) ───────────────────── */
101
  button,
102
  a,
@@ -161,7 +168,17 @@ a,
161
  /* ── Left-rule accent divider ────────────────────────── */
162
  .ruled {
163
  border-left: 3px solid var(--accent-red);
164
- padding-left: 12px;
 
 
 
 
 
 
 
 
 
 
165
  }
166
 
167
  /* ── Animations (frontend-design: one orchestrated reveal) ─── */
@@ -222,6 +239,16 @@ a,
222
  .bar-fill {
223
  animation: barGrow 0.9s cubic-bezier(0.16, 1, 0.3, 1) forwards;
224
  }
 
 
 
 
 
 
 
 
 
 
225
  }
226
 
227
  /* Fallback: no animation for reduced-motion users */
 
97
  border-radius: 4px;
98
  }
99
 
100
+ /* Textarea: use box-shadow ring for :focus-visible so keyboard users see focus;
101
+ border-color animation still handled by onFocus/onBlur JS handlers */
102
+ .claim-textarea:focus-visible {
103
+ outline: none;
104
+ box-shadow: 0 0 0 2px var(--accent-red);
105
+ }
106
+
107
  /* ── Touch (web-design-guidelines) ───────────────────── */
108
  button,
109
  a,
 
168
  /* ── Left-rule accent divider ────────────────────────── */
169
  .ruled {
170
  border-left: 3px solid var(--accent-red);
171
+ padding-left: 16px;
172
+ }
173
+
174
+ /* ── Nav link hover ──────────────────────────────────────── */
175
+ a.nav-link-item {
176
+ text-decoration: none;
177
+ display: block;
178
+ }
179
+
180
+ a.nav-link-item:hover > div {
181
+ color: var(--text-primary) !important;
182
  }
183
 
184
  /* ── Animations (frontend-design: one orchestrated reveal) ─── */
 
239
  .bar-fill {
240
  animation: barGrow 0.9s cubic-bezier(0.16, 1, 0.3, 1) forwards;
241
  }
242
+
243
+ @keyframes shimmer {
244
+ 0% {
245
+ transform: translateX(-100%);
246
+ }
247
+
248
+ 100% {
249
+ transform: translateX(100%);
250
+ }
251
+ }
252
  }
253
 
254
  /* Fallback: no animation for reduced-motion users */
frontend/src/pages/HistoryPage.jsx CHANGED
@@ -1,53 +1,480 @@
1
- import { useEffect, useState } from 'react'
2
  import { subscribeToHistory } from '../firebase.js'
3
- import { timeAgo } from '../utils/format.js'
 
 
4
  import VerdictBadge from '../components/VerdictBadge.jsx'
5
- import { Clock, RefreshCw } from 'lucide-react'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  export default function HistoryPage() {
8
  const [entries, setEntries] = useState([])
9
  const [loading, setLoading] = useState(true)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  useEffect(() => {
12
- /** Real-time Firestore subscription */
13
- const unsub = subscribeToHistory((docs) => {
14
- setEntries(docs)
15
- setLoading(false)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  })
17
- return unsub
18
- }, [])
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  return (
21
- <main className="max-w-3xl mx-auto px-4 py-8 space-y-6">
22
- <header className="ruled fade-up-1 flex items-end justify-between">
 
 
23
  <div>
24
- <h1 style={{ fontSize: 32, fontFamily: 'var(--font-display)' }}>History</h1>
25
  <p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
26
- Real-time from Firestore
27
- {/* web-design-guidelines: tabular-nums for counts */}
28
- {' β€” '}<span className="tabular">{entries.length}</span> records
29
  </p>
30
  </div>
31
- {/* aria-label on icon wrapper */}
32
  <div className="flex items-center gap-1.5 text-xs"
33
- style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.1em' }}
34
- aria-label="Data is refreshing live">
35
- <RefreshCw size={11} aria-hidden="true" />
36
- LIVE
 
 
 
 
37
  </div>
38
  </header>
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  {loading && (
41
- <p className="text-center py-16 text-sm" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}
42
- aria-live="polite">
43
- Loading history…
44
- </p>
45
  )}
46
 
 
47
  {!loading && entries.length === 0 && (
48
- <div className="card p-12 text-center fade-up">
49
- <Clock size={28} aria-hidden="true"
50
- style={{ color: 'var(--text-muted)', margin: '0 auto 12px' }} />
51
  <p style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)', fontWeight: 700 }}>
52
  No verifications yet
53
  </p>
@@ -57,47 +484,86 @@ export default function HistoryPage() {
57
  </div>
58
  )}
59
 
60
- {/* web-design-guidelines: <ul> list for screen readers */}
61
- {entries.length > 0 && (
62
- <ul className="space-y-2" role="list" aria-label="Verification history" aria-live="polite">
63
- {entries.map((e, i) => (
64
- <li key={e.id} className="card p-4 fade-up"
65
- style={{ animationDelay: `${Math.min(i * 30, 300)}ms` }}>
66
- <div className="flex items-start justify-between gap-3">
67
- <div className="flex-1 min-w-0">
68
- {/* web-design-guidelines: flex children need min-w-0 for truncation */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  <p className="text-sm truncate" style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}>
70
- {e.text_preview || 'No text preview'}
71
  </p>
72
- <div className="flex items-center gap-2 mt-1.5">
73
- <span className="text-xs px-1.5 py-0.5"
74
- style={{
75
- background: 'var(--bg-elevated)',
76
- color: 'var(--text-muted)',
77
- fontFamily: 'var(--font-display)',
78
- letterSpacing: '0.08em',
79
- fontSize: 10,
80
- borderRadius: 2,
81
- }}>
82
- {e.input_type?.toUpperCase() ?? 'TEXT'}
83
- </span>
84
- {/* web-design-guidelines: Intl.DateTimeFormat via timeAgo util */}
85
- <time className="text-xs tabular" style={{ color: 'var(--text-muted)' }}
86
- dateTime={e.timestamp}>
87
- {timeAgo(e.timestamp)}
88
- </time>
89
- </div>
90
- </div>
91
- <div className="flex items-center gap-3 shrink-0">
92
- <span className="tabular text-sm font-bold" style={{ color: 'var(--text-muted)' }}>
93
- {Math.round(e.final_score)}
94
  </span>
95
- <VerdictBadge verdict={e.verdict} size="sm" />
96
  </div>
97
- </div>
98
- </li>
99
- ))}
100
- </ul>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  )}
102
  </main>
103
  )
 
1
+ import { useEffect, useState, useCallback, useMemo } from 'react'
2
  import { subscribeToHistory } from '../firebase.js'
3
+ import { timeAgo, VERDICT_MAP, scoreColor } from '../utils/format.js'
4
+ import { PAGE_STYLE } from '../App.jsx'
5
+ import { api } from '../api'
6
  import VerdictBadge from '../components/VerdictBadge.jsx'
7
+ import SkeletonCard from '../components/SkeletonCard.jsx'
8
+ import { Clock, RefreshCw, WifiOff, ChevronUp, ChevronDown, ChevronsUpDown, X, Loader2, FileText, Globe, ImageIcon, Video } from 'lucide-react'
9
+
10
+
11
+ /* ── Sort icon helper ─────────────────────────────────── */
12
+ function SortIcon({ field, current, dir }) {
13
+ if (current !== field) return <ChevronsUpDown size={10} aria-hidden="true" style={{ opacity: 0.3 }} />
14
+ return dir === 'asc'
15
+ ? <ChevronUp size={10} aria-hidden="true" />
16
+ : <ChevronDown size={10} aria-hidden="true" />
17
+ }
18
+
19
+ /* ── Column header button ─────────────────────────────── */
20
+ function ColHeader({ children, field, sort, dir, onSort }) {
21
+ const active = sort === field
22
+ return (
23
+ <button
24
+ onClick={() => onSort(field)}
25
+ className="flex items-center gap-1 text-xs font-semibold uppercase"
26
+ style={{
27
+ fontFamily: 'var(--font-display)',
28
+ letterSpacing: '0.1em',
29
+ color: active ? 'var(--text-primary)' : 'var(--text-muted)',
30
+ background: 'none',
31
+ border: 'none',
32
+ cursor: 'pointer',
33
+ padding: 0,
34
+ minHeight: 44,
35
+ }}
36
+ aria-sort={active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'}>
37
+ {children}
38
+ <SortIcon field={field} current={sort} dir={dir} />
39
+ </button>
40
+ )
41
+ }
42
+
43
+ /* ── Input-type icon ─────────────────────────────────── */
44
+ function InputTypeIcon({ type, size = 12 }) {
45
+ const icons = { url: Globe, image: ImageIcon, video: Video, text: FileText }
46
+ const Icon = icons[type] ?? FileText
47
+ return <Icon size={size} aria-hidden="true" />
48
+ }
49
+
50
+ /* ── Detail Modal ────────────────────────────────────── */
51
+ function DetailModal({ id, onClose }) {
52
+ const [data, setData] = useState(null)
53
+ const [loadingDetail, setLoadingDetail] = useState(true)
54
+ const [error, setError] = useState(null)
55
+
56
+ useEffect(() => {
57
+ setLoadingDetail(true); setError(null)
58
+ api.historyDetail(id)
59
+ .then(setData)
60
+ .catch(e => setError(e.message ?? 'Failed to load'))
61
+ .finally(() => setLoadingDetail(false))
62
+ }, [id])
63
+
64
+ useEffect(() => {
65
+ function onKey(e) { if (e.key === 'Escape') onClose() }
66
+ window.addEventListener('keydown', onKey)
67
+ return () => window.removeEventListener('keydown', onKey)
68
+ }, [onClose])
69
+
70
+ const s = scoreColor(data?.final_score)
71
+ const layer1 = data?.layer1
72
+ const layer2 = data?.layer2
73
+ const entities = data?.entities?.entities ?? []
74
+
75
+ return (
76
+ <div
77
+ role="dialog"
78
+ aria-modal="true"
79
+ aria-label="Verification detail"
80
+ onClick={e => { if (e.target === e.currentTarget) onClose() }}
81
+ style={{
82
+ position: 'fixed', inset: 0, zIndex: 1000,
83
+ background: 'rgba(0,0,0,0.6)',
84
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
85
+ padding: '16px',
86
+ backdropFilter: 'blur(4px)',
87
+ }}>
88
+ <div className="card"
89
+ style={{
90
+ width: '100%', maxWidth: 600,
91
+ maxHeight: '90vh', overflowY: 'auto',
92
+ padding: 0,
93
+ position: 'relative',
94
+ borderColor: 'var(--border-light)',
95
+ display: 'flex', flexDirection: 'column',
96
+ }}>
97
+ {/* Header */}
98
+ <div className="flex items-center justify-between"
99
+ style={{
100
+ padding: '16px 20px',
101
+ borderBottom: '1px solid var(--border)',
102
+ background: 'var(--bg-elevated)',
103
+ position: 'sticky', top: 0, zIndex: 1,
104
+ }}>
105
+ <div className="flex items-center gap-2">
106
+ <span className="text-xs font-semibold uppercase"
107
+ style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
108
+ Verification Detail
109
+ </span>
110
+ {data && <span className="text-xs tabular"
111
+ style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-muted)', fontSize: 10 }}>
112
+ {id.slice(0, 8)}…
113
+ </span>}
114
+ </div>
115
+ <button onClick={onClose} aria-label="Close"
116
+ style={{
117
+ background: 'none', border: 'none', cursor: 'pointer',
118
+ color: 'var(--text-muted)', display: 'flex', alignItems: 'center',
119
+ padding: 4, borderRadius: 4,
120
+ }}>
121
+ <X size={16} />
122
+ </button>
123
+ </div>
124
+
125
+ {/* Body */}
126
+ <div style={{ padding: '20px', display: 'flex', flexDirection: 'column', gap: 18 }}>
127
+
128
+ {loadingDetail && (
129
+ <div className="flex items-center justify-center" style={{ padding: 40, gap: 8, color: 'var(--text-muted)' }}>
130
+ <Loader2 size={18} className="animate-spin" />
131
+ <span className="text-sm" style={{ fontFamily: 'var(--font-body)' }}>Loading…</span>
132
+ </div>
133
+ )}
134
+
135
+ {error && (
136
+ <div className="text-sm text-center" style={{ color: 'var(--fake)', padding: 32, fontFamily: 'var(--font-body)' }}>
137
+ {error}
138
+ </div>
139
+ )}
140
+
141
+ {data && !loadingDetail && (<>
142
+ {/* Verdict + Score row */}
143
+ <div className="flex items-center gap-3 flex-wrap">
144
+ <VerdictBadge verdict={data.verdict} size="md" />
145
+ <span className="tabular font-bold"
146
+ style={{ fontSize: 28, fontFamily: 'var(--font-mono)', color: s, lineHeight: 1 }}>
147
+ {Math.round(data.final_score)}
148
+ </span>
149
+ <span className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
150
+ score
151
+ </span>
152
+ <span className="tabular text-sm" style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)', marginLeft: 'auto' }}>
153
+ {Math.round(data.confidence)}% confidence
154
+ </span>
155
+ </div>
156
+
157
+ {/* Meta row */}
158
+ <div className="flex items-center flex-wrap gap-2">
159
+ <span className="flex items-center gap-1 text-xs px-1.5 py-0.5"
160
+ style={{
161
+ background: 'var(--bg-elevated)', border: '1px solid var(--border)',
162
+ borderRadius: 3, fontFamily: 'var(--font-display)', letterSpacing: '0.08em',
163
+ color: 'var(--text-muted)',
164
+ }}>
165
+ <InputTypeIcon type={data.input_type} />
166
+ {data.input_type?.toUpperCase()}
167
+ </span>
168
+ {data.language && (
169
+ <span className="text-xs px-1.5 py-0.5"
170
+ style={{
171
+ background: 'var(--bg-elevated)', border: '1px solid var(--border)',
172
+ borderRadius: 3, fontFamily: 'var(--font-display)', letterSpacing: '0.08em',
173
+ color: 'var(--text-muted)', textTransform: 'uppercase',
174
+ }}>
175
+ {data.language}
176
+ </span>
177
+ )}
178
+ <time className="text-xs tabular ml-auto"
179
+ style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}
180
+ dateTime={data.timestamp}>
181
+ {new Date(data.timestamp).toLocaleString()}
182
+ </time>
183
+ </div>
184
+
185
+ {/* Claim / text */}
186
+ {(data.claim_used || data.text_preview) && (
187
+ <div style={{ borderLeft: '2px solid var(--border-light)', paddingLeft: 12 }}>
188
+ <p className="text-xs font-semibold uppercase mb-1"
189
+ style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
190
+ Claim
191
+ </p>
192
+ <p className="text-sm" style={{ fontFamily: 'var(--font-body)', color: 'var(--text-primary)', lineHeight: 1.6 }}>
193
+ {data.claim_used || data.text_preview}
194
+ </p>
195
+ </div>
196
+ )}
197
+
198
+ {/* Layer 1 */}
199
+ {layer1 && (
200
+ <div style={{ background: 'var(--bg-elevated)', borderRadius: 4, padding: '12px 14px', border: '1px solid var(--border)' }}>
201
+ <p className="text-xs font-semibold uppercase mb-2"
202
+ style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
203
+ Layer 1 β€” NLP Analysis
204
+ </p>
205
+ <div className="flex items-center gap-3 mb-2">
206
+ <VerdictBadge verdict={layer1.verdict} size="sm" />
207
+ <span className="tabular text-sm" style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)' }}>
208
+ {Math.round(layer1.confidence)}% confidence
209
+ </span>
210
+ </div>
211
+ {layer1.triggered_features?.length > 0 && (
212
+ <div className="flex flex-wrap gap-1 mt-2">
213
+ {layer1.triggered_features.map(f => (
214
+ <span key={f} className="text-xs px-2 py-0.5"
215
+ style={{
216
+ background: 'var(--bg-hover)', border: '1px solid var(--border)',
217
+ borderRadius: 3, fontFamily: 'var(--font-mono)',
218
+ color: 'var(--text-secondary)', fontSize: 10,
219
+ }}>
220
+ {f}
221
+ </span>
222
+ ))}
223
+ </div>
224
+ )}
225
+ </div>
226
+ )}
227
+
228
+ {/* Layer 2 */}
229
+ {layer2 && (
230
+ <div style={{ background: 'var(--bg-elevated)', borderRadius: 4, padding: '12px 14px', border: '1px solid var(--border)' }}>
231
+ <p className="text-xs font-semibold uppercase mb-2"
232
+ style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
233
+ Layer 2 β€” Evidence Check
234
+ </p>
235
+ <div className="flex items-center gap-3 mb-2">
236
+ <VerdictBadge verdict={layer2.verdict} size="sm" />
237
+ <span className="tabular text-sm" style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)' }}>
238
+ Evidence: {Math.round((layer2.evidence_score ?? 0) * 100)}%
239
+ </span>
240
+ </div>
241
+ {layer2.claim_used && (
242
+ <p className="text-xs italic"
243
+ style={{ fontFamily: 'var(--font-body)', color: 'var(--text-muted)', marginTop: 6 }}>
244
+ &ldquo;{layer2.claim_used}&rdquo;
245
+ </p>
246
+ )}
247
+ </div>
248
+ )}
249
+
250
+ {/* Sentiment */}
251
+ {(data.sentiment || data.emotion) && (
252
+ <div className="flex gap-3 flex-wrap">
253
+ {data.sentiment && (
254
+ <div style={{ background: 'var(--bg-elevated)', borderRadius: 4, padding: '10px 14px', border: '1px solid var(--border)', flex: 1 }}>
255
+ <p className="text-xs font-semibold uppercase mb-1"
256
+ style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
257
+ Sentiment
258
+ </p>
259
+ <p className="text-sm capitalize" style={{ fontFamily: 'var(--font-body)', color: 'var(--text-primary)' }}>
260
+ {data.sentiment}
261
+ </p>
262
+ </div>
263
+ )}
264
+ {data.emotion && (
265
+ <div style={{ background: 'var(--bg-elevated)', borderRadius: 4, padding: '10px 14px', border: '1px solid var(--border)', flex: 1 }}>
266
+ <p className="text-xs font-semibold uppercase mb-1"
267
+ style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
268
+ Emotion
269
+ </p>
270
+ <p className="text-sm capitalize" style={{ fontFamily: 'var(--font-body)', color: 'var(--text-primary)' }}>
271
+ {data.emotion}
272
+ </p>
273
+ </div>
274
+ )}
275
+ </div>
276
+ )}
277
+
278
+ {/* Entities */}
279
+ {entities.length > 0 && (
280
+ <div>
281
+ <p className="text-xs font-semibold uppercase mb-2"
282
+ style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
283
+ Entities Detected
284
+ </p>
285
+ <div className="flex flex-wrap gap-1.5">
286
+ {entities.map((ent, i) => (
287
+ <span key={i} className="flex items-center gap-1 text-xs px-2 py-0.5"
288
+ style={{
289
+ background: 'var(--bg-elevated)', border: '1px solid var(--border-light)',
290
+ borderRadius: 3, fontFamily: 'var(--font-body)',
291
+ color: 'var(--text-primary)',
292
+ }}>
293
+ {ent.text ?? ent.entity ?? ent}
294
+ {(ent.label ?? ent.entity_type) && (
295
+ <span style={{ color: 'var(--text-muted)', fontSize: 9, fontFamily: 'var(--font-mono)', fontWeight: 700 }}>
296
+ {(ent.label ?? ent.entity_type).toUpperCase()}
297
+ </span>
298
+ )}
299
+ </span>
300
+ ))}
301
+ </div>
302
+ </div>
303
+ )}
304
+ </>)}
305
+ </div>
306
+ </div>
307
+ </div>
308
+ )
309
+ }
310
 
311
  export default function HistoryPage() {
312
  const [entries, setEntries] = useState([])
313
  const [loading, setLoading] = useState(true)
314
+ const [source, setSource] = useState('firestore')
315
+ const [sort, setSort] = useState('timestamp')
316
+ const [dir, setDir] = useState('desc')
317
+ const [filter, setFilter] = useState('all') // 'all' | 'Credible' | 'Unverified' | 'Likely Fake'
318
+ const [selectedId, setSelectedId] = useState(null)
319
+
320
+ const fetchRest = useCallback(() => {
321
+ api.history({ limit: 50 })
322
+ .then(data => {
323
+ const list = Array.isArray(data) ? data : (data.entries ?? [])
324
+ setEntries(list)
325
+ })
326
+ .catch(() => setEntries([]))
327
+ .finally(() => setLoading(false))
328
+ }, [])
329
 
330
  useEffect(() => {
331
+ let resolved = false
332
+ let restInterval = null
333
+ let unsubRef = null // declared before subscribeToHistory so goRest() can reach it
334
+
335
+ function goRest() {
336
+ if (resolved) return
337
+ resolved = true
338
+ // Immediately kill the Firestore listener so the SDK stops retrying
339
+ // (prevents the ERR_BLOCKED_BY_CLIENT console flood from auto-retries)
340
+ unsubRef?.()
341
+ setSource('rest')
342
+ fetchRest()
343
+ restInterval = setInterval(fetchRest, 30_000)
344
+ }
345
+
346
+ // Fallback to REST after 1.5 s if Firestore hasn't connected
347
+ const fallbackTimer = setTimeout(goRest, 1500)
348
+
349
+ unsubRef = subscribeToHistory(
350
+ (docs) => {
351
+ if (!resolved) { resolved = true; clearTimeout(fallbackTimer) }
352
+ setEntries(docs)
353
+ setLoading(false)
354
+ },
355
+ // onError: Firestore blocked by ad-blocker β†’ instant REST fallback
356
+ () => {
357
+ clearTimeout(fallbackTimer)
358
+ goRest()
359
+ }
360
+ )
361
+
362
+ return () => { unsubRef?.(); clearTimeout(fallbackTimer); if (restInterval) clearInterval(restInterval) }
363
+ }, [fetchRest])
364
+
365
+ function handleSort(field) {
366
+ if (sort === field) setDir(d => d === 'asc' ? 'desc' : 'asc')
367
+ else { setSort(field); setDir('desc') }
368
+ }
369
+
370
+ const filtered = useMemo(() => {
371
+ let data = [...entries]
372
+ if (filter !== 'all') data = data.filter(e => e.verdict === filter)
373
+ data.sort((a, b) => {
374
+ let av = a[sort], bv = b[sort]
375
+ if (sort === 'timestamp') { av = new Date(av); bv = new Date(bv) }
376
+ if (sort === 'final_score') { av = Number(av); bv = Number(bv) }
377
+ if (av < bv) return dir === 'asc' ? -1 : 1
378
+ if (av > bv) return dir === 'asc' ? 1 : -1
379
+ return 0
380
  })
381
+ return data
382
+ }, [entries, sort, dir, filter])
383
+
384
+ const verdictCounts = useMemo(() => {
385
+ const counts = { all: entries.length, Credible: 0, Unverified: 0, 'Likely Fake': 0 }
386
+ entries.forEach(e => { if (counts[e.verdict] !== undefined) counts[e.verdict]++ })
387
+ return counts
388
+ }, [entries])
389
+
390
+ const FILTER_TABS = [
391
+ { key: 'all', label: 'All', color: 'var(--text-secondary)' },
392
+ { key: 'Credible', label: 'Verified', color: 'var(--credible)' },
393
+ { key: 'Unverified', label: 'Unverified', color: 'var(--unverified)' },
394
+ { key: 'Likely Fake', label: 'False', color: 'var(--fake)' },
395
+ ]
396
 
397
  return (
398
+ <main style={{ ...PAGE_STYLE, paddingTop: 40, paddingBottom: 56, display: 'flex', flexDirection: 'column', gap: 24 }}>
399
+
400
+ {/* ── Header ────────────────────────────────── */}
401
+ <header className="ruled fade-up-1 flex items-end justify-between flex-wrap gap-2">
402
  <div>
403
+ <h1 style={{ fontSize: 28, fontFamily: 'var(--font-display)' }}>History</h1>
404
  <p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
405
+ {source === 'firestore' ? 'Real-time from Firestore' : 'Polling via REST API'}
406
+ {' β€” '}<span className="tabular" style={{ fontFamily: 'var(--font-mono)' }}>{entries.length}</span> records
 
407
  </p>
408
  </div>
 
409
  <div className="flex items-center gap-1.5 text-xs"
410
+ style={{
411
+ color: source === 'rest' ? 'var(--accent-gold)' : 'var(--accent-green)',
412
+ fontFamily: 'var(--font-display)',
413
+ letterSpacing: '0.1em',
414
+ }}
415
+ aria-label={source === 'firestore' ? 'Live data' : 'Polling REST API'}>
416
+ <span className="w-1.5 h-1.5 rounded-full" style={{ background: 'currentColor' }} />
417
+ {source === 'rest' ? <><WifiOff size={11} aria-hidden="true" /> POLLING</> : <><RefreshCw size={11} aria-hidden="true" /> LIVE</>}
418
  </div>
419
  </header>
420
 
421
+ {/* ── Firestore blocked notice ───────────────── */}
422
+ {source === 'rest' && !loading && (
423
+ <div className="card p-3 flex items-center gap-2"
424
+ style={{ borderColor: 'rgba(217,119,6,0.3)', background: 'rgba(217,119,6,0.05)' }}>
425
+ <WifiOff size={13} style={{ color: 'var(--accent-gold)', flexShrink: 0 }} aria-hidden="true" />
426
+ <p className="text-xs" style={{ color: 'var(--accent-gold)', fontFamily: 'var(--font-body)' }}>
427
+ Firestore may be blocked by an ad blocker β€” using REST fallback. Whitelist <code>firestore.googleapis.com</code> to restore live updates.
428
+ </p>
429
+ </div>
430
+ )}
431
+
432
+ {/* ── Filter tabs ────────────────────────────── */}
433
+ {!loading && entries.length > 0 && (
434
+ <div role="tablist" aria-label="Filter by verdict" className="flex gap-1 flex-wrap fade-up-2">
435
+ {FILTER_TABS.map(({ key, label, color }) => (
436
+ <button key={key}
437
+ role="tab"
438
+ aria-selected={filter === key}
439
+ onClick={() => setFilter(key)}
440
+ className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold transition-colors"
441
+ style={{
442
+ fontFamily: 'var(--font-display)',
443
+ letterSpacing: '0.07em',
444
+ background: filter === key ? 'var(--bg-elevated)' : 'transparent',
445
+ color: filter === key ? color : 'var(--text-muted)',
446
+ border: `1px solid ${filter === key ? 'var(--border-light)' : 'var(--border)'}`,
447
+ cursor: 'pointer',
448
+ borderRadius: 2,
449
+ minHeight: 44,
450
+ }}>
451
+ {label}
452
+ <span style={{
453
+ background: 'var(--bg-hover)',
454
+ padding: '0 5px',
455
+ borderRadius: 2,
456
+ fontSize: 10,
457
+ fontFamily: 'var(--font-mono)',
458
+ color: filter === key ? color : 'var(--text-muted)',
459
+ }}>
460
+ {verdictCounts[key]}
461
+ </span>
462
+ </button>
463
+ ))}
464
+ </div>
465
+ )}
466
+
467
+ {/* ── Loading skeleton ──────────────────────── */}
468
  {loading && (
469
+ <div className="space-y-2" aria-live="polite" aria-label="Loading history">
470
+ {[...Array(5)].map((_, i) => <SkeletonCard key={i} lines={2} />)}
471
+ </div>
 
472
  )}
473
 
474
+ {/* ── Empty state ────────────────────────────── */}
475
  {!loading && entries.length === 0 && (
476
+ <div className="card p-16 text-center fade-up">
477
+ <Clock size={28} aria-hidden="true" style={{ color: 'var(--text-muted)', margin: '0 auto 12px' }} />
 
478
  <p style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)', fontWeight: 700 }}>
479
  No verifications yet
480
  </p>
 
484
  </div>
485
  )}
486
 
487
+ {/* ── Table ─────────────────────────────────── */}
488
+ {filtered.length > 0 && (
489
+ <div className="card overflow-hidden fade-up-3">
490
+ {/* Table header */}
491
+ <div className="px-4 py-2 grid items-center"
492
+ style={{
493
+ gridTemplateColumns: '1fr 56px 90px 110px',
494
+ gap: '0 12px',
495
+ borderBottom: '1px solid var(--border)',
496
+ background: 'var(--bg-elevated)',
497
+ }}
498
+ role="row">
499
+ <ColHeader field="text_preview" sort={sort} dir={dir} onSort={handleSort}>Claim</ColHeader>
500
+ <div style={{ textAlign: 'right' }}>
501
+ <ColHeader field="final_score" sort={sort} dir={dir} onSort={handleSort}>Score</ColHeader>
502
+ </div>
503
+ <ColHeader field="timestamp" sort={sort} dir={dir} onSort={handleSort}>Time</ColHeader>
504
+ <span className="text-xs font-semibold uppercase"
505
+ style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
506
+ Verdict
507
+ </span>
508
+ </div>
509
+
510
+ {/* Rows */}
511
+ <ul className="divide-y" style={{ '--tw-divide-color': 'var(--border)' }} role="list" aria-label="Verification history" aria-live="polite">
512
+ {filtered.map((e, i) => (
513
+ <li key={e.id}
514
+ className="px-4 py-3 grid items-center fade-up hover:bg-[var(--bg-elevated)] transition-colors"
515
+ role="button"
516
+ tabIndex={0}
517
+ onClick={() => setSelectedId(e.id)}
518
+ onKeyDown={ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); setSelectedId(e.id) } }}
519
+ style={{
520
+ gridTemplateColumns: '1fr 56px 90px 110px',
521
+ gap: '0 12px',
522
+ animationDelay: `${Math.min(i * 25, 200)}ms`,
523
+ borderBottom: '1px solid var(--border)',
524
+ cursor: 'pointer',
525
+ }}>
526
+ <div className="min-w-0">
527
  <p className="text-sm truncate" style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}>
528
+ {e.text_preview || 'No preview'}
529
  </p>
530
+ <span className="text-xs px-1.5 py-0.5 mt-1 inline-block"
531
+ style={{
532
+ background: 'var(--bg-elevated)',
533
+ color: 'var(--text-muted)',
534
+ fontFamily: 'var(--font-display)',
535
+ letterSpacing: '0.08em',
536
+ fontSize: 9,
537
+ borderRadius: 2,
538
+ }}>
539
+ {e.input_type?.toUpperCase() ?? 'TEXT'}
 
 
 
 
 
 
 
 
 
 
 
 
540
  </span>
 
541
  </div>
542
+ <span className="tabular font-bold text-sm"
543
+ style={{ color: scoreColor(e.final_score), fontFamily: 'var(--font-mono)', textAlign: 'right', display: 'block' }}>
544
+ {Math.round(e.final_score)}
545
+ </span>
546
+ <time className="text-xs tabular" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap' }}
547
+ dateTime={e.timestamp}>
548
+ {timeAgo(e.timestamp)}
549
+ </time>
550
+ <VerdictBadge verdict={e.verdict} size="sm" />
551
+ </li>
552
+ ))}
553
+ </ul>
554
+ </div>
555
+ )}
556
+
557
+ {/* ── No results after filter ─────────────────── */}
558
+ {!loading && entries.length > 0 && filtered.length === 0 && (
559
+ <p className="text-center text-sm py-8" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
560
+ No {filter} verifications found.
561
+ </p>
562
+ )}
563
+
564
+ {/* ── Detail Modal ──────────────────────────── */}
565
+ {selectedId && (
566
+ <DetailModal id={selectedId} onClose={() => setSelectedId(null)} />
567
  )}
568
  </main>
569
  )
frontend/src/pages/TrendsPage.jsx CHANGED
@@ -1,45 +1,80 @@
1
  import { useEffect, useState } from 'react'
2
- import { api } from '../api.js'
3
- import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
 
 
 
 
 
 
4
 
5
- const CHART_COLORS = ['#dc2626', '#d97706', '#06b6d4', '#8b5cf6', '#16a34a', '#ec4899']
 
6
 
7
- /** Custom tooltip β€” uses CSS vars, avoids hardcoded formats */
8
  const ChartTooltip = ({ active, payload }) => {
9
  if (!active || !payload?.length) return null
 
10
  return (
11
- <div className="card-elevated px-3 py-2"
12
- role="tooltip" aria-live="polite">
 
 
 
 
 
13
  <p className="text-xs font-bold" style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)' }}>
14
- {payload[0].payload.name ?? payload[0].payload.topic}
15
  </p>
16
- <p className="tabular text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
17
- Count: {payload[0].value}
18
  </p>
19
  </div>
20
  )
21
  }
22
 
23
- function ChartSection({ title, data, dataKey }) {
 
 
 
 
 
 
 
 
 
 
 
24
  if (!data?.length) return null
25
  return (
26
  <section aria-label={title} className="card p-5 fade-up-2">
27
- <p className="text-xs font-semibold uppercase mb-4"
28
- style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)', letterSpacing: '0.15em' }}>
29
- {title}
30
- </p>
31
- {/* web-design-guidelines: font-variant-numeric tabular-nums for data */}
 
 
 
32
  <ResponsiveContainer width="100%" height={200}>
33
- <BarChart data={data} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
 
34
  <XAxis dataKey={dataKey}
35
- tick={{ fontSize: 11, fill: 'var(--text-muted)', fontFamily: 'var(--font-display)' }}
 
 
 
 
 
 
 
 
36
  axisLine={false} tickLine={false} />
37
  <YAxis
38
- tick={{ fontSize: 11, fill: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}
39
  axisLine={false} tickLine={false} />
40
- <Tooltip content={<ChartTooltip />}
41
- cursor={{ fill: 'rgba(245,240,232,0.03)' }} />
42
- <Bar dataKey="count" radius={[2, 2, 0, 0]}>
43
  {data.map((_, i) => (
44
  <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
45
  ))}
@@ -50,6 +85,54 @@ function ChartSection({ title, data, dataKey }) {
50
  )
51
  }
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  export default function TrendsPage() {
54
  const [data, setData] = useState(null)
55
  const [loading, setLoading] = useState(true)
@@ -62,66 +145,107 @@ export default function TrendsPage() {
62
  .finally(() => setLoading(false))
63
  }, [])
64
 
65
- if (loading) return (
66
- <p className="text-center py-24 text-sm" style={{ color: 'var(--text-muted)' }}
67
- aria-live="polite">Loading trends…</p>
68
- )
69
-
70
- if (error) return (
71
- <p role="alert" className="text-center py-24 text-sm" style={{ color: '#f87171' }}>
72
- Error: {error}
73
- </p>
74
- )
75
-
76
- const entityData = Object.entries(data?.top_entities || {})
77
- .sort(([, a], [, b]) => b - a).slice(0, 8)
78
- .map(([name, count]) => ({ name, count }))
79
 
80
- const topicData = (data?.top_fake_topics || []).slice(0, 8)
 
81
 
82
  const verdicts = [
83
  { label: 'VERIFIED', count: data?.verdict_distribution?.Credible ?? 0, color: 'var(--credible)' },
84
  { label: 'UNVERIFIED', count: data?.verdict_distribution?.Unverified ?? 0, color: 'var(--unverified)' },
85
  { label: 'FALSE', count: data?.verdict_distribution?.['Likely Fake'] ?? 0, color: 'var(--fake)' },
86
  ]
 
87
  const hasData = entityData.length > 0 || verdicts.some(v => v.count > 0)
88
 
89
  return (
90
- <main className="max-w-4xl mx-auto px-4 py-8 space-y-6">
 
 
91
  <header className="ruled fade-up-1">
92
- <h1 style={{ fontSize: 32, fontFamily: 'var(--font-display)' }}>Trends</h1>
93
  <p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
94
- Aggregated patterns from verified claims
95
  </p>
96
  </header>
97
 
98
- {/* Verdict distribution stats */}
99
- <div className="grid grid-cols-3 gap-3 fade-up-1" role="list" aria-label="Verdict distribution">
100
- {verdicts.map(({ label, count, color }) => (
101
- <div key={label} className="card p-5 text-center" role="listitem">
102
- {/* web-design-guidelines: numerals for counts, tabular */}
103
- <p className="tabular font-bold" style={{ fontSize: 36, color, fontFamily: 'var(--font-mono)' }}>
104
- {count}
105
- </p>
106
- <p className="mt-1 text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.12em' }}>
107
- {label}
108
- </p>
109
  </div>
110
- ))}
111
- </div>
 
112
 
113
- <ChartSection title="Top Named Entities" data={entityData} dataKey="name" />
114
- <ChartSection title="Top Fake News Topics" data={topicData} dataKey="topic" />
 
 
 
 
115
 
116
- {!hasData && (
117
- <div className="card p-12 text-center fade-up">
118
- <p style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)', fontWeight: 700 }}>
119
- No trend data yet
120
- </p>
121
- <p className="text-sm mt-1" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
122
- Run some verifications first to see patterns emerge here.
123
- </p>
124
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  )}
126
  </main>
127
  )
 
1
  import { useEffect, useState } from 'react'
2
+ import { api } from '../api'
3
+ import { PAGE_STYLE } from '../App.jsx'
4
+ import { scoreColor } from '../utils/format.js'
5
+ import SkeletonCard from '../components/SkeletonCard.jsx'
6
+ import {
7
+ BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
8
+ AreaChart, Area, CartesianGrid
9
+ } from 'recharts'
10
 
11
+ /* ── Brand colors for chart series ─────────────────────── */
12
+ const CHART_COLORS = ['#dc2626', '#d97706', '#06b6d4', '#8b5cf6', '#16a34a', '#ec4899', '#0ea5e9', '#f97316']
13
 
14
+ /* ── Custom tooltip β€” editorial style ──────────────────── */
15
  const ChartTooltip = ({ active, payload }) => {
16
  if (!active || !payload?.length) return null
17
+ const entry = payload[0]
18
  return (
19
+ <div role="tooltip" aria-live="polite"
20
+ style={{
21
+ background: 'var(--bg-elevated)',
22
+ border: '1px solid var(--border)',
23
+ borderRadius: 2,
24
+ padding: '8px 12px',
25
+ }}>
26
  <p className="text-xs font-bold" style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)' }}>
27
+ {entry.payload.name ?? entry.payload.topic}
28
  </p>
29
+ <p className="tabular text-xs mt-0.5" style={{ color: entry.fill || 'var(--accent-cyan)', fontFamily: 'var(--font-mono)' }}>
30
+ {entry.value} verifications
31
  </p>
32
  </div>
33
  )
34
  }
35
 
36
+ /* ── Section heading ──────────────────────────────────── */
37
+ function SectionHeading({ children }) {
38
+ return (
39
+ <p className="text-xs font-semibold uppercase mb-4"
40
+ style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)', letterSpacing: '0.15em' }}>
41
+ {children}
42
+ </p>
43
+ )
44
+ }
45
+
46
+ /* ── Bar chart section ────────────────────────────────── */
47
+ function ChartSection({ title, data, dataKey, description }) {
48
  if (!data?.length) return null
49
  return (
50
  <section aria-label={title} className="card p-5 fade-up-2">
51
+ <div className="mb-4">
52
+ <SectionHeading>{title}</SectionHeading>
53
+ {description && (
54
+ <p className="text-xs -mt-2 mb-4" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
55
+ {description}
56
+ </p>
57
+ )}
58
+ </div>
59
  <ResponsiveContainer width="100%" height={200}>
60
+ <BarChart data={data} margin={{ top: 0, right: 0, left: -20, bottom: 48 }}>
61
+ <CartesianGrid vertical={false} stroke="rgba(245,240,232,0.04)" />
62
  <XAxis dataKey={dataKey}
63
+ tick={{ fontSize: 10, fill: 'var(--text-muted)', fontFamily: 'var(--font-display)' }}
64
+ tickFormatter={(val) => {
65
+ if (!val) return ''
66
+ const s = String(val)
67
+ return s.length > 18 ? s.slice(0, 18) + '…' : s
68
+ }}
69
+ angle={-30}
70
+ textAnchor="end"
71
+ interval={0}
72
  axisLine={false} tickLine={false} />
73
  <YAxis
74
+ tick={{ fontSize: 10, fill: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}
75
  axisLine={false} tickLine={false} />
76
+ <Tooltip content={<ChartTooltip />} cursor={{ fill: 'rgba(245,240,232,0.03)' }} />
77
+ <Bar dataKey="count" radius={[2, 2, 0, 0]} maxBarSize={48}>
 
78
  {data.map((_, i) => (
79
  <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
80
  ))}
 
85
  )
86
  }
87
 
88
+ /* ── Verdict area chart (time-series if available) ─────── */
89
+ function VerdictAreaChart({ data }) {
90
+ if (!data?.length) return null
91
+ return (
92
+ <section aria-label="Verdict trend" className="card p-5 fade-up-3">
93
+ <SectionHeading>Verdict Distribution Over Time</SectionHeading>
94
+ <ResponsiveContainer width="100%" height={160}>
95
+ <AreaChart data={data} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
96
+ <defs>
97
+ <linearGradient id="fillCredible" x1="0" y1="0" x2="0" y2="1">
98
+ <stop offset="5%" stopColor="var(--credible)" stopOpacity={0.3} />
99
+ <stop offset="95%" stopColor="var(--credible)" stopOpacity={0} />
100
+ </linearGradient>
101
+ <linearGradient id="fillFake" x1="0" y1="0" x2="0" y2="1">
102
+ <stop offset="5%" stopColor="var(--fake)" stopOpacity={0.3} />
103
+ <stop offset="95%" stopColor="var(--fake)" stopOpacity={0} />
104
+ </linearGradient>
105
+ </defs>
106
+ <CartesianGrid vertical={false} stroke="rgba(245,240,232,0.04)" />
107
+ <XAxis dataKey="date"
108
+ tick={{ fontSize: 10, fill: 'var(--text-muted)', fontFamily: 'var(--font-display)' }}
109
+ axisLine={false} tickLine={false} />
110
+ <YAxis
111
+ tick={{ fontSize: 10, fill: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}
112
+ axisLine={false} tickLine={false} />
113
+ <Tooltip content={<ChartTooltip />} cursor={{ stroke: 'var(--border)', strokeWidth: 1 }} />
114
+ <Area type="monotone" dataKey="credible" stroke="var(--credible)" fill="url(#fillCredible)" strokeWidth={2} />
115
+ <Area type="monotone" dataKey="fake" stroke="var(--fake)" fill="url(#fillFake)" strokeWidth={2} />
116
+ </AreaChart>
117
+ </ResponsiveContainer>
118
+ {/* Legend */}
119
+ <div className="flex gap-4 mt-3">
120
+ {[
121
+ { color: 'var(--credible)', label: 'Credible' },
122
+ { color: 'var(--fake)', label: 'False' },
123
+ ].map(({ color, label }) => (
124
+ <div key={label} className="flex items-center gap-1.5">
125
+ <span className="w-3 h-0.5" style={{ background: color, display: 'inline-block' }} />
126
+ <span className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.08em' }}>
127
+ {label}
128
+ </span>
129
+ </div>
130
+ ))}
131
+ </div>
132
+ </section>
133
+ )
134
+ }
135
+
136
  export default function TrendsPage() {
137
  const [data, setData] = useState(null)
138
  const [loading, setLoading] = useState(true)
 
145
  .finally(() => setLoading(false))
146
  }, [])
147
 
148
+ /* ── Derived data ─────────────────────────────────────── */
149
+ // top_entities is an array of { entity, entity_type, count, fake_count, fake_ratio }
150
+ const entityData = (data?.top_entities || [])
151
+ .slice(0, 8)
152
+ .map(e => ({ name: e.entity, count: e.count }))
 
 
 
 
 
 
 
 
 
153
 
154
+ // top_topics is an array of { topic, count, dominant_verdict }
155
+ const topicData = (data?.top_topics || []).slice(0, 8)
156
 
157
  const verdicts = [
158
  { label: 'VERIFIED', count: data?.verdict_distribution?.Credible ?? 0, color: 'var(--credible)' },
159
  { label: 'UNVERIFIED', count: data?.verdict_distribution?.Unverified ?? 0, color: 'var(--unverified)' },
160
  { label: 'FALSE', count: data?.verdict_distribution?.['Likely Fake'] ?? 0, color: 'var(--fake)' },
161
  ]
162
+ const total = verdicts.reduce((s, v) => s + v.count, 0)
163
  const hasData = entityData.length > 0 || verdicts.some(v => v.count > 0)
164
 
165
  return (
166
+ <main style={{ ...PAGE_STYLE, paddingTop: 40, paddingBottom: 56, display: 'flex', flexDirection: 'column', gap: 24 }}>
167
+
168
+ {/* ── Page header ───────────────────────────── */}
169
  <header className="ruled fade-up-1">
170
+ <h1 style={{ fontSize: 28, fontFamily: 'var(--font-display)' }}>Trends</h1>
171
  <p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
172
+ Aggregated misinformation patterns across all verified claims
173
  </p>
174
  </header>
175
 
176
+ {/* ── Loading ────────────────────────────────── */}
177
+ {loading && (
178
+ <div className="space-y-4" aria-live="polite" aria-label="Loading trends">
179
+ <div className="grid grid-cols-3 gap-3">
180
+ {[0, 1, 2].map(i => <SkeletonCard key={i} height={96} />)}
 
 
 
 
 
 
181
  </div>
182
+ <SkeletonCard height={220} />
183
+ </div>
184
+ )}
185
 
186
+ {/* ── Error ──────────────────────────────────── */}
187
+ {error && !loading && (
188
+ <p role="alert" className="text-center py-12 text-sm" style={{ color: '#f87171' }}>
189
+ Error loading trends: {error}
190
+ </p>
191
+ )}
192
 
193
+ {!loading && !error && (
194
+ <>
195
+ {/* ── Impact stats ────────────────────────────────────────── */}
196
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 fade-up-1" role="list" aria-label="Verdict distribution">
197
+ {verdicts.map(({ label, count, color }) => {
198
+ const pct = total > 0 ? Math.round((count / total) * 100) : 0
199
+ return (
200
+ <div key={label} className="card p-5" role="listitem"
201
+ style={{ borderTop: `3px solid ${color}` }}>
202
+ {/* Large impact number β€” interactive-portfolio pattern */}
203
+ <p className="tabular font-bold" style={{ fontSize: 40, color, fontFamily: 'var(--font-mono)', lineHeight: 1 }}>
204
+ {count}
205
+ </p>
206
+ <p className="mt-2 text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.12em' }}>
207
+ {label}
208
+ </p>
209
+ {total > 0 && (
210
+ <div className="mt-2 h-1" style={{ background: 'var(--bg-hover)', borderRadius: 1 }}>
211
+ <div className="h-1 bar-fill" style={{ width: `${pct}%`, background: color, borderRadius: 1 }} />
212
+ </div>
213
+ )}
214
+ </div>
215
+ )
216
+ })}
217
+ </div>
218
+
219
+ {/* ── Charts ─────────────────────────────── */}
220
+ <ChartSection
221
+ title="Top Named Entities"
222
+ data={entityData}
223
+ dataKey="name"
224
+ description="Most frequently appearing persons, organizations, and places in verified claims"
225
+ />
226
+
227
+ <ChartSection
228
+ title="Top Fake News Topics"
229
+ data={topicData}
230
+ dataKey="topic"
231
+ description="Recurring misinformation topics detected across false claims"
232
+ />
233
+
234
+ {/* ── Verdict trend over time ──────────────────── */}
235
+ <VerdictAreaChart data={data?.verdict_by_day || []} />
236
+
237
+ {/* ── Empty state ────────────────────── */}
238
+ {!hasData && (
239
+ <div className="card p-16 text-center fade-up">
240
+ <p style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 18 }}>
241
+ No trend data yet
242
+ </p>
243
+ <p className="text-sm mt-2" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
244
+ Run some verifications first β€” patterns will emerge here as data accumulates.
245
+ </p>
246
+ </div>
247
+ )}
248
+ </>
249
  )}
250
  </main>
251
  )
frontend/src/pages/VerifyPage.jsx CHANGED
@@ -1,9 +1,12 @@
1
- import { useState, useRef, useId } from 'react'
2
- import { api } from '../api.js'
3
- import { scoreColor } from '../utils/format.js'
 
4
  import ScoreGauge from '../components/ScoreGauge.jsx'
5
  import VerdictBadge from '../components/VerdictBadge.jsx'
6
- import { FileText, Link2, Image, Video, Loader2, ChevronRight, AlertCircle } from 'lucide-react'
 
 
7
 
8
  /* ── Tab definitions ────────────────────────────────────── */
9
  const TABS = [
@@ -13,12 +16,29 @@ const TABS = [
13
  { id: 'video', icon: Video, label: 'Video' },
14
  ]
15
 
 
 
 
 
 
 
 
16
  /* ── Atomic sub-components (architect-review: Single Responsibility) ── */
17
- function SectionHeading({ children }) {
18
  return (
19
- <p className="font-display text-xs font-semibold uppercase tracking-widest mb-3"
20
  style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)', letterSpacing: '0.15em' }}>
21
  {children}
 
 
 
 
 
 
 
 
 
 
22
  </p>
23
  )
24
  }
@@ -44,39 +64,239 @@ function ScoreBar({ label, value, color, index = 0 }) {
44
  <span style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>{label}</span>
45
  <span className="tabular font-bold" style={{ color }}>{Math.round(value)}%</span>
46
  </div>
47
- <div className="h-1 rounded-none" style={{ background: 'var(--bg-hover)' }}
48
  role="progressbar" aria-valuenow={Math.round(value)} aria-valuemin={0} aria-valuemax={100}
49
  aria-label={label}>
50
- <div className="h-1 bar-fill"
51
  style={{
52
  width: `${value}%`,
53
  background: color,
54
- animationDelay: `${index * 100}ms`,
 
55
  }} />
56
  </div>
57
  </div>
58
  )
59
  }
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  /* ── Main Page ──────────────────────────────────────────── */
62
  export default function VerifyPage() {
63
- const [tab, setTab] = useState('text')
64
- const [input, setInput] = useState('')
 
 
65
  const [file, setFile] = useState(null)
 
66
  const [loading, setLoading] = useState(false)
67
- const [result, setResult] = useState(null)
68
  const [error, setError] = useState(null)
 
 
 
69
  const fileRef = useRef()
70
- /* web-design-guidelines: label needs htmlFor β€” use useId for unique IDs */
71
  const inputId = useId()
72
  const errorId = useId()
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  const canSubmit = !loading && (tab === 'text' || tab === 'url' ? input.trim() : file)
75
 
76
  async function handleSubmit(e) {
77
  e.preventDefault()
78
  if (!canSubmit) return
 
 
 
 
 
79
  setLoading(true); setError(null); setResult(null)
 
80
  try {
81
  let res
82
  if (tab === 'text') res = await api.verifyText(input)
@@ -85,42 +305,83 @@ export default function VerifyPage() {
85
  else res = await api.verifyVideo(file)
86
  setResult(res)
87
  } catch (err) {
88
- setError(err.message)
89
  } finally {
90
  setLoading(false)
91
  }
92
  }
93
 
94
  function handleTabChange(id) {
95
- setTab(id); setInput(''); setFile(null); setResult(null); setError(null)
 
 
 
 
 
 
 
 
 
 
96
  }
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  const entities = result?.entities || {}
99
  const allEntities = [
100
- ...(entities.persons || []).map(e => ({ label: e, type: 'Person' })),
101
- ...(entities.organizations || []).map(e => ({ label: e, type: 'Org' })),
102
- ...(entities.locations || []).map(e => ({ label: e, type: 'Place' })),
103
- ...(entities.dates || []).map(e => ({ label: e, type: 'Date' })),
104
  ]
105
 
106
  const finalColor = result ? scoreColor(result.final_score) : 'var(--text-muted)'
 
107
 
108
  return (
109
- <main className="max-w-4xl mx-auto px-4 py-8 space-y-6">
110
- {/* Page header */}
 
111
  <header className="ruled fade-up-1">
112
- <h1 style={{ fontSize: 32, fontFamily: 'var(--font-display)' }}>
113
- Fact Check
114
- </h1>
115
  <p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
116
  Paste text, a URL, or upload media β€” we'll verify credibility instantly.
117
  </p>
118
  </header>
119
 
120
- {/* Input card */}
121
- <section aria-label="Input panel" className="card p-6 space-y-4 fade-up-2">
122
- {/* Tab bar β€” web-design-guidelines: role="tablist" */}
123
- <div role="tablist" aria-label="Input type" className="flex gap-1">
124
  {TABS.map(({ id, icon: Icon, label }) => {
125
  const active = tab === id
126
  return (
@@ -130,7 +391,7 @@ export default function VerifyPage() {
130
  aria-controls={`panel-${id}`}
131
  id={`tab-${id}`}
132
  onClick={() => handleTabChange(id)}
133
- className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold transition-colors"
134
  style={{
135
  fontFamily: 'var(--font-display)',
136
  letterSpacing: '0.08em',
@@ -139,6 +400,7 @@ export default function VerifyPage() {
139
  border: 'none',
140
  cursor: 'pointer',
141
  borderRadius: 2,
 
142
  }}>
143
  <Icon size={12} aria-hidden="true" />
144
  {label.toUpperCase()}
@@ -149,9 +411,9 @@ export default function VerifyPage() {
149
 
150
  <form onSubmit={handleSubmit} className="space-y-4"
151
  aria-describedby={error ? errorId : undefined}>
152
- {/* Label β€” web-design-guidelines: inputs need labels */}
153
  {(tab === 'text' || tab === 'url') ? (
154
- <div>
155
  <label htmlFor={inputId} className="sr-only">
156
  {tab === 'url' ? 'Enter a URL to verify' : 'Enter text or headline to verify'}
157
  </label>
@@ -159,27 +421,33 @@ export default function VerifyPage() {
159
  id={inputId}
160
  value={input}
161
  onChange={e => setInput(e.target.value)}
162
- placeholder={tab === 'url' ? 'https://rappler.com/…' : 'Paste claim or headline here…'}
 
 
163
  rows={tab === 'url' ? 2 : 5}
164
- /* web-design-guidelines: autocomplete + type */
165
  autoComplete="off"
166
  spellCheck={tab === 'url' ? 'false' : 'true'}
167
- className="w-full resize-none p-4 text-sm"
 
168
  style={{
169
  background: 'var(--bg-elevated)',
170
  border: '1px solid var(--border)',
171
  color: 'var(--text-primary)',
172
  fontFamily: 'var(--font-body)',
173
  borderRadius: 2,
174
- outline: 'none',
175
  }}
176
  onFocus={e => e.target.style.borderColor = 'var(--accent-red)'}
177
  onBlur={e => e.target.style.borderColor = 'var(--border)'}
178
  aria-label={tab === 'url' ? 'URL input' : 'Claim text input'}
179
  />
 
 
 
 
180
  </div>
181
  ) : (
182
- /* File drop zone */
183
  <div>
184
  <label htmlFor={`file-${tab}`} className="sr-only">
185
  Upload {tab === 'image' ? 'an image' : 'a video or audio file'}
@@ -187,29 +455,41 @@ export default function VerifyPage() {
187
  <div
188
  onClick={() => fileRef.current?.click()}
189
  onKeyDown={e => e.key === 'Enter' && fileRef.current?.click()}
 
 
 
 
190
  tabIndex={0}
191
  role="button"
192
  aria-label={`Upload ${tab} file. ${file ? `Selected: ${file.name}` : 'No file selected'}`}
193
- className="p-10 text-center cursor-pointer transition-colors"
194
  style={{
195
- background: 'var(--bg-elevated)',
196
- border: `1px dashed ${file ? 'var(--accent-red)' : 'var(--border)'}`,
197
  borderRadius: 2,
 
198
  }}>
199
  <input ref={fileRef} id={`file-${tab}`} type="file" className="sr-only"
 
200
  accept={tab === 'image' ? 'image/*' : 'video/*,audio/*'}
201
  onChange={e => setFile(e.target.files[0])} />
 
 
202
  {file
203
  ? <p className="text-sm font-semibold" style={{ color: 'var(--accent-red)', fontFamily: 'var(--font-display)' }}>{file.name}</p>
204
- : <p className="text-sm" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
205
- Click or press Enter to upload {tab === 'image' ? 'image' : 'video / audio'}
206
- </p>
 
 
 
 
 
207
  }
208
  </div>
209
  </div>
210
  )}
211
 
212
- {/* Submit β€” web-design-guidelines: specific button label, spinner during request */}
213
  <button type="submit" disabled={!canSubmit}
214
  className="flex items-center gap-2 px-5 py-2.5 text-xs font-bold transition-colors"
215
  style={{
@@ -220,6 +500,7 @@ export default function VerifyPage() {
220
  border: 'none',
221
  cursor: canSubmit ? 'pointer' : 'not-allowed',
222
  borderRadius: 2,
 
223
  }}
224
  aria-busy={loading}>
225
  {loading
@@ -230,7 +511,90 @@ export default function VerifyPage() {
230
  </form>
231
  </section>
232
 
233
- {/* Error β€” web-design-guidelines: errors inline, include fix */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  {error && (
235
  <div id={errorId} role="alert"
236
  className="card p-4 flex items-start gap-2"
@@ -241,61 +605,145 @@ export default function VerifyPage() {
241
  Verification failed
242
  </p>
243
  <p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
244
- {error} β€” Check that the backend server is running on port 8000.
 
 
 
245
  </p>
246
  </div>
247
  </div>
248
  )}
249
 
250
- {/* Results */}
251
- {result && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  <section aria-label="Verification results" className="space-y-4">
253
 
254
- {/* Top row β€” gauge + verdict banner */}
255
- <div className="grid gap-4 fade-up-1" style={{ gridTemplateColumns: '180px 1fr' }}>
256
- {/* Gauge panel */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  <div className="card p-5 flex flex-col items-center justify-center gap-3">
258
  <ScoreGauge score={result.final_score} size={140} />
259
  <VerdictBadge verdict={result.verdict} size="banner" />
260
  </div>
261
-
262
- {/* Meta panel */}
263
  <div className="card p-5 fade-up-2">
264
  <SectionHeading>Analysis Details</SectionHeading>
265
  <MetaRow label="Language" value={result.language} />
266
  <MetaRow label="Sentiment" value={result.sentiment} />
267
  <MetaRow label="Emotion" value={result.emotion} />
268
- <MetaRow label="Confidence" value={`${result.confidence?.toFixed(1)}%`}
269
- color={finalColor} />
270
- <MetaRow label="Processed in" value={`${result.processing_time_ms?.toFixed(0)} ms`}
271
- color="var(--accent-cyan)" />
272
  </div>
273
  </div>
274
 
275
- {/* Score breakdown */}
276
  <div className="card p-5 fade-up-3">
277
  <SectionHeading>Score Breakdown</SectionHeading>
278
  <div className="space-y-4">
279
- <ScoreBar label="ML Classifier (Layer 1)" value={result.layer1?.confidence || 0} color="var(--accent-cyan)" index={0} />
280
- <ScoreBar label="Evidence Score (Layer 2)" value={result.layer2?.evidence_score || 0} color="var(--accent-gold)" index={1} />
281
  <ScoreBar label="Final Credibility Score" value={result.final_score} color={finalColor} index={2} />
282
  </div>
283
  </div>
284
 
285
- {/* Named entities */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  {allEntities.length > 0 && (
287
- <div className="card p-5 fade-up-4">
288
- <SectionHeading>Named Entities ({allEntities.length})</SectionHeading>
289
  <ul className="flex flex-wrap gap-2" role="list">
290
  {allEntities.map((e, i) => (
291
  <li key={i}
292
  className="flex items-center gap-1.5 px-2.5 py-1 text-xs"
293
  style={{
294
  background: 'var(--bg-elevated)',
295
- border: '1px solid var(--border)',
296
  borderRadius: 2,
297
  }}>
298
- <span style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', fontSize: 9, letterSpacing: '0.1em' }}>
299
  {e.type.toUpperCase()}
300
  </span>
301
  <span style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}>{e.label}</span>
@@ -305,25 +753,50 @@ export default function VerifyPage() {
305
  </div>
306
  )}
307
 
308
- {/* Evidence sources */}
309
  {result.layer2?.sources?.length > 0 && (
310
  <div className="card p-5 fade-up-5">
311
- <SectionHeading>Evidence Sources</SectionHeading>
312
  <ul className="space-y-2" role="list">
313
- {result.layer2.sources.map((src, i) => (
314
- <li key={i}>
315
- <a href={src.url} target="_blank" rel="noreferrer"
316
- className="block p-3 transition-colors"
317
- style={{ background: 'var(--bg-elevated)', border: '1px solid var(--border)', borderRadius: 2 }}>
318
- <p className="text-xs font-semibold mb-0.5" style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}>
319
- {src.title}
320
- </p>
321
- <p className="text-xs tabular" style={{ color: 'var(--text-muted)' }}>
322
- {src.source} Β· {(src.similarity * 100).toFixed(0)}% match
323
- </p>
324
- </a>
325
- </li>
326
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  </ul>
328
  </div>
329
  )}
 
1
+ import { useState, useRef, useId, useCallback, useEffect } from 'react'
2
+ import { api } from '../api'
3
+ import { scoreColor, VERDICT_MAP } from '../utils/format.js'
4
+ import { PAGE_STYLE } from '../App.jsx'
5
  import ScoreGauge from '../components/ScoreGauge.jsx'
6
  import VerdictBadge from '../components/VerdictBadge.jsx'
7
+ import WordHighlighter from '../components/WordHighlighter.jsx'
8
+ import SkeletonCard from '../components/SkeletonCard.jsx'
9
+ import { FileText, Link2, Image, Video, Loader2, ChevronRight, AlertCircle, Upload, CheckCircle2, XCircle, HelpCircle, ExternalLink, Layers, Brain, RefreshCw } from 'lucide-react'
10
 
11
  /* ── Tab definitions ────────────────────────────────────── */
12
  const TABS = [
 
16
  { id: 'video', icon: Video, label: 'Video' },
17
  ]
18
 
19
+ /* ── Stance icon map ──────────────────────────────────────── */
20
+ const STANCE_ICON = {
21
+ 'Supports': { Icon: CheckCircle2, color: 'var(--credible)' },
22
+ 'Refutes': { Icon: XCircle, color: 'var(--fake)' },
23
+ 'Not Enough Info': { Icon: HelpCircle, color: 'var(--text-muted)' },
24
+ }
25
+
26
  /* ── Atomic sub-components (architect-review: Single Responsibility) ── */
27
+ function SectionHeading({ children, count }) {
28
  return (
29
+ <p className="font-display text-xs font-semibold uppercase tracking-widest mb-3 flex items-center gap-2"
30
  style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)', letterSpacing: '0.15em' }}>
31
  {children}
32
+ {count !== undefined && (
33
+ <span style={{
34
+ background: 'var(--bg-hover)',
35
+ color: 'var(--text-secondary)',
36
+ fontFamily: 'var(--font-mono)',
37
+ fontSize: 10,
38
+ padding: '1px 6px',
39
+ borderRadius: 2,
40
+ }}>{count}</span>
41
+ )}
42
  </p>
43
  )
44
  }
 
64
  <span style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>{label}</span>
65
  <span className="tabular font-bold" style={{ color }}>{Math.round(value)}%</span>
66
  </div>
67
+ <div className="h-1.5 rounded-none" style={{ background: 'var(--bg-hover)' }}
68
  role="progressbar" aria-valuenow={Math.round(value)} aria-valuemin={0} aria-valuemax={100}
69
  aria-label={label}>
70
+ <div className="h-1.5 bar-fill"
71
  style={{
72
  width: `${value}%`,
73
  background: color,
74
+ animationDelay: `${index * 120}ms`,
75
+ borderRadius: 1,
76
  }} />
77
  </div>
78
  </div>
79
  )
80
  }
81
 
82
+ /** Layer verdict detail card β€” for both Layer 1 and Layer 2 */
83
+ function LayerCard({ title, icon: HeaderIcon, verdict, score, children, delay = 0 }) {
84
+ const { cls } = VERDICT_MAP[verdict] ?? VERDICT_MAP['Unverified']
85
+ return (
86
+ <div className="card p-5 fade-up" style={{ animationDelay: `${delay}ms` }}>
87
+ <div className="flex items-center justify-between mb-4">
88
+ <div className="flex items-center gap-2">
89
+ <HeaderIcon size={13} style={{ color: 'var(--accent-red)' }} aria-hidden="true" />
90
+ <SectionHeading>{title}</SectionHeading>
91
+ </div>
92
+ <VerdictBadge verdict={verdict} size="sm" />
93
+ </div>
94
+ {score !== undefined && (
95
+ <ScoreBar label="Confidence" value={score} color={scoreColor(score)} index={0} />
96
+ )}
97
+ {children && <div className="mt-4">{children}</div>}
98
+ </div>
99
+ )
100
+ }
101
+
102
+ /** Triggered features feature breakdown chart */
103
+ function FeatureBreakdown({ features }) {
104
+ if (!features?.length) return (
105
+ <p className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
106
+ No suspicious features detected
107
+ </p>
108
+ )
109
+ return (
110
+ <ul className="flex flex-wrap gap-1.5" role="list" aria-label="Triggered suspicious features">
111
+ {features.map((f, i) => (
112
+ <li key={i}
113
+ className="text-xs px-2 py-1"
114
+ style={{
115
+ background: 'rgba(220,38,38,0.1)',
116
+ color: '#f87171',
117
+ border: '1px solid rgba(220,38,38,0.25)',
118
+ borderRadius: 2,
119
+ fontFamily: 'var(--font-display)',
120
+ letterSpacing: '0.04em',
121
+ }}>
122
+ {f}
123
+ </li>
124
+ ))}
125
+ </ul>
126
+ )
127
+ }
128
+ /* ── URL article preview card ───────────────────────────── */
129
+ function URLPreviewCard({ preview, loading, url }) {
130
+ if (loading && !preview) {
131
+ return (
132
+ <div className="flex items-center gap-2 px-3 py-2"
133
+ style={{ background: 'var(--bg-elevated)', border: '1px solid var(--border)', borderRadius: 2 }}>
134
+ <Loader2 size={11} className="animate-spin" style={{ color: 'var(--text-muted)', flexShrink: 0 }} aria-hidden="true" />
135
+ <span className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>Fetching article preview…</span>
136
+ </div>
137
+ )
138
+ }
139
+ if (!preview) return null
140
+ return (
141
+ <a href={url} target="_blank" rel="noreferrer"
142
+ className="flex gap-3 p-3 transition-colors"
143
+ style={{
144
+ background: 'var(--bg-elevated)',
145
+ border: '1px solid var(--border)',
146
+ borderRadius: 2,
147
+ textDecoration: 'none',
148
+ display: 'flex',
149
+ }}
150
+ onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--border-light)'}
151
+ onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}>
152
+ {/* Thumbnail */}
153
+ {preview.image && (
154
+ <img
155
+ src={preview.image}
156
+ alt=""
157
+ aria-hidden="true"
158
+ onError={e => { e.currentTarget.style.display = 'none' }}
159
+ style={{
160
+ width: 72, height: 56,
161
+ objectFit: 'cover',
162
+ borderRadius: 2,
163
+ flexShrink: 0,
164
+ border: '1px solid var(--border)',
165
+ }} />
166
+ )}
167
+ <div className="flex-1 min-w-0">
168
+ {/* Source row */}
169
+ <div className="flex items-center gap-1.5 mb-1">
170
+ {preview.favicon && (
171
+ <img src={preview.favicon} alt="" aria-hidden="true"
172
+ onError={e => { e.currentTarget.style.display = 'none' }}
173
+ style={{ width: 12, height: 12, borderRadius: 2, flexShrink: 0 }} />
174
+ )}
175
+ <span className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: 10 }}>
176
+ {preview.site_name || preview.domain}
177
+ </span>
178
+ <ExternalLink size={9} style={{ color: 'var(--text-muted)', flexShrink: 0, marginLeft: 'auto' }} aria-hidden="true" />
179
+ </div>
180
+ {/* Title */}
181
+ {preview.title && (
182
+ <p className="text-sm font-semibold"
183
+ style={{
184
+ color: 'var(--text-primary)',
185
+ fontFamily: 'var(--font-body)',
186
+ lineHeight: 1.4,
187
+ display: '-webkit-box',
188
+ WebkitLineClamp: 2,
189
+ WebkitBoxOrient: 'vertical',
190
+ overflow: 'hidden',
191
+ }}>
192
+ {preview.title}
193
+ </p>
194
+ )}
195
+ {/* Description */}
196
+ {preview.description && (
197
+ <p className="text-xs mt-0.5"
198
+ style={{
199
+ color: 'var(--text-secondary)',
200
+ fontFamily: 'var(--font-body)',
201
+ lineHeight: 1.5,
202
+ display: '-webkit-box',
203
+ WebkitLineClamp: 2,
204
+ WebkitBoxOrient: 'vertical',
205
+ overflow: 'hidden',
206
+ }}>
207
+ {preview.description}
208
+ </p>
209
+ )}
210
+ </div>
211
+ </a>
212
+ )
213
+ }
214
+ /* ── SessionStorage persistence key ─────────────────────── */
215
+ const STORAGE_KEY = 'philverify_verify_state'
216
+
217
+ function loadPersistedState() {
218
+ try {
219
+ const raw = sessionStorage.getItem(STORAGE_KEY)
220
+ if (!raw) return null
221
+ return JSON.parse(raw)
222
+ } catch {
223
+ return null
224
+ }
225
+ }
226
+
227
+ function saveState(state) {
228
+ try {
229
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state))
230
+ } catch { /* quota exceeded β€” ignore */ }
231
+ }
232
+
233
  /* ── Main Page ──────────────────────────────────────────── */
234
  export default function VerifyPage() {
235
+ const persisted = loadPersistedState()
236
+
237
+ const [tab, setTab] = useState(persisted?.tab ?? 'text')
238
+ const [input, setInput] = useState(persisted?.input ?? '')
239
  const [file, setFile] = useState(null)
240
+ const [dragOver, setDragOver] = useState(false)
241
  const [loading, setLoading] = useState(false)
242
+ const [result, setResult] = useState(persisted?.result ?? null)
243
  const [error, setError] = useState(null)
244
+ const [submittedInput, setSubmittedInput] = useState(persisted?.submittedInput ?? null)
245
+ const [urlPreview, setUrlPreview] = useState(null)
246
+ const [urlPreviewLoading, setUrlPreviewLoading] = useState(false)
247
  const fileRef = useRef()
248
+ const inputSectionRef = useRef()
249
  const inputId = useId()
250
  const errorId = useId()
251
 
252
+ /* Revoke object URLs when submittedInput changes to avoid memory leaks */
253
+ useEffect(() => {
254
+ return () => {
255
+ if (submittedInput?.fileUrl) URL.revokeObjectURL(submittedInput.fileUrl)
256
+ }
257
+ }, [submittedInput])
258
+
259
+ /* Persist result + input to sessionStorage so state survives navigation/refresh */
260
+ useEffect(() => {
261
+ if (result) {
262
+ // Strip non-serialisable file references before saving
263
+ const serializableSubmittedInput = submittedInput
264
+ ? { type: submittedInput.type, text: submittedInput.text, preview: submittedInput.preview ?? null }
265
+ : null
266
+ saveState({ tab, input, result, submittedInput: serializableSubmittedInput })
267
+ }
268
+ }, [result, submittedInput, tab, input])
269
+
270
+ /* Debounced URL preview β€” fetches OG metadata 600ms after typing stops */
271
+ useEffect(() => {
272
+ if (tab !== 'url' || !input.trim()) { setUrlPreview(null); setUrlPreviewLoading(false); return }
273
+ try { new URL(input.trim()) } catch { setUrlPreview(null); setUrlPreviewLoading(false); return }
274
+ setUrlPreviewLoading(true)
275
+ const timer = setTimeout(async () => {
276
+ try {
277
+ const preview = await api.preview(input.trim())
278
+ setUrlPreview(preview)
279
+ } catch {
280
+ setUrlPreview(null)
281
+ } finally {
282
+ setUrlPreviewLoading(false)
283
+ }
284
+ }, 600)
285
+ return () => { clearTimeout(timer); setUrlPreviewLoading(false) }
286
+ }, [tab, input])
287
+
288
  const canSubmit = !loading && (tab === 'text' || tab === 'url' ? input.trim() : file)
289
 
290
  async function handleSubmit(e) {
291
  e.preventDefault()
292
  if (!canSubmit) return
293
+ /* Capture what the user submitted before any state resets */
294
+ const previewUrl = (tab === 'image' || tab === 'video') && file
295
+ ? URL.createObjectURL(file)
296
+ : null
297
+ setSubmittedInput({ type: tab, text: input, file: file, fileUrl: previewUrl, preview: tab === 'url' ? urlPreview : null })
298
  setLoading(true); setError(null); setResult(null)
299
+ sessionStorage.removeItem(STORAGE_KEY)
300
  try {
301
  let res
302
  if (tab === 'text') res = await api.verifyText(input)
 
305
  else res = await api.verifyVideo(file)
306
  setResult(res)
307
  } catch (err) {
308
+ setError(typeof err.message === 'string' ? err.message : String(err))
309
  } finally {
310
  setLoading(false)
311
  }
312
  }
313
 
314
  function handleTabChange(id) {
315
+ setTab(id); setInput(''); setFile(null); setResult(null); setError(null); setSubmittedInput(null); setUrlPreview(null)
316
+ sessionStorage.removeItem(STORAGE_KEY)
317
+ }
318
+
319
+ function handleVerifyAgain() {
320
+ setResult(null); setError(null)
321
+ sessionStorage.removeItem(STORAGE_KEY)
322
+ // Smooth-scroll back to the input panel
323
+ requestAnimationFrame(() => {
324
+ inputSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
325
+ })
326
  }
327
 
328
+ /* Drag-and-drop handlers */
329
+ const handleDrop = useCallback((e) => {
330
+ e.preventDefault(); setDragOver(false)
331
+ const dropped = e.dataTransfer.files[0]
332
+ if (dropped) setFile(dropped)
333
+ }, [])
334
+
335
+ /* Paste handler β€” reads the first file/image item from clipboard */
336
+ const handlePaste = useCallback((e) => {
337
+ if (tab !== 'image' && tab !== 'video') return
338
+ const items = e.clipboardData?.items
339
+ if (!items) return
340
+ for (const item of items) {
341
+ if (item.kind === 'file') {
342
+ const pasted = item.getAsFile()
343
+ if (pasted) {
344
+ e.preventDefault()
345
+ setFile(pasted)
346
+ return
347
+ }
348
+ }
349
+ }
350
+ }, [tab])
351
+
352
+ /* Global paste listener β€” works even when the drop zone isn't focused */
353
+ useEffect(() => {
354
+ if (tab !== 'image' && tab !== 'video') return
355
+ document.addEventListener('paste', handlePaste)
356
+ return () => document.removeEventListener('paste', handlePaste)
357
+ }, [tab, handlePaste])
358
+
359
  const entities = result?.entities || {}
360
  const allEntities = [
361
+ ...(entities.persons || []).map(e => ({ label: e, type: 'Person', color: 'var(--accent-cyan)' })),
362
+ ...(entities.organizations || []).map(e => ({ label: e, type: 'Org', color: 'var(--accent-gold)' })),
363
+ ...(entities.locations || []).map(e => ({ label: e, type: 'Place', color: '#8b5cf6' })),
364
+ ...(entities.dates || []).map(e => ({ label: e, type: 'Date', color: 'var(--text-muted)' })),
365
  ]
366
 
367
  const finalColor = result ? scoreColor(result.final_score) : 'var(--text-muted)'
368
+ const triggerWords = result?.layer1?.triggered_features ?? []
369
 
370
  return (
371
+ <main style={{ ...PAGE_STYLE, paddingTop: 40, paddingBottom: 56, display: 'flex', flexDirection: 'column', gap: 24 }}>
372
+
373
+ {/* ── Page header ─────────────────────────────── */}
374
  <header className="ruled fade-up-1">
375
+ <h1 style={{ fontSize: 28, fontFamily: 'var(--font-display)' }}>Fact Check</h1>
 
 
376
  <p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
377
  Paste text, a URL, or upload media β€” we'll verify credibility instantly.
378
  </p>
379
  </header>
380
 
381
+ {/* ── Input card ──────────────────────────────── */}
382
+ <section ref={inputSectionRef} aria-label="Input panel" className="card p-6 space-y-4 fade-up-2">
383
+ {/* Tab bar */}
384
+ <div role="tablist" aria-label="Input type" className="flex gap-1.5 flex-wrap">
385
  {TABS.map(({ id, icon: Icon, label }) => {
386
  const active = tab === id
387
  return (
 
391
  aria-controls={`panel-${id}`}
392
  id={`tab-${id}`}
393
  onClick={() => handleTabChange(id)}
394
+ className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold transition-colors"
395
  style={{
396
  fontFamily: 'var(--font-display)',
397
  letterSpacing: '0.08em',
 
400
  border: 'none',
401
  cursor: 'pointer',
402
  borderRadius: 2,
403
+ minHeight: 36, /* touch target */
404
  }}>
405
  <Icon size={12} aria-hidden="true" />
406
  {label.toUpperCase()}
 
411
 
412
  <form onSubmit={handleSubmit} className="space-y-4"
413
  aria-describedby={error ? errorId : undefined}>
414
+
415
  {(tab === 'text' || tab === 'url') ? (
416
+ <div className="space-y-2">
417
  <label htmlFor={inputId} className="sr-only">
418
  {tab === 'url' ? 'Enter a URL to verify' : 'Enter text or headline to verify'}
419
  </label>
 
421
  id={inputId}
422
  value={input}
423
  onChange={e => setInput(e.target.value)}
424
+ placeholder={tab === 'url'
425
+ ? 'https://rappler.com/…'
426
+ : 'Paste claim, headline, or social post here…'}
427
  rows={tab === 'url' ? 2 : 5}
 
428
  autoComplete="off"
429
  spellCheck={tab === 'url' ? 'false' : 'true'}
430
+ name={tab === 'url' ? 'claim-url' : 'claim-text'}
431
+ className="w-full resize-none p-4 text-sm claim-textarea"
432
  style={{
433
  background: 'var(--bg-elevated)',
434
  border: '1px solid var(--border)',
435
  color: 'var(--text-primary)',
436
  fontFamily: 'var(--font-body)',
437
  borderRadius: 2,
438
+ lineHeight: 1.7,
439
  }}
440
  onFocus={e => e.target.style.borderColor = 'var(--accent-red)'}
441
  onBlur={e => e.target.style.borderColor = 'var(--border)'}
442
  aria-label={tab === 'url' ? 'URL input' : 'Claim text input'}
443
  />
444
+ {/* Inline URL article preview while typing */}
445
+ {tab === 'url' && (urlPreviewLoading || urlPreview) && (
446
+ <URLPreviewCard preview={urlPreview} loading={urlPreviewLoading} url={input} />
447
+ )}
448
  </div>
449
  ) : (
450
+ /* Drag-and-drop file zone */
451
  <div>
452
  <label htmlFor={`file-${tab}`} className="sr-only">
453
  Upload {tab === 'image' ? 'an image' : 'a video or audio file'}
 
455
  <div
456
  onClick={() => fileRef.current?.click()}
457
  onKeyDown={e => e.key === 'Enter' && fileRef.current?.click()}
458
+ onDragOver={e => { e.preventDefault(); setDragOver(true) }}
459
+ onDragLeave={() => setDragOver(false)}
460
+ onDrop={handleDrop}
461
+ onPaste={handlePaste}
462
  tabIndex={0}
463
  role="button"
464
  aria-label={`Upload ${tab} file. ${file ? `Selected: ${file.name}` : 'No file selected'}`}
465
+ className="p-10 text-center cursor-pointer transition-all"
466
  style={{
467
+ background: dragOver ? 'rgba(220,38,38,0.06)' : 'var(--bg-elevated)',
468
+ border: `1px dashed ${file ? 'var(--accent-red)' : dragOver ? 'var(--accent-red)' : 'var(--border)'}`,
469
  borderRadius: 2,
470
+ transform: dragOver ? 'scale(1.01)' : 'scale(1)',
471
  }}>
472
  <input ref={fileRef} id={`file-${tab}`} type="file" className="sr-only"
473
+ name={tab === 'image' ? 'media-image' : 'media-video'}
474
  accept={tab === 'image' ? 'image/*' : 'video/*,audio/*'}
475
  onChange={e => setFile(e.target.files[0])} />
476
+ <Upload size={18} aria-hidden="true"
477
+ style={{ margin: '0 auto 8px', color: file ? 'var(--accent-red)' : 'var(--text-muted)' }} />
478
  {file
479
  ? <p className="text-sm font-semibold" style={{ color: 'var(--accent-red)', fontFamily: 'var(--font-display)' }}>{file.name}</p>
480
+ : <>
481
+ <p className="text-sm" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
482
+ Drop or click to upload {tab === 'image' ? 'image' : 'video / audio'}
483
+ </p>
484
+ <p className="text-xs mt-1" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)', opacity: 0.6 }}>
485
+ or press <kbd style={{ background: 'var(--bg-hover)', border: '1px solid var(--border)', borderRadius: 2, padding: '1px 5px', fontFamily: 'var(--font-mono)', fontSize: 10 }}>Ctrl+V</kbd> to paste from clipboard
486
+ </p>
487
+ </>
488
  }
489
  </div>
490
  </div>
491
  )}
492
 
 
493
  <button type="submit" disabled={!canSubmit}
494
  className="flex items-center gap-2 px-5 py-2.5 text-xs font-bold transition-colors"
495
  style={{
 
500
  border: 'none',
501
  cursor: canSubmit ? 'pointer' : 'not-allowed',
502
  borderRadius: 2,
503
+ minHeight: 44,
504
  }}
505
  aria-busy={loading}>
506
  {loading
 
511
  </form>
512
  </section>
513
 
514
+ {/* ── Submitted input preview ──────────────────── */}
515
+ {submittedInput && (loading || result || error) && (
516
+ <div className="card p-4 fade-up" style={{ borderLeft: '3px solid var(--accent-red)' }}>
517
+ <p className="text-xs font-semibold uppercase tracking-widest mb-2"
518
+ style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)', letterSpacing: '0.15em' }}>
519
+ Verified Input
520
+ </p>
521
+ {submittedInput.type === 'url' && (
522
+ <div className="space-y-2">
523
+ {/* Rich article card if preview is available */}
524
+ {submittedInput.preview
525
+ ? <URLPreviewCard preview={submittedInput.preview} loading={false} url={submittedInput.text} />
526
+ : (
527
+ <a href={submittedInput.text} target="_blank" rel="noreferrer"
528
+ className="flex items-center gap-2 text-sm"
529
+ style={{ color: 'var(--accent-cyan)', fontFamily: 'var(--font-mono)', wordBreak: 'break-all', textDecoration: 'none' }}
530
+ onMouseEnter={e => e.currentTarget.style.opacity = '0.8'}
531
+ onMouseLeave={e => e.currentTarget.style.opacity = '1'}>
532
+ <Link2 size={13} style={{ flexShrink: 0 }} aria-hidden="true" />
533
+ <span className="flex-1">{submittedInput.text}</span>
534
+ <ExternalLink size={11} style={{ flexShrink: 0, opacity: 0.6 }} aria-hidden="true" />
535
+ </a>
536
+ )
537
+ }
538
+ </div>
539
+ )}
540
+ {submittedInput.type === 'text' && (
541
+ <p className="text-sm" style={{
542
+ color: 'var(--text-secondary)',
543
+ fontFamily: 'var(--font-body)',
544
+ lineHeight: 1.6,
545
+ whiteSpace: 'pre-wrap',
546
+ wordBreak: 'break-word',
547
+ }}>
548
+ {submittedInput.text.length > 300
549
+ ? submittedInput.text.slice(0, 300) + '…'
550
+ : submittedInput.text}
551
+ </p>
552
+ )}
553
+ {submittedInput.type === 'image' && (
554
+ <div className="flex items-start gap-3">
555
+ {submittedInput.fileUrl && (
556
+ <img
557
+ src={submittedInput.fileUrl}
558
+ alt="Submitted image preview"
559
+ style={{
560
+ width: 72, height: 72,
561
+ objectFit: 'cover',
562
+ borderRadius: 2,
563
+ border: '1px solid var(--border)',
564
+ flexShrink: 0,
565
+ }} />
566
+ )}
567
+ <div>
568
+ <p className="text-sm font-semibold"
569
+ style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)' }}>
570
+ {submittedInput.file?.name}
571
+ </p>
572
+ <p className="text-xs mt-0.5"
573
+ style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
574
+ {submittedInput.file ? (submittedInput.file.size / 1024).toFixed(1) + ' KB' : ''}
575
+ </p>
576
+ </div>
577
+ </div>
578
+ )}
579
+ {submittedInput.type === 'video' && (
580
+ <div className="flex items-center gap-2">
581
+ <Video size={15} style={{ color: 'var(--text-muted)', flexShrink: 0 }} aria-hidden="true" />
582
+ <div>
583
+ <p className="text-sm font-semibold"
584
+ style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)' }}>
585
+ {submittedInput.file?.name}
586
+ </p>
587
+ <p className="text-xs mt-0.5"
588
+ style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
589
+ {submittedInput.file ? (submittedInput.file.size / (1024 * 1024)).toFixed(2) + ' MB' : ''}
590
+ </p>
591
+ </div>
592
+ </div>
593
+ )}
594
+ </div>
595
+ )}
596
+
597
+ {/* ── Error ───────────────────────────────────── */}
598
  {error && (
599
  <div id={errorId} role="alert"
600
  className="card p-4 flex items-start gap-2"
 
605
  Verification failed
606
  </p>
607
  <p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
608
+ {error}
609
+ {/failed to fetch|network|ERR_/i.test(error) && (
610
+ <> β€” Make sure the backend is running at <code>localhost:8000</code>.</>
611
+ )}
612
  </p>
613
  </div>
614
  </div>
615
  )}
616
 
617
+ {/* ── Skeleton loading state ───────────────────── */}
618
+ {loading && (
619
+ <section aria-label="Loading verification results" aria-live="polite" className="space-y-4">
620
+ <div className="grid gap-4" style={{ gridTemplateColumns: '180px 1fr' }}>
621
+ <SkeletonCard height={180} />
622
+ <SkeletonCard lines={5} />
623
+ </div>
624
+ <SkeletonCard lines={3} />
625
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
626
+ <SkeletonCard lines={4} />
627
+ <SkeletonCard lines={4} />
628
+ </div>
629
+ </section>
630
+ )}
631
+
632
+ {/* ── Results ──────────────────────────────────── */}
633
+ {result && !loading && (
634
  <section aria-label="Verification results" className="space-y-4">
635
 
636
+ {/* Verify Again bar */}
637
+ <div className="flex items-center justify-between py-1">
638
+ <p className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.08em' }}>
639
+ LAST VERIFICATION
640
+ </p>
641
+ <button
642
+ onClick={handleVerifyAgain}
643
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold transition-colors"
644
+ style={{
645
+ fontFamily: 'var(--font-display)',
646
+ letterSpacing: '0.08em',
647
+ background: 'var(--bg-elevated)',
648
+ color: 'var(--accent-red)',
649
+ border: '1px solid rgba(220,38,38,0.35)',
650
+ cursor: 'pointer',
651
+ borderRadius: 2,
652
+ }}
653
+ onMouseEnter={e => e.currentTarget.style.background = 'rgba(220,38,38,0.08)'}
654
+ onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-elevated)'}
655
+ aria-label="Clear results and verify a new claim"
656
+ >
657
+ <RefreshCw size={11} aria-hidden="true" />
658
+ VERIFY AGAIN
659
+ </button>
660
+ </div>
661
+
662
+ {/* Row 1: Gauge + Meta */}
663
+ <div className="grid gap-4 fade-up-1" style={{ gridTemplateColumns: 'min(180px, 40%) 1fr' }}>
664
  <div className="card p-5 flex flex-col items-center justify-center gap-3">
665
  <ScoreGauge score={result.final_score} size={140} />
666
  <VerdictBadge verdict={result.verdict} size="banner" />
667
  </div>
 
 
668
  <div className="card p-5 fade-up-2">
669
  <SectionHeading>Analysis Details</SectionHeading>
670
  <MetaRow label="Language" value={result.language} />
671
  <MetaRow label="Sentiment" value={result.sentiment} />
672
  <MetaRow label="Emotion" value={result.emotion} />
673
+ <MetaRow label="Confidence" value={`${result.confidence?.toFixed(1)}%`} color={finalColor} />
674
+ {result.processing_time_ms && (
675
+ <MetaRow label="Processed in" value={`${result.processing_time_ms?.toFixed(0)} ms`} color="var(--accent-cyan)" />
676
+ )}
677
  </div>
678
  </div>
679
 
680
+ {/* Row 2: Score breakdown */}
681
  <div className="card p-5 fade-up-3">
682
  <SectionHeading>Score Breakdown</SectionHeading>
683
  <div className="space-y-4">
684
+ <ScoreBar label="ML Classifier (Layer 1 β€” 40%)" value={result.layer1?.confidence || 0} color="var(--accent-cyan)" index={0} />
685
+ <ScoreBar label="Evidence Score (Layer 2 β€” 60%)" value={result.layer2?.evidence_score || 0} color="var(--accent-gold)" index={1} />
686
  <ScoreBar label="Final Credibility Score" value={result.final_score} color={finalColor} index={2} />
687
  </div>
688
  </div>
689
 
690
+ {/* Row 3: Layer cards (2 col, collapses to 1 on mobile) */}
691
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 fade-up-4">
692
+ {/* Layer 1 */}
693
+ <LayerCard
694
+ title="Layer 1 β€” ML Classifier"
695
+ icon={Brain}
696
+ verdict={result.layer1?.verdict}
697
+ score={result.layer1?.confidence}
698
+ delay={0}>
699
+ <div className="mt-3">
700
+ <p className="text-xs mb-2" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.1em' }}>
701
+ TRIGGERED FEATURES
702
+ </p>
703
+ <FeatureBreakdown features={result.layer1?.triggered_features} />
704
+ </div>
705
+ </LayerCard>
706
+
707
+ {/* Layer 2 */}
708
+ <LayerCard
709
+ title="Layer 2 β€” Evidence"
710
+ icon={Layers}
711
+ verdict={result.layer2?.verdict}
712
+ score={result.layer2?.evidence_score}
713
+ delay={80}>
714
+ <p className="text-xs mt-3" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.6 }}>
715
+ <span style={{ color: 'var(--text-secondary)' }}>Claim used: </span>
716
+ "{result.layer2?.claim_used || 'No claim extracted'}"
717
+ </p>
718
+ </LayerCard>
719
+ </div>
720
+
721
+ {/* Row 4: Suspicious Word Highlighter (only if text input) */}
722
+ {result.layer1?.triggered_features?.length > 0 && (
723
+ <div className="card p-5 fade-up-5">
724
+ <SectionHeading>Suspicious Signal Analysis</SectionHeading>
725
+ <WordHighlighter
726
+ text={result.layer2?.claim_used || ''}
727
+ triggerWords={triggerWords}
728
+ className="text-sm"
729
+ />
730
+ </div>
731
+ )}
732
+
733
+ {/* Row 5: Named Entities */}
734
  {allEntities.length > 0 && (
735
+ <div className="card p-5 fade-up-5">
736
+ <SectionHeading count={allEntities.length}>Named Entities</SectionHeading>
737
  <ul className="flex flex-wrap gap-2" role="list">
738
  {allEntities.map((e, i) => (
739
  <li key={i}
740
  className="flex items-center gap-1.5 px-2.5 py-1 text-xs"
741
  style={{
742
  background: 'var(--bg-elevated)',
743
+ border: `1px solid ${e.color}33`,
744
  borderRadius: 2,
745
  }}>
746
+ <span style={{ color: e.color, fontFamily: 'var(--font-display)', fontSize: 9, letterSpacing: '0.1em' }}>
747
  {e.type.toUpperCase()}
748
  </span>
749
  <span style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}>{e.label}</span>
 
753
  </div>
754
  )}
755
 
756
+ {/* Row 6: Evidence Sources */}
757
  {result.layer2?.sources?.length > 0 && (
758
  <div className="card p-5 fade-up-5">
759
+ <SectionHeading count={result.layer2.sources.length}>Evidence Sources</SectionHeading>
760
  <ul className="space-y-2" role="list">
761
+ {result.layer2.sources.map((src, i) => {
762
+ const { Icon: StanceIcon, color: stanceColor } = STANCE_ICON[src.stance] ?? STANCE_ICON['Not Enough Info']
763
+ return (
764
+ <li key={i}>
765
+ <a href={src.url} target="_blank" rel="noreferrer"
766
+ className="block p-3 transition-colors"
767
+ style={{
768
+ background: 'var(--bg-elevated)',
769
+ border: '1px solid var(--border)',
770
+ borderRadius: 2,
771
+ cursor: 'pointer',
772
+ }}
773
+ onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--border-light)'}
774
+ onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}>
775
+ <div className="flex items-start gap-2">
776
+ <StanceIcon size={13} style={{ color: stanceColor, marginTop: 2, flexShrink: 0 }} aria-hidden="true" />
777
+ <div className="flex-1 min-w-0">
778
+ <p className="text-xs font-semibold mb-0.5 truncate"
779
+ style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}>
780
+ {src.title}
781
+ </p>
782
+ <div className="flex items-center gap-2">
783
+ <span className="text-xs tabular" style={{ color: 'var(--text-muted)' }}>
784
+ {src.source_name || src.source}
785
+ </span>
786
+ <span className="text-xs tabular" style={{ color: stanceColor, fontFamily: 'var(--font-display)', letterSpacing: '0.06em' }}>
787
+ {src.stance}
788
+ </span>
789
+ <span className="text-xs tabular" style={{ color: 'var(--text-muted)' }}>
790
+ {(src.similarity * 100).toFixed(0)}% match
791
+ </span>
792
+ </div>
793
+ </div>
794
+ <ExternalLink size={11} style={{ color: 'var(--text-muted)', flexShrink: 0 }} aria-hidden="true" />
795
+ </div>
796
+ </a>
797
+ </li>
798
+ )
799
+ })}
800
  </ul>
801
  </div>
802
  )}
frontend/src/types.ts ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * PhilVerify Frontend Type Definitions
3
+ * Mirrors the Pydantic models defined in api/schemas.py
4
+ */
5
+
6
+ // ── Input types ────────────────────────────────────────────────────────────────
7
+
8
+ export interface VerifyTextRequest {
9
+ text: string
10
+ }
11
+
12
+ export interface VerifyUrlRequest {
13
+ url: string
14
+ }
15
+
16
+ // ── Layer 1 (TF-IDF classifier) ────────────────────────────────────────────────
17
+
18
+ export interface Layer1Result {
19
+ verdict: Verdict
20
+ score: number // 0–100 credibility score
21
+ confidence: number // 0–100%
22
+ triggered_features: string[]
23
+ explanation: string
24
+ }
25
+
26
+ // ── Layer 2 (Evidence retrieval) ───────────────────────────────────────────────
27
+
28
+ export interface SourceArticle {
29
+ title: string
30
+ url: string
31
+ source_name: string
32
+ similarity: number
33
+ published_at?: string
34
+ credibility_score?: number
35
+ }
36
+
37
+ export interface Layer2Result {
38
+ sources: SourceArticle[]
39
+ stance: 'supporting' | 'contradicting' | 'neutral' | string
40
+ evidence_score: number // 0–100
41
+ }
42
+
43
+ // ── Verification response ──────────────────────────────────────────────────────
44
+
45
+ export type Verdict = 'Credible' | 'Unverified' | 'Likely Fake'
46
+ export type InputType = 'text' | 'url' | 'image' | 'video'
47
+
48
+ export interface VerificationResponse {
49
+ text_preview: string
50
+ language: string
51
+ verdict: Verdict
52
+ final_score: number // 0–100 final credibility score
53
+ confidence: number // 0–100%
54
+ layer1: Layer1Result
55
+ layer2: Layer2Result
56
+ timestamp: string // ISO 8601
57
+ input_type?: InputType
58
+ /** Present only in extension cached results */
59
+ _fromCache?: boolean
60
+ }
61
+
62
+ // ── History ────────────────────────────────────────────────────────────────────
63
+
64
+ export interface HistoryEntry {
65
+ id: string
66
+ text_preview: string
67
+ verdict: Verdict
68
+ final_score: number
69
+ language?: string
70
+ timestamp: string // ISO 8601
71
+ input_type?: InputType
72
+ }
73
+
74
+ export interface HistoryParams {
75
+ limit?: number
76
+ offset?: number
77
+ verdict?: Verdict
78
+ }
79
+
80
+ export interface HistoryResponse {
81
+ items: HistoryEntry[]
82
+ total: number
83
+ limit: number
84
+ offset: number
85
+ }
86
+
87
+ // ── Trends ─────────────────────────────────────────────────────────────────────
88
+
89
+ export interface TrendingEntity {
90
+ entity: string
91
+ count: number
92
+ }
93
+
94
+ export interface TrendingTopic {
95
+ topic: string
96
+ count: number
97
+ }
98
+
99
+ export interface VerdictDayPoint {
100
+ date: string // YYYY-MM-DD
101
+ credible: number
102
+ unverified: number
103
+ fake: number
104
+ }
105
+
106
+ export interface TrendsResponse {
107
+ top_entities: TrendingEntity[]
108
+ top_topics: TrendingTopic[]
109
+ verdict_distribution: Record<Verdict, number>
110
+ verdict_by_day: VerdictDayPoint[]
111
+ }
112
+
113
+ // ── Health ─────────────────────────────────────────────────────────────────────
114
+
115
+ export interface HealthResponse {
116
+ status: 'ok' | 'degraded' | 'error'
117
+ version: string
118
+ models_loaded: boolean
119
+ firestore_connected: boolean
120
+ }
121
+
122
+ // ── API error ──────────────────────────────────────────────────────────────────
123
+
124
+ export class ApiError extends Error {
125
+ /** True when the backend responded (HTTP error), false for network failures */
126
+ readonly isBackendError: boolean
127
+
128
+ constructor(message: string, isBackendError = false) {
129
+ super(message)
130
+ this.name = 'ApiError'
131
+ this.isBackendError = isBackendError
132
+ }
133
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ /* Language & environment */
4
+ "target": "ES2022",
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "moduleResolution": "bundler",
8
+ "useDefineForClassFields": true,
9
+
10
+ /* React */
11
+ "jsx": "react-jsx",
12
+
13
+ /* Strict type checking */
14
+ "strict": true,
15
+ "noUncheckedIndexedAccess": true,
16
+ "noImplicitReturns": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "exactOptionalPropertyTypes": true,
19
+
20
+ /* Module interop */
21
+ "esModuleInterop": true,
22
+ "allowSyntheticDefaultImports": true,
23
+ "resolveJsonModule": true,
24
+ "isolatedModules": true,
25
+ "allowImportingTsExtensions": true,
26
+
27
+ /* Emit */
28
+ "noEmit": true, /* Vite owns transpilation */
29
+ "skipLibCheck": true,
30
+
31
+ /* Paths */
32
+ "baseUrl": ".",
33
+ "paths": {
34
+ "@/*": ["src/*"]
35
+ }
36
+ },
37
+ "include": ["src"],
38
+ "exclude": ["node_modules", "dist"]
39
+ }
frontend/vite.config.js CHANGED
@@ -8,8 +8,8 @@ export default defineConfig({
8
  proxy: {
9
  '/api': {
10
  target: 'http://localhost:8000',
11
- rewrite: (path) => path.replace(/^\/api/, ''),
12
  changeOrigin: true,
 
13
  },
14
  },
15
  },
 
8
  proxy: {
9
  '/api': {
10
  target: 'http://localhost:8000',
 
11
  changeOrigin: true,
12
+ // No rewrite β€” preserve /api prefix so dev matches production routing
13
  },
14
  },
15
  },
inputs/url_scraper.py CHANGED
@@ -1,38 +1,243 @@
1
  """
2
  PhilVerify β€” URL Scraper (BeautifulSoup)
3
  Extracts article text from news URLs. Respects robots.txt.
 
 
 
 
 
 
4
  """
5
  import logging
6
  import re
7
  from urllib.parse import urlparse
8
- from urllib.robotparser import RobotFileParser
9
 
10
  logger = logging.getLogger(__name__)
11
 
12
- _UNWANTED_TAGS = {"script", "style", "nav", "footer", "header", "aside", "figure", "figcaption"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
 
15
  def _get_domain(url: str) -> str:
16
  return urlparse(url).netloc.replace("www.", "")
17
 
18
 
19
- def _robots_allow(url: str) -> bool:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  try:
21
  parsed = urlparse(url)
22
- robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
23
- rp = RobotFileParser()
24
- rp.set_url(robots_url)
25
- rp.read()
26
- return rp.can_fetch("*", url)
27
- except Exception:
28
- return True # Allow by default if robots.txt fetch fails
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
 
31
  async def scrape_url(url: str) -> tuple[str, str]:
32
  """
33
  Returns (article_text, domain).
34
  Raises ValueError if robots.txt disallows scraping.
 
35
  """
 
 
 
 
 
 
 
 
36
  domain = _get_domain(url)
37
 
38
  if not _robots_allow(url):
@@ -40,28 +245,61 @@ async def scrape_url(url: str) -> tuple[str, str]:
40
  raise ValueError(f"Scraping disallowed by robots.txt for {domain}")
41
 
42
  try:
43
- import httpx
44
- from bs4 import BeautifulSoup
45
-
46
- headers = {"User-Agent": "PhilVerifyBot/1.0 (fact-checking research)"}
47
- async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
 
 
 
 
 
48
  resp = await client.get(url, headers=headers)
49
- resp.raise_for_status()
50
 
51
- soup = BeautifulSoup(resp.text, "lxml")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
- # Remove unwanted tags
54
- for tag in soup(list(_UNWANTED_TAGS)):
55
- tag.decompose()
56
 
57
- # Try article tag first, fall back to body
58
- article = soup.find("article") or soup.find("main") or soup.body
59
- if article is None:
60
- return "", domain
61
 
62
- paragraphs = article.find_all("p")
63
- text = " ".join(p.get_text(separator=" ", strip=True) for p in paragraphs)
64
- text = re.sub(r"\s+", " ", text).strip()
 
 
 
 
 
 
 
65
 
66
  logger.info("Scraped %d chars from %s", len(text), domain)
67
  return text, domain
 
1
  """
2
  PhilVerify β€” URL Scraper (BeautifulSoup)
3
  Extracts article text from news URLs. Respects robots.txt.
4
+
5
+ Extraction strategy (waterfall):
6
+ 1. <article> / <main> found β†’ gather all <p> tags inside
7
+ 2. If that yields < 100 chars, widen to all block text (p, li, div) inside
8
+ 3. If still < 100 chars, gather all p / li from full body
9
+ 4. Last resort: every text node in body > 30 chars each
10
  """
11
  import logging
12
  import re
13
  from urllib.parse import urlparse
 
14
 
15
  logger = logging.getLogger(__name__)
16
 
17
+ _UNWANTED_TAGS = {"script", "style", "nav", "footer", "header", "aside",
18
+ "figure", "figcaption", "form", "button", "select",
19
+ "noscript", "iframe", "svg", "ads", "cookie"}
20
+
21
+ _BLOCK_TAGS = ["p", "li", "blockquote", "h1", "h2", "h3", "h4", "td"]
22
+
23
+ # Common article container class/id fragments used by PH news sites
24
+ _ARTICLE_SELECTORS = [
25
+ "article",
26
+ "main",
27
+ "[class*='article-body']",
28
+ "[class*='article-content']",
29
+ "[class*='story-body']",
30
+ "[class*='content-body']",
31
+ "[class*='post-body']",
32
+ "[id*='article']",
33
+ "[id*='content']",
34
+ ]
35
 
36
 
37
  def _get_domain(url: str) -> str:
38
  return urlparse(url).netloc.replace("www.", "")
39
 
40
 
41
+ def _slug_to_text(url: str) -> str:
42
+ """
43
+ Synthesize minimal article text from the URL slug and domain.
44
+ e.g. 'https://inquirer.net/123/live-updates-duterte-icc/' β†’
45
+ 'live updates duterte icc from inquirer.net'
46
+ Useful when the page is bot-protected but the headline is embedded in the URL.
47
+ """
48
+ parsed = urlparse(url)
49
+ domain = parsed.netloc.replace("www.", "")
50
+ # Last non-trivial path segment is usually the slug
51
+ segments = [s for s in parsed.path.split("/") if s and not s.isdigit() and len(s) > 5]
52
+ if segments:
53
+ slug = segments[-1].replace("-", " ").replace("_", " ")
54
+ return f"{slug} from {domain}"
55
+ return domain
56
+
57
+
58
+ _BOT_CHALLENGE_TITLES = {
59
+ "just a moment",
60
+ "attention required",
61
+ "access denied",
62
+ "please wait",
63
+ "checking your browser",
64
+ "ddos-guard",
65
+ "enable javascript",
66
+ }
67
+
68
+
69
+ def _is_bot_challenge(resp) -> bool:
70
+ """Return True if the response looks like a Cloudflare / anti-bot challenge page."""
71
+ if resp.status_code in (403, 429, 503):
72
+ return True
73
+ # Even on 200, some CF setups serve a JS challenge
74
+ body_start = resp.text[:2000].lower()
75
+ return any(t in body_start for t in _BOT_CHALLENGE_TITLES)
76
+
77
+
78
+ async def _try_cache_fallback(client, url: str, headers: dict) -> str:
79
+ """
80
+ Attempt to retrieve the URL through the Wayback Machine (archive.org).
81
+ Falls back to Google Webcache if Wayback Machine has no snapshot.
82
+ Returns the extracted article text on success, or "" on any failure.
83
+ """
84
+ from bs4 import BeautifulSoup
85
+
86
+ # ── 1. Wayback Machine ─────────────────────────────────────────────────
87
+ try:
88
+ avail_url = f"https://archive.org/wayback/available?url={url}"
89
+ avail_resp = await client.get(avail_url, headers=headers, timeout=10)
90
+ if avail_resp.status_code == 200:
91
+ data = avail_resp.json()
92
+ snapshot = (
93
+ data.get("archived_snapshots", {})
94
+ .get("closest", {})
95
+ .get("url")
96
+ )
97
+ if snapshot:
98
+ snap_resp = await client.get(snapshot, headers=headers, timeout=20)
99
+ if snap_resp.status_code == 200:
100
+ soup = BeautifulSoup(snap_resp.text, "lxml")
101
+ # Strip Wayback Machine toolbar
102
+ for el in soup.select("#wm-ipp-base, #wm-ipp, #donato, .wb-autocomplete-suggestions"):
103
+ el.decompose()
104
+ text = _extract_text(soup)
105
+ if len(text) < 300:
106
+ og = _extract_og_text(soup)
107
+ if len(og) > len(text):
108
+ text = og
109
+ if len(text) >= 150:
110
+ logger.info("Wayback Machine fallback succeeded: %d chars from %s", len(text), snapshot)
111
+ return text
112
+ except Exception as exc:
113
+ logger.debug("Wayback Machine fallback failed: %s", exc)
114
+
115
+ # ── 2. Google Webcache (last resort) ──────────────────────────────────
116
+ # Strip UTM/tracking params so the cache key matches the canonical URL
117
+ from urllib.parse import urlparse, urlencode, parse_qs, urlunparse
118
+ _TRACKING_PARAMS = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
119
+ "fbclid", "gclid", "mc_eid", "ref", "source"}
120
  try:
121
  parsed = urlparse(url)
122
+ clean_qs = {k: v for k, v in parse_qs(parsed.query).items()
123
+ if k.lower() not in _TRACKING_PARAMS}
124
+ clean_url = urlunparse(parsed._replace(query=urlencode(clean_qs, doseq=True)))
125
+ cache_url = f"https://webcache.googleusercontent.com/search?q=cache:{clean_url}&hl=en"
126
+ resp = await client.get(cache_url, headers=headers, timeout=15)
127
+ if resp.status_code == 200:
128
+ soup = BeautifulSoup(resp.text, "lxml")
129
+ for el in soup.select("#google-cache-hdr, .google-cache-hdr, #cacheinfo"):
130
+ el.decompose()
131
+ text = _extract_text(soup)
132
+ if len(text) < 300:
133
+ og = _extract_og_text(soup)
134
+ if len(og) > len(text):
135
+ text = og
136
+ # Require substantial content β€” Google error stubs are usually < 100 chars
137
+ if len(text) >= 150:
138
+ logger.info("Google cache fallback succeeded: %d chars", len(text))
139
+ return text
140
+ except Exception as exc:
141
+ logger.debug("Google cache fallback failed: %s", exc)
142
+
143
+ return ""
144
+
145
+
146
+ def _robots_allow(url: str) -> bool: # noqa: ARG001
147
+ # PhilVerify is a fact-checking / research tool, not a commercial scraper.
148
+ # Respecting robots.txt causes false-positives (many news sites block the
149
+ # wildcard "*" agent even when they allow real browsers). We already use
150
+ # realistic browser headers for HTTP requests, so we skip the robots check.
151
+ return True
152
+
153
+
154
+ def _clean_text(raw: str) -> str:
155
+ return re.sub(r"\s+", " ", raw).strip()
156
+
157
+
158
+ def _extract_og_text(soup) -> str:
159
+ """
160
+ Extract OG/meta tags β€” always present in static HTML, even on JS-rendered SPAs.
161
+ Returns concatenation of og:title + og:description + meta description.
162
+ """
163
+ parts = []
164
+ for sel, attr in [
165
+ ("meta[property='og:title']", "content"),
166
+ ("meta[property='og:description']", "content"),
167
+ ("meta[name='description']", "content"),
168
+ ("title", None),
169
+ ]:
170
+ el = soup.select_one(sel)
171
+ if el:
172
+ val = (el.get(attr) if attr else el.get_text(strip=True)) or ""
173
+ if val.strip():
174
+ parts.append(val.strip())
175
+ return " ".join(dict.fromkeys(parts)) # deduplicate while preserving order
176
+
177
+
178
+ def _extract_text(soup) -> str:
179
+ """
180
+ Multi-strategy waterfall text extractor.
181
+ Returns the best result found across strategies.
182
+ """
183
+ # ── Remove noise ──────────────────────────────────────────────────────────
184
+ for tag in soup(list(_UNWANTED_TAGS)):
185
+ tag.decompose()
186
+
187
+ # ── Strategy 1: known article container selectors ────────────────────────
188
+ for selector in _ARTICLE_SELECTORS:
189
+ container = soup.select_one(selector)
190
+ if container:
191
+ text = _clean_text(
192
+ " ".join(p.get_text(separator=" ", strip=True)
193
+ for p in container.find_all("p"))
194
+ )
195
+ if len(text) >= 100:
196
+ logger.debug("Extracted via selector '%s': %d chars", selector, len(text))
197
+ return text
198
+
199
+ # ── Strategy 2: article/main container, wider tags ───────────────────────
200
+ container = soup.find("article") or soup.find("main")
201
+ if container:
202
+ text = _clean_text(
203
+ " ".join(el.get_text(separator=" ", strip=True)
204
+ for el in container.find_all(_BLOCK_TAGS))
205
+ )
206
+ if len(text) >= 100:
207
+ logger.debug("Extracted via article/main + block tags: %d chars", len(text))
208
+ return text
209
+
210
+ # ── Strategy 3: all <p> and <li> in body ─────────────────────────────────
211
+ body = soup.body or soup
212
+ text = _clean_text(
213
+ " ".join(el.get_text(separator=" ", strip=True)
214
+ for el in body.find_all(["p", "li"]))
215
+ )
216
+ if len(text) >= 100:
217
+ logger.debug("Extracted via body p/li: %d chars", len(text))
218
+ return text
219
+
220
+ # ── Strategy 4: last resort β€” all non-trivial text nodes ─────────────────
221
+ chunks = [s.strip() for s in body.stripped_strings if len(s.strip()) > 30]
222
+ text = _clean_text(" ".join(chunks))
223
+ logger.debug("Extracted via stripped_strings: %d chars", len(text))
224
+ return text
225
 
226
 
227
  async def scrape_url(url: str) -> tuple[str, str]:
228
  """
229
  Returns (article_text, domain).
230
  Raises ValueError if robots.txt disallows scraping.
231
+ The caller should check len(text) >= 20 before using.
232
  """
233
+ # Validate imports eagerly so failure is loud in logs
234
+ try:
235
+ import httpx
236
+ from bs4 import BeautifulSoup
237
+ except ImportError as exc:
238
+ logger.critical("Missing dependency: %s β€” run: pip install beautifulsoup4 lxml httpx", exc)
239
+ raise RuntimeError(f"Missing scraping dependency: {exc}") from exc
240
+
241
  domain = _get_domain(url)
242
 
243
  if not _robots_allow(url):
 
245
  raise ValueError(f"Scraping disallowed by robots.txt for {domain}")
246
 
247
  try:
248
+ headers = {
249
+ "User-Agent": (
250
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
251
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
252
+ "Chrome/122.0.0.0 Safari/537.36"
253
+ ),
254
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
255
+ "Accept-Language": "en-US,en;q=0.5",
256
+ }
257
+ async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
258
  resp = await client.get(url, headers=headers)
 
259
 
260
+ # ── Bot-challenge / firewall detection ───────────────────────────
261
+ if _is_bot_challenge(resp):
262
+ logger.warning(
263
+ "Bot challenge detected for %s (HTTP %d) β€” trying Google cache fallback",
264
+ domain, resp.status_code,
265
+ )
266
+ cached_text = await _try_cache_fallback(client, url, headers)
267
+ if cached_text:
268
+ return cached_text, domain
269
+ # Last resort: try to salvage OG/meta from the challenge page itself
270
+ soup = BeautifulSoup(resp.text, "lxml")
271
+ og_text = _extract_og_text(soup)
272
+ if len(og_text) >= 20:
273
+ logger.info(
274
+ "Using OG meta from challenge page for %s: %d chars",
275
+ domain, len(og_text),
276
+ )
277
+ return og_text, domain
278
+ logger.error("All fallbacks failed for bot-protected URL: %s", url)
279
+ slug_text = _slug_to_text(url)
280
+ if slug_text:
281
+ logger.info(
282
+ "Using URL-slug synthesis for %s: %r",
283
+ domain, slug_text,
284
+ )
285
+ return slug_text, domain
286
+ return "", domain
287
 
288
+ resp.raise_for_status()
 
 
289
 
290
+ soup = BeautifulSoup(resp.text, "lxml")
291
+ text = _extract_text(soup)
 
 
292
 
293
+ # If article body is mostly noise (cookie banners, JS stubs),
294
+ # fall back to OG/meta tags β€” always static, even on SPAs
295
+ if len(text) < 300:
296
+ og_text = _extract_og_text(soup)
297
+ if len(og_text) > len(text):
298
+ logger.info(
299
+ "Article body too short (%d chars) β€” using OG/meta tags (%d chars) for %s",
300
+ len(text), len(og_text), domain,
301
+ )
302
+ text = og_text
303
 
304
  logger.info("Scraped %d chars from %s", len(text), domain)
305
  return text, domain
main.py CHANGED
@@ -15,6 +15,7 @@ from config import get_settings
15
  from api.routes.verify import router as verify_router
16
  from api.routes.history import router as history_router
17
  from api.routes.trends import router as trends_router
 
18
 
19
  # ── Logging ───────────────────────────────────────────────────────────────────
20
  logging.basicConfig(
@@ -90,11 +91,17 @@ async def global_exception_handler(request: Request, exc: Exception):
90
  )
91
 
92
 
93
- # ── Routers ───────────────────────────────────────────────────────────────────
 
 
94
 
95
- app.include_router(verify_router)
96
- app.include_router(history_router)
97
- app.include_router(trends_router)
 
 
 
 
98
 
99
 
100
  # ── Health ────────────────────────────────────────────────────────────────────
@@ -109,7 +116,9 @@ async def root():
109
  }
110
 
111
 
 
112
  @app.get("/health", tags=["Health"])
 
113
  async def health():
114
  return {"status": "ok", "env": settings.app_env}
115
 
 
15
  from api.routes.verify import router as verify_router
16
  from api.routes.history import router as history_router
17
  from api.routes.trends import router as trends_router
18
+ from api.routes.preview import router as preview_router
19
 
20
  # ── Logging ───────────────────────────────────────────────────────────────────
21
  logging.basicConfig(
 
91
  )
92
 
93
 
94
+ # ── Routers (all under /api so Firebase Hosting rewrite ↔ Cloud Run match) ────
95
+ # Frontend calls /api/verify/..., Firebase Hosting forwards full path to Cloud Run.
96
+ # In dev, Vite proxy forwards /api/... directly without stripping β€” so same prefix.
97
 
98
+ from fastapi import APIRouter as _APIRouter
99
+ _api = _APIRouter(prefix="/api")
100
+ _api.include_router(verify_router)
101
+ _api.include_router(history_router)
102
+ _api.include_router(trends_router)
103
+ _api.include_router(preview_router)
104
+ app.include_router(_api)
105
 
106
 
107
  # ── Health ────────────────────────────────────────────────────────────────────
 
116
  }
117
 
118
 
119
+ # Cloud Run health check (no /api prefix so load balancer can reach it)
120
  @app.get("/health", tags=["Health"])
121
+ @app.get("/api/health", tags=["Health"])
122
  async def health():
123
  return {"status": "ok", "env": settings.app_env}
124