Spaces:
Sleeping
Sleeping
Commit ·
a7e77af
0
Parent(s):
Initial commit - TruthScan AI v2.0 master thesis
Browse filesCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +18 -0
- CLAUDE.md +106 -0
- TruthScan AI_backend/app/__init__.py +0 -0
- TruthScan AI_backend/app/benchmark.py +240 -0
- TruthScan AI_backend/app/config.py +50 -0
- TruthScan AI_backend/app/main.py +57 -0
- TruthScan AI_backend/app/models.py +16 -0
- TruthScan AI_backend/app/nlp_service.py +459 -0
- TruthScan AI_backend/app/routes/__init__.py +0 -0
- TruthScan AI_backend/app/routes/misc.py +68 -0
- TruthScan AI_backend/app/routes/news.py +234 -0
- TruthScan AI_backend/app/routes/saved.py +34 -0
- TruthScan AI_backend/app/rss_utils.py +47 -0
- TruthScan AI_backend/app/storage.py +60 -0
- TruthScan AI_backend/requirements.txt +22 -0
- TruthScan AI_backend/setup_test_env.bat +21 -0
- TruthScan AI_backend/truthscan_report.html +417 -0
- TruthScan AI_backend/truthscan_report.txt +97 -0
- TruthScan AI_backend/truthscan_test.py +1135 -0
- TruthScan AI_frontend/.gitignore +41 -0
- TruthScan AI_frontend/README.md +36 -0
- TruthScan AI_frontend/Uruchamianie.md +30 -0
- TruthScan AI_frontend/app/TipModel.tsx +90 -0
- TruthScan AI_frontend/app/api/fetch-article-content/route.ts +198 -0
- TruthScan AI_frontend/app/dashboard/components/ArticlesModal.tsx +83 -0
- TruthScan AI_frontend/app/dashboard/components/ChartsSection.tsx +282 -0
- TruthScan AI_frontend/app/dashboard/components/DashboardHeader.tsx +62 -0
- TruthScan AI_frontend/app/dashboard/components/MiniSpinner.tsx +19 -0
- TruthScan AI_frontend/app/dashboard/components/SourceComparison.tsx +53 -0
- TruthScan AI_frontend/app/dashboard/error.tsx +33 -0
- TruthScan AI_frontend/app/dashboard/loading.tsx +21 -0
- TruthScan AI_frontend/app/dashboard/page.tsx +71 -0
- TruthScan AI_frontend/app/error.tsx +39 -0
- TruthScan AI_frontend/app/hooks/useArticleContent.ts +121 -0
- TruthScan AI_frontend/app/hooks/useCachedEmotionStats.ts +63 -0
- TruthScan AI_frontend/app/hooks/useDashboardCharts.ts +102 -0
- TruthScan AI_frontend/app/hooks/useFavicon.ts +73 -0
- TruthScan AI_frontend/app/hooks/useLanguage.ts +54 -0
- TruthScan AI_frontend/app/hooks/useNewsCache.ts +87 -0
- TruthScan AI_frontend/app/hooks/useNewsStream.ts +128 -0
- TruthScan AI_frontend/app/hooks/usePDFExport.ts +160 -0
- TruthScan AI_frontend/app/hooks/useSentiment.ts +40 -0
- TruthScan AI_frontend/app/layout.tsx +28 -0
- TruthScan AI_frontend/app/page.tsx +331 -0
- TruthScan AI_frontend/app/saved/page.tsx +15 -0
- TruthScan AI_frontend/app/stores/newsCache.tsx +90 -0
- TruthScan AI_frontend/components/ArticleCard.tsx +234 -0
- TruthScan AI_frontend/components/ArticleList.tsx +61 -0
- TruthScan AI_frontend/components/DarkModeToggle.tsx +53 -0
- TruthScan AI_frontend/components/EmotionalPieChart.tsx +190 -0
.gitignore
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
venv/
|
| 4 |
+
|
| 5 |
+
# Python
|
| 6 |
+
__pycache__/
|
| 7 |
+
*.pyc
|
| 8 |
+
*.egg-info/
|
| 9 |
+
|
| 10 |
+
# Environment
|
| 11 |
+
.env
|
| 12 |
+
|
| 13 |
+
# Data / generated files
|
| 14 |
+
saved_articles.json
|
| 15 |
+
benchmark_results/
|
| 16 |
+
|
| 17 |
+
# Next.js build output
|
| 18 |
+
.next/
|
CLAUDE.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CLAUDE.md
|
| 2 |
+
|
| 3 |
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
| 4 |
+
|
| 5 |
+
## Project Overview
|
| 6 |
+
|
| 7 |
+
TruthScan AI is a master's thesis full-stack application for real-time news credibility analysis. It fetches articles from RSS feeds, runs them through HuggingFace Transformer models (sentiment analysis + fake news detection), and presents results in a bilingual (PL/EN) Next.js dashboard.
|
| 8 |
+
|
| 9 |
+
## Commands
|
| 10 |
+
|
| 11 |
+
### Backend (`TruthScan AI_backend/`)
|
| 12 |
+
|
| 13 |
+
```bash
|
| 14 |
+
# Install dependencies
|
| 15 |
+
cd "TruthScan AI_backend"
|
| 16 |
+
pip install -r requirements.txt
|
| 17 |
+
|
| 18 |
+
# Start dev server (http://127.0.0.1:8000)
|
| 19 |
+
python -m uvicorn app.main:app
|
| 20 |
+
|
| 21 |
+
# Run tests
|
| 22 |
+
python truthscan_test.py
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
Swagger UI available at `http://localhost:8000/docs`.
|
| 26 |
+
|
| 27 |
+
### Frontend (`TruthScan AI_frontend/`)
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
# Install dependencies
|
| 31 |
+
cd "TruthScan AI_frontend"
|
| 32 |
+
npm install
|
| 33 |
+
|
| 34 |
+
# Start dev server (http://localhost:3000)
|
| 35 |
+
npm run dev
|
| 36 |
+
|
| 37 |
+
# Build for production
|
| 38 |
+
npm run build
|
| 39 |
+
|
| 40 |
+
# Lint
|
| 41 |
+
npm run lint
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
## Architecture
|
| 45 |
+
|
| 46 |
+
### Data Flow
|
| 47 |
+
|
| 48 |
+
```
|
| 49 |
+
Browser (Next.js :3000)
|
| 50 |
+
→ HTTP/SSE → FastAPI (:8000)
|
| 51 |
+
→ RSS fetch → feedparser + BeautifulSoup
|
| 52 |
+
→ NLP pipeline → HuggingFace Transformers
|
| 53 |
+
→ JSON/SSE response → React UI
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
### Backend (`app/`)
|
| 57 |
+
|
| 58 |
+
- **`config.py`** — All constants: 10 RSS sources (BBC, CNN, NYTimes, Guardian, AlJazeera, PolsatNews, etc.), CORS settings, sentiment label mappings (PL/EN), cache TTLs.
|
| 59 |
+
- **`nlp_service.py`** — Plug-in NLP pipeline:
|
| 60 |
+
- Abstract base class `ModelAdapter` with `analyze_sentiment()` and `analyze_fake_news()`.
|
| 61 |
+
- Three adapters: `RoBERTaAdapter` (en), `XLMRoBERTaAdapter` (pl/en/no), `NorBERTAdapter` (no).
|
| 62 |
+
- All adapters lazy-load their pipelines on first use.
|
| 63 |
+
- Global registry `_REGISTRY`; active adapter changed via `set_active_adapter(name)`.
|
| 64 |
+
- `register_adapter(adapter)` adds custom/fine-tuned checkpoints at runtime.
|
| 65 |
+
- Public functions `analyze_news()` / `analyze_news_batch()` preserve the original interface — `routes/news.py` requires no changes.
|
| 66 |
+
- Uses `ThreadPoolExecutor` (max 3 workers) for parallel batch processing.
|
| 67 |
+
- **`rss_utils.py`** — RSS fetching with 7s timeout, HTML stripping via BeautifulSoup, simple in-memory TTL cache.
|
| 68 |
+
- **`storage.py`** — Thread-safe JSON file persistence for saved articles (`saved_articles.json`).
|
| 69 |
+
- **`routes/news.py`** — Main endpoints: `GET /news/{source}` (5 articles with NLP), `GET /stream-news/{source}` (SSE streaming), `GET /emotion-stats/{source}`, `GET /charts-data`.
|
| 70 |
+
- **`routes/saved.py`** — CRUD for saved articles.
|
| 71 |
+
- **`routes/misc.py`** — `GET /sources` lists available RSS sources.
|
| 72 |
+
|
| 73 |
+
### Frontend (`app/` + `components/` + `lib/`)
|
| 74 |
+
|
| 75 |
+
- **Routing**: Next.js App Router — pages at `app/page.tsx` (home), `app/dashboard/page.tsx`, `app/saved/page.tsx`.
|
| 76 |
+
- **State**: Zustand store in `stores/newsCache.tsx` — caches articles per source/language with 5-min TTL, persisted to `localStorage` (key: `truthscan_news_cache_v1`).
|
| 77 |
+
- **API client**: `lib/fetchNews.ts` — `fetchOneSource()` and `fetchAllNews()` (concurrent, 3 workers default), plus `normalizeArticle()`.
|
| 78 |
+
- **SSE streaming**: `hooks/useNewsStream.ts` consumes `GET /stream-news/{source}`, tracks progress and collects articles, writes to Zustand cache on completion.
|
| 79 |
+
- **i18n**: `lib/locales.js` holds all PL/EN UI strings. Language state managed in `hooks/useLanguage.ts`, synced via `localStorage` and `app:langchange` custom event.
|
| 80 |
+
- **Charts**: Recharts via `hooks/useDashboardCharts.ts` and `hooks/useCachedEmotionStats.ts`.
|
| 81 |
+
- **PDF export**: `hooks/usePDFExport.ts` (jsPDF) + `components/PDFGenerator.tsx` (react-to-print).
|
| 82 |
+
- **Dark mode**: Tailwind `.dark` class toggle, CSS variables in `styles/globals.css`.
|
| 83 |
+
|
| 84 |
+
## Master's Thesis Goals
|
| 85 |
+
|
| 86 |
+
The thesis extends TruthScan AI by comparing NLP models across three languages: **Polish**, **Norwegian**, and **English**. Models under comparison: **XLM-RoBERTa**, **NorBERT 3**, **HerBERT**.
|
| 87 |
+
|
| 88 |
+
Planned work items:
|
| 89 |
+
|
| 90 |
+
1. **`nlp_service.py` plug-in architecture** — replace the hardcoded model with a swappable interface so each model (XLM-RoBERTa, NorBERT 3, HerBERT) can be loaded and hot-swapped without changing route logic.
|
| 91 |
+
|
| 92 |
+
2. **Norwegian RSS sources in `config.py`** — add NRK, VG, and Dagbladet alongside the existing 10 sources.
|
| 93 |
+
|
| 94 |
+
3. **`SENTIMENT_MAP` extension** — add a `"no"` (Norwegian) key to the sentiment label mapping in `config.py`, parallel to the existing `"pl"` and `"en"` keys.
|
| 95 |
+
|
| 96 |
+
4. **Benchmarking module** — new module that measures **F1**, **accuracy**, and **inference time** per model/language combination and exposes results via a dedicated API endpoint (or offline report).
|
| 97 |
+
|
| 98 |
+
5. **PostgreSQL migration** — replace `storage.py` / `saved_articles.json` with a PostgreSQL backend (SQLAlchemy or asyncpg). `storage.py` read/write interface should be preserved so routes need minimal changes.
|
| 99 |
+
|
| 100 |
+
6. **Public deployment** — frontend on **Vercel**, backend + models on **Hugging Face Spaces**.
|
| 101 |
+
|
| 102 |
+
### Environment
|
| 103 |
+
|
| 104 |
+
- Backend API base URL: `process.env.NEXT_PUBLIC_API_URL` (defaults to `http://127.0.0.1:8000`).
|
| 105 |
+
- Backend CORS is open (`"*"`) — intentional for thesis/dev use.
|
| 106 |
+
- NLP models are downloaded automatically by HuggingFace on first run (can be slow).
|
TruthScan AI_backend/app/__init__.py
ADDED
|
File without changes
|
TruthScan AI_backend/app/benchmark.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Moduł benchmarkowania modeli NLP.
|
| 3 |
+
|
| 4 |
+
Mierzy czas inferencji, rozkład sentymentów i prawdopodobieństwo fake news
|
| 5 |
+
dla każdego adaptera (roberta, xlm-roberta, norbert) na próbce tekstów
|
| 6 |
+
z trzech grup językowych (en, pl, no).
|
| 7 |
+
|
| 8 |
+
Użycie standalone:
|
| 9 |
+
python -m app.benchmark
|
| 10 |
+
|
| 11 |
+
Użycie z API:
|
| 12 |
+
GET /benchmark
|
| 13 |
+
GET /benchmark?adapters=roberta,xlm-roberta&langs=en,pl
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import csv
|
| 17 |
+
import json
|
| 18 |
+
import time
|
| 19 |
+
from collections import Counter
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Dict, List, Optional
|
| 22 |
+
|
| 23 |
+
from .nlp_service import get_adapter, ModelAdapter, analyze_news
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Próbka tekstów testowych
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
SAMPLE_TEXTS: Dict[str, List[str]] = {
|
| 30 |
+
"en": [
|
| 31 |
+
"The government announced new economic reforms to boost growth and reduce unemployment.",
|
| 32 |
+
"Flooding devastated coastal towns overnight, leaving thousands homeless.",
|
| 33 |
+
"Scientists discover a new vaccine that shows 95% efficacy against the virus.",
|
| 34 |
+
"Stock markets surged to record highs after positive inflation data.",
|
| 35 |
+
"A major scandal erupted as leaked documents exposed corporate corruption.",
|
| 36 |
+
"The peace talks collapsed after both sides failed to reach an agreement.",
|
| 37 |
+
"Renewable energy investments hit an all-time high this quarter.",
|
| 38 |
+
"Crime rates in the capital have dropped significantly over the past year.",
|
| 39 |
+
],
|
| 40 |
+
"pl": [
|
| 41 |
+
"Rząd ogłosił nowe reformy gospodarcze mające na celu pobudzenie wzrostu.",
|
| 42 |
+
"Powódź zniszczyła nadmorskie miejscowości, tysiące osób zostało bez dachu.",
|
| 43 |
+
"Naukowcy odkryli szczepionkę o 95-procentowej skuteczności przeciw wirusowi.",
|
| 44 |
+
"Giełda osiągnęła rekordowe poziomy po pozytywnych danych o inflacji.",
|
| 45 |
+
"Wybuchł wielki skandal po ujawnieniu dokumentów o korupcji korporacyjnej.",
|
| 46 |
+
"Rozmowy pokojowe załamały się po niepowodzeniu negocjacji.",
|
| 47 |
+
"Inwestycje w energię odnawialną osiągnęły historyczny rekord w tym kwartale.",
|
| 48 |
+
"Wskaźniki przestępczości w stolicy znacząco spadły w ciągu ostatniego roku.",
|
| 49 |
+
],
|
| 50 |
+
"no": [
|
| 51 |
+
"Regjeringen kunngjorde nye økonomiske reformer for å øke veksten.",
|
| 52 |
+
"Flom ødela kystbyer over natten og etterlot tusenvis uten hjem.",
|
| 53 |
+
"Forskere oppdaget en vaksine med 95 prosent effektivitet mot viruset.",
|
| 54 |
+
"Aksjemarkedene steg til rekordhøyder etter positive inflasjonsdata.",
|
| 55 |
+
"En stor skandale brøt ut da lekkede dokumenter avslørte korrupsjon.",
|
| 56 |
+
"Fredssamtalene brøt sammen etter at begge sider ikke klarte å bli enige.",
|
| 57 |
+
"Investeringer i fornybar energi nådde en historisk topp dette kvartalet.",
|
| 58 |
+
"Kriminalitetsratene i hovedstaden har falt betydelig det siste året.",
|
| 59 |
+
],
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
+
# Typy wyników
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
|
| 66 |
+
BenchmarkResult = Dict # TypedDict zastąpiony zwykłym Dict dla czytelności
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
# Funkcje benchmarkowania
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
|
| 72 |
+
def _run_single(
|
| 73 |
+
adapter: ModelAdapter,
|
| 74 |
+
text: str,
|
| 75 |
+
lang: str,
|
| 76 |
+
) -> Dict:
|
| 77 |
+
"""Uruchamia analyze_news dla jednego tekstu i mierzy czas."""
|
| 78 |
+
start = time.perf_counter()
|
| 79 |
+
result = analyze_news(text, lang=lang, adapter=adapter)
|
| 80 |
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
| 81 |
+
return {**result, "inference_time_ms": elapsed_ms}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def run_benchmark(
|
| 85 |
+
adapter_names: Optional[List[str]] = None,
|
| 86 |
+
langs: Optional[List[str]] = None,
|
| 87 |
+
) -> List[BenchmarkResult]:
|
| 88 |
+
"""
|
| 89 |
+
Uruchamia benchmark dla wskazanych adapterów i języków.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
adapter_names: Lista nazw adapterów; None = wszystkie trzy.
|
| 93 |
+
langs: Lista kodów języków; None = ['en', 'pl', 'no'].
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
Lista słowników z wynikami — jeden wpis na kombinację adapter × język.
|
| 97 |
+
"""
|
| 98 |
+
if adapter_names is None:
|
| 99 |
+
adapter_names = ["roberta", "xlm-roberta", "norbert"]
|
| 100 |
+
if langs is None:
|
| 101 |
+
langs = ["en", "pl", "no"]
|
| 102 |
+
|
| 103 |
+
results: List[BenchmarkResult] = []
|
| 104 |
+
|
| 105 |
+
for adapter_name in adapter_names:
|
| 106 |
+
try:
|
| 107 |
+
adapter = get_adapter(adapter_name)
|
| 108 |
+
except ValueError as exc:
|
| 109 |
+
results.append({
|
| 110 |
+
"adapter_name": adapter_name,
|
| 111 |
+
"error": str(exc),
|
| 112 |
+
})
|
| 113 |
+
continue
|
| 114 |
+
|
| 115 |
+
for lang in langs:
|
| 116 |
+
texts = SAMPLE_TEXTS.get(lang, [])
|
| 117 |
+
if not texts:
|
| 118 |
+
continue
|
| 119 |
+
|
| 120 |
+
per_text: List[Dict] = []
|
| 121 |
+
for text in texts:
|
| 122 |
+
try:
|
| 123 |
+
per_text.append(_run_single(adapter, text, lang))
|
| 124 |
+
except Exception as exc:
|
| 125 |
+
per_text.append({
|
| 126 |
+
"sentiment": None,
|
| 127 |
+
"fake_probability": None,
|
| 128 |
+
"sentiment_score": None,
|
| 129 |
+
"inference_time_ms": None,
|
| 130 |
+
"error": str(exc),
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
# Agregacja
|
| 134 |
+
valid = [r for r in per_text if r.get("inference_time_ms") is not None]
|
| 135 |
+
times = [r["inference_time_ms"] for r in valid]
|
| 136 |
+
fakes = [r["fake_probability"] for r in valid if r.get("fake_probability") is not None]
|
| 137 |
+
sentiments = [r["sentiment"] for r in valid if r.get("sentiment")]
|
| 138 |
+
|
| 139 |
+
results.append({
|
| 140 |
+
"adapter_name": adapter_name,
|
| 141 |
+
"language": lang,
|
| 142 |
+
"sample_size": len(texts),
|
| 143 |
+
"successful_runs": len(valid),
|
| 144 |
+
"avg_inference_time_ms": round(sum(times) / len(times), 2) if times else None,
|
| 145 |
+
"min_inference_time_ms": round(min(times), 2) if times else None,
|
| 146 |
+
"max_inference_time_ms": round(max(times), 2) if times else None,
|
| 147 |
+
"avg_fake_probability": round(sum(fakes) / len(fakes), 2) if fakes else None,
|
| 148 |
+
"sentiments_distribution": dict(Counter(sentiments)),
|
| 149 |
+
"per_text": per_text,
|
| 150 |
+
})
|
| 151 |
+
|
| 152 |
+
return results
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# ---------------------------------------------------------------------------
|
| 156 |
+
# Eksport wyników
|
| 157 |
+
# ---------------------------------------------------------------------------
|
| 158 |
+
|
| 159 |
+
def export_json(results: List[BenchmarkResult], path: Path) -> None:
|
| 160 |
+
"""Zapisuje pełne wyniki (z per_text) do pliku JSON."""
|
| 161 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 162 |
+
with open(path, "w", encoding="utf-8") as fh:
|
| 163 |
+
json.dump(results, fh, ensure_ascii=False, indent=2)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def export_csv(results: List[BenchmarkResult], path: Path) -> None:
|
| 167 |
+
"""
|
| 168 |
+
Zapisuje wyniki zbiorcze (bez per_text) do pliku CSV.
|
| 169 |
+
Jeden wiersz = jedna kombinacja adapter × język.
|
| 170 |
+
"""
|
| 171 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 172 |
+
summary_fields = [
|
| 173 |
+
"adapter_name", "language", "sample_size", "successful_runs",
|
| 174 |
+
"avg_inference_time_ms", "min_inference_time_ms", "max_inference_time_ms",
|
| 175 |
+
"avg_fake_probability", "sentiments_distribution",
|
| 176 |
+
]
|
| 177 |
+
with open(path, "w", newline="", encoding="utf-8") as fh:
|
| 178 |
+
writer = csv.DictWriter(fh, fieldnames=summary_fields, extrasaction="ignore")
|
| 179 |
+
writer.writeheader()
|
| 180 |
+
for row in results:
|
| 181 |
+
if "error" in row:
|
| 182 |
+
continue
|
| 183 |
+
flat = {k: row.get(k) for k in summary_fields}
|
| 184 |
+
# Rozkład sentymentów jako string JSON w komórce CSV
|
| 185 |
+
flat["sentiments_distribution"] = json.dumps(
|
| 186 |
+
row.get("sentiments_distribution", {}), ensure_ascii=False
|
| 187 |
+
)
|
| 188 |
+
writer.writerow(flat)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _summary_only(results: List[BenchmarkResult]) -> List[BenchmarkResult]:
|
| 192 |
+
"""Zwraca wyniki bez pola per_text (lżejsza odpowiedź HTTP)."""
|
| 193 |
+
return [{k: v for k, v in r.items() if k != "per_text"} for r in results]
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# ---------------------------------------------------------------------------
|
| 197 |
+
# Uruchomienie standalone
|
| 198 |
+
# ---------------------------------------------------------------------------
|
| 199 |
+
|
| 200 |
+
if __name__ == "__main__":
|
| 201 |
+
import argparse
|
| 202 |
+
|
| 203 |
+
parser = argparse.ArgumentParser(description="TruthScan NLP benchmark")
|
| 204 |
+
parser.add_argument(
|
| 205 |
+
"--adapters", default="roberta,xlm-roberta,norbert",
|
| 206 |
+
help="Przecinkowa lista adapterów (domyślnie: wszystkie)",
|
| 207 |
+
)
|
| 208 |
+
parser.add_argument(
|
| 209 |
+
"--langs", default="en,pl,no",
|
| 210 |
+
help="Przecinkowa lista języków (domyślnie: en,pl,no)",
|
| 211 |
+
)
|
| 212 |
+
parser.add_argument(
|
| 213 |
+
"--out-dir", default="benchmark_results",
|
| 214 |
+
help="Katalog wyjściowy dla plików JSON i CSV",
|
| 215 |
+
)
|
| 216 |
+
args = parser.parse_args()
|
| 217 |
+
|
| 218 |
+
adapter_names = [a.strip() for a in args.adapters.split(",")]
|
| 219 |
+
langs = [l.strip() for l in args.langs.split(",")]
|
| 220 |
+
out_dir = Path(args.out_dir)
|
| 221 |
+
|
| 222 |
+
print(f"Uruchamiam benchmark: adaptery={adapter_names}, języki={langs}")
|
| 223 |
+
results = run_benchmark(adapter_names=adapter_names, langs=langs)
|
| 224 |
+
|
| 225 |
+
json_path = out_dir / "benchmark.json"
|
| 226 |
+
csv_path = out_dir / "benchmark.csv"
|
| 227 |
+
export_json(results, json_path)
|
| 228 |
+
export_csv(results, csv_path)
|
| 229 |
+
|
| 230 |
+
print(f"Wyniki zapisane: {json_path}, {csv_path}")
|
| 231 |
+
for r in _summary_only(results):
|
| 232 |
+
if "error" in r:
|
| 233 |
+
print(f" [{r['adapter_name']}] BŁĄD: {r['error']}")
|
| 234 |
+
else:
|
| 235 |
+
print(
|
| 236 |
+
f" [{r['adapter_name']:12s} / {r['language']}] "
|
| 237 |
+
f"avg={r['avg_inference_time_ms']} ms "
|
| 238 |
+
f"fake={r['avg_fake_probability']}% "
|
| 239 |
+
f"sentiments={r['sentiments_distribution']}"
|
| 240 |
+
)
|
TruthScan AI_backend/app/config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Centralna konfiguracja aplikacji oraz stałe wykorzystywane w wielu modułach.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
# Konfiguracja CORS
|
| 9 |
+
CORS_ALLOW_ORIGINS = ["*"]
|
| 10 |
+
CORS_ALLOW_CREDENTIALS = True
|
| 11 |
+
CORS_ALLOW_METHODS = ["*"]
|
| 12 |
+
CORS_ALLOW_HEADERS = ["*"]
|
| 13 |
+
|
| 14 |
+
# Lista obsługiwanych źródeł RSS
|
| 15 |
+
NEWS_FEEDS = {
|
| 16 |
+
# Angielskie
|
| 17 |
+
"BBC": "https://feeds.bbci.co.uk/news/rss.xml",
|
| 18 |
+
"CNN": "http://rss.cnn.com/rss/edition.rss",
|
| 19 |
+
"NYTimes": "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml",
|
| 20 |
+
"Guardian": "https://www.theguardian.com/world/rss",
|
| 21 |
+
"AlJazeera": "https://www.aljazeera.com/xml/rss/all.xml",
|
| 22 |
+
# Polskie
|
| 23 |
+
"Money": "https://www.money.pl/rss/",
|
| 24 |
+
"PolsatNews": "https://www.polsatnews.pl/rss/wszystkie.xml",
|
| 25 |
+
"GazetaPrawna": "https://www.gazetaprawna.pl/rss.xml",
|
| 26 |
+
"SpidersWeb": "https://spidersweb.pl/feed",
|
| 27 |
+
"Bankier": "https://www.bankier.pl/rss/wiadomosci.xml",
|
| 28 |
+
# Norweskie
|
| 29 |
+
"NRK": "https://www.nrk.no/toppsaker.rss",
|
| 30 |
+
"VG": "https://www.vg.no/rss/feed/?limit=10",
|
| 31 |
+
"Dagbladet": "https://www.dagbladet.no/rss",
|
| 32 |
+
"Aftenposten": "https://www.aftenposten.no/rss",
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
# Mapowanie wyników analizy sentymentu na etykiety językowe
|
| 36 |
+
SENTIMENT_MAP = {
|
| 37 |
+
"negative": {"pl": "Negatywne", "en": "Negative", "no": "Negativt"},
|
| 38 |
+
"neutral": {"pl": "Neutralne", "en": "Neutral", "no": "Nøytralt"},
|
| 39 |
+
"positive": {"pl": "Pozytywne", "en": "Positive", "no": "Positivt"},
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
# Ścieżka do pliku z zapisanymi artykułami
|
| 43 |
+
SAVED_FILE = Path("saved_articles.json")
|
| 44 |
+
|
| 45 |
+
# Czas życia cache (sekundy)
|
| 46 |
+
CACHE_TTL_SECONDS = 120
|
| 47 |
+
|
| 48 |
+
# Konfiguracja Redis (jeśli używany jako backend cache)
|
| 49 |
+
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379")
|
| 50 |
+
CACHE_TTL = 300
|
TruthScan AI_backend/app/main.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Główna konfiguracja i uruchomienie aplikacji FastAPI.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from fastapi import FastAPI
|
| 6 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
+
from fastapi_cache import FastAPICache
|
| 8 |
+
from fastapi_cache.backends.inmemory import InMemoryBackend
|
| 9 |
+
|
| 10 |
+
# Konfiguracja CORS (źródła, metody, nagłówki itp.)
|
| 11 |
+
from .config import (
|
| 12 |
+
CORS_ALLOW_ORIGINS,
|
| 13 |
+
CORS_ALLOW_CREDENTIALS,
|
| 14 |
+
CORS_ALLOW_METHODS,
|
| 15 |
+
CORS_ALLOW_HEADERS
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
# Routery aplikacji
|
| 19 |
+
from .routes import misc, news, saved
|
| 20 |
+
|
| 21 |
+
# Inicjalizacja aplikacji FastAPI
|
| 22 |
+
app = FastAPI()
|
| 23 |
+
|
| 24 |
+
# Middleware CORS – umożliwia dostęp do API z innych domen
|
| 25 |
+
app.add_middleware(
|
| 26 |
+
CORSMiddleware,
|
| 27 |
+
allow_origins=CORS_ALLOW_ORIGINS,
|
| 28 |
+
allow_credentials=CORS_ALLOW_CREDENTIALS,
|
| 29 |
+
allow_methods=CORS_ALLOW_METHODS,
|
| 30 |
+
allow_headers=CORS_ALLOW_HEADERS,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# Logika wykonywana przy starcie aplikacji
|
| 34 |
+
@app.on_event("startup")
|
| 35 |
+
async def startup():
|
| 36 |
+
# Inicjalizacja cache w pamięci (np. do cache’owania newsów)
|
| 37 |
+
FastAPICache.init(InMemoryBackend(), prefix="news-cache")
|
| 38 |
+
print("✓ Cache initialized (5 minut TTL)")
|
| 39 |
+
|
| 40 |
+
# Rejestracja routerów
|
| 41 |
+
app.include_router(misc.router)
|
| 42 |
+
app.include_router(news.router)
|
| 43 |
+
app.include_router(saved.router)
|
| 44 |
+
|
| 45 |
+
# Endpoint główny – informacja o stanie API
|
| 46 |
+
@app.get("/")
|
| 47 |
+
async def root():
|
| 48 |
+
return {
|
| 49 |
+
"message": "ThruScan API",
|
| 50 |
+
"status": "running",
|
| 51 |
+
"cached": True
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
# Endpoint zdrowia – używany np. przez monitoring / load balancer
|
| 55 |
+
@app.get("/health")
|
| 56 |
+
async def health_check():
|
| 57 |
+
return {"status": "healthy"}
|
TruthScan AI_backend/app/models.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Modele danych wykorzystywane do walidacji i serializacji artykułów.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Article(BaseModel):
|
| 9 |
+
# Model artykułu wykorzystywany w komunikacji API
|
| 10 |
+
title: str
|
| 11 |
+
link: str
|
| 12 |
+
summary: str
|
| 13 |
+
published: str
|
| 14 |
+
sentiment: str
|
| 15 |
+
fake_probability: float
|
| 16 |
+
source: str
|
TruthScan AI_backend/app/nlp_service.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Serwis NLP z architekturą plug-in.
|
| 3 |
+
|
| 4 |
+
Każdy model jest reprezentowany przez adapter dziedziczący z ModelAdapter.
|
| 5 |
+
Publiczne API (analyze_news, analyze_news_batch) pozostaje niezmienione,
|
| 6 |
+
więc routes/news.py nie wymaga modyfikacji.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from abc import ABC, abstractmethod
|
| 10 |
+
from typing import Dict, Any, List, Optional
|
| 11 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 12 |
+
|
| 13 |
+
from transformers import pipeline
|
| 14 |
+
|
| 15 |
+
from .config import SENTIMENT_MAP
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ---------------------------------------------------------------------------
|
| 19 |
+
# Klasa bazowa
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
|
| 22 |
+
class ModelAdapter(ABC):
|
| 23 |
+
"""
|
| 24 |
+
Abstrakcyjny adapter modelu NLP.
|
| 25 |
+
|
| 26 |
+
Każdy konkretny adapter musi zaimplementować:
|
| 27 |
+
- analyze_sentiment(text) -> {"label": str, "score": float}
|
| 28 |
+
- analyze_fake_news(text) -> {"labels": List[str], "scores": List[float]}
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
@property
|
| 32 |
+
@abstractmethod
|
| 33 |
+
def name(self) -> str:
|
| 34 |
+
"""Unikalny identyfikator adaptera (np. 'roberta', 'xlm-roberta')."""
|
| 35 |
+
...
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
@abstractmethod
|
| 39 |
+
def supported_languages(self) -> List[str]:
|
| 40 |
+
"""Kody języków obsługiwanych przez model (np. ['pl', 'en', 'no'])."""
|
| 41 |
+
...
|
| 42 |
+
|
| 43 |
+
@abstractmethod
|
| 44 |
+
def analyze_sentiment(self, text: str) -> Dict[str, Any]:
|
| 45 |
+
"""
|
| 46 |
+
Analizuje sentyment tekstu.
|
| 47 |
+
|
| 48 |
+
Zwraca:
|
| 49 |
+
{"label": str, "score": float}
|
| 50 |
+
gdzie label to jedna z wartości: 'positive' | 'negative' | 'neutral'
|
| 51 |
+
"""
|
| 52 |
+
...
|
| 53 |
+
|
| 54 |
+
@abstractmethod
|
| 55 |
+
def analyze_fake_news(self, text: str) -> Dict[str, Any]:
|
| 56 |
+
"""
|
| 57 |
+
Klasyfikuje tekst jako real/fake.
|
| 58 |
+
|
| 59 |
+
Zwraca:
|
| 60 |
+
{"labels": List[str], "scores": List[float]}
|
| 61 |
+
"""
|
| 62 |
+
...
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
# Adaptery
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
def _normalize_sentiment_label(raw_label: str) -> str:
|
| 70 |
+
"""
|
| 71 |
+
Normalizuje etykietę sentymentu z modelu do jednego z trzech wariantów:
|
| 72 |
+
'positive' | 'negative' | 'neutral'.
|
| 73 |
+
|
| 74 |
+
Obsługuje różne konwencje nazewnictwa stosowane przez modele HuggingFace.
|
| 75 |
+
"""
|
| 76 |
+
label = (raw_label or "").strip().lower()
|
| 77 |
+
|
| 78 |
+
_POSITIVE = {"positive", "pos", "label_2", "2", "very positive"}
|
| 79 |
+
_NEGATIVE = {"negative", "neg", "label_0", "0", "very negative"}
|
| 80 |
+
|
| 81 |
+
if label in _POSITIVE or label.startswith("pos"):
|
| 82 |
+
return "positive"
|
| 83 |
+
if label in _NEGATIVE or label.startswith("neg"):
|
| 84 |
+
return "negative"
|
| 85 |
+
return "neutral"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class RoBERTaAdapter(ModelAdapter):
|
| 89 |
+
"""
|
| 90 |
+
Adapter dla modeli anglojęzycznych (domyślny, zachowuje obecne zachowanie):
|
| 91 |
+
- sentyment : cardiffnlp/twitter-roberta-base-sentiment-latest
|
| 92 |
+
- fake news : facebook/bart-large-mnli (zero-shot)
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
def __init__(self) -> None:
|
| 96 |
+
self._sentiment_pipe = None
|
| 97 |
+
self._fake_pipe = None
|
| 98 |
+
|
| 99 |
+
def _load(self) -> None:
|
| 100 |
+
if self._sentiment_pipe is None:
|
| 101 |
+
self._sentiment_pipe = pipeline(
|
| 102 |
+
"text-classification",
|
| 103 |
+
model="cardiffnlp/twitter-roberta-base-sentiment-latest",
|
| 104 |
+
return_all_scores=False,
|
| 105 |
+
)
|
| 106 |
+
if self._fake_pipe is None:
|
| 107 |
+
self._fake_pipe = pipeline(
|
| 108 |
+
"zero-shot-classification",
|
| 109 |
+
model="facebook/bart-large-mnli",
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
@property
|
| 113 |
+
def name(self) -> str:
|
| 114 |
+
return "roberta"
|
| 115 |
+
|
| 116 |
+
@property
|
| 117 |
+
def supported_languages(self) -> List[str]:
|
| 118 |
+
return ["en"]
|
| 119 |
+
|
| 120 |
+
def analyze_sentiment(self, text: str) -> Dict[str, Any]:
|
| 121 |
+
self._load()
|
| 122 |
+
result = self._sentiment_pipe(text)[0]
|
| 123 |
+
return {
|
| 124 |
+
"label": _normalize_sentiment_label(result.get("label", "")),
|
| 125 |
+
"score": float(result.get("score", 0.0)),
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
def analyze_fake_news(self, text: str) -> Dict[str, Any]:
|
| 129 |
+
self._load()
|
| 130 |
+
result = self._fake_pipe(text, candidate_labels=["real", "fake"])
|
| 131 |
+
return {"labels": result["labels"], "scores": result["scores"]}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class XLMRoBERTaAdapter(ModelAdapter):
|
| 135 |
+
"""
|
| 136 |
+
Adapter wielojęzyczny (pl, en, no):
|
| 137 |
+
- sentyment : cardiffnlp/twitter-xlm-roberta-base-sentiment
|
| 138 |
+
- fake news : facebook/bart-large-mnli (zero-shot, transfer między językami)
|
| 139 |
+
"""
|
| 140 |
+
|
| 141 |
+
def __init__(self) -> None:
|
| 142 |
+
self._sentiment_pipe = None
|
| 143 |
+
self._fake_pipe = None
|
| 144 |
+
|
| 145 |
+
def _load(self) -> None:
|
| 146 |
+
if self._sentiment_pipe is None:
|
| 147 |
+
self._sentiment_pipe = pipeline(
|
| 148 |
+
"text-classification",
|
| 149 |
+
model="cardiffnlp/twitter-xlm-roberta-base-sentiment",
|
| 150 |
+
return_all_scores=False,
|
| 151 |
+
)
|
| 152 |
+
if self._fake_pipe is None:
|
| 153 |
+
self._fake_pipe = pipeline(
|
| 154 |
+
"zero-shot-classification",
|
| 155 |
+
model="facebook/bart-large-mnli",
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
@property
|
| 159 |
+
def name(self) -> str:
|
| 160 |
+
return "xlm-roberta"
|
| 161 |
+
|
| 162 |
+
@property
|
| 163 |
+
def supported_languages(self) -> List[str]:
|
| 164 |
+
return ["pl", "en", "no"]
|
| 165 |
+
|
| 166 |
+
def analyze_sentiment(self, text: str) -> Dict[str, Any]:
|
| 167 |
+
self._load()
|
| 168 |
+
result = self._sentiment_pipe(text)[0]
|
| 169 |
+
return {
|
| 170 |
+
"label": _normalize_sentiment_label(result.get("label", "")),
|
| 171 |
+
"score": float(result.get("score", 0.0)),
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
def analyze_fake_news(self, text: str) -> Dict[str, Any]:
|
| 175 |
+
self._load()
|
| 176 |
+
result = self._fake_pipe(text, candidate_labels=["real", "fake"])
|
| 177 |
+
return {"labels": result["labels"], "scores": result["scores"]}
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
class HerBERTAdapter(ModelAdapter):
|
| 181 |
+
"""
|
| 182 |
+
Adapter dla języka polskiego (HerBERT):
|
| 183 |
+
- sentyment : allegro/herbert-base-cased
|
| 184 |
+
UWAGA: to model bazowy – wymaga fine-tuningu na zbiorze sentymentu
|
| 185 |
+
(np. PolEmo 2.0). Aby podmienić checkpoint, przekaż sentiment_model
|
| 186 |
+
w konstruktorze lub zmień SENTIMENT_MODEL przed pierwszym użyciem.
|
| 187 |
+
- fake news : facebook/bart-large-mnli (zero-shot, transfer EN→PL)
|
| 188 |
+
"""
|
| 189 |
+
|
| 190 |
+
SENTIMENT_MODEL: str = "allegro/herbert-base-cased"
|
| 191 |
+
|
| 192 |
+
def __init__(self, sentiment_model: Optional[str] = None) -> None:
|
| 193 |
+
self._sentiment_model_id = sentiment_model or self.SENTIMENT_MODEL
|
| 194 |
+
self._sentiment_pipe = None
|
| 195 |
+
self._fake_pipe = None
|
| 196 |
+
|
| 197 |
+
def _load(self) -> None:
|
| 198 |
+
if self._sentiment_pipe is None:
|
| 199 |
+
self._sentiment_pipe = pipeline(
|
| 200 |
+
"text-classification",
|
| 201 |
+
model=self._sentiment_model_id,
|
| 202 |
+
return_all_scores=False,
|
| 203 |
+
)
|
| 204 |
+
if self._fake_pipe is None:
|
| 205 |
+
self._fake_pipe = pipeline(
|
| 206 |
+
"zero-shot-classification",
|
| 207 |
+
model="facebook/bart-large-mnli",
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
@property
|
| 211 |
+
def name(self) -> str:
|
| 212 |
+
return "herbert"
|
| 213 |
+
|
| 214 |
+
@property
|
| 215 |
+
def supported_languages(self) -> List[str]:
|
| 216 |
+
return ["pl"]
|
| 217 |
+
|
| 218 |
+
def analyze_sentiment(self, text: str) -> Dict[str, Any]:
|
| 219 |
+
self._load()
|
| 220 |
+
result = self._sentiment_pipe(text)[0]
|
| 221 |
+
return {
|
| 222 |
+
"label": _normalize_sentiment_label(result.get("label", "")),
|
| 223 |
+
"score": float(result.get("score", 0.0)),
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
def analyze_fake_news(self, text: str) -> Dict[str, Any]:
|
| 227 |
+
self._load()
|
| 228 |
+
result = self._fake_pipe(text, candidate_labels=["real", "fake"])
|
| 229 |
+
return {"labels": result["labels"], "scores": result["scores"]}
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
class NorBERTAdapter(ModelAdapter):
|
| 233 |
+
"""
|
| 234 |
+
Adapter dla języka norweskiego (NorBERT 3):
|
| 235 |
+
- sentyment : ltgoslo/norbert3-base
|
| 236 |
+
UWAGA: to model bazowy – wymaga fine-tuningu na zbiorze sentymentu
|
| 237 |
+
(np. NoReC). Aby podmienić checkpoint, przekaż sentiment_model
|
| 238 |
+
w konstruktorze lub zmień SENTIMENT_MODEL przed pierwszym użyciem.
|
| 239 |
+
- fake news : facebook/bart-large-mnli (zero-shot, transfer EN→NO)
|
| 240 |
+
"""
|
| 241 |
+
|
| 242 |
+
SENTIMENT_MODEL: str = "ltgoslo/norbert3-base"
|
| 243 |
+
|
| 244 |
+
def __init__(self, sentiment_model: Optional[str] = None) -> None:
|
| 245 |
+
self._sentiment_model_id = sentiment_model or self.SENTIMENT_MODEL
|
| 246 |
+
self._sentiment_pipe = None
|
| 247 |
+
self._fake_pipe = None
|
| 248 |
+
|
| 249 |
+
def _load(self) -> None:
|
| 250 |
+
if self._sentiment_pipe is None:
|
| 251 |
+
self._sentiment_pipe = pipeline(
|
| 252 |
+
"text-classification",
|
| 253 |
+
model=self._sentiment_model_id,
|
| 254 |
+
return_all_scores=False,
|
| 255 |
+
)
|
| 256 |
+
if self._fake_pipe is None:
|
| 257 |
+
self._fake_pipe = pipeline(
|
| 258 |
+
"zero-shot-classification",
|
| 259 |
+
model="facebook/bart-large-mnli",
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
@property
|
| 263 |
+
def name(self) -> str:
|
| 264 |
+
return "norbert"
|
| 265 |
+
|
| 266 |
+
@property
|
| 267 |
+
def supported_languages(self) -> List[str]:
|
| 268 |
+
return ["no"]
|
| 269 |
+
|
| 270 |
+
def analyze_sentiment(self, text: str) -> Dict[str, Any]:
|
| 271 |
+
self._load()
|
| 272 |
+
result = self._sentiment_pipe(text)[0]
|
| 273 |
+
return {
|
| 274 |
+
"label": _normalize_sentiment_label(result.get("label", "")),
|
| 275 |
+
"score": float(result.get("score", 0.0)),
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
def analyze_fake_news(self, text: str) -> Dict[str, Any]:
|
| 279 |
+
self._load()
|
| 280 |
+
result = self._fake_pipe(text, candidate_labels=["real", "fake"])
|
| 281 |
+
return {"labels": result["labels"], "scores": result["scores"]}
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
# ---------------------------------------------------------------------------
|
| 285 |
+
# Rejestr adapterów — lazy initialization
|
| 286 |
+
# ---------------------------------------------------------------------------
|
| 287 |
+
|
| 288 |
+
# Przy imporcie pusty — żaden adapter nie jest tworzony ani ładowany.
|
| 289 |
+
# Adaptery są instancjonowane dopiero przy pierwszym wywołaniu get_adapter().
|
| 290 |
+
_REGISTRY: Dict[str, ModelAdapter] = {}
|
| 291 |
+
|
| 292 |
+
# Adapter aktywny globalnie; None oznacza „jeszcze nie wybrano".
|
| 293 |
+
_active_adapter: Optional[ModelAdapter] = None
|
| 294 |
+
|
| 295 |
+
# Executor do równoległej analizy (ograniczenie obciążenia CPU)
|
| 296 |
+
executor = ThreadPoolExecutor(max_workers=3)
|
| 297 |
+
|
| 298 |
+
# Zbiór nazw wbudowanych adapterów — służy do walidacji przed inicjalizacją
|
| 299 |
+
_BUILTIN_ADAPTERS = {"roberta", "xlm-roberta", "herbert", "norbert"}
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def _init_registry() -> None:
|
| 303 |
+
"""
|
| 304 |
+
Tworzy wbudowane adaptery i wpisuje je do rejestru.
|
| 305 |
+
|
| 306 |
+
Wywoływana leniwie przy pierwszym get_adapter() lub set_active_adapter().
|
| 307 |
+
Kolejne wywołania są bezoperacyjne (idempotentna).
|
| 308 |
+
"""
|
| 309 |
+
if _REGISTRY:
|
| 310 |
+
return
|
| 311 |
+
_REGISTRY["roberta"] = RoBERTaAdapter()
|
| 312 |
+
_REGISTRY["xlm-roberta"] = XLMRoBERTaAdapter()
|
| 313 |
+
_REGISTRY["herbert"] = HerBERTAdapter()
|
| 314 |
+
_REGISTRY["norbert"] = NorBERTAdapter()
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def get_adapter(name: str) -> ModelAdapter:
|
| 318 |
+
"""
|
| 319 |
+
Zwraca adapter o podanej nazwie.
|
| 320 |
+
|
| 321 |
+
Przy pierwszym wywołaniu inicjalizuje rejestr (bez ładowania wag modeli).
|
| 322 |
+
Rzuca ValueError dla nieznanej nazwy.
|
| 323 |
+
"""
|
| 324 |
+
_init_registry()
|
| 325 |
+
if name not in _REGISTRY:
|
| 326 |
+
raise ValueError(
|
| 327 |
+
f"Nieznany adapter: '{name}'. Dostępne: {list(_REGISTRY.keys())}"
|
| 328 |
+
)
|
| 329 |
+
return _REGISTRY[name]
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def set_active_adapter(name: str) -> None:
|
| 333 |
+
"""
|
| 334 |
+
Ustawia aktywny adapter dla całej aplikacji.
|
| 335 |
+
|
| 336 |
+
Przykład:
|
| 337 |
+
from app.nlp_service import set_active_adapter
|
| 338 |
+
set_active_adapter("xlm-roberta")
|
| 339 |
+
"""
|
| 340 |
+
global _active_adapter
|
| 341 |
+
_active_adapter = get_adapter(name)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def register_adapter(adapter: ModelAdapter) -> None:
|
| 345 |
+
"""
|
| 346 |
+
Rejestruje nowy adapter (np. fine-tuned checkpoint) pod jego nazwą.
|
| 347 |
+
Może być wywołane przed lub po _init_registry().
|
| 348 |
+
|
| 349 |
+
Przykład:
|
| 350 |
+
norbert_ft = NorBERTAdapter(sentiment_model="user/norbert3-norec")
|
| 351 |
+
register_adapter(norbert_ft)
|
| 352 |
+
set_active_adapter("norbert")
|
| 353 |
+
"""
|
| 354 |
+
_init_registry()
|
| 355 |
+
_REGISTRY[adapter.name] = adapter
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def _get_active_adapter() -> ModelAdapter:
|
| 359 |
+
"""
|
| 360 |
+
Zwraca aktualnie aktywny adapter.
|
| 361 |
+
Jeśli nie ustawiono, domyślnie inicjalizuje i zwraca RoBERTaAdapter.
|
| 362 |
+
"""
|
| 363 |
+
global _active_adapter
|
| 364 |
+
if _active_adapter is None:
|
| 365 |
+
_active_adapter = get_adapter("roberta")
|
| 366 |
+
return _active_adapter
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
# ---------------------------------------------------------------------------
|
| 370 |
+
# Publiczne API – interfejs niezmieniony względem poprzedniej wersji
|
| 371 |
+
# ---------------------------------------------------------------------------
|
| 372 |
+
|
| 373 |
+
def analyze_news(
|
| 374 |
+
text: str,
|
| 375 |
+
lang: str = "pl",
|
| 376 |
+
adapter: Optional[ModelAdapter] = None,
|
| 377 |
+
) -> dict:
|
| 378 |
+
"""
|
| 379 |
+
Analizuje pojedynczy tekst pod kątem sentymentu i fake news.
|
| 380 |
+
|
| 381 |
+
Args:
|
| 382 |
+
text: Tekst do analizy.
|
| 383 |
+
lang: Kod języka wynikowych etykiet ('pl' | 'en' | 'no').
|
| 384 |
+
adapter: Opcjonalny adapter; jeśli None, używa _active_adapter.
|
| 385 |
+
|
| 386 |
+
Returns:
|
| 387 |
+
{"sentiment": str, "fake_probability": float, "sentiment_score": float}
|
| 388 |
+
"""
|
| 389 |
+
_neutral = SENTIMENT_MAP["neutral"].get(lang, "Neutral")
|
| 390 |
+
|
| 391 |
+
if not text or len(text.strip()) < 10:
|
| 392 |
+
return {"sentiment": _neutral, "fake_probability": 0.0, "sentiment_score": 0.0}
|
| 393 |
+
|
| 394 |
+
_adapter = adapter or _get_active_adapter()
|
| 395 |
+
|
| 396 |
+
try:
|
| 397 |
+
sentiment_result = _adapter.analyze_sentiment(text)
|
| 398 |
+
fake_result = _adapter.analyze_fake_news(text)
|
| 399 |
+
except Exception:
|
| 400 |
+
return {"sentiment": _neutral, "fake_probability": 0.0, "sentiment_score": 0.0}
|
| 401 |
+
|
| 402 |
+
label = sentiment_result.get("label", "neutral")
|
| 403 |
+
sentiment_translated = SENTIMENT_MAP.get(label, {}).get(lang, _neutral)
|
| 404 |
+
|
| 405 |
+
fake_score = 0.0
|
| 406 |
+
for lbl, score in zip(fake_result["labels"], fake_result["scores"]):
|
| 407 |
+
if lbl == "fake":
|
| 408 |
+
fake_score = score
|
| 409 |
+
break
|
| 410 |
+
|
| 411 |
+
return {
|
| 412 |
+
"sentiment": sentiment_translated,
|
| 413 |
+
"fake_probability": round(fake_score * 100, 2),
|
| 414 |
+
"sentiment_score": round(float(sentiment_result.get("score", 0.0)), 2),
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def analyze_news_batch(
|
| 419 |
+
texts: List[str],
|
| 420 |
+
lang: str = "pl",
|
| 421 |
+
adapter: Optional[ModelAdapter] = None,
|
| 422 |
+
) -> List[Dict[str, Any]]:
|
| 423 |
+
"""
|
| 424 |
+
Analiza wielu tekstów w trybie batch z użyciem ThreadPoolExecutor.
|
| 425 |
+
|
| 426 |
+
Args:
|
| 427 |
+
texts: Lista tekstów do analizy.
|
| 428 |
+
lang: Kod języka wynikowych etykiet.
|
| 429 |
+
adapter: Opcjonalny adapter; jeśli None, używa _active_adapter.
|
| 430 |
+
"""
|
| 431 |
+
if not texts:
|
| 432 |
+
return []
|
| 433 |
+
|
| 434 |
+
_adapter = adapter or _get_active_adapter()
|
| 435 |
+
results: List[Dict[str, Any]] = []
|
| 436 |
+
batch_size = 3
|
| 437 |
+
|
| 438 |
+
batches = [texts[i:i + batch_size] for i in range(0, len(texts), batch_size)]
|
| 439 |
+
|
| 440 |
+
for batch in batches:
|
| 441 |
+
futures = [
|
| 442 |
+
executor.submit(analyze_news, text, lang, _adapter)
|
| 443 |
+
for text in batch
|
| 444 |
+
]
|
| 445 |
+
for future in futures:
|
| 446 |
+
try:
|
| 447 |
+
results.append(future.result())
|
| 448 |
+
except Exception:
|
| 449 |
+
_neutral = SENTIMENT_MAP["neutral"].get(lang, "Neutral")
|
| 450 |
+
results.append(
|
| 451 |
+
{"sentiment": _neutral, "fake_probability": 0.0, "sentiment_score": 0.0}
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
return results
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def analyze_news_single(text: str, lang: str) -> Dict[str, Any]:
|
| 458 |
+
"""Wrapper dla analizy pojedynczego tekstu (używany w batch processing)."""
|
| 459 |
+
return analyze_news(text, lang)
|
TruthScan AI_backend/app/routes/__init__.py
ADDED
|
File without changes
|
TruthScan AI_backend/app/routes/misc.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Endpointy związane z obsługą dostępnych źródeł wiadomości oraz narzędziami deweloperskimi.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Query
|
| 9 |
+
|
| 10 |
+
from ..config import NEWS_FEEDS
|
| 11 |
+
from ..benchmark import run_benchmark, export_json, export_csv, _summary_only
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
# Katalog, do którego endpoint zapisuje artefakty benchmarku
|
| 16 |
+
_BENCHMARK_OUT = Path("benchmark_results")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/sources")
|
| 20 |
+
def get_sources():
|
| 21 |
+
return list(NEWS_FEEDS.keys())
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("/benchmark")
|
| 25 |
+
def benchmark(
|
| 26 |
+
adapters: Optional[str] = Query(
|
| 27 |
+
default=None,
|
| 28 |
+
description="Przecinkowa lista adapterów: roberta,xlm-roberta,norbert",
|
| 29 |
+
),
|
| 30 |
+
langs: Optional[str] = Query(
|
| 31 |
+
default=None,
|
| 32 |
+
description="Przecinkowa lista języków: en,pl,no",
|
| 33 |
+
),
|
| 34 |
+
save: bool = Query(
|
| 35 |
+
default=True,
|
| 36 |
+
description="Czy zapisać wyniki do JSON i CSV w katalogu benchmark_results/",
|
| 37 |
+
),
|
| 38 |
+
full: bool = Query(
|
| 39 |
+
default=False,
|
| 40 |
+
description="Czy zwrócić szczegółowe wyniki per_text (domyślnie tylko podsumowanie)",
|
| 41 |
+
),
|
| 42 |
+
):
|
| 43 |
+
"""
|
| 44 |
+
Uruchamia benchmark NLP i zwraca wyniki.
|
| 45 |
+
|
| 46 |
+
Czas odpowiedzi zależy od liczby adapterów i języków — może wynosić kilkadziesiąt sekund
|
| 47 |
+
przy pierwszym uruchomieniu (lazy-loading modeli HuggingFace).
|
| 48 |
+
|
| 49 |
+
Przykłady:
|
| 50 |
+
- GET /benchmark
|
| 51 |
+
- GET /benchmark?adapters=roberta,xlm-roberta&langs=en,pl
|
| 52 |
+
- GET /benchmark?full=true&save=false
|
| 53 |
+
"""
|
| 54 |
+
adapter_names = [a.strip() for a in adapters.split(",")] if adapters else None
|
| 55 |
+
lang_list = [l.strip() for l in langs.split(",")] if langs else None
|
| 56 |
+
|
| 57 |
+
results = run_benchmark(adapter_names=adapter_names, langs=lang_list)
|
| 58 |
+
|
| 59 |
+
if save:
|
| 60 |
+
export_json(results, _BENCHMARK_OUT / "benchmark.json")
|
| 61 |
+
export_csv(results, _BENCHMARK_OUT / "benchmark.csv")
|
| 62 |
+
|
| 63 |
+
return {
|
| 64 |
+
"results": results if full else _summary_only(results),
|
| 65 |
+
"saved": save,
|
| 66 |
+
"output_dir": str(_BENCHMARK_OUT) if save else None,
|
| 67 |
+
}
|
| 68 |
+
|
TruthScan AI_backend/app/routes/news.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Endpointy związane z pobieraniem, analizą i strumieniowaniem wiadomości.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
from typing import List
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, HTTPException
|
| 10 |
+
from fastapi.responses import StreamingResponse
|
| 11 |
+
from fastapi_cache.decorator import cache
|
| 12 |
+
|
| 13 |
+
from ..config import CACHE_TTL, NEWS_FEEDS
|
| 14 |
+
from ..rss_utils import fetch_feed, clean_html, get_from_cache, set_to_cache
|
| 15 |
+
from ..nlp_service import analyze_news
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.get("/news/{source}")
|
| 21 |
+
def get_news(source: str, lang: str = "pl"):
|
| 22 |
+
# Walidacja źródła
|
| 23 |
+
if source not in NEWS_FEEDS:
|
| 24 |
+
raise HTTPException(status_code=404, detail="Źródło nieobsługiwane")
|
| 25 |
+
|
| 26 |
+
url = NEWS_FEEDS[source]
|
| 27 |
+
|
| 28 |
+
# Pobranie RSS
|
| 29 |
+
try:
|
| 30 |
+
feed = fetch_feed(url)
|
| 31 |
+
except Exception as e:
|
| 32 |
+
raise HTTPException(status_code=500, detail=f"Błąd pobierania newsów: {str(e)}")
|
| 33 |
+
|
| 34 |
+
if not feed.entries:
|
| 35 |
+
return {"source": source, "articles": []}
|
| 36 |
+
|
| 37 |
+
MAX_ARTICLES = 5
|
| 38 |
+
articles: List[dict] = []
|
| 39 |
+
|
| 40 |
+
# Przetwarzanie i analiza artykułów
|
| 41 |
+
for entry in feed.entries[:MAX_ARTICLES]:
|
| 42 |
+
summary = clean_html(entry.get("summary", "Brak opisu"))
|
| 43 |
+
text_to_analyze = (summary or "").strip() or entry.get("title", "")
|
| 44 |
+
analysis = analyze_news(text_to_analyze, lang)
|
| 45 |
+
|
| 46 |
+
articles.append({
|
| 47 |
+
"title": entry.get("title", "Bez tytułu"),
|
| 48 |
+
"link": entry.get("link", ""),
|
| 49 |
+
"summary": summary,
|
| 50 |
+
"published": entry.get("published", "Brak daty"),
|
| 51 |
+
"source": source,
|
| 52 |
+
**analysis
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
return {"source": source, "articles": articles}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# Strumieniowanie newsów przez SSE
|
| 59 |
+
@router.get("/stream-news/{source}")
|
| 60 |
+
async def stream_news(source: str, lang: str = "pl"):
|
| 61 |
+
if source not in NEWS_FEEDS:
|
| 62 |
+
raise HTTPException(status_code=404, detail="Źródło nieobsługiwane")
|
| 63 |
+
|
| 64 |
+
async def event_generator():
|
| 65 |
+
# Próba pobrania RSS z cache
|
| 66 |
+
try:
|
| 67 |
+
cached = get_from_cache(source)
|
| 68 |
+
if cached:
|
| 69 |
+
feed = cached
|
| 70 |
+
else:
|
| 71 |
+
feed = fetch_feed(NEWS_FEEDS[source])
|
| 72 |
+
set_to_cache(source, feed)
|
| 73 |
+
except Exception as e:
|
| 74 |
+
yield f"event: backend_error\ndata: {json.dumps({'message': str(e)})}\n\n"
|
| 75 |
+
yield f"event: done\ndata: {json.dumps({'count': 0})}\n\n"
|
| 76 |
+
return
|
| 77 |
+
|
| 78 |
+
entries = feed.entries or []
|
| 79 |
+
MAX_ARTICLES = 5
|
| 80 |
+
to_send = entries[:MAX_ARTICLES]
|
| 81 |
+
|
| 82 |
+
# Metadane dla klienta
|
| 83 |
+
yield f"event: meta\ndata: {json.dumps({'total': len(to_send)})}\n\n"
|
| 84 |
+
|
| 85 |
+
sent = 0
|
| 86 |
+
for entry in to_send:
|
| 87 |
+
summary = clean_html(entry.get("summary", "Brak opisu"))
|
| 88 |
+
text_to_analyze = (summary or "").strip() or entry.get("title", "")
|
| 89 |
+
|
| 90 |
+
# Analiza NLP uruchamiana w executorze (CPU-bound)
|
| 91 |
+
loop = asyncio.get_event_loop()
|
| 92 |
+
analysis = await loop.run_in_executor(
|
| 93 |
+
None, lambda: analyze_news(text_to_analyze, lang)
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
article = {
|
| 97 |
+
"title": entry.get("title", "Bez tytułu"),
|
| 98 |
+
"link": entry.get("link", ""),
|
| 99 |
+
"summary": summary,
|
| 100 |
+
"published": entry.get("published", "Brak daty"),
|
| 101 |
+
"source": source,
|
| 102 |
+
**analysis,
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
yield f"data: {json.dumps(article, ensure_ascii=False)}\n\n"
|
| 106 |
+
sent += 1
|
| 107 |
+
await asyncio.sleep(0.3)
|
| 108 |
+
|
| 109 |
+
yield f"event: done\ndata: {json.dumps({'count': sent})}\n\n"
|
| 110 |
+
|
| 111 |
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@router.get("/api/charts/summary")
|
| 115 |
+
@cache(expire=CACHE_TTL)
|
| 116 |
+
async def get_charts_summary(lang: str = "pl"):
|
| 117 |
+
"""
|
| 118 |
+
Szybkie statystyki zbiorcze dla wszystkich źródeł
|
| 119 |
+
(uproszczona analiza oparta na tytułach).
|
| 120 |
+
"""
|
| 121 |
+
sources = [
|
| 122 |
+
"BBC", "CNN", "NYTimes", "Guardian", "AlJazeera",
|
| 123 |
+
"PolsatNews", "Money", "Bankier", "SpidersWeb", "GazetaPrawna"
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
summary_data = {}
|
| 127 |
+
|
| 128 |
+
for source in sources:
|
| 129 |
+
if source not in NEWS_FEEDS:
|
| 130 |
+
continue
|
| 131 |
+
|
| 132 |
+
try:
|
| 133 |
+
feed = fetch_feed(NEWS_FEEDS[source])
|
| 134 |
+
|
| 135 |
+
if not feed.entries:
|
| 136 |
+
summary_data[source] = {
|
| 137 |
+
"count": 0,
|
| 138 |
+
"emotions": {"Pozytywne": 0, "Negatywne": 0, "Neutralne": 0}
|
| 139 |
+
}
|
| 140 |
+
continue
|
| 141 |
+
|
| 142 |
+
# Analiza tylko kilku tytułów (szybko)
|
| 143 |
+
articles = feed.entries[:3]
|
| 144 |
+
emotion_counts = {"Pozytywne": 0, "Negatywne": 0, "Neutralne": 0}
|
| 145 |
+
|
| 146 |
+
for entry in articles:
|
| 147 |
+
title = entry.get("title", "").lower()
|
| 148 |
+
if any(word in title for word in ["good", "positive", "gain", "up", "success", "dobry", "wzrost", "zysk"]):
|
| 149 |
+
emotion_counts["Pozytywne"] += 1
|
| 150 |
+
elif any(word in title for word in ["bad", "negative", "fall", "down", "loss", "crisis", "zły", "spadek", "kryzys"]):
|
| 151 |
+
emotion_counts["Negatywne"] += 1
|
| 152 |
+
else:
|
| 153 |
+
emotion_counts["Neutralne"] += 1
|
| 154 |
+
|
| 155 |
+
summary_data[source] = {
|
| 156 |
+
"count": len(feed.entries),
|
| 157 |
+
"analyzed": len(articles),
|
| 158 |
+
"emotions": emotion_counts,
|
| 159 |
+
"latest_title": articles[0].get("title", "")[:50] if articles else ""
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
except Exception:
|
| 163 |
+
summary_data[source] = {
|
| 164 |
+
"count": 0,
|
| 165 |
+
"emotions": {"Pozytywne": 0, "Negatywne": 0, "Neutralne": 0}
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
return {
|
| 169 |
+
"summary": summary_data,
|
| 170 |
+
"total_sources": len(summary_data),
|
| 171 |
+
"cache_ttl": CACHE_TTL
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@router.get("/emotion-stats/{source}")
|
| 176 |
+
@cache(expire=CACHE_TTL)
|
| 177 |
+
def get_emotion_stats(source: str, lang: str = "pl"):
|
| 178 |
+
# Statystyki emocji dla jednego źródła
|
| 179 |
+
if source not in NEWS_FEEDS:
|
| 180 |
+
raise HTTPException(status_code=404, detail="Źródło nieobsługiwane")
|
| 181 |
+
|
| 182 |
+
try:
|
| 183 |
+
news_data = get_news(source, lang)
|
| 184 |
+
articles = news_data.get("articles", [])
|
| 185 |
+
except Exception as e:
|
| 186 |
+
raise HTTPException(status_code=500, detail=f"Błąd generowania statystyk: {str(e)}")
|
| 187 |
+
|
| 188 |
+
emotion_counts = {"Pozytywne": 0, "Negatywne": 0, "Neutralne": 0}
|
| 189 |
+
|
| 190 |
+
for article in articles:
|
| 191 |
+
sentiment = article.get("sentiment", "Neutralne")
|
| 192 |
+
if sentiment in emotion_counts:
|
| 193 |
+
emotion_counts[sentiment] += 1
|
| 194 |
+
|
| 195 |
+
total = len(articles)
|
| 196 |
+
emotion_percentages = {
|
| 197 |
+
emotion: (round((count / total) * 100, 2) if total > 0 else 0)
|
| 198 |
+
for emotion, count in emotion_counts.items()
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
return {
|
| 202 |
+
"source": source,
|
| 203 |
+
"total_articles": total,
|
| 204 |
+
"emotion_counts": emotion_counts,
|
| 205 |
+
"emotion_percentages": emotion_percentages
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@router.get("/charts-data")
|
| 210 |
+
@cache(expire=CACHE_TTL)
|
| 211 |
+
async def get_all_charts_data(lang: str = "pl"):
|
| 212 |
+
"""
|
| 213 |
+
Kompatybilność ze starszym frontendem – agreguje dane ze wszystkich źródeł.
|
| 214 |
+
"""
|
| 215 |
+
sources = [
|
| 216 |
+
"BBC", "CNN", "NYTimes", "Guardian", "AlJazeera",
|
| 217 |
+
"PolsatNews", "Money", "Bankier", "SpidersWeb", "GazetaPrawna"
|
| 218 |
+
]
|
| 219 |
+
|
| 220 |
+
async def fetch_stats(source):
|
| 221 |
+
try:
|
| 222 |
+
return {source: await get_emotion_stats(source, lang)}
|
| 223 |
+
except Exception:
|
| 224 |
+
return {source: None}
|
| 225 |
+
|
| 226 |
+
tasks = [fetch_stats(source) for source in sources]
|
| 227 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 228 |
+
|
| 229 |
+
all_data = {}
|
| 230 |
+
for result in results:
|
| 231 |
+
if isinstance(result, dict):
|
| 232 |
+
all_data.update(result)
|
| 233 |
+
|
| 234 |
+
return {"charts": all_data}
|
TruthScan AI_backend/app/routes/saved.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Endpointy związane z zapisywaniem i zarządzaniem zapisanymi artykułami.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 6 |
+
|
| 7 |
+
from ..storage import read_all, append_article, delete_by_title
|
| 8 |
+
from ..models import Article
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/saved-articles")
|
| 14 |
+
def get_saved_articles():
|
| 15 |
+
# Zwraca listę wszystkich zapisanych artykułów
|
| 16 |
+
return read_all()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.post("/save-article")
|
| 20 |
+
def save_article(article: Article):
|
| 21 |
+
# Zapisuje nowy artykuł do magazynu danych
|
| 22 |
+
return append_article(article.dict())
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.delete("/delete-article")
|
| 26 |
+
async def delete_article(request: Request):
|
| 27 |
+
# Usuwa artykuł na podstawie tytułu przekazanego w body requestu
|
| 28 |
+
data = await request.json()
|
| 29 |
+
title = data.get("title")
|
| 30 |
+
|
| 31 |
+
if not title:
|
| 32 |
+
raise HTTPException(status_code=400, detail="Brak pola 'title'")
|
| 33 |
+
|
| 34 |
+
return delete_by_title(title)
|
TruthScan AI_backend/app/rss_utils.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Narzędzia pomocnicze do pobierania i przetwarzania kanałów RSS oraz cache w pamięci.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
import requests
|
| 7 |
+
import feedparser
|
| 8 |
+
from bs4 import BeautifulSoup
|
| 9 |
+
from typing import Dict, Any
|
| 10 |
+
|
| 11 |
+
from .config import CACHE_TTL_SECONDS
|
| 12 |
+
|
| 13 |
+
# Prosty cache w pamięci (key -> (value, timestamp))
|
| 14 |
+
_cache_data: Dict[str, tuple[Any, float]] = {}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def clean_html(text: str) -> str:
|
| 18 |
+
# Usuwa znaczniki HTML z treści RSS
|
| 19 |
+
return BeautifulSoup(text or "", "html.parser").get_text()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def get_from_cache(key: str):
|
| 23 |
+
# Pobiera dane z cache, jeśli nie przekroczyły TTL
|
| 24 |
+
entry = _cache_data.get(key)
|
| 25 |
+
if not entry:
|
| 26 |
+
return None
|
| 27 |
+
|
| 28 |
+
value, ts = entry
|
| 29 |
+
if time.time() - ts > CACHE_TTL_SECONDS:
|
| 30 |
+
del _cache_data[key]
|
| 31 |
+
return None
|
| 32 |
+
|
| 33 |
+
return value
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def set_to_cache(key: str, value):
|
| 37 |
+
# Zapisuje dane do cache wraz z timestampem
|
| 38 |
+
_cache_data[key] = (value, time.time())
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def fetch_feed(url: str):
|
| 42 |
+
# Pobiera i parsuje kanał RSS z ustawionym User-Agent
|
| 43 |
+
headers = {"User-Agent": "Mozilla/5.0 (compatible; ThruScanBot/1.0)"}
|
| 44 |
+
resp = requests.get(url, timeout=7, headers=headers)
|
| 45 |
+
resp.raise_for_status()
|
| 46 |
+
|
| 47 |
+
return feedparser.parse(resp.content)
|
TruthScan AI_backend/app/storage.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Warstwa dostępu do danych dla zapisanych artykułów (plik JSON).
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import threading
|
| 7 |
+
from fastapi import HTTPException
|
| 8 |
+
|
| 9 |
+
from .config import SAVED_FILE
|
| 10 |
+
|
| 11 |
+
# Blokada wątków dla operacji zapisu/odczytu
|
| 12 |
+
_lock = threading.Lock()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def ensure_file():
|
| 16 |
+
# Tworzy plik danych, jeśli nie istnieje
|
| 17 |
+
if not SAVED_FILE.exists():
|
| 18 |
+
SAVED_FILE.write_text("[]", encoding="utf-8")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def read_all():
|
| 22 |
+
# Zwraca wszystkie zapisane artykuły
|
| 23 |
+
ensure_file()
|
| 24 |
+
try:
|
| 25 |
+
return json.loads(SAVED_FILE.read_text(encoding="utf-8"))
|
| 26 |
+
except json.JSONDecodeError:
|
| 27 |
+
return []
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def append_article(article: dict):
|
| 31 |
+
# Dodaje nowy artykuł do pliku JSON
|
| 32 |
+
ensure_file()
|
| 33 |
+
try:
|
| 34 |
+
with _lock, open(SAVED_FILE, "r+", encoding="utf-8") as f:
|
| 35 |
+
data = json.load(f)
|
| 36 |
+
data.append(article)
|
| 37 |
+
f.seek(0)
|
| 38 |
+
json.dump(data, f, ensure_ascii=False, indent=4)
|
| 39 |
+
|
| 40 |
+
return {"message": "Artykuł zapisany pomyślnie."}
|
| 41 |
+
except Exception as e:
|
| 42 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def delete_by_title(title: str):
|
| 46 |
+
# Usuwa artykuł na podstawie tytułu
|
| 47 |
+
ensure_file()
|
| 48 |
+
try:
|
| 49 |
+
with _lock, open(SAVED_FILE, "r+", encoding="utf-8") as f:
|
| 50 |
+
saved = json.load(f)
|
| 51 |
+
new_saved = [
|
| 52 |
+
a for a in saved if a.get("title") != title
|
| 53 |
+
]
|
| 54 |
+
f.seek(0)
|
| 55 |
+
f.truncate()
|
| 56 |
+
json.dump(new_saved, f, ensure_ascii=False, indent=4)
|
| 57 |
+
|
| 58 |
+
return {"message": "Artykuł usunięty."}
|
| 59 |
+
except Exception as e:
|
| 60 |
+
raise HTTPException(status_code=500, detail=str(e))
|
TruthScan AI_backend/requirements.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Zależności backendu aplikacji ThruScan
|
| 2 |
+
|
| 3 |
+
# Framework API i serwer ASGI
|
| 4 |
+
fastapi==0.115.12
|
| 5 |
+
uvicorn==0.34.3
|
| 6 |
+
starlette==0.46.2
|
| 7 |
+
|
| 8 |
+
# Walidacja danych i modele
|
| 9 |
+
pydantic==2.11.7
|
| 10 |
+
pydantic_core==2.33.2
|
| 11 |
+
annotated-types==0.7.0
|
| 12 |
+
typing-extensions>=4.12.2
|
| 13 |
+
|
| 14 |
+
# NLP i uczenie maszynowe
|
| 15 |
+
transformers==4.44.2
|
| 16 |
+
tokenizers==0.19.1
|
| 17 |
+
torch==2.3.1
|
| 18 |
+
|
| 19 |
+
# Przetwarzanie RSS i HTML
|
| 20 |
+
feedparser==6.0.11
|
| 21 |
+
beautifulsoup4==4.12.3
|
| 22 |
+
requests==2.31.0
|
TruthScan AI_backend/setup_test_env.bat
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
echo ========================================
|
| 3 |
+
echo 🛠 Przygotowanie środowiska testowego
|
| 4 |
+
echo ========================================
|
| 5 |
+
|
| 6 |
+
echo.
|
| 7 |
+
echo 📦 Instalowanie wymaganych bibliotek...
|
| 8 |
+
pip install requests pandas
|
| 9 |
+
|
| 10 |
+
echo.
|
| 11 |
+
echo 🔍 Sprawdzanie czy backend działa...
|
| 12 |
+
timeout /t 3 /nobreak > nul
|
| 13 |
+
|
| 14 |
+
echo.
|
| 15 |
+
echo 🚀 Uruchamianie testów...
|
| 16 |
+
python test_truthscan.py
|
| 17 |
+
|
| 18 |
+
echo.
|
| 19 |
+
echo 📊 Testy zakończone!
|
| 20 |
+
echo Otwórz raport: truthscan_test_report.html
|
| 21 |
+
pause
|
TruthScan AI_backend/truthscan_report.html
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<title>Raport porównawczy TruthScan AI - BBC vs Gazeta Prawna</title>
|
| 6 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 7 |
+
<style>
|
| 8 |
+
body {
|
| 9 |
+
font-family: 'Segoe UI', Arial, sans-serif;
|
| 10 |
+
margin: 0;
|
| 11 |
+
padding: 20px;
|
| 12 |
+
background: #f0f2f5;
|
| 13 |
+
}
|
| 14 |
+
.container {
|
| 15 |
+
max-width: 1200px;
|
| 16 |
+
margin: 0 auto;
|
| 17 |
+
background: white;
|
| 18 |
+
padding: 30px;
|
| 19 |
+
border-radius: 10px;
|
| 20 |
+
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
| 21 |
+
}
|
| 22 |
+
h1 {
|
| 23 |
+
color: #2c3e50;
|
| 24 |
+
text-align: center;
|
| 25 |
+
margin-bottom: 20px;
|
| 26 |
+
border-bottom: 3px solid #3498db;
|
| 27 |
+
padding-bottom: 10px;
|
| 28 |
+
}
|
| 29 |
+
.header {
|
| 30 |
+
text-align: center;
|
| 31 |
+
margin-bottom: 30px;
|
| 32 |
+
}
|
| 33 |
+
.test-date {
|
| 34 |
+
color: #7f8c8d;
|
| 35 |
+
font-size: 1em;
|
| 36 |
+
margin-top: 5px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.comparison-section {
|
| 40 |
+
display: grid;
|
| 41 |
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
| 42 |
+
gap: 20px;
|
| 43 |
+
margin-bottom: 30px;
|
| 44 |
+
}
|
| 45 |
+
.comparison-card {
|
| 46 |
+
background: #fff;
|
| 47 |
+
padding: 20px;
|
| 48 |
+
border-radius: 8px;
|
| 49 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
| 50 |
+
border-left: 4px solid #3498db;
|
| 51 |
+
}
|
| 52 |
+
.comparison-card h3 {
|
| 53 |
+
color: #2c3e50;
|
| 54 |
+
margin-top: 0;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.chart-container {
|
| 58 |
+
position: relative;
|
| 59 |
+
height: 250px;
|
| 60 |
+
margin: 15px 0;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.stats-grid {
|
| 64 |
+
display: grid;
|
| 65 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 66 |
+
gap: 15px;
|
| 67 |
+
margin: 20px 0;
|
| 68 |
+
}
|
| 69 |
+
.stat-box {
|
| 70 |
+
padding: 15px;
|
| 71 |
+
border-radius: 6px;
|
| 72 |
+
color: white;
|
| 73 |
+
text-align: center;
|
| 74 |
+
}
|
| 75 |
+
.bbc-stat {
|
| 76 |
+
background: linear-gradient(135deg, #e74c3c, #c0392b);
|
| 77 |
+
}
|
| 78 |
+
.gazeta-stat {
|
| 79 |
+
background: linear-gradient(135deg, #27ae60, #229954);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.source-details {
|
| 83 |
+
background: #ecf0f1;
|
| 84 |
+
padding: 15px;
|
| 85 |
+
border-radius: 6px;
|
| 86 |
+
margin: 20px 0;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.results-table {
|
| 90 |
+
width: 100%;
|
| 91 |
+
border-collapse: collapse;
|
| 92 |
+
margin-top: 20px;
|
| 93 |
+
}
|
| 94 |
+
.results-table th, .results-table td {
|
| 95 |
+
padding: 12px;
|
| 96 |
+
text-align: left;
|
| 97 |
+
border-bottom: 1px solid #ddd;
|
| 98 |
+
}
|
| 99 |
+
.results-table th {
|
| 100 |
+
background-color: #3498db;
|
| 101 |
+
color: white;
|
| 102 |
+
}
|
| 103 |
+
.results-table tr:hover {
|
| 104 |
+
background-color: #f5f5f5;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.pass { color: #27ae60; font-weight: bold; }
|
| 108 |
+
.fail { color: #e74c3c; font-weight: bold; }
|
| 109 |
+
.warn { color: #f39c12; font-weight: bold; }
|
| 110 |
+
|
| 111 |
+
.insights {
|
| 112 |
+
background: #2c3e50;
|
| 113 |
+
color: white;
|
| 114 |
+
padding: 20px;
|
| 115 |
+
border-radius: 8px;
|
| 116 |
+
margin-top: 30px;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.footer {
|
| 120 |
+
text-align: center;
|
| 121 |
+
margin-top: 30px;
|
| 122 |
+
color: #7f8c8d;
|
| 123 |
+
font-size: 0.9em;
|
| 124 |
+
border-top: 1px solid #ecf0f1;
|
| 125 |
+
padding-top: 15px;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.highlight {
|
| 129 |
+
background: #fff3cd;
|
| 130 |
+
padding: 10px;
|
| 131 |
+
border-radius: 5px;
|
| 132 |
+
border-left: 4px solid #ffc107;
|
| 133 |
+
margin: 15px 0;
|
| 134 |
+
}
|
| 135 |
+
.conclusions {
|
| 136 |
+
background: #2c3e50;
|
| 137 |
+
color: white;
|
| 138 |
+
padding: 20px;
|
| 139 |
+
border-radius: 8px;
|
| 140 |
+
margin-top: 30px;
|
| 141 |
+
}
|
| 142 |
+
</style>
|
| 143 |
+
</head>
|
| 144 |
+
<body>
|
| 145 |
+
<div class="container">
|
| 146 |
+
<div class="header">
|
| 147 |
+
<h1>📊 RAPORT PORÓWNAWCZY TRUTHSCAN AI</h1>
|
| 148 |
+
<h2>BBC vs Gazeta Prawna - Analiza systemu detekcji dezinformacji</h2>
|
| 149 |
+
<div class="test-date">Data testów: 20.12.2025 08:56:33</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<div class="stats-grid">
|
| 153 |
+
<div class="stat-box bbc-stat">
|
| 154 |
+
<h3>BBC</h3>
|
| 155 |
+
<p>Artykułów: 5</p>
|
| 156 |
+
<p>Fake: 25.5%</p>
|
| 157 |
+
<p>Czas: 5.22s</p>
|
| 158 |
+
</div>
|
| 159 |
+
<div class="stat-box gazeta-stat">
|
| 160 |
+
<h3>Gazeta Prawna</h3>
|
| 161 |
+
<p>Artykułów: 5</p>
|
| 162 |
+
<p>Fake: 17.2%</p>
|
| 163 |
+
<p>Czas: 15.03s</p>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
<div class="comparison-section">
|
| 168 |
+
<div class="comparison-card">
|
| 169 |
+
<h3>📈 Średnie ryzyko dezinformacji</h3>
|
| 170 |
+
<div class="chart-container">
|
| 171 |
+
<canvas id="fakeScoreChart"></canvas>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
<div class="comparison-card">
|
| 176 |
+
<h3>⚡ Wydajność systemu</h3>
|
| 177 |
+
<div class="chart-container">
|
| 178 |
+
<canvas id="performanceChart"></canvas>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
<div class="comparison-section">
|
| 184 |
+
<div class="comparison-card">
|
| 185 |
+
<h3>🎭 Rozkład sentymentu - BBC</h3>
|
| 186 |
+
<div class="chart-container">
|
| 187 |
+
<canvas id="bbcSentimentChart"></canvas>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
<div class="comparison-card">
|
| 192 |
+
<h3>🎭 Rozkład sentymentu - Gazeta Prawna</h3>
|
| 193 |
+
<div class="chart-container">
|
| 194 |
+
<canvas id="gazetaSentimentChart"></canvas>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
|
| 199 |
+
<div class="highlight">
|
| 200 |
+
<h4>🔍 Kluczowe różnice:</h4>
|
| 201 |
+
<p>• Różnica w ryzyku dezinformacji: <strong>8.3%</strong></p>
|
| 202 |
+
<p>• Różnica w czasie analizy: <strong>9.81s</strong></p>
|
| 203 |
+
<p style='color: #27ae60;'>• ✅ Spójna skuteczność detekcji między językami</p>
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
<div class="source-details">
|
| 207 |
+
<h4>📰 Przykładowe artykuły z analizą</h4>
|
| 208 |
+
|
| 209 |
+
<h5>BBC:</h5><p><strong>1.</strong> Who and what is in the Epstein files?...</p><p><strong>2.</strong> David Walliams denies inappropriate behaviour after publisher drops him...</p>
|
| 210 |
+
<h5>Gazeta Prawna:</h5><p><strong>1.</strong> Fałszywe oskarżenia na policji mogą zrujnować życie – sprawdź, kiedy grozi za ni...</p><p><strong>2.</strong> Polacy za granicą: w tym kraju mieszka ich tylko dwoje. Zamiast polskiej zimy ma...</p>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
<h3>📋 Wyniki testów (7/7 przepuszczonych)</h3>
|
| 214 |
+
<table class="results-table">
|
| 215 |
+
<thead>
|
| 216 |
+
<tr>
|
| 217 |
+
<th>Test</th>
|
| 218 |
+
<th>Status</th>
|
| 219 |
+
<th>Szczegóły</th>
|
| 220 |
+
<th>Czas [s]</th>
|
| 221 |
+
</tr>
|
| 222 |
+
</thead>
|
| 223 |
+
<tbody>
|
| 224 |
+
<tr>
|
| 225 |
+
<td>API Availability</td>
|
| 226 |
+
<td class="pass">✅ PASS</td>
|
| 227 |
+
<td>Swagger UI dostępny (200)</td>
|
| 228 |
+
<td>2.07</td>
|
| 229 |
+
</tr>
|
| 230 |
+
<tr>
|
| 231 |
+
<td>GET /sources</td>
|
| 232 |
+
<td class="pass">✅ PASS</td>
|
| 233 |
+
<td>Znaleziono 10 źródeł | ✅ BBC dostępne | ✅ Gazeta Prawna dostępne</td>
|
| 234 |
+
<td>2.06</td>
|
| 235 |
+
</tr>
|
| 236 |
+
<tr>
|
| 237 |
+
<td>GET /news/BBC</td>
|
| 238 |
+
<td class="pass">✅ PASS</td>
|
| 239 |
+
<td>Pobrano 5 artykułów | Źródło: BBC | Pola: title, sentiment, fake_probability, summary, link, published | Przykład: 'Who and what is in the Epstein files?'</td>
|
| 240 |
+
<td>5.22</td>
|
| 241 |
+
</tr>
|
| 242 |
+
<tr>
|
| 243 |
+
<td>GET /news/GazetaPrawna</td>
|
| 244 |
+
<td class="pass">✅ PASS</td>
|
| 245 |
+
<td>Pobrano 5 artykułów | Źródło: GazetaPrawna | Pola: title, sentiment, fake_probability, summary, link, published | Przykład: 'Fałszywe oskarżenia na policji mogą zrujnować życi...'</td>
|
| 246 |
+
<td>15.03</td>
|
| 247 |
+
</tr>
|
| 248 |
+
<tr>
|
| 249 |
+
<td>Source Comparison</td>
|
| 250 |
+
<td class="pass">✅ PASS</td>
|
| 251 |
+
<td>BBC: 5 art, 25.5% fake, 5.2s | Gazeta: 5 art, 17.2% fake, 15.0s</td>
|
| 252 |
+
<td>-</td>
|
| 253 |
+
</tr>
|
| 254 |
+
<tr>
|
| 255 |
+
<td>CRUD Operations - BBC</td>
|
| 256 |
+
<td class="pass">✅ PASS</td>
|
| 257 |
+
<td>Zapis: ✅ (2.07s) | Odczyt: ✅ (15 artykułów, 2.09s) | Usuwanie: ✅ (Status: 200)</td>
|
| 258 |
+
<td>-</td>
|
| 259 |
+
</tr>
|
| 260 |
+
<tr>
|
| 261 |
+
<td>CRUD Operations - GazetaPrawna</td>
|
| 262 |
+
<td class="pass">✅ PASS</td>
|
| 263 |
+
<td>Zapis: ✅ (2.06s) | Odczyt: ✅ (15 artykułów, 2.11s) | Usuwanie: ✅ (Status: 200)</td>
|
| 264 |
+
<td>-</td>
|
| 265 |
+
</tr>
|
| 266 |
+
</tbody>
|
| 267 |
+
</table>
|
| 268 |
+
|
| 269 |
+
<div class="conclusions">
|
| 270 |
+
<h4>Wnioski i rekomendacje</h4>
|
| 271 |
+
|
| 272 |
+
<h5>Kluczowe wnioski:</h5>
|
| 273 |
+
<ul>
|
| 274 |
+
<li>System skutecznie analizuje źródła anglojęzyczne i polskojęzyczne</li>
|
| 275 |
+
<li>Analiza treści polskich zajmuje więcej czasu: różnica 12.91s</li>
|
| 276 |
+
<li>Różnica w wykrywaniu dezinformacji między źródłami: 0.1%</li>
|
| 277 |
+
<li>Spójna skuteczność detekcji między językami</li>
|
| 278 |
+
</ul>
|
| 279 |
+
|
| 280 |
+
<h5>Rekomendacje:</h5>
|
| 281 |
+
<ol>
|
| 282 |
+
<li>Fine-tuning modeli NLP na polskich danych fact-checkingowych</li>
|
| 283 |
+
<li>Optymalizacja parsowania polskich znaków diakrytycznych</li>
|
| 284 |
+
<li>Implementacja cache'owania wyników dla często analizowanych źródeł</li>
|
| 285 |
+
<li>Rozszerzenie testów o więcej polskich źródeł informacji</li>
|
| 286 |
+
<li>Przeprowadzenie testów z rzeczywistymi użytkownikami</li>
|
| 287 |
+
</ol>
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
|
| 291 |
+
<div class="footer">
|
| 292 |
+
<p>Raport wygenerowany automatycznie przez TruthScan AI Comparative Tester</p>
|
| 293 |
+
<p>System TruthScan AI - Prototyp do walki z dezinformacją</p>
|
| 294 |
+
<p>Wskaźnik sukcesu testów: 100.0% | Błędy: 0</p>
|
| 295 |
+
</div>
|
| 296 |
+
</div>
|
| 297 |
+
|
| 298 |
+
<script>
|
| 299 |
+
// Wykres ryzyka dezinformacji
|
| 300 |
+
const fakeScoreCtx = document.getElementById('fakeScoreChart').getContext('2d');
|
| 301 |
+
new Chart(fakeScoreCtx, {
|
| 302 |
+
type: 'bar',
|
| 303 |
+
data: {
|
| 304 |
+
labels: ["BBC", "Gazeta Prawna"],
|
| 305 |
+
datasets: [{
|
| 306 |
+
label: 'Średnie ryzyko dezinformacji (%)',
|
| 307 |
+
data: [25.47, 17.156],
|
| 308 |
+
backgroundColor: ['#e74c3c', '#27ae60'],
|
| 309 |
+
borderColor: ['#c0392b', '#229954'],
|
| 310 |
+
borderWidth: 1
|
| 311 |
+
}]
|
| 312 |
+
},
|
| 313 |
+
options: {
|
| 314 |
+
responsive: true,
|
| 315 |
+
plugins: {
|
| 316 |
+
legend: { display: true, position: 'top' }
|
| 317 |
+
},
|
| 318 |
+
scales: {
|
| 319 |
+
y: {
|
| 320 |
+
beginAtZero: true,
|
| 321 |
+
max: Math.max(25.47, 50),
|
| 322 |
+
title: {
|
| 323 |
+
display: true,
|
| 324 |
+
text: 'Procent ryzyka'
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
});
|
| 330 |
+
|
| 331 |
+
// Wykres wydajności
|
| 332 |
+
const performanceCtx = document.getElementById('performanceChart').getContext('2d');
|
| 333 |
+
new Chart(performanceCtx, {
|
| 334 |
+
type: 'line',
|
| 335 |
+
data: {
|
| 336 |
+
labels: ["BBC", "Gazeta Prawna"],
|
| 337 |
+
datasets: [{
|
| 338 |
+
label: 'Czas analizy (sekundy)',
|
| 339 |
+
data: [5.216013669967651, 15.029404878616333],
|
| 340 |
+
backgroundColor: 'rgba(52, 152, 219, 0.2)',
|
| 341 |
+
borderColor: '#3498db',
|
| 342 |
+
borderWidth: 2,
|
| 343 |
+
tension: 0.3,
|
| 344 |
+
fill: true
|
| 345 |
+
}]
|
| 346 |
+
},
|
| 347 |
+
options: {
|
| 348 |
+
responsive: true,
|
| 349 |
+
plugins: {
|
| 350 |
+
legend: { display: true, position: 'top' }
|
| 351 |
+
},
|
| 352 |
+
scales: {
|
| 353 |
+
y: {
|
| 354 |
+
beginAtZero: true,
|
| 355 |
+
title: {
|
| 356 |
+
display: true,
|
| 357 |
+
text: 'Czas (s)'
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
// Funkcja pomocnicza do mapowania sentymentów na kolory
|
| 365 |
+
function getSentimentColors(labels) {
|
| 366 |
+
const colorMap = {
|
| 367 |
+
'Negatywne': '#e74c3c', // czerwony
|
| 368 |
+
'Neutralne': '#3498db', // niebieski
|
| 369 |
+
'Pozytywne': '#2ecc71', // zielony
|
| 370 |
+
'bardzo negatywne': '#c0392b',
|
| 371 |
+
'bardzo pozytywne': '#27ae60'
|
| 372 |
+
};
|
| 373 |
+
|
| 374 |
+
return labels.map(label => colorMap[label] || '#95a5a6');
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
// Wykresy sentymentu
|
| 378 |
+
const bbcSentimentCtx = document.getElementById('bbcSentimentChart').getContext('2d');
|
| 379 |
+
new Chart(bbcSentimentCtx, {
|
| 380 |
+
type: 'doughnut',
|
| 381 |
+
data: {
|
| 382 |
+
labels: ["Neutralne", "Pozytywne", "Negatywne"],
|
| 383 |
+
datasets: [{
|
| 384 |
+
data: [3, 1, 1],
|
| 385 |
+
backgroundColor: getSentimentColors(["Neutralne", "Pozytywne", "Negatywne"]),
|
| 386 |
+
hoverOffset: 10
|
| 387 |
+
}]
|
| 388 |
+
},
|
| 389 |
+
options: {
|
| 390 |
+
responsive: true,
|
| 391 |
+
plugins: {
|
| 392 |
+
legend: { position: 'bottom' }
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
});
|
| 396 |
+
|
| 397 |
+
const gazetaSentimentCtx = document.getElementById('gazetaSentimentChart').getContext('2d');
|
| 398 |
+
new Chart(gazetaSentimentCtx, {
|
| 399 |
+
type: 'doughnut',
|
| 400 |
+
data: {
|
| 401 |
+
labels: ["Neutralne"],
|
| 402 |
+
datasets: [{
|
| 403 |
+
data: [5],
|
| 404 |
+
backgroundColor: getSentimentColors(["Neutralne"]),
|
| 405 |
+
hoverOffset: 10
|
| 406 |
+
}]
|
| 407 |
+
},
|
| 408 |
+
options: {
|
| 409 |
+
responsive: true,
|
| 410 |
+
plugins: {
|
| 411 |
+
legend: { position: 'bottom' }
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
});
|
| 415 |
+
</script>
|
| 416 |
+
</body>
|
| 417 |
+
</html>
|
TruthScan AI_backend/truthscan_report.txt
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
================================================================================
|
| 3 |
+
RAPORT PORÓWNAWCZY TRUTHSCAN AI - BBC vs GAZETA PRAWNA
|
| 4 |
+
================================================================================
|
| 5 |
+
Data wykonania: 2025-12-20 08:56:33
|
| 6 |
+
Backend URL: http://localhost:8000
|
| 7 |
+
Frontend URL: http://localhost:3000
|
| 8 |
+
================================================================================
|
| 9 |
+
|
| 10 |
+
PODSUMOWANIE TESTOW:
|
| 11 |
+
================================================================================
|
| 12 |
+
Wszystkie testy: 7
|
| 13 |
+
Przepuszczone: 7
|
| 14 |
+
Nieudane: 0
|
| 15 |
+
Ostrzeżenia: 0
|
| 16 |
+
Wskaźnik sukcesu: 100.0%
|
| 17 |
+
|
| 18 |
+
================================================================================
|
| 19 |
+
WYNIKI PORÓWNANIA:
|
| 20 |
+
================================================================================
|
| 21 |
+
|
| 22 |
+
BBC:
|
| 23 |
+
• Artykułów: 5
|
| 24 |
+
• Średnie fake_probability: 25.47%
|
| 25 |
+
• Czas odpowiedzi: 5.22s
|
| 26 |
+
• Rozkład sentymentu: {"Neutralne": 3, "Pozytywne": 1, "Negatywne": 1}
|
| 27 |
+
|
| 28 |
+
Gazeta Prawna:
|
| 29 |
+
• Artykułów: 5
|
| 30 |
+
• Średnie fake_probability: 17.16%
|
| 31 |
+
• Czas odpowiedzi: 15.03s
|
| 32 |
+
• Rozkład sentymentu: {"Neutralne": 5}
|
| 33 |
+
|
| 34 |
+
ANALIZA RÓŻNIC:
|
| 35 |
+
• Różnica w fake_probability: 8.31%
|
| 36 |
+
• Różnica w czasie odpowiedzi: 9.81s
|
| 37 |
+
|
| 38 |
+
================================================================================
|
| 39 |
+
WYNIKI SZCZEGÓŁOWE:
|
| 40 |
+
================================================================================
|
| 41 |
+
✅ API Availability [2.07s]
|
| 42 |
+
Swagger UI dostępny (200)
|
| 43 |
+
Czas: 08:55:32
|
| 44 |
+
|
| 45 |
+
✅ GET /sources [2.06s]
|
| 46 |
+
Znaleziono 10 źródeł | ✅ BBC dostępne | ✅ Gazeta Prawna dostępne
|
| 47 |
+
Czas: 08:55:35
|
| 48 |
+
|
| 49 |
+
✅ GET /news/BBC [5.22s]
|
| 50 |
+
Pobrano 5 artykułów | Źródło: BBC | Pola: title, sentiment, fake_probability, summary, link, published | Przykład: 'Who and what is in the Epstein files?'
|
| 51 |
+
Czas: 08:55:40
|
| 52 |
+
|
| 53 |
+
✅ GET /news/GazetaPrawna [15.03s]
|
| 54 |
+
Pobrano 5 artykułów | Źródło: GazetaPrawna | Pola: title, sentiment, fake_probability, summary, link, published | Przykład: 'Fałszywe oskarżenia na policji mogą zrujnować życi...'
|
| 55 |
+
Czas: 08:55:55
|
| 56 |
+
|
| 57 |
+
✅ Source Comparison
|
| 58 |
+
BBC: 5 art, 25.5% fake, 5.2s | Gazeta: 5 art, 17.2% fake, 15.0s
|
| 59 |
+
Czas: 08:55:55
|
| 60 |
+
|
| 61 |
+
✅ CRUD Operations - BBC
|
| 62 |
+
Zapis: ✅ (2.07s) | Odczyt: ✅ (15 artykułów, 2.09s) | Usuwanie: ✅ (Status: 200)
|
| 63 |
+
Czas: 08:56:10
|
| 64 |
+
|
| 65 |
+
✅ CRUD Operations - GazetaPrawna
|
| 66 |
+
Zapis: ✅ (2.06s) | Odczyt: ✅ (15 artykułów, 2.11s) | Usuwanie: ✅ (Status: 200)
|
| 67 |
+
Czas: 08:56:33
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
================================================================================
|
| 71 |
+
PRZYKŁADOWE ARTYKUŁY:
|
| 72 |
+
================================================================================
|
| 73 |
+
BBC:
|
| 74 |
+
1. Who and what is in the Epstein files?
|
| 75 |
+
2. David Walliams denies inappropriate behaviour after publisher drops him
|
| 76 |
+
3. US carries out 'massive' strike against IS in Syria
|
| 77 |
+
|
| 78 |
+
Gazeta Prawna:
|
| 79 |
+
1. Fałszywe oskarżenia na policji mogą zrujnować życie – sprawdź, kiedy grozi za ni
|
| 80 |
+
2. Polacy za granicą: w tym kraju mieszka ich tylko dwoje. Zamiast polskiej zimy ma
|
| 81 |
+
3. Okrągły Stół porwany przez nurt Historii [FELIETON]
|
| 82 |
+
|
| 83 |
+
================================================================================
|
| 84 |
+
WNIOSKI I REKOMENDACJE:
|
| 85 |
+
================================================================================
|
| 86 |
+
1. System działa poprawnie
|
| 87 |
+
2. Obsługa języka polskiego: SPRAWNIE
|
| 88 |
+
3. Średni czas odpowiedzi: 6.09s
|
| 89 |
+
4. Główne problemy: 0 błędów
|
| 90 |
+
5. Gotowość do dalszych testów: TAK
|
| 91 |
+
|
| 92 |
+
REKOMENDACJE:
|
| 93 |
+
1. Wszystko działa poprawnie
|
| 94 |
+
2. Przeprowadzić testy manualne interfejsu
|
| 95 |
+
3. Przetestować więcej źródeł RSS
|
| 96 |
+
4. Sprawdzić działanie na różnych przeglądarkach
|
| 97 |
+
5. Modele działają spójnie dla obu języków
|
TruthScan AI_backend/truthscan_test.py
ADDED
|
@@ -0,0 +1,1135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SKRYPT TESTOWY TRUTHSCAN AI - TEST PORÓWNAWCZY BBC I GAZETA PRAWNA
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import requests
|
| 6 |
+
import json
|
| 7 |
+
import time
|
| 8 |
+
import statistics
|
| 9 |
+
import os
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from collections import defaultdict
|
| 12 |
+
from typing import Dict, List, Any, Optional
|
| 13 |
+
|
| 14 |
+
class TruthScanComparativeTester:
|
| 15 |
+
def __init__(self, base_url="http://localhost:8000", frontend_url="http://localhost:3000"):
|
| 16 |
+
self.base_url = base_url
|
| 17 |
+
self.frontend_url = frontend_url
|
| 18 |
+
self.results = []
|
| 19 |
+
self.errors = []
|
| 20 |
+
self.performance_data = []
|
| 21 |
+
self.bbc_results = {}
|
| 22 |
+
self.gazeta_results = {}
|
| 23 |
+
self.comparison_data = {}
|
| 24 |
+
|
| 25 |
+
def log_test(self, test_name: str, status: str, details: str = "", duration: float = None):
|
| 26 |
+
"""Zapisuje wynik testu"""
|
| 27 |
+
result = {
|
| 28 |
+
"test_name": test_name,
|
| 29 |
+
"status": status,
|
| 30 |
+
"timestamp": datetime.now().isoformat(),
|
| 31 |
+
"details": details,
|
| 32 |
+
"duration": duration
|
| 33 |
+
}
|
| 34 |
+
self.results.append(result)
|
| 35 |
+
|
| 36 |
+
status_symbol = "✅" if status == "PASS" else "❌" if status == "FAIL" else "⚠️"
|
| 37 |
+
print(f"{status_symbol} {test_name}: {details}")
|
| 38 |
+
|
| 39 |
+
if status == "FAIL":
|
| 40 |
+
self.errors.append(result)
|
| 41 |
+
|
| 42 |
+
##########
|
| 43 |
+
# Test dostępności API
|
| 44 |
+
##########
|
| 45 |
+
|
| 46 |
+
def test_api_availability(self):
|
| 47 |
+
test_name = "API Availability"
|
| 48 |
+
start = time.time()
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
response = requests.get(f"{self.base_url}/docs", timeout=10)
|
| 52 |
+
duration = time.time() - start
|
| 53 |
+
|
| 54 |
+
if response.status_code == 200:
|
| 55 |
+
self.log_test(test_name, "PASS",
|
| 56 |
+
f"Swagger UI dostępny ({response.status_code})", duration)
|
| 57 |
+
return True
|
| 58 |
+
else:
|
| 59 |
+
self.log_test(test_name, "FAIL",
|
| 60 |
+
f"Status code: {response.status_code}", duration)
|
| 61 |
+
return False
|
| 62 |
+
except Exception as e:
|
| 63 |
+
self.log_test(test_name, "FAIL", f"Błąd połączenia: {str(e)}", time.time() - start)
|
| 64 |
+
return False
|
| 65 |
+
|
| 66 |
+
############
|
| 67 |
+
# Test: Sprawdzenie dostępnych źródeł
|
| 68 |
+
############
|
| 69 |
+
|
| 70 |
+
def test_sources_endpoint(self):
|
| 71 |
+
|
| 72 |
+
test_name = "GET /sources"
|
| 73 |
+
start = time.time()
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
response = requests.get(f"{self.base_url}/sources", timeout=15)
|
| 77 |
+
duration = time.time() - start
|
| 78 |
+
|
| 79 |
+
if response.status_code == 200:
|
| 80 |
+
data = response.json()
|
| 81 |
+
|
| 82 |
+
if isinstance(data, list):
|
| 83 |
+
details = f"Znaleziono {len(data)} źródeł"
|
| 84 |
+
|
| 85 |
+
# Sprawdzenie czy oba źródła są dostępne
|
| 86 |
+
sources_lower = [s.lower() for s in data]
|
| 87 |
+
bbc_available = "bbc" in sources_lower or any("bbc" in s.lower() for s in data)
|
| 88 |
+
gazeta_available = "gazetaprawna" in sources_lower or any("gazeta" in s.lower() for s in data)
|
| 89 |
+
|
| 90 |
+
if not bbc_available:
|
| 91 |
+
details += " | ❌ BBC niedostępne"
|
| 92 |
+
status = "WARNING"
|
| 93 |
+
elif not gazeta_available:
|
| 94 |
+
details += " | ❌ Gazeta Prawna niedostępne"
|
| 95 |
+
status = "WARNING"
|
| 96 |
+
else:
|
| 97 |
+
details += " | ✅ BBC dostępne | ✅ Gazeta Prawna dostępne"
|
| 98 |
+
status = "PASS"
|
| 99 |
+
|
| 100 |
+
self.log_test(test_name, status, details, duration)
|
| 101 |
+
return data
|
| 102 |
+
else:
|
| 103 |
+
self.log_test(test_name, "FAIL", f"Nieoczekiwany typ danych: {type(data)}", duration)
|
| 104 |
+
return None
|
| 105 |
+
else:
|
| 106 |
+
self.log_test(test_name, "FAIL",
|
| 107 |
+
f"Status code: {response.status_code}", duration)
|
| 108 |
+
return None
|
| 109 |
+
except Exception as e:
|
| 110 |
+
self.log_test(test_name, "FAIL", f"Błąd: {str(e)}", time.time() - start)
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
def test_single_source_detailed(self, source: str, source_name: str):
|
| 114 |
+
print(f"\n{'='*60}")
|
| 115 |
+
print(f"🔍 SZCZEGÓŁOWY TEST: {source_name} ({source})")
|
| 116 |
+
print(f"{'='*60}")
|
| 117 |
+
|
| 118 |
+
test_results = {
|
| 119 |
+
"source": source,
|
| 120 |
+
"source_name": source_name,
|
| 121 |
+
"articles": [],
|
| 122 |
+
"sentiment_distribution": defaultdict(int),
|
| 123 |
+
"fake_scores": [],
|
| 124 |
+
"performance": 0,
|
| 125 |
+
"errors": [],
|
| 126 |
+
"sample_titles": []
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
#### Test 1: Pobieranie artykułów
|
| 130 |
+
test_name = f"GET /news/{source}"
|
| 131 |
+
start = time.time()
|
| 132 |
+
|
| 133 |
+
try:
|
| 134 |
+
response = requests.get(f"{self.base_url}/news/{source}", timeout=30)
|
| 135 |
+
duration = time.time() - start
|
| 136 |
+
test_results["performance"] = duration
|
| 137 |
+
|
| 138 |
+
if response.status_code != 200:
|
| 139 |
+
self.log_test(test_name, "FAIL",
|
| 140 |
+
f"Status code: {response.status_code}", duration)
|
| 141 |
+
test_results["errors"].append(f"HTTP {response.status_code}")
|
| 142 |
+
return test_results
|
| 143 |
+
|
| 144 |
+
data = response.json()
|
| 145 |
+
|
| 146 |
+
if not data:
|
| 147 |
+
self.log_test(test_name, "FAIL", "Brak danych w odpowiedzi", duration)
|
| 148 |
+
return test_results
|
| 149 |
+
|
| 150 |
+
if isinstance(data, dict) and "articles" in data:
|
| 151 |
+
articles = data["articles"]
|
| 152 |
+
source_from_response = data.get("source", source)
|
| 153 |
+
test_results["articles"] = articles
|
| 154 |
+
|
| 155 |
+
details = f"Pobrano {len(articles)} artykułów | Źródło: {source_from_response}"
|
| 156 |
+
|
| 157 |
+
if len(articles) > 0:
|
| 158 |
+
|
| 159 |
+
first_article = articles[0]
|
| 160 |
+
available_fields = [field for field in ["title", "sentiment", "fake_probability", "summary", "link", "published"]
|
| 161 |
+
if field in first_article]
|
| 162 |
+
|
| 163 |
+
details += f" | Pola: {', '.join(available_fields)}"
|
| 164 |
+
|
| 165 |
+
title = first_article.get("title", "Brak tytułu")
|
| 166 |
+
if len(title) > 50:
|
| 167 |
+
title = title[:50] + "..."
|
| 168 |
+
details += f" | Przykład: '{title}'"
|
| 169 |
+
|
| 170 |
+
for i, article in enumerate(articles[:3]):
|
| 171 |
+
title = article.get("title", "")
|
| 172 |
+
if title:
|
| 173 |
+
test_results["sample_titles"].append(title[:80])
|
| 174 |
+
|
| 175 |
+
self.log_test(test_name, "PASS", details, duration)
|
| 176 |
+
|
| 177 |
+
# Zapis danych wydajności
|
| 178 |
+
self.performance_data.append({
|
| 179 |
+
"endpoint": test_name,
|
| 180 |
+
"duration": duration,
|
| 181 |
+
"source": source
|
| 182 |
+
})
|
| 183 |
+
|
| 184 |
+
else:
|
| 185 |
+
self.log_test(test_name, "WARNING",
|
| 186 |
+
f"Nieoczekiwany format danych: {type(data)}", duration)
|
| 187 |
+
return test_results
|
| 188 |
+
|
| 189 |
+
except Exception as e:
|
| 190 |
+
self.log_test(test_name, "FAIL", f"Błąd: {str(e)}", time.time() - start)
|
| 191 |
+
test_results["errors"].append(str(e))
|
| 192 |
+
return test_results
|
| 193 |
+
|
| 194 |
+
#### Test 2: Szczegółowa analiza NLP dla każdego artykułu
|
| 195 |
+
if test_results["articles"]:
|
| 196 |
+
print(f"\n📊 ANALIZA NLP DLA {source_name}:")
|
| 197 |
+
|
| 198 |
+
for i, article in enumerate(test_results["articles"][:5]):
|
| 199 |
+
print(f"\n 📄 Artykuł {i+1}:")
|
| 200 |
+
|
| 201 |
+
title = article.get("title", "Brak tytułu")
|
| 202 |
+
if len(title) > 60:
|
| 203 |
+
title_display = title[:60] + "..."
|
| 204 |
+
else:
|
| 205 |
+
title_display = title
|
| 206 |
+
print(f" Tytuł: {title_display}")
|
| 207 |
+
|
| 208 |
+
#### Sentyment
|
| 209 |
+
sentiment = article.get("sentiment", "Nieznany")
|
| 210 |
+
sentiment_score = article.get("sentiment_score", 0)
|
| 211 |
+
print(f" Sentyment: {sentiment} ({sentiment_score:.2f})")
|
| 212 |
+
|
| 213 |
+
#### Fake probability
|
| 214 |
+
fake_prob = article.get("fake_probability", 0)
|
| 215 |
+
if isinstance(fake_prob, (int, float)):
|
| 216 |
+
print(f" Fake probability: {fake_prob}%")
|
| 217 |
+
|
| 218 |
+
#### Kategoryzacja ryzyka
|
| 219 |
+
if fake_prob < 15:
|
| 220 |
+
risk = "NISKIE"
|
| 221 |
+
elif fake_prob < 30:
|
| 222 |
+
risk = "ŚREDNIE"
|
| 223 |
+
else:
|
| 224 |
+
risk = "WYSOKIE"
|
| 225 |
+
print(f" Ryzyko dezinformacji: {risk}")
|
| 226 |
+
|
| 227 |
+
test_results["fake_scores"].append(fake_prob)
|
| 228 |
+
|
| 229 |
+
test_results["sentiment_distribution"][sentiment] += 1
|
| 230 |
+
|
| 231 |
+
#### Link i data
|
| 232 |
+
link = article.get("link", "")
|
| 233 |
+
if link:
|
| 234 |
+
domain = link.split('/')[2] if len(link.split('/')) > 2 else link
|
| 235 |
+
print(f" Źródło: {domain}")
|
| 236 |
+
|
| 237 |
+
published = article.get("published", "Brak daty")
|
| 238 |
+
print(f" Data publikacji: {published}")
|
| 239 |
+
|
| 240 |
+
#### Test 3: Analiza statystyczna
|
| 241 |
+
if test_results["fake_scores"]:
|
| 242 |
+
avg_fake = statistics.mean(test_results["fake_scores"])
|
| 243 |
+
min_fake = min(test_results["fake_scores"])
|
| 244 |
+
max_fake = max(test_results["fake_scores"])
|
| 245 |
+
|
| 246 |
+
print(f"\n 📈 STATYSTYKI {source_name}:")
|
| 247 |
+
print(f" Średnie fake_probability: {avg_fake:.2f}%")
|
| 248 |
+
print(f" Zakres: {min_fake:.2f}% - {max_fake:.2f}%")
|
| 249 |
+
print(f" Czas odpowiedzi: {test_results['performance']:.2f}s")
|
| 250 |
+
|
| 251 |
+
# Analiza rozkładu sentymentu
|
| 252 |
+
if test_results["sentiment_distribution"]:
|
| 253 |
+
print(f" Rozkład sentymentu:")
|
| 254 |
+
for sentiment, count in test_results["sentiment_distribution"].items():
|
| 255 |
+
percentage = (count / len(test_results["articles"])) * 100
|
| 256 |
+
print(f" {sentiment}: {count} ({percentage:.1f}%)")
|
| 257 |
+
|
| 258 |
+
return test_results
|
| 259 |
+
|
| 260 |
+
###########
|
| 261 |
+
# Porównanie wyników BBC i Gazety Prawnej
|
| 262 |
+
###########
|
| 263 |
+
def compare_sources(self, bbc_data: Dict, gazeta_data: Dict):
|
| 264 |
+
|
| 265 |
+
print(f"\n{'='*60}")
|
| 266 |
+
print(f"🔄 PORÓWNANIE BBC vs GAZETA PRAWNA")
|
| 267 |
+
print(f"{'='*60}")
|
| 268 |
+
|
| 269 |
+
# Obliczanie średnich - z obsługą pustych list
|
| 270 |
+
bbc_fake_scores = bbc_data.get("fake_scores", [])
|
| 271 |
+
gazeta_fake_scores = gazeta_data.get("fake_scores", [])
|
| 272 |
+
|
| 273 |
+
bbc_avg_fake = statistics.mean(bbc_fake_scores) if bbc_fake_scores else 0
|
| 274 |
+
gazeta_avg_fake = statistics.mean(gazeta_fake_scores) if gazeta_fake_scores else 0
|
| 275 |
+
|
| 276 |
+
comparison = {
|
| 277 |
+
"source_count": {
|
| 278 |
+
"BBC": len(bbc_data.get("articles", [])),
|
| 279 |
+
"Gazeta Prawna": len(gazeta_data.get("articles", []))
|
| 280 |
+
},
|
| 281 |
+
"avg_fake_score": {
|
| 282 |
+
"BBC": bbc_avg_fake,
|
| 283 |
+
"Gazeta Prawna": gazeta_avg_fake
|
| 284 |
+
},
|
| 285 |
+
"sentiment_distribution": {
|
| 286 |
+
"BBC": dict(bbc_data.get("sentiment_distribution", {})),
|
| 287 |
+
"Gazeta Prawna": dict(gazeta_data.get("sentiment_distribution", {}))
|
| 288 |
+
},
|
| 289 |
+
"performance": {
|
| 290 |
+
"BBC": bbc_data.get("performance", 0),
|
| 291 |
+
"Gazeta Prawna": gazeta_data.get("performance", 0)
|
| 292 |
+
},
|
| 293 |
+
"sample_titles": {
|
| 294 |
+
"BBC": bbc_data.get("sample_titles", []),
|
| 295 |
+
"Gazeta Prawna": gazeta_data.get("sample_titles", [])
|
| 296 |
+
}
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
#### Wyświetlanie wyników porównania
|
| 300 |
+
print(f"\n📊 LICZBA ARTYKUŁÓW:")
|
| 301 |
+
print(f" BBC: {comparison['source_count']['BBC']}")
|
| 302 |
+
print(f" Gazeta Prawna: {comparison['source_count']['Gazeta Prawna']}")
|
| 303 |
+
|
| 304 |
+
print(f"\n📊 ŚREDNIE RYZYKO DEZINFORMACJI:")
|
| 305 |
+
print(f" BBC: {comparison['avg_fake_score']['BBC']:.2f}%")
|
| 306 |
+
print(f" Gazeta Prawna: {comparison['avg_fake_score']['Gazeta Prawna']:.2f}%")
|
| 307 |
+
|
| 308 |
+
#### Analiza różnic
|
| 309 |
+
if comparison['avg_fake_score']['BBC'] > 0 and comparison['avg_fake_score']['Gazeta Prawna'] > 0:
|
| 310 |
+
fake_diff = abs(comparison['avg_fake_score']['BBC'] - comparison['avg_fake_score']['Gazeta Prawna'])
|
| 311 |
+
print(f" Różnica: {fake_diff:.2f}%")
|
| 312 |
+
|
| 313 |
+
if fake_diff > 10:
|
| 314 |
+
print(f" ⚠️ Znacząca różnica w ryzyku dezinformacji")
|
| 315 |
+
|
| 316 |
+
print(f"\n📊 ROZKŁAD SENTYMENTU:")
|
| 317 |
+
|
| 318 |
+
for source in ["BBC", "Gazeta Prawna"]:
|
| 319 |
+
print(f"\n {source}:")
|
| 320 |
+
dist = comparison['sentiment_distribution'][source]
|
| 321 |
+
total = sum(dist.values()) if dist else 1
|
| 322 |
+
|
| 323 |
+
if dist:
|
| 324 |
+
for sentiment, count in dist.items():
|
| 325 |
+
percentage = (count / total) * 100 if total > 0 else 0
|
| 326 |
+
print(f" {sentiment}: {count} ({percentage:.1f}%)")
|
| 327 |
+
else:
|
| 328 |
+
print(" Brak danych o sentymencie")
|
| 329 |
+
|
| 330 |
+
print(f"\n📊 WYDANOŚĆ:")
|
| 331 |
+
print(f" BBC: {comparison['performance']['BBC']:.2f}s")
|
| 332 |
+
print(f" Gazeta Prawna: {comparison['performance']['Gazeta Prawna']:.2f}s")
|
| 333 |
+
|
| 334 |
+
if comparison['performance']['BBC'] > 0 and comparison['performance']['Gazeta Prawna'] > 0:
|
| 335 |
+
perf_diff = comparison['performance']['Gazeta Prawna'] - comparison['performance']['BBC']
|
| 336 |
+
if perf_diff > 1:
|
| 337 |
+
print(f" ⏱️ Gazeta Prawna wolniejsza o {perf_diff:.2f}s (język polski)")
|
| 338 |
+
elif perf_diff < -1:
|
| 339 |
+
print(f" ⏱️ BBC wolniejsze o {abs(perf_diff):.2f}s")
|
| 340 |
+
else:
|
| 341 |
+
print(f" ⚡ Porównywalna wydajność")
|
| 342 |
+
|
| 343 |
+
#### Logowanie testu porównawczego
|
| 344 |
+
details = (f"BBC: {comparison['source_count']['BBC']} art, "
|
| 345 |
+
f"{comparison['avg_fake_score']['BBC']:.1f}% fake, "
|
| 346 |
+
f"{comparison['performance']['BBC']:.1f}s | "
|
| 347 |
+
f"Gazeta: {comparison['source_count']['Gazeta Prawna']} art, "
|
| 348 |
+
f"{comparison['avg_fake_score']['Gazeta Prawna']:.1f}% fake, "
|
| 349 |
+
f"{comparison['performance']['Gazeta Prawna']:.1f}s")
|
| 350 |
+
|
| 351 |
+
self.log_test("Source Comparison", "PASS", details)
|
| 352 |
+
|
| 353 |
+
self.comparison_data = comparison
|
| 354 |
+
return comparison
|
| 355 |
+
|
| 356 |
+
################
|
| 357 |
+
#Test operacji CRUD z artykułami z danego źródła
|
| 358 |
+
##############
|
| 359 |
+
|
| 360 |
+
def test_crud_operations_for_source(self, source: str):
|
| 361 |
+
|
| 362 |
+
test_name = f"CRUD Operations - {source}"
|
| 363 |
+
|
| 364 |
+
##### Najpierw pobierz artykuły ze źródła
|
| 365 |
+
|
| 366 |
+
try:
|
| 367 |
+
response = requests.get(f"{self.base_url}/news/{source}", timeout=20)
|
| 368 |
+
if response.status_code != 200:
|
| 369 |
+
self.log_test(test_name, "FAIL", f"Nie można pobrać artykułów: {response.status_code}")
|
| 370 |
+
return None
|
| 371 |
+
|
| 372 |
+
data = response.json()
|
| 373 |
+
articles = data.get("articles", []) if isinstance(data, dict) else data
|
| 374 |
+
|
| 375 |
+
if not articles:
|
| 376 |
+
self.log_test(test_name, "WARNING", "Brak artykułów do testu CRUD")
|
| 377 |
+
return None
|
| 378 |
+
|
| 379 |
+
##### Użyj pierwszego artykułu do testu
|
| 380 |
+
test_article = articles[0]
|
| 381 |
+
|
| 382 |
+
#### Dostosuj artykuł do formatu zapisu#############
|
| 383 |
+
article_to_save = {
|
| 384 |
+
"title": f"[TEST {source}] {test_article.get('title', 'Testowy artykuł')}",
|
| 385 |
+
"link": test_article.get("link", "https://example.com/test"),
|
| 386 |
+
"summary": test_article.get("summary", "Testowy artykuł do weryfikacji systemu."),
|
| 387 |
+
"published": datetime.now().isoformat(),
|
| 388 |
+
"sentiment": test_article.get("sentiment", "Neutral"),
|
| 389 |
+
"fake_probability": test_article.get("fake_probability", 15.5),
|
| 390 |
+
"source": source
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
operations = []
|
| 394 |
+
|
| 395 |
+
#### 1. Zapis artykułu#############################
|
| 396 |
+
try:
|
| 397 |
+
start = time.time()
|
| 398 |
+
response = requests.post(
|
| 399 |
+
f"{self.base_url}/save-article",
|
| 400 |
+
json=article_to_save,
|
| 401 |
+
timeout=10
|
| 402 |
+
)
|
| 403 |
+
save_time = time.time() - start
|
| 404 |
+
|
| 405 |
+
if response.status_code in [200, 201]:
|
| 406 |
+
operations.append(("Zapis", "✅", f"{save_time:.2f}s"))
|
| 407 |
+
else:
|
| 408 |
+
operations.append(("Zapis", "❌", f"Status: {response.status_code}"))
|
| 409 |
+
except Exception as e:
|
| 410 |
+
operations.append(("Zapis", "❌", f"Błąd: {str(e)[:30]}"))
|
| 411 |
+
|
| 412 |
+
#### 2. Odczyt zapisanych artykułów############
|
| 413 |
+
try:
|
| 414 |
+
start = time.time()
|
| 415 |
+
response = requests.get(f"{self.base_url}/saved-articles", timeout=10)
|
| 416 |
+
fetch_time = time.time() - start
|
| 417 |
+
|
| 418 |
+
if response.status_code == 200:
|
| 419 |
+
data = response.json()
|
| 420 |
+
if isinstance(data, list):
|
| 421 |
+
found = any(isinstance(a, dict) and source in a.get("title", "") for a in data)
|
| 422 |
+
operations.append(("Odczyt", "✅" if found else "⚠️",
|
| 423 |
+
f"{len(data)} artykułów, {fetch_time:.2f}s"))
|
| 424 |
+
else:
|
| 425 |
+
operations.append(("Odczyt", "❌", f"Niewłaściwy format: {type(data)}"))
|
| 426 |
+
else:
|
| 427 |
+
operations.append(("Odczyt", "❌", f"Status: {response.status_code}"))
|
| 428 |
+
except Exception as e:
|
| 429 |
+
operations.append(("Odczyt", "❌", f"Błąd: {str(e)[:30]}"))
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
#### 3. Usuwanie testowego artykułu##############
|
| 433 |
+
|
| 434 |
+
try:
|
| 435 |
+
response = requests.delete(
|
| 436 |
+
f"{self.base_url}/delete-article",
|
| 437 |
+
json={"title": article_to_save["title"]},
|
| 438 |
+
timeout=5
|
| 439 |
+
)
|
| 440 |
+
operations.append(("Usuwanie", "✅" if response.status_code == 200 else "⚠️",
|
| 441 |
+
f"Status: {response.status_code}"))
|
| 442 |
+
except Exception as e:
|
| 443 |
+
operations.append(("Usuwanie", "SKIP", f"Błąd: {str(e)[:30]}"))
|
| 444 |
+
|
| 445 |
+
details = " | ".join([f"{op[0]}: {op[1]} ({op[2]})" for op in operations])
|
| 446 |
+
success_ops = sum(1 for op in operations if op[1] in ["✅", "SKIP"])
|
| 447 |
+
status = "PASS" if success_ops >= 2 else "FAIL"
|
| 448 |
+
|
| 449 |
+
self.log_test(test_name, status, details)
|
| 450 |
+
return operations
|
| 451 |
+
|
| 452 |
+
except Exception as e:
|
| 453 |
+
self.log_test(test_name, "FAIL", f"Błąd ogólny: {str(e)}")
|
| 454 |
+
return None
|
| 455 |
+
|
| 456 |
+
##################### Generuje raport tekstowy z porównaniem ##########
|
| 457 |
+
|
| 458 |
+
def generate_text_report(self, filename: str = "truthscan_report.txt"):
|
| 459 |
+
|
| 460 |
+
total_tests = len(self.results)
|
| 461 |
+
passed = sum(1 for r in self.results if r["status"] == "PASS")
|
| 462 |
+
|
| 463 |
+
report = f"""
|
| 464 |
+
{'='*80}
|
| 465 |
+
RAPORT PORÓWNAWCZY TRUTHSCAN AI - BBC vs GAZETA PRAWNA
|
| 466 |
+
{'='*80}
|
| 467 |
+
Data wykonania: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
| 468 |
+
Backend URL: {self.base_url}
|
| 469 |
+
Frontend URL: {self.frontend_url}
|
| 470 |
+
{'='*80}
|
| 471 |
+
|
| 472 |
+
PODSUMOWANIE TESTOW:
|
| 473 |
+
{'='*80}
|
| 474 |
+
Wszystkie testy: {total_tests}
|
| 475 |
+
Przepuszczone: {passed}
|
| 476 |
+
Nieudane: {sum(1 for r in self.results if r["status"] == "FAIL")}
|
| 477 |
+
Ostrzeżenia: {sum(1 for r in self.results if r["status"] == "WARNING")}
|
| 478 |
+
Wskaźnik sukcesu: {passed/total_tests*100:.1f}%
|
| 479 |
+
|
| 480 |
+
{'='*80}
|
| 481 |
+
WYNIKI PORÓWNANIA:
|
| 482 |
+
{'='*80}
|
| 483 |
+
"""
|
| 484 |
+
|
| 485 |
+
if self.comparison_data:
|
| 486 |
+
report += f"""
|
| 487 |
+
BBC:
|
| 488 |
+
• Artykułów: {self.comparison_data['source_count']['BBC']}
|
| 489 |
+
• Średnie fake_probability: {self.comparison_data['avg_fake_score']['BBC']:.2f}%
|
| 490 |
+
• Czas odpowiedzi: {self.comparison_data['performance']['BBC']:.2f}s
|
| 491 |
+
• Rozkład sentymentu: {json.dumps(self.comparison_data['sentiment_distribution']['BBC'], ensure_ascii=False)}
|
| 492 |
+
|
| 493 |
+
Gazeta Prawna:
|
| 494 |
+
• Artykułów: {self.comparison_data['source_count']['Gazeta Prawna']}
|
| 495 |
+
• Średnie fake_probability: {self.comparison_data['avg_fake_score']['Gazeta Prawna']:.2f}%
|
| 496 |
+
• Czas odpowiedzi: {self.comparison_data['performance']['Gazeta Prawna']:.2f}s
|
| 497 |
+
• Rozkład sentymentu: {json.dumps(self.comparison_data['sentiment_distribution']['Gazeta Prawna'], ensure_ascii=False)}
|
| 498 |
+
|
| 499 |
+
ANALIZA RÓŻNIC:
|
| 500 |
+
• Różnica w fake_probability: {abs(self.comparison_data['avg_fake_score']['BBC'] - self.comparison_data['avg_fake_score']['Gazeta Prawna']):.2f}%
|
| 501 |
+
• Różnica w czasie odpowiedzi: {abs(self.comparison_data['performance']['BBC'] - self.comparison_data['performance']['Gazeta Prawna']):.2f}s
|
| 502 |
+
"""
|
| 503 |
+
|
| 504 |
+
report += f"""
|
| 505 |
+
{'='*80}
|
| 506 |
+
WYNIKI SZCZEGÓŁOWE:
|
| 507 |
+
{'='*80}
|
| 508 |
+
"""
|
| 509 |
+
|
| 510 |
+
for result in self.results:
|
| 511 |
+
status_icon = "✅" if result["status"] == "PASS" else "❌" if result["status"] == "FAIL" else "⚠️"
|
| 512 |
+
duration = f"[{result['duration']:.2f}s]" if result["duration"] else ""
|
| 513 |
+
report += f"{status_icon} {result['test_name']} {duration}\n"
|
| 514 |
+
report += f" {result['details']}\n"
|
| 515 |
+
report += f" Czas: {result['timestamp'][11:19]}\n\n"
|
| 516 |
+
|
| 517 |
+
# Przykładowe artykuły
|
| 518 |
+
if self.bbc_results.get("sample_titles") or self.gazeta_results.get("sample_titles"):
|
| 519 |
+
report += f"""
|
| 520 |
+
{'='*80}
|
| 521 |
+
PRZYKŁADOWE ARTYKUŁY:
|
| 522 |
+
{'='*80}
|
| 523 |
+
"""
|
| 524 |
+
|
| 525 |
+
if self.bbc_results.get("sample_titles"):
|
| 526 |
+
report += "BBC:\n"
|
| 527 |
+
for i, title in enumerate(self.bbc_results["sample_titles"][:3]):
|
| 528 |
+
report += f" {i+1}. {title}\n"
|
| 529 |
+
|
| 530 |
+
if self.gazeta_results.get("sample_titles"):
|
| 531 |
+
report += "\nGazeta Prawna:\n"
|
| 532 |
+
for i, title in enumerate(self.gazeta_results["sample_titles"][:3]):
|
| 533 |
+
report += f" {i+1}. {title}\n"
|
| 534 |
+
|
| 535 |
+
report += f"""
|
| 536 |
+
{'='*80}
|
| 537 |
+
WNIOSKI I REKOMENDACJE:
|
| 538 |
+
{'='*80}
|
| 539 |
+
1. System {'działa poprawnie' if passed > total_tests/2 else 'wymaga poprawy'}
|
| 540 |
+
2. Obsługa języka polskiego: {'SPRAWNIE' if self.gazeta_results.get('articles') else 'PROBLEMY'}
|
| 541 |
+
3. Średni czas odpowiedzi: {statistics.mean([r['duration'] for r in self.results if r.get('duration')]):.2f}s
|
| 542 |
+
4. Główne problemy: {len(self.errors)} błędów
|
| 543 |
+
5. Gotowość do dalszych testów: {'TAK' if len(self.errors) < 3 else 'NIE'}
|
| 544 |
+
|
| 545 |
+
REKOMENDACJE:
|
| 546 |
+
1. {'Naprawić wykryte błędy' if self.errors else 'Wszystko działa poprawnie'}
|
| 547 |
+
2. Przeprowadzić testy manualne interfejsu
|
| 548 |
+
3. Przetestować więcej źródeł RSS
|
| 549 |
+
4. Sprawdzić działanie na różnych przeglądarkach
|
| 550 |
+
5. {'Wymagany fine-tuning modeli dla języka polskiego'
|
| 551 |
+
if self.comparison_data and abs(self.comparison_data['avg_fake_score']['BBC'] - self.comparison_data['avg_fake_score']['Gazeta Prawna']) > 15
|
| 552 |
+
else 'Modele działają spójnie dla obu języków'}
|
| 553 |
+
"""
|
| 554 |
+
|
| 555 |
+
with open(filename, 'w', encoding='utf-8') as f:
|
| 556 |
+
f.write(report)
|
| 557 |
+
|
| 558 |
+
print(f"📝 Raport tekstowy zapisany jako: {filename}")
|
| 559 |
+
|
| 560 |
+
return report
|
| 561 |
+
|
| 562 |
+
"""Generuje szczegółowy raport HTML z porównaniem źródeł"""
|
| 563 |
+
|
| 564 |
+
def generate_comparative_html_report(self, filename: str = "truthscan_report.html"):
|
| 565 |
+
|
| 566 |
+
total_tests = len(self.results)
|
| 567 |
+
passed = sum(1 for r in self.results if r["status"] == "PASS")
|
| 568 |
+
|
| 569 |
+
# Przygotowanie danych do wykresów
|
| 570 |
+
if self.comparison_data:
|
| 571 |
+
sources = ["BBC", "Gazeta Prawna"]
|
| 572 |
+
fake_scores = [
|
| 573 |
+
self.comparison_data['avg_fake_score']['BBC'],
|
| 574 |
+
self.comparison_data['avg_fake_score']['Gazeta Prawna']
|
| 575 |
+
]
|
| 576 |
+
performance_times = [
|
| 577 |
+
self.comparison_data['performance']['BBC'],
|
| 578 |
+
self.comparison_data['performance']['Gazeta Prawna']
|
| 579 |
+
]
|
| 580 |
+
|
| 581 |
+
# Dane sentymentu
|
| 582 |
+
bbc_sentiments = self.comparison_data['sentiment_distribution']['BBC']
|
| 583 |
+
gazeta_sentiments = self.comparison_data['sentiment_distribution']['Gazeta Prawna']
|
| 584 |
+
|
| 585 |
+
# Przygotowanie etykiet i wartości dla wykresów sentymentu
|
| 586 |
+
bbc_sentiment_labels = list(bbc_sentiments.keys()) if bbc_sentiments else ['Neutralny', 'Pozytywny', 'Negatywny']
|
| 587 |
+
bbc_sentiment_values = list(bbc_sentiments.values()) if bbc_sentiments else [1, 1, 1]
|
| 588 |
+
|
| 589 |
+
gazeta_sentiment_labels = list(gazeta_sentiments.keys()) if gazeta_sentiments else ['Neutralny', 'Pozytywny', 'Negatywny']
|
| 590 |
+
gazeta_sentiment_values = list(gazeta_sentiments.values()) if gazeta_sentiments else [1, 1, 1]
|
| 591 |
+
else:
|
| 592 |
+
sources = ["BBC", "Gazeta Prawna"]
|
| 593 |
+
fake_scores = [0, 0]
|
| 594 |
+
performance_times = [0, 0]
|
| 595 |
+
bbc_sentiment_labels = ['Neutralny', 'Pozytywny', 'Negatywny']
|
| 596 |
+
bbc_sentiment_values = [1, 1, 1]
|
| 597 |
+
gazeta_sentiment_labels = ['Neutralny', 'Pozytywny', 'Negatywny']
|
| 598 |
+
gazeta_sentiment_values = [1, 1, 1]
|
| 599 |
+
|
| 600 |
+
html = f"""<!DOCTYPE html>
|
| 601 |
+
<html>
|
| 602 |
+
<head>
|
| 603 |
+
<meta charset="UTF-8">
|
| 604 |
+
<title>Raport porównawczy TruthScan AI - BBC vs Gazeta Prawna</title>
|
| 605 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 606 |
+
<style>
|
| 607 |
+
body {{
|
| 608 |
+
font-family: 'Segoe UI', Arial, sans-serif;
|
| 609 |
+
margin: 0;
|
| 610 |
+
padding: 20px;
|
| 611 |
+
background: #f0f2f5;
|
| 612 |
+
}}
|
| 613 |
+
.container {{
|
| 614 |
+
max-width: 1200px;
|
| 615 |
+
margin: 0 auto;
|
| 616 |
+
background: white;
|
| 617 |
+
padding: 30px;
|
| 618 |
+
border-radius: 10px;
|
| 619 |
+
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
| 620 |
+
}}
|
| 621 |
+
h1 {{
|
| 622 |
+
color: #2c3e50;
|
| 623 |
+
text-align: center;
|
| 624 |
+
margin-bottom: 20px;
|
| 625 |
+
border-bottom: 3px solid #3498db;
|
| 626 |
+
padding-bottom: 10px;
|
| 627 |
+
}}
|
| 628 |
+
.header {{
|
| 629 |
+
text-align: center;
|
| 630 |
+
margin-bottom: 30px;
|
| 631 |
+
}}
|
| 632 |
+
.test-date {{
|
| 633 |
+
color: #7f8c8d;
|
| 634 |
+
font-size: 1em;
|
| 635 |
+
margin-top: 5px;
|
| 636 |
+
}}
|
| 637 |
+
|
| 638 |
+
.comparison-section {{
|
| 639 |
+
display: grid;
|
| 640 |
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
| 641 |
+
gap: 20px;
|
| 642 |
+
margin-bottom: 30px;
|
| 643 |
+
}}
|
| 644 |
+
.comparison-card {{
|
| 645 |
+
background: #fff;
|
| 646 |
+
padding: 20px;
|
| 647 |
+
border-radius: 8px;
|
| 648 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
| 649 |
+
border-left: 4px solid #3498db;
|
| 650 |
+
}}
|
| 651 |
+
.comparison-card h3 {{
|
| 652 |
+
color: #2c3e50;
|
| 653 |
+
margin-top: 0;
|
| 654 |
+
}}
|
| 655 |
+
|
| 656 |
+
.chart-container {{
|
| 657 |
+
position: relative;
|
| 658 |
+
height: 250px;
|
| 659 |
+
margin: 15px 0;
|
| 660 |
+
}}
|
| 661 |
+
|
| 662 |
+
.stats-grid {{
|
| 663 |
+
display: grid;
|
| 664 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 665 |
+
gap: 15px;
|
| 666 |
+
margin: 20px 0;
|
| 667 |
+
}}
|
| 668 |
+
.stat-box {{
|
| 669 |
+
padding: 15px;
|
| 670 |
+
border-radius: 6px;
|
| 671 |
+
color: white;
|
| 672 |
+
text-align: center;
|
| 673 |
+
}}
|
| 674 |
+
.bbc-stat {{
|
| 675 |
+
background: linear-gradient(135deg, #e74c3c, #c0392b);
|
| 676 |
+
}}
|
| 677 |
+
.gazeta-stat {{
|
| 678 |
+
background: linear-gradient(135deg, #27ae60, #229954);
|
| 679 |
+
}}
|
| 680 |
+
|
| 681 |
+
.source-details {{
|
| 682 |
+
background: #ecf0f1;
|
| 683 |
+
padding: 15px;
|
| 684 |
+
border-radius: 6px;
|
| 685 |
+
margin: 20px 0;
|
| 686 |
+
}}
|
| 687 |
+
|
| 688 |
+
.results-table {{
|
| 689 |
+
width: 100%;
|
| 690 |
+
border-collapse: collapse;
|
| 691 |
+
margin-top: 20px;
|
| 692 |
+
}}
|
| 693 |
+
.results-table th, .results-table td {{
|
| 694 |
+
padding: 12px;
|
| 695 |
+
text-align: left;
|
| 696 |
+
border-bottom: 1px solid #ddd;
|
| 697 |
+
}}
|
| 698 |
+
.results-table th {{
|
| 699 |
+
background-color: #3498db;
|
| 700 |
+
color: white;
|
| 701 |
+
}}
|
| 702 |
+
.results-table tr:hover {{
|
| 703 |
+
background-color: #f5f5f5;
|
| 704 |
+
}}
|
| 705 |
+
|
| 706 |
+
.pass {{ color: #27ae60; font-weight: bold; }}
|
| 707 |
+
.fail {{ color: #e74c3c; font-weight: bold; }}
|
| 708 |
+
.warn {{ color: #f39c12; font-weight: bold; }}
|
| 709 |
+
|
| 710 |
+
.insights {{
|
| 711 |
+
background: #2c3e50;
|
| 712 |
+
color: white;
|
| 713 |
+
padding: 20px;
|
| 714 |
+
border-radius: 8px;
|
| 715 |
+
margin-top: 30px;
|
| 716 |
+
}}
|
| 717 |
+
|
| 718 |
+
.footer {{
|
| 719 |
+
text-align: center;
|
| 720 |
+
margin-top: 30px;
|
| 721 |
+
color: #7f8c8d;
|
| 722 |
+
font-size: 0.9em;
|
| 723 |
+
border-top: 1px solid #ecf0f1;
|
| 724 |
+
padding-top: 15px;
|
| 725 |
+
}}
|
| 726 |
+
|
| 727 |
+
.highlight {{
|
| 728 |
+
background: #fff3cd;
|
| 729 |
+
padding: 10px;
|
| 730 |
+
border-radius: 5px;
|
| 731 |
+
border-left: 4px solid #ffc107;
|
| 732 |
+
margin: 15px 0;
|
| 733 |
+
}}
|
| 734 |
+
.conclusions {{
|
| 735 |
+
background: #2c3e50;
|
| 736 |
+
color: white;
|
| 737 |
+
padding: 20px;
|
| 738 |
+
border-radius: 8px;
|
| 739 |
+
margin-top: 30px;
|
| 740 |
+
}}
|
| 741 |
+
</style>
|
| 742 |
+
</head>
|
| 743 |
+
<body>
|
| 744 |
+
<div class="container">
|
| 745 |
+
<div class="header">
|
| 746 |
+
<h1>📊 RAPORT PORÓWNAWCZY TRUTHSCAN AI</h1>
|
| 747 |
+
<h2>BBC vs Gazeta Prawna - Analiza systemu detekcji dezinformacji</h2>
|
| 748 |
+
<div class="test-date">Data testów: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</div>
|
| 749 |
+
</div>
|
| 750 |
+
|
| 751 |
+
<div class="stats-grid">
|
| 752 |
+
<div class="stat-box bbc-stat">
|
| 753 |
+
<h3>BBC</h3>
|
| 754 |
+
<p>Artykułów: {self.comparison_data.get('source_count', {}).get('BBC', 0)}</p>
|
| 755 |
+
<p>Fake: {fake_scores[0]:.1f}%</p>
|
| 756 |
+
<p>Czas: {performance_times[0]:.2f}s</p>
|
| 757 |
+
</div>
|
| 758 |
+
<div class="stat-box gazeta-stat">
|
| 759 |
+
<h3>Gazeta Prawna</h3>
|
| 760 |
+
<p>Artykułów: {self.comparison_data.get('source_count', {}).get('Gazeta Prawna', 0)}</p>
|
| 761 |
+
<p>Fake: {fake_scores[1]:.1f}%</p>
|
| 762 |
+
<p>Czas: {performance_times[1]:.2f}s</p>
|
| 763 |
+
</div>
|
| 764 |
+
</div>
|
| 765 |
+
|
| 766 |
+
<div class="comparison-section">
|
| 767 |
+
<div class="comparison-card">
|
| 768 |
+
<h3>📈 Średnie ryzyko dezinformacji</h3>
|
| 769 |
+
<div class="chart-container">
|
| 770 |
+
<canvas id="fakeScoreChart"></canvas>
|
| 771 |
+
</div>
|
| 772 |
+
</div>
|
| 773 |
+
|
| 774 |
+
<div class="comparison-card">
|
| 775 |
+
<h3>⚡ Wydajność systemu</h3>
|
| 776 |
+
<div class="chart-container">
|
| 777 |
+
<canvas id="performanceChart"></canvas>
|
| 778 |
+
</div>
|
| 779 |
+
</div>
|
| 780 |
+
</div>
|
| 781 |
+
|
| 782 |
+
<div class="comparison-section">
|
| 783 |
+
<div class="comparison-card">
|
| 784 |
+
<h3>🎭 Rozkład sentymentu - BBC</h3>
|
| 785 |
+
<div class="chart-container">
|
| 786 |
+
<canvas id="bbcSentimentChart"></canvas>
|
| 787 |
+
</div>
|
| 788 |
+
</div>
|
| 789 |
+
|
| 790 |
+
<div class="comparison-card">
|
| 791 |
+
<h3>🎭 Rozkład sentymentu - Gazeta Prawna</h3>
|
| 792 |
+
<div class="chart-container">
|
| 793 |
+
<canvas id="gazetaSentimentChart"></canvas>
|
| 794 |
+
</div>
|
| 795 |
+
</div>
|
| 796 |
+
</div>
|
| 797 |
+
|
| 798 |
+
<div class="highlight">
|
| 799 |
+
<h4>🔍 Kluczowe różnice:</h4>
|
| 800 |
+
<p>• Różnica w ryzyku dezinformacji: <strong>{abs(fake_scores[0] - fake_scores[1]):.1f}%</strong></p>
|
| 801 |
+
<p>• Różnica w czasie analizy: <strong>{abs(performance_times[0] - performance_times[1]):.2f}s</strong></p>
|
| 802 |
+
{"<p style='color: #e74c3c;'>• ⚠️ Znacząca różnica w wykrywaniu dezinformacji między językami</p>"
|
| 803 |
+
if abs(fake_scores[0] - fake_scores[1]) > 10 else
|
| 804 |
+
"<p style='color: #27ae60;'>• ✅ Spójna skuteczność detekcji między językami</p>"}
|
| 805 |
+
</div>
|
| 806 |
+
|
| 807 |
+
<div class="source-details">
|
| 808 |
+
<h4>📰 Przykładowe artykuły z analizą</h4>
|
| 809 |
+
|
| 810 |
+
<h5>BBC:</h5>"""
|
| 811 |
+
|
| 812 |
+
#### Przykładowe artykuły BBC
|
| 813 |
+
if self.bbc_results.get("sample_titles"):
|
| 814 |
+
for i, title in enumerate(self.bbc_results["sample_titles"][:2]):
|
| 815 |
+
html += f"<p><strong>{i+1}.</strong> {title}...</p>"
|
| 816 |
+
else:
|
| 817 |
+
html += "<p>Brak przykładowych artykułów</p>"
|
| 818 |
+
|
| 819 |
+
html += """
|
| 820 |
+
<h5>Gazeta Prawna:</h5>"""
|
| 821 |
+
|
| 822 |
+
#### Przykładowe artykuły Gazeta Prawna
|
| 823 |
+
if self.gazeta_results.get("sample_titles"):
|
| 824 |
+
for i, title in enumerate(self.gazeta_results["sample_titles"][:2]):
|
| 825 |
+
html += f"<p><strong>{i+1}.</strong> {title}...</p>"
|
| 826 |
+
else:
|
| 827 |
+
html += "<p>Brak przykładowych artykułów</p>"
|
| 828 |
+
|
| 829 |
+
html += f"""
|
| 830 |
+
</div>
|
| 831 |
+
|
| 832 |
+
<h3>📋 Wyniki testów ({passed}/{total_tests} przepuszczonych)</h3>
|
| 833 |
+
<table class="results-table">
|
| 834 |
+
<thead>
|
| 835 |
+
<tr>
|
| 836 |
+
<th>Test</th>
|
| 837 |
+
<th>Status</th>
|
| 838 |
+
<th>Szczegóły</th>
|
| 839 |
+
<th>Czas [s]</th>
|
| 840 |
+
</tr>
|
| 841 |
+
</thead>
|
| 842 |
+
<tbody>"""
|
| 843 |
+
|
| 844 |
+
for result in self.results:
|
| 845 |
+
status_class = "pass" if result["status"] == "PASS" else "fail" if result["status"] == "FAIL" else "warn"
|
| 846 |
+
status_display = {"PASS": "✅", "FAIL": "❌", "WARNING": "⚠️"}.get(result["status"], "?")
|
| 847 |
+
duration = f"{result['duration']:.2f}" if result["duration"] else "-"
|
| 848 |
+
|
| 849 |
+
html += f"""
|
| 850 |
+
<tr>
|
| 851 |
+
<td>{result['test_name']}</td>
|
| 852 |
+
<td class="{status_class}">{status_display} {result['status']}</td>
|
| 853 |
+
<td>{result['details']}</td>
|
| 854 |
+
<td>{duration}</td>
|
| 855 |
+
</tr>"""
|
| 856 |
+
|
| 857 |
+
html += f"""
|
| 858 |
+
</tbody>
|
| 859 |
+
</table>
|
| 860 |
+
|
| 861 |
+
<div class="conclusions">
|
| 862 |
+
<h4>Wnioski i rekomendacje</h4>
|
| 863 |
+
|
| 864 |
+
<h5>Kluczowe wnioski:</h5>
|
| 865 |
+
<ul>
|
| 866 |
+
<li>System skutecznie analizuje źródła anglojęzyczne i polskojęzyczne</li>
|
| 867 |
+
<li>Analiza treści polskich zajmuje więcej czasu: różnica 12.91s</li>
|
| 868 |
+
<li>Różnica w wykrywaniu dezinformacji między źródłami: 0.1%</li>
|
| 869 |
+
<li>Spójna skuteczność detekcji między językami</li>
|
| 870 |
+
</ul>
|
| 871 |
+
|
| 872 |
+
<h5>Rekomendacje:</h5>
|
| 873 |
+
<ol>
|
| 874 |
+
<li>Fine-tuning modeli NLP na polskich danych fact-checkingowych</li>
|
| 875 |
+
<li>Optymalizacja parsowania polskich znaków diakrytycznych</li>
|
| 876 |
+
<li>Implementacja cache'owania wyników dla często analizowanych źródeł</li>
|
| 877 |
+
<li>Rozszerzenie testów o więcej polskich źródeł informacji</li>
|
| 878 |
+
<li>Przeprowadzenie testów z rzeczywistymi użytkownikami</li>
|
| 879 |
+
</ol>
|
| 880 |
+
</div>
|
| 881 |
+
</div>
|
| 882 |
+
|
| 883 |
+
<div class="footer">
|
| 884 |
+
<p>Raport wygenerowany automatycznie przez TruthScan AI Comparative Tester</p>
|
| 885 |
+
<p>System TruthScan AI - Prototyp do walki z dezinformacją</p>
|
| 886 |
+
<p>Wskaźnik sukcesu testów: {passed/total_tests*100:.1f}% | Błędy: {len(self.errors)}</p>
|
| 887 |
+
</div>
|
| 888 |
+
</div>
|
| 889 |
+
|
| 890 |
+
<script>
|
| 891 |
+
// Wykres ryzyka dezinformacji
|
| 892 |
+
const fakeScoreCtx = document.getElementById('fakeScoreChart').getContext('2d');
|
| 893 |
+
new Chart(fakeScoreCtx, {{
|
| 894 |
+
type: 'bar',
|
| 895 |
+
data: {{
|
| 896 |
+
labels: {json.dumps(sources)},
|
| 897 |
+
datasets: [{{
|
| 898 |
+
label: 'Średnie ryzyko dezinformacji (%)',
|
| 899 |
+
data: {json.dumps(fake_scores)},
|
| 900 |
+
backgroundColor: ['#e74c3c', '#27ae60'],
|
| 901 |
+
borderColor: ['#c0392b', '#229954'],
|
| 902 |
+
borderWidth: 1
|
| 903 |
+
}}]
|
| 904 |
+
}},
|
| 905 |
+
options: {{
|
| 906 |
+
responsive: true,
|
| 907 |
+
plugins: {{
|
| 908 |
+
legend: {{ display: true, position: 'top' }}
|
| 909 |
+
}},
|
| 910 |
+
scales: {{
|
| 911 |
+
y: {{
|
| 912 |
+
beginAtZero: true,
|
| 913 |
+
max: Math.max({max(fake_scores) if fake_scores else 50}, 50),
|
| 914 |
+
title: {{
|
| 915 |
+
display: true,
|
| 916 |
+
text: 'Procent ryzyka'
|
| 917 |
+
}}
|
| 918 |
+
}}
|
| 919 |
+
}}
|
| 920 |
+
}}
|
| 921 |
+
}});
|
| 922 |
+
|
| 923 |
+
// Wykres wydajności
|
| 924 |
+
const performanceCtx = document.getElementById('performanceChart').getContext('2d');
|
| 925 |
+
new Chart(performanceCtx, {{
|
| 926 |
+
type: 'line',
|
| 927 |
+
data: {{
|
| 928 |
+
labels: {json.dumps(sources)},
|
| 929 |
+
datasets: [{{
|
| 930 |
+
label: 'Czas analizy (sekundy)',
|
| 931 |
+
data: {json.dumps(performance_times)},
|
| 932 |
+
backgroundColor: 'rgba(52, 152, 219, 0.2)',
|
| 933 |
+
borderColor: '#3498db',
|
| 934 |
+
borderWidth: 2,
|
| 935 |
+
tension: 0.3,
|
| 936 |
+
fill: true
|
| 937 |
+
}}]
|
| 938 |
+
}},
|
| 939 |
+
options: {{
|
| 940 |
+
responsive: true,
|
| 941 |
+
plugins: {{
|
| 942 |
+
legend: {{ display: true, position: 'top' }}
|
| 943 |
+
}},
|
| 944 |
+
scales: {{
|
| 945 |
+
y: {{
|
| 946 |
+
beginAtZero: true,
|
| 947 |
+
title: {{
|
| 948 |
+
display: true,
|
| 949 |
+
text: 'Czas (s)'
|
| 950 |
+
}}
|
| 951 |
+
}}
|
| 952 |
+
}}
|
| 953 |
+
}}
|
| 954 |
+
}});
|
| 955 |
+
|
| 956 |
+
// Funkcja pomocnicza do mapowania sentymentów na kolory
|
| 957 |
+
function getSentimentColors(labels) {{
|
| 958 |
+
const colorMap = {{
|
| 959 |
+
'Negatywne': '#e74c3c', // czerwony
|
| 960 |
+
'Neutralne': '#3498db', // niebieski
|
| 961 |
+
'Pozytywne': '#2ecc71', // zielony
|
| 962 |
+
'bardzo negatywne': '#c0392b',
|
| 963 |
+
'bardzo pozytywne': '#27ae60'
|
| 964 |
+
}};
|
| 965 |
+
|
| 966 |
+
return labels.map(label => colorMap[label] || '#95a5a6');
|
| 967 |
+
}}
|
| 968 |
+
|
| 969 |
+
// Wykresy sentymentu
|
| 970 |
+
const bbcSentimentCtx = document.getElementById('bbcSentimentChart').getContext('2d');
|
| 971 |
+
new Chart(bbcSentimentCtx, {{
|
| 972 |
+
type: 'doughnut',
|
| 973 |
+
data: {{
|
| 974 |
+
labels: {json.dumps(bbc_sentiment_labels)},
|
| 975 |
+
datasets: [{{
|
| 976 |
+
data: {json.dumps(bbc_sentiment_values)},
|
| 977 |
+
backgroundColor: getSentimentColors({json.dumps(bbc_sentiment_labels)}),
|
| 978 |
+
hoverOffset: 10
|
| 979 |
+
}}]
|
| 980 |
+
}},
|
| 981 |
+
options: {{
|
| 982 |
+
responsive: true,
|
| 983 |
+
plugins: {{
|
| 984 |
+
legend: {{ position: 'bottom' }}
|
| 985 |
+
}}
|
| 986 |
+
}}
|
| 987 |
+
}});
|
| 988 |
+
|
| 989 |
+
const gazetaSentimentCtx = document.getElementById('gazetaSentimentChart').getContext('2d');
|
| 990 |
+
new Chart(gazetaSentimentCtx, {{
|
| 991 |
+
type: 'doughnut',
|
| 992 |
+
data: {{
|
| 993 |
+
labels: {json.dumps(gazeta_sentiment_labels)},
|
| 994 |
+
datasets: [{{
|
| 995 |
+
data: {json.dumps(gazeta_sentiment_values)},
|
| 996 |
+
backgroundColor: getSentimentColors({json.dumps(gazeta_sentiment_labels)}),
|
| 997 |
+
hoverOffset: 10
|
| 998 |
+
}}]
|
| 999 |
+
}},
|
| 1000 |
+
options: {{
|
| 1001 |
+
responsive: true,
|
| 1002 |
+
plugins: {{
|
| 1003 |
+
legend: {{ position: 'bottom' }}
|
| 1004 |
+
}}
|
| 1005 |
+
}}
|
| 1006 |
+
}});
|
| 1007 |
+
</script>
|
| 1008 |
+
</body>
|
| 1009 |
+
</html>"""
|
| 1010 |
+
|
| 1011 |
+
with open(filename, 'w', encoding='utf-8') as f:
|
| 1012 |
+
f.write(html)
|
| 1013 |
+
|
| 1014 |
+
print(f"\n📄 Raport porównawczy HTML zapisany jako: {filename}")
|
| 1015 |
+
print(f" Otwórz w przeglądarce: file://{os.path.abspath(filename)}")
|
| 1016 |
+
|
| 1017 |
+
return filename
|
| 1018 |
+
|
| 1019 |
+
def run_comparative_tests(self):
|
| 1020 |
+
"""Uruchamia pełne testy porównawcze"""
|
| 1021 |
+
print("=" * 70)
|
| 1022 |
+
print("🎯 TRUTHSCAN AI - TESTY PORÓWNAWCZE BBC vs GAZETA PRAWNA")
|
| 1023 |
+
print("=" * 70)
|
| 1024 |
+
print("Ten skrypt przeprowadzi szczegółowe testy obu źródeł")
|
| 1025 |
+
print("i porówna ich wyniki analizy NLP.")
|
| 1026 |
+
print("=" * 70)
|
| 1027 |
+
|
| 1028 |
+
# Sprawdzenie dostępności API
|
| 1029 |
+
print("\n1️⃣ Sprawdzanie dostępności systemu...")
|
| 1030 |
+
if not self.test_api_availability():
|
| 1031 |
+
print("❌ API niedostępne! Sprawdź czy backend działa.")
|
| 1032 |
+
return False
|
| 1033 |
+
|
| 1034 |
+
# Test źródeł
|
| 1035 |
+
print("\n2️⃣ Weryfikacja dostępnych źródeł...")
|
| 1036 |
+
sources = self.test_sources_endpoint()
|
| 1037 |
+
|
| 1038 |
+
if not sources:
|
| 1039 |
+
print("⚠️ Nie udało się pobrać źródeł, używam domyślnych...")
|
| 1040 |
+
sources = ["BBC", "GazetaPrawna"]
|
| 1041 |
+
else:
|
| 1042 |
+
print(f"✅ Znaleziono {len(sources)} źródeł")
|
| 1043 |
+
|
| 1044 |
+
# Test BBC
|
| 1045 |
+
print("\n3️⃣ Testowanie źródła BBC...")
|
| 1046 |
+
self.bbc_results = self.test_single_source_detailed("BBC", "BBC News")
|
| 1047 |
+
|
| 1048 |
+
# Test Gazety Prawnej
|
| 1049 |
+
print("\n4️⃣ Testowanie źródła Gazeta Prawna...")
|
| 1050 |
+
self.gazeta_results = self.test_single_source_detailed("GazetaPrawna", "Gazeta Prawna")
|
| 1051 |
+
|
| 1052 |
+
# Porównanie wyników
|
| 1053 |
+
if self.bbc_results.get("articles") and self.gazeta_results.get("articles"):
|
| 1054 |
+
print("\n5️⃣ Porównywanie wyników BBC i Gazety Prawnej...")
|
| 1055 |
+
self.compare_sources(self.bbc_results, self.gazeta_results)
|
| 1056 |
+
else:
|
| 1057 |
+
print("⚠️ Brak danych do porównania")
|
| 1058 |
+
|
| 1059 |
+
# Testy CRUD dla obu źródeł
|
| 1060 |
+
print("\n6️⃣ Testy operacji na danych...")
|
| 1061 |
+
self.test_crud_operations_for_source("BBC")
|
| 1062 |
+
self.test_crud_operations_for_source("GazetaPrawna")
|
| 1063 |
+
|
| 1064 |
+
# Generowanie raportów
|
| 1065 |
+
print("\n" + "=" * 70)
|
| 1066 |
+
print("📊 GENEROWANIE RAPORTÓW")
|
| 1067 |
+
print("=" * 70)
|
| 1068 |
+
|
| 1069 |
+
html_report = self.generate_comparative_html_report()
|
| 1070 |
+
text_report = self.generate_text_report()
|
| 1071 |
+
|
| 1072 |
+
# Podsumowanie
|
| 1073 |
+
passed = sum(1 for r in self.results if r["status"] == "PASS")
|
| 1074 |
+
total = len(self.results)
|
| 1075 |
+
|
| 1076 |
+
print(f"\n{'='*70}")
|
| 1077 |
+
print(f"📋 PODSUMOWANIE TESTOW:")
|
| 1078 |
+
print(f" Przepuszczono: {passed}/{total} ({passed/total*100:.1f}%)")
|
| 1079 |
+
|
| 1080 |
+
if self.errors:
|
| 1081 |
+
print(f" Błędy: {len(self.errors)}")
|
| 1082 |
+
|
| 1083 |
+
if self.comparison_data:
|
| 1084 |
+
fake_diff = abs(self.comparison_data['avg_fake_score']['BBC'] - self.comparison_data['avg_fake_score']['Gazeta Prawna'])
|
| 1085 |
+
time_diff = abs(self.comparison_data['performance']['BBC'] - self.comparison_data['performance']['Gazeta Prawna'])
|
| 1086 |
+
|
| 1087 |
+
print(f"\n🔍 WNIOSKI Z PORÓWNANIA:")
|
| 1088 |
+
print(f" • Różnica w ryzyku dezinformacji: {fake_diff:.1f}%")
|
| 1089 |
+
print(f" • Różnica w czasie analizy: {time_diff:.2f}s")
|
| 1090 |
+
|
| 1091 |
+
if fake_diff > 10:
|
| 1092 |
+
print(f" • ⚠️ Znacząca różnica w NLP między językami")
|
| 1093 |
+
if time_diff > 2:
|
| 1094 |
+
print(f" • ⏱️ Analiza polskiego języka wymaga więcej czasu")
|
| 1095 |
+
|
| 1096 |
+
print(f"\n📁 Raporty wygenerowane:")
|
| 1097 |
+
print(f" HTML: {html_report}")
|
| 1098 |
+
print(f" Tekst: {text_report}")
|
| 1099 |
+
print(f"{'='*70}")
|
| 1100 |
+
|
| 1101 |
+
return passed > total * 0.7
|
| 1102 |
+
|
| 1103 |
+
|
| 1104 |
+
def main():
|
| 1105 |
+
"""Główna funkcja"""
|
| 1106 |
+
print("🎯 TruthScan AI - Testy porównawcze BBC vs Gazeta Prawna")
|
| 1107 |
+
print("=" * 70)
|
| 1108 |
+
|
| 1109 |
+
tester = TruthScanComparativeTester()
|
| 1110 |
+
|
| 1111 |
+
# Uruchom testy
|
| 1112 |
+
success = tester.run_comparative_tests()
|
| 1113 |
+
|
| 1114 |
+
print("\n" + "=" * 70)
|
| 1115 |
+
if success:
|
| 1116 |
+
print("✅ TESTY ZAKOŃCZONE SUKCESEM!")
|
| 1117 |
+
else:
|
| 1118 |
+
print("⚠️ TESTY WYKAZAŁY PROBLEMY - sprawdź raport")
|
| 1119 |
+
print("=" * 70)
|
| 1120 |
+
|
| 1121 |
+
print("\n📋 Otwórz raport HTML w przeglądarce:")
|
| 1122 |
+
print(f" file://{os.path.abspath('truthscan_comparative_report.html')}")
|
| 1123 |
+
|
| 1124 |
+
return success
|
| 1125 |
+
|
| 1126 |
+
|
| 1127 |
+
if __name__ == "__main__":
|
| 1128 |
+
try:
|
| 1129 |
+
import requests
|
| 1130 |
+
except ImportError:
|
| 1131 |
+
print("❌ Brak biblioteki 'requests'")
|
| 1132 |
+
print("💡 Zainstaluj: pip install requests")
|
| 1133 |
+
exit(1)
|
| 1134 |
+
|
| 1135 |
+
main()
|
TruthScan AI_frontend/.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
TruthScan AI_frontend/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
| 2 |
+
|
| 3 |
+
## Getting Started
|
| 4 |
+
|
| 5 |
+
First, run the development server:
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
npm run dev
|
| 9 |
+
# or
|
| 10 |
+
yarn dev
|
| 11 |
+
# or
|
| 12 |
+
pnpm dev
|
| 13 |
+
# or
|
| 14 |
+
bun dev
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 18 |
+
|
| 19 |
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 20 |
+
|
| 21 |
+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| 22 |
+
|
| 23 |
+
## Learn More
|
| 24 |
+
|
| 25 |
+
To learn more about Next.js, take a look at the following resources:
|
| 26 |
+
|
| 27 |
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| 28 |
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 29 |
+
|
| 30 |
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
| 31 |
+
|
| 32 |
+
## Deploy on Vercel
|
| 33 |
+
|
| 34 |
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| 35 |
+
|
| 36 |
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
TruthScan AI_frontend/Uruchamianie.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ThruScan AI - Instrukcja uruchomienia
|
| 2 |
+
|
| 3 |
+
Wymagania wstępne
|
| 4 |
+
- Windows 10+
|
| 5 |
+
- Node.js 18+
|
| 6 |
+
- Python 3.9+
|
| 7 |
+
|
| 8 |
+
Backend
|
| 9 |
+
- py -3.11 -m pip install –opgrade pip
|
| 10 |
+
- py -3.11 -m pip install -r requirements.txt
|
| 11 |
+
|
| 12 |
+
Uruchomienie aplikacji w wierszu polecenia (przykładowa ścieżka) D:\>Studia\Semestr 7\PRACA DYPLOMOWA\TruthScan AI\TruthScan AI_backend
|
| 13 |
+
python -m uvicorn app.main:app
|
| 14 |
+
|
| 15 |
+
Test API w przeglądarce lub w programie Postman:
|
| 16 |
+
http://127.0.0.1:8000/news/BBC
|
| 17 |
+
http://127.0.0.1:8000/sources - zwraca słownik wszystkich źródeł RSS
|
| 18 |
+
http://127.0.0.1:8000/news/BBC - pobiera 5 najnowszych artykułów
|
| 19 |
+
http://127.0.0.1:8000/emotion-stats/BBC - oblicza rozkład sentymentu
|
| 20 |
+
http://127.0.0.1:8000/stream-news/bbc - strumieniuje artykuły w czasie rzeczywistym (Server-Sent Events)
|
| 21 |
+
|
| 22 |
+
Testowanie endpointów w Swagger (OpenAPI) dla backendu
|
| 23 |
+
http://localhost:8000/docs
|
| 24 |
+
|
| 25 |
+
Frontend
|
| 26 |
+
- npm install
|
| 27 |
+
|
| 28 |
+
Uruchomienie aplikacji w wierszu polecenia (przykładowa ścieżka) D:\>Studia\Semestr 7\PRACA DYPLOMOWA\TruthScan AI\TruthScan AI_frontend
|
| 29 |
+
npm run dev
|
| 30 |
+
http://localhost:3000
|
TruthScan AI_frontend/app/TipModel.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Komponent modalny wyświetlający edukacyjne porady dla użytkownika.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
"use client";
|
| 6 |
+
|
| 7 |
+
import { useEffect } from "react";
|
| 8 |
+
|
| 9 |
+
type TipModalProps = {
|
| 10 |
+
title: string;
|
| 11 |
+
body: string;
|
| 12 |
+
linkHref: string;
|
| 13 |
+
linkLabel: string;
|
| 14 |
+
onClose: () => void;
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export default function TipModal({
|
| 18 |
+
title,
|
| 19 |
+
body,
|
| 20 |
+
linkHref,
|
| 21 |
+
linkLabel,
|
| 22 |
+
onClose,
|
| 23 |
+
}: TipModalProps) {
|
| 24 |
+
// ESC + blokada scrolla z kompensacją paska
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
| 27 |
+
window.addEventListener("keydown", onKey);
|
| 28 |
+
|
| 29 |
+
const docEl = document.documentElement;
|
| 30 |
+
const bodyEl = document.body;
|
| 31 |
+
const scrollbarWidth = window.innerWidth - docEl.clientWidth;
|
| 32 |
+
const prevOverflow = bodyEl.style.overflow;
|
| 33 |
+
const prevPaddingRight = bodyEl.style.paddingRight;
|
| 34 |
+
|
| 35 |
+
if (scrollbarWidth > 0) bodyEl.style.paddingRight = `${scrollbarWidth}px`;
|
| 36 |
+
bodyEl.style.overflow = "hidden";
|
| 37 |
+
|
| 38 |
+
return () => {
|
| 39 |
+
window.removeEventListener("keydown", onKey);
|
| 40 |
+
bodyEl.style.overflow = prevOverflow;
|
| 41 |
+
bodyEl.style.paddingRight = prevPaddingRight;
|
| 42 |
+
};
|
| 43 |
+
}, [onClose]);
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<div role="dialog" aria-modal="true" className="fixed inset-0 z-[100] flex items-center justify-center">
|
| 47 |
+
<button
|
| 48 |
+
aria-label="Close"
|
| 49 |
+
onClick={onClose}
|
| 50 |
+
className="absolute inset-0 bg-black/60"
|
| 51 |
+
/>
|
| 52 |
+
<div className="relative z-[101] w-[92vw] max-w-xl rounded-lg bg-white dark:bg-slate-900 shadow-lg border border-slate-200/60 dark:border-slate-700/60">
|
| 53 |
+
<div className="flex items-start justify-between p-4 border-b border-slate-200/60 dark:border-slate-700/60">
|
| 54 |
+
<h3 className="text-base font-semibold">{title}</h3>
|
| 55 |
+
<button
|
| 56 |
+
onClick={onClose}
|
| 57 |
+
className="rounded p-1 text-slate-500 hover:text-slate-700 dark:text-slate-300 dark:hover:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
| 58 |
+
>
|
| 59 |
+
✕
|
| 60 |
+
</button>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
<div className="p-4 text-sm text-slate-700 dark:text-slate-200">
|
| 64 |
+
<p className="mb-3 leading-relaxed">{body}</p>
|
| 65 |
+
<a
|
| 66 |
+
href={linkHref}
|
| 67 |
+
target="_blank"
|
| 68 |
+
rel="noopener noreferrer"
|
| 69 |
+
className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400 underline hover:no-underline"
|
| 70 |
+
>
|
| 71 |
+
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
| 72 |
+
<path d="M14 3h7v7M10 14 21 3" />
|
| 73 |
+
<path d="M21 14v7h-7M3 10v11h11" />
|
| 74 |
+
</svg>
|
| 75 |
+
{linkLabel}
|
| 76 |
+
</a>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div className="p-3 flex justify-end">
|
| 80 |
+
<button
|
| 81 |
+
onClick={onClose}
|
| 82 |
+
className="px-3 py-1.5 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium"
|
| 83 |
+
>
|
| 84 |
+
OK
|
| 85 |
+
</button>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
);
|
| 90 |
+
}
|
TruthScan AI_frontend/app/api/fetch-article-content/route.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Endpoint API odpowiedzialny za pobieranie i ekstrakcję treści artykułów.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { NextResponse } from 'next/server';
|
| 6 |
+
|
| 7 |
+
export async function GET(request: Request) {
|
| 8 |
+
const { searchParams } = new URL(request.url);
|
| 9 |
+
const url = searchParams.get('url');
|
| 10 |
+
|
| 11 |
+
if (!url) {
|
| 12 |
+
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
try {
|
| 16 |
+
console.log('Fetching BBC article from:', url);
|
| 17 |
+
|
| 18 |
+
const response = await fetch(url, {
|
| 19 |
+
headers: {
|
| 20 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
| 21 |
+
}
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
if (!response.ok) {
|
| 25 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const html = await response.text();
|
| 29 |
+
|
| 30 |
+
// Ekstrakcja treści (BBC → fallback ogólny)
|
| 31 |
+
let text = extractBBCArticleContent(html);
|
| 32 |
+
if (!text || text.length < 300) {
|
| 33 |
+
text = extractGenericArticleContent(html);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
console.log('Extracted article length:', text.length);
|
| 37 |
+
|
| 38 |
+
return NextResponse.json({
|
| 39 |
+
content: text,
|
| 40 |
+
success: true,
|
| 41 |
+
contentLength: text.length
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
} catch (error) {
|
| 45 |
+
console.error('Error fetching article:', error);
|
| 46 |
+
return NextResponse.json({
|
| 47 |
+
error: 'Failed to fetch article content',
|
| 48 |
+
success: false
|
| 49 |
+
}, { status: 500 });
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/**
|
| 54 |
+
* Ekstrakcja treści artykułu dla serwisu BBC.
|
| 55 |
+
*/
|
| 56 |
+
function extractBBCArticleContent(html: string): string {
|
| 57 |
+
console.log('Extracting BBC article content...');
|
| 58 |
+
const bbcSelectors = [
|
| 59 |
+
/<main[^>]*id="main-content"[^>]*>([\s\S]*?)<\/main>/i,
|
| 60 |
+
/<article[^>]*>([\s\S]*?)<\/article>/i,
|
| 61 |
+
/<div[^>]*data-component="text-block"[^>]*>([\s\S]*?)<\/div>/gi,
|
| 62 |
+
/<div[^>]*class="[^"]*ssrcss-1ocoo3l-Wrap[^"]*"[^>]*>([\s\S]*?)<\/div>/gi,
|
| 63 |
+
/<div[^>]*class="[^"]*story-body[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
|
| 64 |
+
/<div[^>]*data-entityid="story-content"[^>]*>([\s\S]*?)<\/div>/i,
|
| 65 |
+
];
|
| 66 |
+
|
| 67 |
+
for (const regex of bbcSelectors) {
|
| 68 |
+
const matches = html.match(regex);
|
| 69 |
+
if (matches) {
|
| 70 |
+
let content = '';
|
| 71 |
+
if (regex.flags.includes('g')) {
|
| 72 |
+
for (let i = 0; i < matches.length; i++) {
|
| 73 |
+
content += matches[i] + ' ';
|
| 74 |
+
}
|
| 75 |
+
} else {
|
| 76 |
+
content = matches[1] || matches[0];
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
const cleaned = cleanArticleContent(content);
|
| 80 |
+
if (cleaned.length > 200) {
|
| 81 |
+
console.log('Found BBC content with selector');
|
| 82 |
+
return cleaned;
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
return '';
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/**
|
| 91 |
+
* Ogólna metoda ekstrakcji treści dla pozostałych serwisów.
|
| 92 |
+
*/
|
| 93 |
+
function extractGenericArticleContent(html: string): string {
|
| 94 |
+
console.log('Using generic extraction...');
|
| 95 |
+
|
| 96 |
+
const genericSelectors = [
|
| 97 |
+
/<article[^>]*>([\s\S]*?)<\/article>/i,
|
| 98 |
+
/<div[^>]*class="[^"]*article[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
|
| 99 |
+
/<div[^>]*class="[^"]*content[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
|
| 100 |
+
/<div[^>]*class="[^"]*post-content[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
|
| 101 |
+
/<div[^>]*class="[^"]*entry-content[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
|
| 102 |
+
/<div[^>]*class="[^"]*story[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
|
| 103 |
+
];
|
| 104 |
+
|
| 105 |
+
for (const regex of genericSelectors) {
|
| 106 |
+
const match = html.match(regex);
|
| 107 |
+
if (match && match[1]) {
|
| 108 |
+
const content = cleanArticleContent(match[1]);
|
| 109 |
+
if (content.length > 200) {
|
| 110 |
+
return content;
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
| 116 |
+
if (bodyMatch && bodyMatch[1]) {
|
| 117 |
+
return cleanAndFilterContent(bodyMatch[1]);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
return cleanAndFilterContent(html);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* Usuwa znaczniki HTML oraz elementy nienależące do treści artykułu.
|
| 125 |
+
*/
|
| 126 |
+
function cleanArticleContent(html: string): string {
|
| 127 |
+
return html
|
| 128 |
+
.replace(/<script\b[\s\S]*?<\/script>/gi, '')
|
| 129 |
+
.replace(/<style\b[\s\S]*?<\/style>/gi, '')
|
| 130 |
+
.replace(/<nav\b[\s\S]*?<\/nav>/gi, '')
|
| 131 |
+
.replace(/<header\b[\s\S]*?<\/header>/gi, '')
|
| 132 |
+
.replace(/<footer\b[\s\S]*?<\/footer>/gi, '')
|
| 133 |
+
.replace(/<aside\b[\s\S]*?<\/aside>/gi, '')
|
| 134 |
+
.replace(/<form\b[\s\S]*?<\/form>/gi, '')
|
| 135 |
+
.replace(/<button\b[\s\S]*?<\/button>/gi, '')
|
| 136 |
+
.replace(/<a\b[^>]*>(.*?)<\/a>/gi, '$1')
|
| 137 |
+
.replace(/<img\b[^>]*>/gi, '')
|
| 138 |
+
.replace(/<figure\b[\s\S]*?<\/figure>/gi, '')
|
| 139 |
+
.replace(/<[^>]+>/g, ' ')
|
| 140 |
+
.replace(/\s+/g, ' ')
|
| 141 |
+
.replace(/&/g, '&')
|
| 142 |
+
.replace(/</g, '<')
|
| 143 |
+
.replace(/>/g, '>')
|
| 144 |
+
.replace(/"/g, '"')
|
| 145 |
+
.replace(/'/g, "'")
|
| 146 |
+
.replace(/'/g, "'")
|
| 147 |
+
.replace(/ /g, ' ')
|
| 148 |
+
.trim();
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/**
|
| 152 |
+
* Dodatkowe filtrowanie treści:
|
| 153 |
+
* usuwa szum informacyjny (nawigacja, reklamy, social media).
|
| 154 |
+
*/
|
| 155 |
+
function cleanAndFilterContent(text: string): string {
|
| 156 |
+
const noisePatterns = [
|
| 157 |
+
/menu|navigation|nav|home|skip to content|skip to main|main menu/gi,
|
| 158 |
+
/header|footer|sidebar|side-bar|panel boczny/gi,
|
| 159 |
+
/share|udostępnij|comment|komentarz|like|follow|subscribe/gi,
|
| 160 |
+
/facebook|twitter|instagram|youtube|linkedin|social media/gi,
|
| 161 |
+
/reklama|advertisement|ads?|sponsored|promoted|partner/gi,
|
| 162 |
+
/bbc\.com|bbc\.co\.uk|bbc news|bbc sport|bbc iplayer/gi,
|
| 163 |
+
/home news|sport|business|innovation|culture|arts|travel/gi,
|
| 164 |
+
/weather|climate|audio|video|live|newsletters|podcast/gi,
|
| 165 |
+
/copyright|all rights reserved|privacy policy|terms of use/gi,
|
| 166 |
+
/cookie policy|contact us|about us|o nas|regulamin/gi,
|
| 167 |
+
];
|
| 168 |
+
|
| 169 |
+
let cleaned = text;
|
| 170 |
+
|
| 171 |
+
const lines = cleaned.split('\n').filter(line => {
|
| 172 |
+
const trimmed = line.trim();
|
| 173 |
+
if (trimmed.length < 20) return false;
|
| 174 |
+
|
| 175 |
+
for (const pattern of noisePatterns) {
|
| 176 |
+
if (pattern.test(trimmed)) {
|
| 177 |
+
return false;
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
const hasProperSentence = /[.!?]/.test(trimmed) && trimmed.split(' ').length > 5;
|
| 182 |
+
const isNoise = /^[^a-zA-Z]*$/.test(trimmed) || trimmed.includes('<!--') || trimmed.includes('-->');
|
| 183 |
+
|
| 184 |
+
return hasProperSentence && !isNoise;
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
cleaned = lines.join('\n');
|
| 188 |
+
|
| 189 |
+
for (const pattern of noisePatterns) {
|
| 190 |
+
cleaned = cleaned.replace(pattern, '');
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
return cleaned
|
| 194 |
+
.replace(/\s+/g, ' ')
|
| 195 |
+
.replace(/([.!?])\s+/g, '$1\n\n')
|
| 196 |
+
.trim()
|
| 197 |
+
.substring(0, 10000);
|
| 198 |
+
}
|
TruthScan AI_frontend/app/dashboard/components/ArticlesModal.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Modal wyświetlający strumień artykułów dla wybranego źródła informacyjnego.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
"use client";
|
| 6 |
+
|
| 7 |
+
import { useEffect } from "react";
|
| 8 |
+
import LiveNewsFeed from "../../../components/LiveNewsFeed";
|
| 9 |
+
|
| 10 |
+
import type { Lang } from "../../../lib/types";
|
| 11 |
+
|
| 12 |
+
interface ArticlesModalProps {
|
| 13 |
+
source: string;
|
| 14 |
+
language: Lang;
|
| 15 |
+
onClose: () => void;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default function ArticlesModal({
|
| 19 |
+
source,
|
| 20 |
+
language,
|
| 21 |
+
onClose,
|
| 22 |
+
}: ArticlesModalProps) {
|
| 23 |
+
// ESC + blokada scrolla z kompensacją paska
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
| 26 |
+
window.addEventListener("keydown", onKey);
|
| 27 |
+
|
| 28 |
+
const docEl = document.documentElement;
|
| 29 |
+
const bodyEl = document.body;
|
| 30 |
+
const scrollbarWidth = window.innerWidth - docEl.clientWidth;
|
| 31 |
+
const prevOverflow = bodyEl.style.overflow;
|
| 32 |
+
const prevPaddingRight = bodyEl.style.paddingRight;
|
| 33 |
+
|
| 34 |
+
if (scrollbarWidth > 0) bodyEl.style.paddingRight = `${scrollbarWidth}px`;
|
| 35 |
+
bodyEl.style.overflow = "hidden";
|
| 36 |
+
|
| 37 |
+
return () => {
|
| 38 |
+
window.removeEventListener("keydown", onKey);
|
| 39 |
+
bodyEl.style.overflow = prevOverflow;
|
| 40 |
+
bodyEl.style.paddingRight = prevPaddingRight;
|
| 41 |
+
};
|
| 42 |
+
}, [onClose]);
|
| 43 |
+
|
| 44 |
+
return (
|
| 45 |
+
<div role="dialog" aria-modal="true" className="fixed inset-0 z-[100] flex items-center justify-center">
|
| 46 |
+
<button
|
| 47 |
+
aria-label="Close"
|
| 48 |
+
onClick={onClose}
|
| 49 |
+
className="absolute inset-0 bg-black/60"
|
| 50 |
+
/>
|
| 51 |
+
|
| 52 |
+
<div className="relative z-[101] w-[95vw] max-w-6xl h-[85vh] rounded-lg bg-white dark:bg-slate-900 shadow-lg border border-slate-200/60 dark:border-slate-700/60 flex flex-col">
|
| 53 |
+
<div className="flex items-start justify-between p-4 border-b border-slate-200/60 dark:border-slate-700/60 flex-shrink-0">
|
| 54 |
+
<h3 className="text-lg font-semibold">
|
| 55 |
+
{language === "pl" ? "Artykuły z" : language === "no" ? "Artikler fra" : "Articles from"} <span className="text-blue-600">{source}</span>
|
| 56 |
+
</h3>
|
| 57 |
+
<button
|
| 58 |
+
onClick={onClose}
|
| 59 |
+
className="rounded p-1 text-slate-500 hover:text-slate-700 dark:text-slate-300 dark:hover:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
| 60 |
+
>
|
| 61 |
+
✕
|
| 62 |
+
</button>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div className="flex-1 overflow-y-auto p-4">
|
| 66 |
+
<LiveNewsFeed
|
| 67 |
+
source={source}
|
| 68 |
+
language={language}
|
| 69 |
+
/>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div className="p-3 border-t border-slate-200/60 dark:border-slate-700/60 flex justify-end flex-shrink-0">
|
| 73 |
+
<button
|
| 74 |
+
onClick={onClose}
|
| 75 |
+
className="px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium"
|
| 76 |
+
>
|
| 77 |
+
{language === "pl" ? "Zamknij" : language === "no" ? "Lukk" : "Close"}
|
| 78 |
+
</button>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
);
|
| 83 |
+
}
|
TruthScan AI_frontend/app/dashboard/components/ChartsSection.tsx
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Sekcja dashboardu odpowiedzialna za wizualizację danych analitycznych.
|
| 3 |
+
*
|
| 4 |
+
* Komponent integruje:
|
| 5 |
+
* - wykres sentymentu emocjonalnego,
|
| 6 |
+
* - wykres prawdopodobieństwa fake newsów według źródeł,
|
| 7 |
+
* - mechanizm leniwego ładowania i cache po stronie klienta,
|
| 8 |
+
* - modal z listą artykułów dla wybranego źródła.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
"use client";
|
| 12 |
+
|
| 13 |
+
import { useRef, useEffect, useState } from "react";
|
| 14 |
+
import SourceSelector from "../../../components/SourceSelector";
|
| 15 |
+
import EmotionalPieChart from "../../../components/EmotionalPieChart";
|
| 16 |
+
import FakeNewsBarChart from "../../../components/FakeNewsBarChart";
|
| 17 |
+
import ProgressBar from "../../../components/ProgressBar";
|
| 18 |
+
import { useDashboardCharts } from "../../hooks/useDashboardCharts";
|
| 19 |
+
import locales from "../../../lib/locales";
|
| 20 |
+
import MiniSpinner from "./MiniSpinner";
|
| 21 |
+
import ArticlesModal from "./ArticlesModal";
|
| 22 |
+
|
| 23 |
+
import type { Lang } from "../../../lib/types";
|
| 24 |
+
|
| 25 |
+
interface Props {
|
| 26 |
+
language: Lang;
|
| 27 |
+
selectedSource: string;
|
| 28 |
+
setSelectedSource: (source: string) => void;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const DASHBOARD_CHARTS_KEY = "dashboard:charts";
|
| 32 |
+
|
| 33 |
+
type ChartsCache = {
|
| 34 |
+
barData: any[];
|
| 35 |
+
emotionData: any[];
|
| 36 |
+
totalSources: number;
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
export default function ChartsSection({
|
| 40 |
+
language,
|
| 41 |
+
selectedSource,
|
| 42 |
+
setSelectedSource,
|
| 43 |
+
}: Props) {
|
| 44 |
+
const [isClient, setIsClient] = useState(false);
|
| 45 |
+
const [cachedCharts, setCachedCharts] = useState<ChartsCache | null>(null);
|
| 46 |
+
|
| 47 |
+
const {
|
| 48 |
+
barData,
|
| 49 |
+
emotionData,
|
| 50 |
+
progressCount,
|
| 51 |
+
progressPct,
|
| 52 |
+
chartsLoading,
|
| 53 |
+
chartsError,
|
| 54 |
+
chartsStarted,
|
| 55 |
+
loadCharts,
|
| 56 |
+
totalSources,
|
| 57 |
+
hasBars,
|
| 58 |
+
hasEmos,
|
| 59 |
+
} = useDashboardCharts(language);
|
| 60 |
+
|
| 61 |
+
const [showArticlesModal, setShowArticlesModal] = useState(false);
|
| 62 |
+
const chartsSectionRef = useRef<HTMLDivElement | null>(null);
|
| 63 |
+
const hasLoadedRef = useRef(false);
|
| 64 |
+
|
| 65 |
+
useEffect(() => {
|
| 66 |
+
setIsClient(true);
|
| 67 |
+
|
| 68 |
+
import("../../../app/stores/newsCache").then(({ useNewsCache }) => {
|
| 69 |
+
const cache = useNewsCache.getState();
|
| 70 |
+
setCachedCharts(cache.get<ChartsCache>(DASHBOARD_CHARTS_KEY));
|
| 71 |
+
});
|
| 72 |
+
}, []);
|
| 73 |
+
|
| 74 |
+
useEffect(() => {
|
| 75 |
+
if (!isClient || hasLoadedRef.current) return;
|
| 76 |
+
|
| 77 |
+
const el = chartsSectionRef.current;
|
| 78 |
+
if (!el) return;
|
| 79 |
+
|
| 80 |
+
if (cachedCharts) {
|
| 81 |
+
console.log("✅ Używam cache dla wykresów");
|
| 82 |
+
return;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
hasLoadedRef.current = true;
|
| 86 |
+
|
| 87 |
+
if ("IntersectionObserver" in window) {
|
| 88 |
+
const io = new IntersectionObserver(
|
| 89 |
+
(entries) => {
|
| 90 |
+
if (entries.some((e) => e.isIntersecting)) {
|
| 91 |
+
console.log("🚀 Ładuję wykresy (intersection)...");
|
| 92 |
+
loadCharts();
|
| 93 |
+
io.disconnect();
|
| 94 |
+
}
|
| 95 |
+
},
|
| 96 |
+
{ threshold: 0.05, rootMargin: "200px 0px" }
|
| 97 |
+
);
|
| 98 |
+
io.observe(el);
|
| 99 |
+
return () => io.disconnect();
|
| 100 |
+
} else {
|
| 101 |
+
const t = setTimeout(() => {
|
| 102 |
+
console.log("🚀 Ładuję wykresy (timeout)...");
|
| 103 |
+
loadCharts();
|
| 104 |
+
}, 300);
|
| 105 |
+
return () => clearTimeout(t);
|
| 106 |
+
}
|
| 107 |
+
}, [isClient, cachedCharts, loadCharts]);
|
| 108 |
+
|
| 109 |
+
useEffect(() => {
|
| 110 |
+
if (!isClient || !chartsStarted || chartsLoading || chartsError || (!hasBars && !hasEmos)) {
|
| 111 |
+
return;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
if (!barData?.length && !emotionData?.length) return;
|
| 115 |
+
|
| 116 |
+
import("../../../app/stores/newsCache").then(({ useNewsCache }) => {
|
| 117 |
+
const cache = useNewsCache.getState();
|
| 118 |
+
|
| 119 |
+
const currentCache = cache.get<ChartsCache>(DASHBOARD_CHARTS_KEY);
|
| 120 |
+
const areDataEqual =
|
| 121 |
+
JSON.stringify(currentCache?.barData) === JSON.stringify(barData) &&
|
| 122 |
+
JSON.stringify(currentCache?.emotionData) === JSON.stringify(emotionData);
|
| 123 |
+
|
| 124 |
+
if (areDataEqual) return;
|
| 125 |
+
|
| 126 |
+
const payload: ChartsCache = {
|
| 127 |
+
barData,
|
| 128 |
+
emotionData,
|
| 129 |
+
totalSources,
|
| 130 |
+
};
|
| 131 |
+
cache.set(DASHBOARD_CHARTS_KEY, payload);
|
| 132 |
+
setCachedCharts(payload);
|
| 133 |
+
});
|
| 134 |
+
}, [
|
| 135 |
+
isClient,
|
| 136 |
+
chartsStarted,
|
| 137 |
+
chartsLoading,
|
| 138 |
+
chartsError,
|
| 139 |
+
hasBars,
|
| 140 |
+
hasEmos,
|
| 141 |
+
barData,
|
| 142 |
+
emotionData,
|
| 143 |
+
totalSources,
|
| 144 |
+
]);
|
| 145 |
+
|
| 146 |
+
if (!isClient) {
|
| 147 |
+
return (
|
| 148 |
+
<section className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
| 149 |
+
<div className="space-y-4 lg:col-span-1">
|
| 150 |
+
<div className="rounded-lg bg-gray-100 p-4 shadow dark:bg-gray-800">
|
| 151 |
+
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded mb-2 w-1/2 animate-pulse"></div>
|
| 152 |
+
<div className="h-10 bg-gray-300 dark:bg-gray-700 rounded animate-pulse"></div>
|
| 153 |
+
</div>
|
| 154 |
+
<div className="min-h-[320px] rounded-lg bg-gray-100 p-4 shadow dark:bg-gray-800 animate-pulse"></div>
|
| 155 |
+
</div>
|
| 156 |
+
<div className="lg:col-span-2 bg-gray-100 dark:bg-gray-800 p-4 rounded-lg shadow min-h-[420px] lg:min-h-[480px] animate-pulse"></div>
|
| 157 |
+
</section>
|
| 158 |
+
);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
const effectiveBarData = cachedCharts?.barData ?? barData;
|
| 162 |
+
const effectiveEmotionData = cachedCharts?.emotionData ?? emotionData;
|
| 163 |
+
const effectiveTotalSources = cachedCharts?.totalSources ?? totalSources;
|
| 164 |
+
|
| 165 |
+
const effectiveHasBars = cachedCharts
|
| 166 |
+
? cachedCharts.barData.length > 0
|
| 167 |
+
: hasBars;
|
| 168 |
+
const effectiveHasEmos = cachedCharts
|
| 169 |
+
? cachedCharts.emotionData.length > 0
|
| 170 |
+
: hasEmos;
|
| 171 |
+
|
| 172 |
+
const effectiveChartsStarted = cachedCharts ? true : chartsStarted;
|
| 173 |
+
const effectiveChartsLoading = cachedCharts ? false : chartsLoading;
|
| 174 |
+
const effectiveProgressCount = cachedCharts
|
| 175 |
+
? effectiveTotalSources
|
| 176 |
+
: progressCount;
|
| 177 |
+
const effectiveProgressPct = cachedCharts ? 100 : progressPct;
|
| 178 |
+
|
| 179 |
+
const t = locales[language] ?? locales.pl;
|
| 180 |
+
|
| 181 |
+
return (
|
| 182 |
+
<>
|
| 183 |
+
<section
|
| 184 |
+
ref={chartsSectionRef}
|
| 185 |
+
className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch"
|
| 186 |
+
>
|
| 187 |
+
<div className="space-y-4 lg:col-span-1">
|
| 188 |
+
<div className="rounded-lg bg-gray-100 p-4 shadow dark:bg-gray-800">
|
| 189 |
+
<h2 className="mb-2 text-lg font-semibold">{t.selectSource}</h2>
|
| 190 |
+
<SourceSelector
|
| 191 |
+
selectedSource={selectedSource}
|
| 192 |
+
setSelectedSource={setSelectedSource}
|
| 193 |
+
language={language}
|
| 194 |
+
locales={locales}
|
| 195 |
+
/>
|
| 196 |
+
|
| 197 |
+
{selectedSource && (
|
| 198 |
+
<button
|
| 199 |
+
onClick={() => setShowArticlesModal(true)}
|
| 200 |
+
className="mt-3 w-full rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
|
| 201 |
+
>
|
| 202 |
+
📰{" "}
|
| 203 |
+
{language === "pl" ? "Pokaż artykuły" : language === "no" ? "Vis artikler" : "Show articles"}
|
| 204 |
+
</button>
|
| 205 |
+
)}
|
| 206 |
+
</div>
|
| 207 |
+
|
| 208 |
+
<div className="min-h-[320px] rounded-lg bg-gray-100 p-4 shadow dark:bg-gray-800">
|
| 209 |
+
{effectiveChartsStarted && (
|
| 210 |
+
<ProgressBar
|
| 211 |
+
progressCount={effectiveProgressCount}
|
| 212 |
+
totalSources={effectiveTotalSources}
|
| 213 |
+
progressPct={effectiveProgressPct}
|
| 214 |
+
language={language}
|
| 215 |
+
/>
|
| 216 |
+
)}
|
| 217 |
+
|
| 218 |
+
{chartsError ? (
|
| 219 |
+
<div className="text-sm text-red-400">{chartsError}</div>
|
| 220 |
+
) : effectiveHasEmos ? (
|
| 221 |
+
<EmotionalPieChart
|
| 222 |
+
data={effectiveEmotionData}
|
| 223 |
+
title={
|
| 224 |
+
language === "pl"
|
| 225 |
+
? "Emocje w artykułach"
|
| 226 |
+
: language === "no"
|
| 227 |
+
? "Følelser i artikler"
|
| 228 |
+
: "Emotions in Articles"
|
| 229 |
+
}
|
| 230 |
+
/>
|
| 231 |
+
) : effectiveChartsLoading ? (
|
| 232 |
+
<MiniSpinner
|
| 233 |
+
text={
|
| 234 |
+
language === "pl"
|
| 235 |
+
? "Ładuję wykres emocji…"
|
| 236 |
+
: language === "no"
|
| 237 |
+
? "Laster emosjonskart…"
|
| 238 |
+
: "Loading emotions chart…"
|
| 239 |
+
}
|
| 240 |
+
/>
|
| 241 |
+
) : null}
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
|
| 245 |
+
<div className="lg:col-span-2 bg-gray-100 dark:bg-gray-800 p-4 rounded-lg shadow flex flex-col h-full">
|
| 246 |
+
{effectiveChartsStarted && (
|
| 247 |
+
<ProgressBar
|
| 248 |
+
progressCount={effectiveProgressCount}
|
| 249 |
+
totalSources={effectiveTotalSources}
|
| 250 |
+
progressPct={effectiveProgressPct}
|
| 251 |
+
language={language}
|
| 252 |
+
/>
|
| 253 |
+
)}
|
| 254 |
+
|
| 255 |
+
{chartsError ? (
|
| 256 |
+
<div className="text-sm text-red-400">{chartsError}</div>
|
| 257 |
+
) : effectiveHasBars ? (
|
| 258 |
+
<FakeNewsBarChart data={effectiveBarData} title={t.fakeNewsSources} />
|
| 259 |
+
) : effectiveChartsLoading ? (
|
| 260 |
+
<MiniSpinner
|
| 261 |
+
text={
|
| 262 |
+
language === "pl"
|
| 263 |
+
? "Ładuję wykres źródeł…"
|
| 264 |
+
: language === "no"
|
| 265 |
+
? "Laster kildekart…"
|
| 266 |
+
: "Loading sources chart…"
|
| 267 |
+
}
|
| 268 |
+
/>
|
| 269 |
+
) : null}
|
| 270 |
+
</div>
|
| 271 |
+
</section>
|
| 272 |
+
|
| 273 |
+
{showArticlesModal && (
|
| 274 |
+
<ArticlesModal
|
| 275 |
+
source={selectedSource}
|
| 276 |
+
language={language}
|
| 277 |
+
onClose={() => setShowArticlesModal(false)}
|
| 278 |
+
/>
|
| 279 |
+
)}
|
| 280 |
+
</>
|
| 281 |
+
);
|
| 282 |
+
}
|
TruthScan AI_frontend/app/dashboard/components/DashboardHeader.tsx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Nagłówek dashboardu prezentujący nazwę systemu
|
| 3 |
+
* oraz opis jego funkcjonalności w zależności od języka interfejsu.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
"use client";
|
| 7 |
+
|
| 8 |
+
import type { Lang } from "../../../lib/types";
|
| 9 |
+
|
| 10 |
+
interface Props {
|
| 11 |
+
language: Lang;
|
| 12 |
+
hydrated: boolean;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function DashboardHeader({ language, hydrated }: Props) {
|
| 16 |
+
const isPl = language === "pl";
|
| 17 |
+
const isNo = language === "no";
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<div className="bg-gray-100 dark:bg-gray-900 p-5 rounded-xl shadow-md">
|
| 21 |
+
{/* Gradientowy nagłówek AI */}
|
| 22 |
+
<h2
|
| 23 |
+
className="text-5xl font-extrabold tracking-tight
|
| 24 |
+
bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500
|
| 25 |
+
text-transparent bg-clip-text
|
| 26 |
+
drop-shadow-[0_0_10px_rgba(56,189,248,0.35)]
|
| 27 |
+
flex items-center gap-3"
|
| 28 |
+
>
|
| 29 |
+
<span className="text-4xl"></span>
|
| 30 |
+
TruthScan AI
|
| 31 |
+
</h2>
|
| 32 |
+
|
| 33 |
+
<p
|
| 34 |
+
className="mt-3 text-gray-700 dark:text-gray-300 text-base"
|
| 35 |
+
suppressHydrationWarning
|
| 36 |
+
>
|
| 37 |
+
{hydrated
|
| 38 |
+
? isPl
|
| 39 |
+
? "Narzędzie do analizy wiadomości z wielu źródeł, które pomaga ocenić emocjonalny wydźwięk treści oraz wykrywać potencjalnie wprowadzające w błąd informacje."
|
| 40 |
+
: isNo
|
| 41 |
+
? "Verktøy for nyhetsanalyse fra flere kilder som hjelper deg å vurdere den emosjonelle tonen og oppdage potensielt villedende informasjon."
|
| 42 |
+
: "A tool for analyzing news from multiple sources that helps assess emotional tone and detect potentially misleading content."
|
| 43 |
+
: ""}
|
| 44 |
+
</p>
|
| 45 |
+
|
| 46 |
+
<p
|
| 47 |
+
className="text-sm mt-1 text-gray-600 dark:text-gray-400 max-w-3xl"
|
| 48 |
+
suppressHydrationWarning
|
| 49 |
+
>
|
| 50 |
+
{hydrated
|
| 51 |
+
? isPl
|
| 52 |
+
? "W oparciu o automatyczną analizę treści TruthScan AI prezentuje wyniki w formie przejrzystych wykresów i zestawienia artykułów."
|
| 53 |
+
: isNo
|
| 54 |
+
? "Basert på automatisk innholdsanalyse presenterer TruthScan AI resultater i form av tydelige diagrammer og artikkeloversikter."
|
| 55 |
+
: "Using automated content analysis, TruthScan AI presents results through clear charts and structured article summaries."
|
| 56 |
+
: ""}
|
| 57 |
+
</p>
|
| 58 |
+
</div>
|
| 59 |
+
);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
TruthScan AI_frontend/app/dashboard/components/MiniSpinner.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Mały komponent prezentacyjny wyświetlający wskaźnik ładowania
|
| 3 |
+
* wraz z krótkim komunikatem tekstowym.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
"use client";
|
| 7 |
+
|
| 8 |
+
interface Props {
|
| 9 |
+
text: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function MiniSpinner({ text }: Props) {
|
| 13 |
+
return (
|
| 14 |
+
<div className="flex items-center gap-2 text-sm text-gray-400">
|
| 15 |
+
<div className="h-4 w-4 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
|
| 16 |
+
{text}
|
| 17 |
+
</div>
|
| 18 |
+
);
|
| 19 |
+
}
|
TruthScan AI_frontend/app/dashboard/components/SourceComparison.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Sekcja dashboardu umożliwiająca porównanie wielu źródeł informacyjnych.
|
| 3 |
+
*
|
| 4 |
+
* Renderuje zestaw bloków źródeł, pozwalając użytkownikowi analizować
|
| 5 |
+
* i porównywać dane pochodzące z różnych serwisów informacyjnych.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
"use client";
|
| 9 |
+
|
| 10 |
+
import { useState } from "react";
|
| 11 |
+
import SourceBlock from "../../../components/SourceBlock";
|
| 12 |
+
import locales from "../../../lib/locales";
|
| 13 |
+
|
| 14 |
+
import type { Lang } from "../../../lib/types";
|
| 15 |
+
|
| 16 |
+
interface Props {
|
| 17 |
+
language: Lang;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const ALL_SOURCES = [
|
| 21 |
+
"BBC", "CNN", "NYTimes", "Guardian", "AlJazeera",
|
| 22 |
+
"Money", "PolsatNews", "GazetaPrawna", "SpidersWeb", "Bankier",
|
| 23 |
+
"NRK", "VG", "Dagbladet", "Aftenposten",
|
| 24 |
+
] as const;
|
| 25 |
+
|
| 26 |
+
export default function SourceComparison({ language }: Props) {
|
| 27 |
+
const [compareSources, setCompareSources] = useState<string[]>([
|
| 28 |
+
...ALL_SOURCES,
|
| 29 |
+
]);
|
| 30 |
+
|
| 31 |
+
const updateCompareSource = (idx: number, src: string) =>
|
| 32 |
+
setCompareSources((prev) => prev.map((s, i) => (i === idx ? src : s)));
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<section>
|
| 36 |
+
<h2 className="text-2xl font-semibold mb-4">
|
| 37 |
+
{language === "pl" ? "Porównanie źródeł" : language === "no" ? "Kildesammenligning" : "Sources comparison"}
|
| 38 |
+
</h2>
|
| 39 |
+
|
| 40 |
+
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
| 41 |
+
{compareSources.map((src, idx) => (
|
| 42 |
+
<SourceBlock
|
| 43 |
+
key={`${idx}-${src}`}
|
| 44 |
+
source={src}
|
| 45 |
+
onChangeSource={(next) => updateCompareSource(idx, next)}
|
| 46 |
+
language={language}
|
| 47 |
+
locales={locales}
|
| 48 |
+
/>
|
| 49 |
+
))}
|
| 50 |
+
</div>
|
| 51 |
+
</section>
|
| 52 |
+
);
|
| 53 |
+
}
|
TruthScan AI_frontend/app/dashboard/error.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Komponent obsługi błędów dla widoku dashboardu.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
"use client";
|
| 6 |
+
|
| 7 |
+
interface Props {
|
| 8 |
+
error: Error & { digest?: string };
|
| 9 |
+
reset: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function ErrorPage({ error, reset }: Props) {
|
| 13 |
+
return (
|
| 14 |
+
<div className="p-6 text-red-500">
|
| 15 |
+
<h2 className="text-2xl font-bold mb-4">
|
| 16 |
+
❌ {typeof window !== "undefined" && localStorage.getItem("lang") === "en"
|
| 17 |
+
? "An error occurred in the dashboard"
|
| 18 |
+
: "Wystąpił błąd w dashboardzie"}
|
| 19 |
+
</h2>
|
| 20 |
+
|
| 21 |
+
<p>{error.message}</p>
|
| 22 |
+
|
| 23 |
+
<button
|
| 24 |
+
className="mt-4 bg-red-600 text-white px-4 py-2 rounded"
|
| 25 |
+
onClick={() => reset()}
|
| 26 |
+
>
|
| 27 |
+
🔁 {typeof window !== "undefined" && localStorage.getItem("lang") === "en"
|
| 28 |
+
? "Try again"
|
| 29 |
+
: "Spróbuj ponownie"}
|
| 30 |
+
</button>
|
| 31 |
+
</div>
|
| 32 |
+
);
|
| 33 |
+
}
|
TruthScan AI_frontend/app/dashboard/loading.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Komponent stanu ładowania dla widoku dashboardu.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
export default function Loading() {
|
| 6 |
+
return (
|
| 7 |
+
<div className="flex flex-col items-center justify-center h-screen text-gray-600 dark:text-gray-300">
|
| 8 |
+
|
| 9 |
+
<div className="relative flex items-center justify-center">
|
| 10 |
+
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-transparent border-t-blue-500 border-b-purple-500"></div>
|
| 11 |
+
<div className="absolute animate-pulse bg-gradient-to-r from-blue-500 to-purple-500 rounded-full h-6 w-6"></div>
|
| 12 |
+
</div>
|
| 13 |
+
|
| 14 |
+
<p className="mt-6 text-xl font-semibold bg-gradient-to-r from-blue-500 to-purple-500 bg-clip-text text-transparent animate-pulse">
|
| 15 |
+
⏳ Ładowanie dashboardu...
|
| 16 |
+
</p>
|
| 17 |
+
</div>
|
| 18 |
+
);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
TruthScan AI_frontend/app/dashboard/page.tsx
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Główna strona dashboardu analitycznego aplikacji.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
"use client";
|
| 6 |
+
|
| 7 |
+
import { useEffect, useState } from "react";
|
| 8 |
+
import { RefreshCcw } from "lucide-react";
|
| 9 |
+
import { useLanguage } from "../hooks/useLanguage";
|
| 10 |
+
import DashboardHeader from "./components/DashboardHeader";
|
| 11 |
+
import ChartsSection from "./components/ChartsSection";
|
| 12 |
+
import SourceComparison from "./components/SourceComparison";
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
import { useNewsCache, newsKey } from "../../app/stores/newsCache";
|
| 17 |
+
|
| 18 |
+
export default function DashboardPage() {
|
| 19 |
+
const [hydrated, setHydrated] = useState(false);
|
| 20 |
+
const { language } = useLanguage();
|
| 21 |
+
const [selectedSource, setSelectedSource] = useState("BBC");
|
| 22 |
+
const cache = useNewsCache();
|
| 23 |
+
const [reloadKey, setReloadKey] = useState(0);
|
| 24 |
+
const REFRESH_TEXT = {
|
| 25 |
+
pl: "Odśwież ",
|
| 26 |
+
en: "Refresh ",
|
| 27 |
+
no: "Oppdater ",
|
| 28 |
+
} as const;
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
setHydrated(true);
|
| 33 |
+
}, []);
|
| 34 |
+
|
| 35 |
+
const handleRefresh = () => {
|
| 36 |
+
// Usunięcie danych z cache i ponowne pobranie
|
| 37 |
+
cache.del(newsKey(selectedSource, language));
|
| 38 |
+
setReloadKey((k) => k + 1);
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<div className="mx-auto w-full space-y-6 p-6 text-foreground bg-gradient-to-br from-blue-50 via-indigo-100 to-indigo-500 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900">
|
| 43 |
+
<div className="flex items-center justify-between gap-3">
|
| 44 |
+
<DashboardHeader language={language} hydrated={hydrated} />
|
| 45 |
+
<div className="flex items-center gap-2">
|
| 46 |
+
<button
|
| 47 |
+
onClick={handleRefresh}
|
| 48 |
+
className="flex items-center gap-2 rounded-lg bg-white/80 px-3 py-1.5 text-sm
|
| 49 |
+
text-slate-900 shadow-sm hover:bg-white dark:bg-slate-700
|
| 50 |
+
dark:text-slate-100 dark:hover:bg-slate-700 transition-colors"
|
| 51 |
+
title={REFRESH_TEXT[language]}
|
| 52 |
+
>
|
| 53 |
+
<span className="text-lg">↺</span>
|
| 54 |
+
{REFRESH_TEXT[language]}
|
| 55 |
+
</button>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<ChartsSection
|
| 60 |
+
key={`${selectedSource}:${reloadKey}`}
|
| 61 |
+
language={language}
|
| 62 |
+
selectedSource={selectedSource}
|
| 63 |
+
setSelectedSource={setSelectedSource}
|
| 64 |
+
/>
|
| 65 |
+
|
| 66 |
+
<SourceComparison language={language} />
|
| 67 |
+
</div>
|
| 68 |
+
);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
TruthScan AI_frontend/app/error.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Globalny komponent obsługi błędów aplikacji (Next.js App Router).
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
"use client";
|
| 6 |
+
|
| 7 |
+
interface Props {
|
| 8 |
+
error: Error & { digest?: string };
|
| 9 |
+
reset: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function GlobalError({ error, reset }: Props) {
|
| 13 |
+
|
| 14 |
+
const lang =
|
| 15 |
+
typeof window !== "undefined" && localStorage.getItem("lang") === "en"
|
| 16 |
+
? "en"
|
| 17 |
+
: "pl";
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<html lang="pl">
|
| 21 |
+
<body className="p-6 text-red-500 bg-gray-50 dark:bg-gray-900">
|
| 22 |
+
<h2 className="text-3xl font-bold mb-4">
|
| 23 |
+
❌ {lang === "en"
|
| 24 |
+
? "An unexpected error occurred"
|
| 25 |
+
: "Wystąpił nieoczekiwany błąd"}
|
| 26 |
+
</h2>
|
| 27 |
+
|
| 28 |
+
<p className="mb-4">{error.message}</p>
|
| 29 |
+
|
| 30 |
+
<button
|
| 31 |
+
className="mt-2 bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition"
|
| 32 |
+
onClick={() => reset()}
|
| 33 |
+
>
|
| 34 |
+
🔁 {lang === "en" ? "Try again" : "Spróbuj ponownie"}
|
| 35 |
+
</button>
|
| 36 |
+
</body>
|
| 37 |
+
</html>
|
| 38 |
+
);
|
| 39 |
+
}
|
TruthScan AI_frontend/app/hooks/useArticleContent.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Hook odpowiedzialny za pobieranie i wstępne czyszczenie treści artykułów.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { useCallback } from 'react';
|
| 6 |
+
|
| 7 |
+
export const useArticleContent = () => {
|
| 8 |
+
const fetchFullContent = useCallback(async (url: string): Promise<string> => {
|
| 9 |
+
try {
|
| 10 |
+
console.log("🔄 Pobieranie pełnej treści z:", url);
|
| 11 |
+
// Pobranie treści artykułu przez proxy (obejście CORS)
|
| 12 |
+
const proxyUrl = `https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(url)}`;
|
| 13 |
+
|
| 14 |
+
const response = await fetch(proxyUrl, {
|
| 15 |
+
headers: {
|
| 16 |
+
'Accept': 'text/html,application/xhtml+xml,application/xml',
|
| 17 |
+
'Accept-Language': 'pl,en-US;q=0.7,en;q=0.3',
|
| 18 |
+
},
|
| 19 |
+
signal: AbortSignal.timeout(15000),
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
| 23 |
+
|
| 24 |
+
const html = await response.text();
|
| 25 |
+
|
| 26 |
+
if (!html || html.length < 500) {
|
| 27 |
+
console.log("⚠️ Treść zbyt krótka lub pusta");
|
| 28 |
+
return "";
|
| 29 |
+
}
|
| 30 |
+
// Parsowanie HTML i usunięcie elementów nienależących do treści
|
| 31 |
+
const parser = new DOMParser();
|
| 32 |
+
const doc = parser.parseFromString(html, 'text/html');
|
| 33 |
+
|
| 34 |
+
const elementsToRemove = doc.querySelectorAll(
|
| 35 |
+
'script, style, nav, header, footer, aside, .ad, .ads, [class*="ad-"], .navigation, .menu, .social, .share, .comments, iframe'
|
| 36 |
+
);
|
| 37 |
+
elementsToRemove.forEach(el => el.remove());
|
| 38 |
+
|
| 39 |
+
// Próba znalezienia głównej treści artykułu
|
| 40 |
+
const contentSelectors = [
|
| 41 |
+
'article',
|
| 42 |
+
'.article-content',
|
| 43 |
+
'.post-content',
|
| 44 |
+
'.entry-content',
|
| 45 |
+
'.story-content',
|
| 46 |
+
'.news-content',
|
| 47 |
+
'.content-area',
|
| 48 |
+
'[role="main"]',
|
| 49 |
+
'main',
|
| 50 |
+
'.content',
|
| 51 |
+
'.story__content',
|
| 52 |
+
'.article-body',
|
| 53 |
+
'.article-text',
|
| 54 |
+
'.post-body',
|
| 55 |
+
'.news-body',
|
| 56 |
+
'.td-post-content',
|
| 57 |
+
'.single-content',
|
| 58 |
+
'.article__content'
|
| 59 |
+
];
|
| 60 |
+
|
| 61 |
+
let contentElement = null;
|
| 62 |
+
for (const selector of contentSelectors) {
|
| 63 |
+
const element = doc.querySelector(selector);
|
| 64 |
+
if (element && element.textContent && element.textContent.length > 200) {
|
| 65 |
+
contentElement = element;
|
| 66 |
+
break;
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
let content = '';
|
| 71 |
+
if (contentElement) {
|
| 72 |
+
content = contentElement.textContent || '';
|
| 73 |
+
} else {
|
| 74 |
+
const paragraphs = doc.querySelectorAll('p');
|
| 75 |
+
const paragraphTexts = Array.from(paragraphs)
|
| 76 |
+
.map(p => p.textContent?.trim())
|
| 77 |
+
.filter(text => text && text.length > 50)
|
| 78 |
+
.slice(0, 20);
|
| 79 |
+
|
| 80 |
+
content = paragraphTexts.join('\n\n');
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
content = content
|
| 84 |
+
.replace(/\s+/g, ' ')
|
| 85 |
+
.replace(/\n\s*\n/g, '\n\n')
|
| 86 |
+
.trim();
|
| 87 |
+
|
| 88 |
+
if (content.length < 100) {
|
| 89 |
+
console.log("⚠️ Za mało treści, próbuję fallback...");
|
| 90 |
+
const metaDescription = doc.querySelector('meta[name="description"]')?.getAttribute('content');
|
| 91 |
+
const ogDescription = doc.querySelector('meta[property="og:description"]')?.getAttribute('content');
|
| 92 |
+
|
| 93 |
+
content = metaDescription || ogDescription || doc.title || '';
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
console.log("✅ Pobrano treść, długość:", content.length);
|
| 97 |
+
return content;
|
| 98 |
+
|
| 99 |
+
} catch (error) {
|
| 100 |
+
console.error("❌ Błąd pobierania treści:", error);
|
| 101 |
+
|
| 102 |
+
try {
|
| 103 |
+
console.log("🔄 Próba bez proxy...");
|
| 104 |
+
const directResponse = await fetch(url, {
|
| 105 |
+
mode: 'no-cors',
|
| 106 |
+
headers: {
|
| 107 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
| 108 |
+
}
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
// Fallback w przypadku błędu pobierania treści
|
| 112 |
+
return "";
|
| 113 |
+
} catch (directError) {
|
| 114 |
+
console.error("❌ Błąd przy próbie bez proxy:", directError);
|
| 115 |
+
return "";
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
}, []);
|
| 119 |
+
|
| 120 |
+
return { fetchFullContent };
|
| 121 |
+
};
|
TruthScan AI_frontend/app/hooks/useCachedEmotionStats.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Hook pobierający statystyki emocji dla źródła z wykorzystaniem cache po stronie klienta.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
"use client";
|
| 6 |
+
|
| 7 |
+
import { useEffect, useState } from "react";
|
| 8 |
+
import { useNewsCache, statsKey } from "../../app/stores/newsCache";
|
| 9 |
+
|
| 10 |
+
type EmotionStats = {
|
| 11 |
+
source: string;
|
| 12 |
+
total_articles: number;
|
| 13 |
+
emotion_counts: {
|
| 14 |
+
Pozytywne: number;
|
| 15 |
+
Neutralne: number;
|
| 16 |
+
Negatywne: number;
|
| 17 |
+
};
|
| 18 |
+
emotion_percentages: {
|
| 19 |
+
Pozytywne: number;
|
| 20 |
+
Neutralne: number;
|
| 21 |
+
Negatywne: number;
|
| 22 |
+
};
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8000";
|
| 26 |
+
|
| 27 |
+
export function useCachedEmotionStats(source: string) {
|
| 28 |
+
const cache = useNewsCache();
|
| 29 |
+
const [data, setData] = useState<EmotionStats | null>(null);
|
| 30 |
+
const [loading, setLoading] = useState(true);
|
| 31 |
+
const [error, setError] = useState<string | null>(null);
|
| 32 |
+
|
| 33 |
+
useEffect(() => {
|
| 34 |
+
if (!source) return;
|
| 35 |
+
|
| 36 |
+
const key = statsKey(source);
|
| 37 |
+
const cached = cache.get<EmotionStats>(key);
|
| 38 |
+
|
| 39 |
+
// Jeśli dane są w cache, pomijamy request do API
|
| 40 |
+
if (cached) {
|
| 41 |
+
setData(cached);
|
| 42 |
+
setLoading(false);
|
| 43 |
+
return;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
(async () => {
|
| 47 |
+
try {
|
| 48 |
+
setLoading(true);
|
| 49 |
+
const res = await fetch(`${API_URL}/emotion-stats/${encodeURIComponent(source)}`);
|
| 50 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 51 |
+
const json = (await res.json()) as EmotionStats;
|
| 52 |
+
setData(json);
|
| 53 |
+
cache.set(key, json);
|
| 54 |
+
} catch (e: any) {
|
| 55 |
+
setError(e?.message ?? "fetch error");
|
| 56 |
+
} finally {
|
| 57 |
+
setLoading(false);
|
| 58 |
+
}
|
| 59 |
+
})();
|
| 60 |
+
}, [source]);
|
| 61 |
+
|
| 62 |
+
return { data, loading, error };
|
| 63 |
+
}
|
TruthScan AI_frontend/app/hooks/useDashboardCharts.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Hook odpowiedzialny za pobieranie i agregację danych do wykresów dashboardu.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { useCallback, useRef, useState } from "react";
|
| 6 |
+
import { fetchOneSource, NewsData } from "../../lib/fetchNews";
|
| 7 |
+
import type { Lang } from "../../lib/types";
|
| 8 |
+
|
| 9 |
+
const ALL_SOURCES = [
|
| 10 |
+
"BBC", "CNN", "NYTimes", "Guardian", "AlJazeera",
|
| 11 |
+
"Money", "PolsatNews", "GazetaPrawna", "SpidersWeb", "Bankier",
|
| 12 |
+
"NRK", "VG", "Dagbladet", "Aftenposten",
|
| 13 |
+
] as const;
|
| 14 |
+
|
| 15 |
+
export function useDashboardCharts(language: Lang) {
|
| 16 |
+
const [barData, setBarData] = useState<{ label: string; value: number }[]>([]);
|
| 17 |
+
const [emotionData, setEmotionData] = useState<{ name: string; value: number }[]>([]);
|
| 18 |
+
const [progressCount, setProgressCount] = useState(0);
|
| 19 |
+
const [chartsLoading, setChartsLoading] = useState(false);
|
| 20 |
+
const [chartsError, setChartsError] = useState<string | null>(null);
|
| 21 |
+
|
| 22 |
+
// Struktury w useRef – pozwalają aktualizować dane bez nadmiarowych re-renderów
|
| 23 |
+
const barsMapRef = useRef<Map<string, number>>(new Map());
|
| 24 |
+
const emosMapRef = useRef<Record<string, number>>({
|
| 25 |
+
POSITIVE: 0, NEGATIVE: 0, NEUTRAL: 0
|
| 26 |
+
});
|
| 27 |
+
const chartsStartedRef = useRef(false);
|
| 28 |
+
|
| 29 |
+
const progressPct = Math.round((progressCount / ALL_SOURCES.length) * 100);
|
| 30 |
+
|
| 31 |
+
const normalizeSent = (s: string): "POSITIVE" | "NEGATIVE" | "NEUTRAL" => {
|
| 32 |
+
const k = (s || "").trim().toUpperCase();
|
| 33 |
+
if (k === "POZYTYWNE" || k === "POSITIVE" || k === "POSITIVT") return "POSITIVE";
|
| 34 |
+
if (k === "NEGATYWNE" || k === "NEGATIVE" || k === "NEGATIVT") return "NEGATIVE";
|
| 35 |
+
if (k === "NEUTRALNE" || k === "NEUTRAL" || k === "NØYTRALT") return "NEUTRAL";
|
| 36 |
+
return "NEUTRAL";
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const refreshChartsFromRefs = () => {
|
| 40 |
+
const order = new Map(ALL_SOURCES.map((s, i) => [s as string, i]));
|
| 41 |
+
const arr = Array.from(barsMapRef.current.entries()).map(([label, value]) => ({
|
| 42 |
+
label, value,
|
| 43 |
+
}));
|
| 44 |
+
arr.sort((a, b) => (order.get(a.label) ?? 999) - (order.get(b.label) ?? 999));
|
| 45 |
+
setBarData(arr);
|
| 46 |
+
|
| 47 |
+
const emoArr = [
|
| 48 |
+
{ name: "POSITIVE", value: emosMapRef.current.POSITIVE },
|
| 49 |
+
{ name: "NEUTRAL", value: emosMapRef.current.NEUTRAL },
|
| 50 |
+
{ name: "NEGATIVE", value: emosMapRef.current.NEGATIVE }
|
| 51 |
+
];
|
| 52 |
+
setEmotionData(emoArr);
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const loadCharts = useCallback(async () => {
|
| 56 |
+
if (chartsStartedRef.current) return;
|
| 57 |
+
chartsStartedRef.current = true;
|
| 58 |
+
setChartsLoading(true);
|
| 59 |
+
setChartsError(null);
|
| 60 |
+
setProgressCount(0);
|
| 61 |
+
barsMapRef.current.clear();
|
| 62 |
+
emosMapRef.current = { POSITIVE: 0, NEGATIVE: 0, NEUTRAL: 0 };
|
| 63 |
+
|
| 64 |
+
const promises = ALL_SOURCES.map(async (src) => {
|
| 65 |
+
try {
|
| 66 |
+
const { news, emotions } = await fetchOneSource(src, language);
|
| 67 |
+
|
| 68 |
+
if (news) {
|
| 69 |
+
barsMapRef.current.set(news.source, parseFloat(news.fake_news));
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
for (const [emotionKey, count] of Object.entries(emotions)) {
|
| 73 |
+
const normalized = normalizeSent(emotionKey);
|
| 74 |
+
emosMapRef.current[normalized] = (emosMapRef.current[normalized] || 0) + (count as number);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
refreshChartsFromRefs();
|
| 78 |
+
setProgressCount((c) => c + 1);
|
| 79 |
+
} catch (error) {
|
| 80 |
+
console.error(`Error loading source ${src}:`, error);
|
| 81 |
+
setProgressCount((c) => c + 1);
|
| 82 |
+
}
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
await Promise.allSettled(promises);
|
| 86 |
+
setChartsLoading(false);
|
| 87 |
+
}, [language]);
|
| 88 |
+
|
| 89 |
+
return {
|
| 90 |
+
barData,
|
| 91 |
+
emotionData,
|
| 92 |
+
progressCount,
|
| 93 |
+
progressPct,
|
| 94 |
+
chartsLoading,
|
| 95 |
+
chartsError,
|
| 96 |
+
chartsStarted: chartsStartedRef.current,
|
| 97 |
+
loadCharts,
|
| 98 |
+
totalSources: ALL_SOURCES.length,
|
| 99 |
+
hasBars: barData.length > 0,
|
| 100 |
+
hasEmos: emotionData.length > 0,
|
| 101 |
+
};
|
| 102 |
+
}
|
TruthScan AI_frontend/app/hooks/useFavicon.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Hook odpowiedzialny za ustalenie i fallback ikonki (favicon) źródła artykułu.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { useState, useEffect, useMemo } from 'react';
|
| 6 |
+
import { Article } from '../../lib/fetchNews';
|
| 7 |
+
|
| 8 |
+
const SOURCE_DOMAINS: Record<string, string> = {
|
| 9 |
+
bbc: "bbc.com",
|
| 10 |
+
cnn: "cnn.com",
|
| 11 |
+
nytimes: "nytimes.com",
|
| 12 |
+
newyorktimes: "nytimes.com",
|
| 13 |
+
guardian: "theguardian.com",
|
| 14 |
+
theguardian: "theguardian.com",
|
| 15 |
+
aljazeera: "aljazeera.com",
|
| 16 |
+
dziennik: "dziennik.pl",
|
| 17 |
+
polsatnews: "polsatnews.pl",
|
| 18 |
+
gazetaprawna: "gazetaprawna.pl",
|
| 19 |
+
spidersweb: "spidersweb.pl",
|
| 20 |
+
bankier: "bankier.pl",
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
function normalizeKey(s?: string) {
|
| 24 |
+
return (s || "")
|
| 25 |
+
.toLowerCase()
|
| 26 |
+
.normalize("NFKD")
|
| 27 |
+
.replace(/[''`"]/g, "")
|
| 28 |
+
.replace(/[^a-z0-9]+/g, " ")
|
| 29 |
+
.trim()
|
| 30 |
+
.replace(/\s+/g, "");
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export function useFavicon(article: Article) {
|
| 34 |
+
// Lista potencjalnych favicon z fallbackami
|
| 35 |
+
const candidates = useMemo(() => {
|
| 36 |
+
const cand: string[] = [];
|
| 37 |
+
const s2 = (host: string) => `https://www.google.com/s2/favicons?domain=${host}&sz=64`;
|
| 38 |
+
const ddg = (host: string) => `https://icons.duckduckgo.com/ip3/${host}.ico`;
|
| 39 |
+
|
| 40 |
+
if (article.link) {
|
| 41 |
+
try {
|
| 42 |
+
const raw = article.link.startsWith("http") ? article.link : `https://${article.link}`;
|
| 43 |
+
const host = new URL(raw).hostname;
|
| 44 |
+
if (host) cand.push(s2(host), ddg(host));
|
| 45 |
+
} catch {}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const srcKey = normalizeKey(article.source);
|
| 49 |
+
const mapped = SOURCE_DOMAINS[srcKey];
|
| 50 |
+
if (mapped) cand.push(s2(mapped), ddg(mapped));
|
| 51 |
+
if (!mapped && srcKey) {
|
| 52 |
+
cand.push(s2(`${srcKey}.com`), ddg(`${srcKey}.com`));
|
| 53 |
+
cand.push(s2(`${srcKey}.pl`), ddg(`${srcKey}.pl`));
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
return [...new Set(cand)];
|
| 57 |
+
}, [article]);
|
| 58 |
+
|
| 59 |
+
const [currentIndex, setCurrentIndex] = useState(0);
|
| 60 |
+
const currentSrc = candidates[currentIndex];
|
| 61 |
+
const sourceInitial = (article.source || "?").slice(0, 1).toUpperCase();
|
| 62 |
+
|
| 63 |
+
useEffect(() => setCurrentIndex(0), [candidates.join("|")]);
|
| 64 |
+
|
| 65 |
+
const nextFavicon = () => setCurrentIndex(prev => prev + 1);
|
| 66 |
+
|
| 67 |
+
return {
|
| 68 |
+
faviconSrc: currentSrc,
|
| 69 |
+
sourceInitial,
|
| 70 |
+
hasFavicon: !!currentSrc,
|
| 71 |
+
nextFavicon
|
| 72 |
+
};
|
| 73 |
+
}
|
TruthScan AI_frontend/app/hooks/useLanguage.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Hook zarządzający językiem interfejsu aplikacji.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { useState, useEffect, useRef } from 'react';
|
| 6 |
+
|
| 7 |
+
import type { Lang } from "../../lib/types";
|
| 8 |
+
|
| 9 |
+
const isLang = (v: unknown): v is Lang => v === "pl" || v === "en" || v === "no";
|
| 10 |
+
|
| 11 |
+
export function useLanguage() {
|
| 12 |
+
const [language, setLanguage] = useState<Lang>("pl");
|
| 13 |
+
const langRef = useRef<Lang>("pl");
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
const htmlLang = document.documentElement.lang;
|
| 17 |
+
if (isLang(htmlLang)) {
|
| 18 |
+
setLanguage(htmlLang);
|
| 19 |
+
langRef.current = htmlLang;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const stored = localStorage.getItem("lang");
|
| 23 |
+
if (isLang(stored)) {
|
| 24 |
+
setLanguage(stored);
|
| 25 |
+
langRef.current = stored;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Synchronizacja zmiany języka między komponentami i kartami przeglądarki
|
| 29 |
+
const onCustom = (e: Event) => {
|
| 30 |
+
const next = (e as CustomEvent).detail;
|
| 31 |
+
if (isLang(next)) {
|
| 32 |
+
setLanguage(next);
|
| 33 |
+
langRef.current = next;
|
| 34 |
+
}
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
const onStorage = (e: StorageEvent) => {
|
| 38 |
+
if (e.key === "lang" && isLang(e.newValue)) {
|
| 39 |
+
setLanguage(e.newValue);
|
| 40 |
+
langRef.current = e.newValue;
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
window.addEventListener("app:langchange", onCustom as EventListener);
|
| 45 |
+
window.addEventListener("storage", onStorage);
|
| 46 |
+
|
| 47 |
+
return () => {
|
| 48 |
+
window.removeEventListener("app:langchange", onCustom as EventListener);
|
| 49 |
+
window.removeEventListener("storage", onStorage);
|
| 50 |
+
};
|
| 51 |
+
}, []);
|
| 52 |
+
|
| 53 |
+
return { language, langRef };
|
| 54 |
+
}
|
TruthScan AI_frontend/app/hooks/useNewsCache.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Hook pobierający listę newsów dla wybranego źródła.
|
| 3 |
+
* Wykorzystuje cache po stronie klienta w celu ograniczenia liczby zapytań do API.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { useEffect, useState } from "react";
|
| 7 |
+
import { useNewsCache, newsKey } from "../../app/stores/newsCache";
|
| 8 |
+
import type { Lang } from "../../lib/types";
|
| 9 |
+
|
| 10 |
+
type Article = {
|
| 11 |
+
title: string;
|
| 12 |
+
link: string;
|
| 13 |
+
summary: string;
|
| 14 |
+
published: string;
|
| 15 |
+
source: string;
|
| 16 |
+
sentiment: string;
|
| 17 |
+
fake_probability: number;
|
| 18 |
+
sentiment_score?: number;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
type NewsResponse = { source: string; articles: Article[] };
|
| 22 |
+
|
| 23 |
+
export function useCachedNews(source: string, lang: Lang = "pl") {
|
| 24 |
+
const [data, setData] = useState<Article[] | null>(null);
|
| 25 |
+
const [loading, setLoading] = useState<boolean>(true);
|
| 26 |
+
const [error, setError] = useState<string | null>(null);
|
| 27 |
+
const cache = useNewsCache();
|
| 28 |
+
|
| 29 |
+
useEffect(() => {
|
| 30 |
+
let mounted = true;
|
| 31 |
+
const key = newsKey(source, lang);
|
| 32 |
+
|
| 33 |
+
// Odczyt danych z cache przed wykonaniem zapytania HTTP
|
| 34 |
+
const cached = cache.get<Article[]>(key);
|
| 35 |
+
if (cached) {
|
| 36 |
+
setData(cached);
|
| 37 |
+
setLoading(false);
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
(async () => {
|
| 42 |
+
try {
|
| 43 |
+
setLoading(true);
|
| 44 |
+
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/news/${encodeURIComponent(source)}?lang=${lang}`);
|
| 45 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 46 |
+
const json: NewsResponse = await res.json();
|
| 47 |
+
const articles = json.articles ?? [];
|
| 48 |
+
if (!mounted) return;
|
| 49 |
+
setData(articles);
|
| 50 |
+
|
| 51 |
+
// Zapis danych w cache po poprawnym pobraniu
|
| 52 |
+
cache.set(key, articles);
|
| 53 |
+
} catch (e: any) {
|
| 54 |
+
if (!mounted) return;
|
| 55 |
+
setError(e?.message ?? "fetch error");
|
| 56 |
+
} finally {
|
| 57 |
+
if (mounted) setLoading(false);
|
| 58 |
+
}
|
| 59 |
+
})();
|
| 60 |
+
|
| 61 |
+
return () => {
|
| 62 |
+
// Zabezpieczenie przed aktualizacją stanu po odmontowaniu komponentu
|
| 63 |
+
mounted = false;
|
| 64 |
+
};
|
| 65 |
+
}, [source, lang]);
|
| 66 |
+
|
| 67 |
+
const refresh = async () => {
|
| 68 |
+
const key = newsKey(source, lang);
|
| 69 |
+
cache.del(key);
|
| 70 |
+
setData(null);
|
| 71 |
+
setLoading(true);
|
| 72 |
+
try {
|
| 73 |
+
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/news/${encodeURIComponent(source)}?lang=${lang}`);
|
| 74 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 75 |
+
const json: NewsResponse = await res.json();
|
| 76 |
+
const articles = json.articles ?? [];
|
| 77 |
+
setData(articles);
|
| 78 |
+
cache.set(key, articles);
|
| 79 |
+
} catch (e: any) {
|
| 80 |
+
setError(e?.message ?? "fetch error");
|
| 81 |
+
} finally {
|
| 82 |
+
setLoading(false);
|
| 83 |
+
}
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
return { data, loading, error, refresh };
|
| 87 |
+
}
|
TruthScan AI_frontend/app/hooks/useNewsStream.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Hook obsługujący strumieniowe pobieranie newsów (SSE) dla wybranego źródła.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
"use client";
|
| 6 |
+
|
| 7 |
+
import { useEffect, useRef, useState } from "react";
|
| 8 |
+
import type { Article } from "@/lib/fetchNews";
|
| 9 |
+
import { useNewsCache, newsKey } from "../../app/stores/newsCache";
|
| 10 |
+
import type { Lang } from "../../lib/types";
|
| 11 |
+
|
| 12 |
+
type StreamState = {
|
| 13 |
+
articles: Article[];
|
| 14 |
+
loading: boolean;
|
| 15 |
+
error: string | null;
|
| 16 |
+
progress: number;
|
| 17 |
+
total: number;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export function useNewsStream(source: string, lang: Lang = "pl", limit: number = 5): StreamState {
|
| 21 |
+
const [state, setState] = useState<StreamState>({
|
| 22 |
+
articles: [],
|
| 23 |
+
loading: true,
|
| 24 |
+
error: null,
|
| 25 |
+
progress: 0,
|
| 26 |
+
total: 0,
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
const esRef = useRef<EventSource | null>(null);
|
| 30 |
+
const cache = useNewsCache();
|
| 31 |
+
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
if (!source) return;
|
| 34 |
+
|
| 35 |
+
const cacheKey = newsKey(source, lang);
|
| 36 |
+
const cached = cache.get<Article[]>(cacheKey);
|
| 37 |
+
|
| 38 |
+
// Jeżeli dane są w cache, pomijamy połączenie SSE
|
| 39 |
+
if (cached && cached.length > 0) {
|
| 40 |
+
setState({
|
| 41 |
+
articles: cached,
|
| 42 |
+
loading: false,
|
| 43 |
+
error: null,
|
| 44 |
+
progress: cached.length,
|
| 45 |
+
total: cached.length,
|
| 46 |
+
});
|
| 47 |
+
return;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
setState({
|
| 51 |
+
articles: [],
|
| 52 |
+
loading: true,
|
| 53 |
+
error: null,
|
| 54 |
+
progress: 0,
|
| 55 |
+
total: 0,
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
const apiBase = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8000";
|
| 59 |
+
const es = new EventSource(`${apiBase}/stream-news/${encodeURIComponent(source)}?lang=${lang}`);
|
| 60 |
+
esRef.current = es;
|
| 61 |
+
|
| 62 |
+
let collected: Article[] = [];
|
| 63 |
+
|
| 64 |
+
const handleMeta = (e: MessageEvent) => {
|
| 65 |
+
try {
|
| 66 |
+
const { total } = JSON.parse(e.data);
|
| 67 |
+
setState((s) => ({ ...s, total: Math.min(total ?? 0, limit) }));
|
| 68 |
+
} catch {
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const handleBackendError = (e: MessageEvent) => {
|
| 73 |
+
const msg = (() => {
|
| 74 |
+
try {
|
| 75 |
+
return JSON.parse(e.data)?.message || "Backend error";
|
| 76 |
+
} catch {
|
| 77 |
+
return "Backend error";
|
| 78 |
+
}
|
| 79 |
+
})();
|
| 80 |
+
setState((s) => ({ ...s, loading: false, error: msg }));
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
const handleMessage = (ev: MessageEvent) => {
|
| 84 |
+
try {
|
| 85 |
+
const article = JSON.parse(ev.data) as Article;
|
| 86 |
+
collected.push(article);
|
| 87 |
+
setState((s) => {
|
| 88 |
+
const next = [...s.articles, article].slice(0, limit);
|
| 89 |
+
return {
|
| 90 |
+
...s,
|
| 91 |
+
articles: next,
|
| 92 |
+
progress: Math.min(next.length, s.total || limit),
|
| 93 |
+
};
|
| 94 |
+
});
|
| 95 |
+
} catch {
|
| 96 |
+
}
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
const handleDone = () => {
|
| 100 |
+
setState((s) => ({ ...s, loading: false }));
|
| 101 |
+
// Zapis pełnego wyniku do cache po zakończeniu strumienia
|
| 102 |
+
if (collected.length > 0) {
|
| 103 |
+
cache.set(cacheKey, collected);
|
| 104 |
+
}
|
| 105 |
+
es.close();
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
const handleError = () => {
|
| 109 |
+
setState((s) => ({ ...s, loading: false, error: "Stream error" }));
|
| 110 |
+
es.close();
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
es.addEventListener("meta", handleMeta);
|
| 114 |
+
es.addEventListener("backend_error", handleBackendError);
|
| 115 |
+
es.addEventListener("done", handleDone);
|
| 116 |
+
es.onmessage = handleMessage;
|
| 117 |
+
es.onerror = handleError;
|
| 118 |
+
|
| 119 |
+
return () => {
|
| 120 |
+
es.removeEventListener("meta", handleMeta);
|
| 121 |
+
es.removeEventListener("backend_error", handleBackendError);
|
| 122 |
+
es.removeEventListener("done", handleDone);
|
| 123 |
+
es.close();
|
| 124 |
+
};
|
| 125 |
+
}, [source, lang, limit]);
|
| 126 |
+
|
| 127 |
+
return state;
|
| 128 |
+
}
|
TruthScan AI_frontend/app/hooks/usePDFExport.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Hook umożliwiający eksport wybranego fragmentu interfejsu do PDF (druk przeglądarki).
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { useCallback } from 'react';
|
| 6 |
+
|
| 7 |
+
interface PDFOptions {
|
| 8 |
+
title?: string;
|
| 9 |
+
margins?: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export const usePDFExport = (
|
| 13 |
+
componentRef: React.RefObject<HTMLDivElement | null>,
|
| 14 |
+
options: PDFOptions = {}
|
| 15 |
+
) => {
|
| 16 |
+
const {
|
| 17 |
+
title = 'ThruScan_Analysis',
|
| 18 |
+
margins = '15mm'
|
| 19 |
+
} = options;
|
| 20 |
+
|
| 21 |
+
const handlePrint = useCallback((): Promise<void> => {
|
| 22 |
+
return new Promise((resolve, reject) => {
|
| 23 |
+
if (!componentRef.current) {
|
| 24 |
+
reject(new Error('Component reference is not available'));
|
| 25 |
+
return;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const printWindow = window.open('', '_blank');
|
| 29 |
+
|
| 30 |
+
// Fallback: jeśli okno nie może zostać otwarte (blokada popupów),
|
| 31 |
+
// zapisujemy zawartość jako statyczny plik HTML
|
| 32 |
+
if (!printWindow) {
|
| 33 |
+
const content = componentRef.current.innerHTML;
|
| 34 |
+
const blob = new Blob([`
|
| 35 |
+
<!DOCTYPE html>
|
| 36 |
+
<html lang="pl">
|
| 37 |
+
<head>
|
| 38 |
+
<meta charset="UTF-8">
|
| 39 |
+
<title>${title}</title>
|
| 40 |
+
<style>
|
| 41 |
+
body { font-family: Arial, sans-serif; margin: 20px; }
|
| 42 |
+
</style>
|
| 43 |
+
</head>
|
| 44 |
+
<body>${content}</body>
|
| 45 |
+
</html>
|
| 46 |
+
`], { type: 'text/html' });
|
| 47 |
+
|
| 48 |
+
const url = URL.createObjectURL(blob);
|
| 49 |
+
const link = document.createElement('a');
|
| 50 |
+
link.href = url;
|
| 51 |
+
link.download = `${title}.html`;
|
| 52 |
+
link.click();
|
| 53 |
+
URL.revokeObjectURL(url);
|
| 54 |
+
resolve();
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
try {
|
| 59 |
+
const content = componentRef.current.cloneNode(true) as HTMLDivElement;
|
| 60 |
+
|
| 61 |
+
// Usunięcie elementów interaktywnych z eksportowanego widoku
|
| 62 |
+
const interactiveElements = content.querySelectorAll('button, input, select, textarea, .pdf-generator-controls');
|
| 63 |
+
interactiveElements.forEach(el => el.remove());
|
| 64 |
+
|
| 65 |
+
const style = `
|
| 66 |
+
<style>
|
| 67 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
| 68 |
+
|
| 69 |
+
@page {
|
| 70 |
+
margin: ${margins};
|
| 71 |
+
size: A4;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
body {
|
| 75 |
+
margin: 0;
|
| 76 |
+
padding: 0;
|
| 77 |
+
font-family: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 78 |
+
-webkit-print-color-adjust: exact;
|
| 79 |
+
print-color-adjust: exact;
|
| 80 |
+
color: #000;
|
| 81 |
+
background: white;
|
| 82 |
+
line-height: 1.4;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
* {
|
| 86 |
+
-webkit-print-color-adjust: exact !important;
|
| 87 |
+
color-adjust: exact !important;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.pdf-content {
|
| 91 |
+
margin: 0 !important;
|
| 92 |
+
padding: 0 !important;
|
| 93 |
+
box-shadow: none !important;
|
| 94 |
+
border: none !important;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
table {
|
| 98 |
+
width: 100%;
|
| 99 |
+
border-collapse: collapse;
|
| 100 |
+
margin: 10px 0;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
th, td {
|
| 104 |
+
border: 1px solid #ddd;
|
| 105 |
+
padding: 8px;
|
| 106 |
+
text-align: left;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
th {
|
| 110 |
+
background-color: #f5f5f5 !important;
|
| 111 |
+
}
|
| 112 |
+
</style>
|
| 113 |
+
`;
|
| 114 |
+
|
| 115 |
+
printWindow.document.write(`
|
| 116 |
+
<!DOCTYPE html>
|
| 117 |
+
<html lang="pl">
|
| 118 |
+
<head>
|
| 119 |
+
<title>${title}</title>
|
| 120 |
+
<meta charset="UTF-8">
|
| 121 |
+
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
| 122 |
+
${style}
|
| 123 |
+
</head>
|
| 124 |
+
<body>
|
| 125 |
+
${content.innerHTML}
|
| 126 |
+
</body>
|
| 127 |
+
</html>
|
| 128 |
+
`);
|
| 129 |
+
|
| 130 |
+
printWindow.document.close();
|
| 131 |
+
|
| 132 |
+
setTimeout(() => {
|
| 133 |
+
printWindow.focus();
|
| 134 |
+
printWindow.print();
|
| 135 |
+
|
| 136 |
+
const checkPrint = setInterval(() => {
|
| 137 |
+
if (printWindow.closed) {
|
| 138 |
+
clearInterval(checkPrint);
|
| 139 |
+
resolve();
|
| 140 |
+
}
|
| 141 |
+
}, 100);
|
| 142 |
+
|
| 143 |
+
setTimeout(() => {
|
| 144 |
+
if (!printWindow.closed) {
|
| 145 |
+
printWindow.close();
|
| 146 |
+
resolve();
|
| 147 |
+
}
|
| 148 |
+
}, 10000);
|
| 149 |
+
|
| 150 |
+
}, 1000);
|
| 151 |
+
|
| 152 |
+
} catch (error) {
|
| 153 |
+
printWindow?.close();
|
| 154 |
+
reject(error);
|
| 155 |
+
}
|
| 156 |
+
});
|
| 157 |
+
}, [componentRef, title, margins]);
|
| 158 |
+
|
| 159 |
+
return handlePrint;
|
| 160 |
+
};
|
TruthScan AI_frontend/app/hooks/useSentiment.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Hook mapujący wynik analizy sentymentu na etykietę i kolor interfejsu.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { useMemo } from 'react';
|
| 6 |
+
|
| 7 |
+
export interface SentimentResult {
|
| 8 |
+
label: string;
|
| 9 |
+
color: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function useSentiment(sentiment: string, language: "pl" | "en" | "no"): SentimentResult {
|
| 13 |
+
return useMemo(() => {
|
| 14 |
+
const sentimentUpper = (sentiment || "").toUpperCase();
|
| 15 |
+
|
| 16 |
+
// Normalizacja wyniku sentymentu do formy prezentacyjnej (UI)
|
| 17 |
+
const label = language === "pl"
|
| 18 |
+
? sentimentUpper.includes("POS") ? "Pozytywne"
|
| 19 |
+
: sentimentUpper.includes("NEG") ? "Negatywne"
|
| 20 |
+
: "Neutralne"
|
| 21 |
+
: language === "no"
|
| 22 |
+
? sentimentUpper.includes("POS") ? "Positivt"
|
| 23 |
+
: sentimentUpper.includes("NEG") ? "Negativt"
|
| 24 |
+
: "Nøytralt"
|
| 25 |
+
: sentimentUpper.includes("POS") ? "Positive"
|
| 26 |
+
: sentimentUpper.includes("NEG") ? "Negative"
|
| 27 |
+
: "Neutral";
|
| 28 |
+
|
| 29 |
+
const positiveLabel = language === "pl" ? "Pozytywne" : language === "no" ? "Positivt" : "Positive";
|
| 30 |
+
const negativeLabel = language === "pl" ? "Negatywne" : language === "no" ? "Negativt" : "Negative";
|
| 31 |
+
|
| 32 |
+
const color = label === positiveLabel
|
| 33 |
+
? "text-green-500"
|
| 34 |
+
: label === negativeLabel
|
| 35 |
+
? "text-red-500"
|
| 36 |
+
: "text-blue-400";
|
| 37 |
+
|
| 38 |
+
return { label, color };
|
| 39 |
+
}, [sentiment, language]);
|
| 40 |
+
}
|
TruthScan AI_frontend/app/layout.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Główny layout aplikacji frontendowej (Next.js App Router).
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import "../styles/globals.css";
|
| 6 |
+
import NavBar from "../components/NavBar";
|
| 7 |
+
|
| 8 |
+
export default function RootLayout({
|
| 9 |
+
children,
|
| 10 |
+
}: {
|
| 11 |
+
children: React.ReactNode;
|
| 12 |
+
}) {
|
| 13 |
+
return (
|
| 14 |
+
<html lang="pl">
|
| 15 |
+
<body>
|
| 16 |
+
<div className="content-overlay min-h-screen flex flex-col">
|
| 17 |
+
<NavBar />
|
| 18 |
+
|
| 19 |
+
<main className="flex-1">{children}</main>
|
| 20 |
+
|
| 21 |
+
<footer className="text-center p-4 text-sm bg-white/80 dark:bg-gray-900/80 text-gray-600 dark:text-gray-400">
|
| 22 |
+
© {new Date().getFullYear()} TruthScan AI
|
| 23 |
+
</footer>
|
| 24 |
+
</div>
|
| 25 |
+
</body>
|
| 26 |
+
</html>
|
| 27 |
+
);
|
| 28 |
+
}
|
TruthScan AI_frontend/app/page.tsx
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Strona główna aplikacji TruthScan AI.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
"use client";
|
| 6 |
+
|
| 7 |
+
import { useEffect, useState } from "react";
|
| 8 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 9 |
+
import locales from "../lib/locales";
|
| 10 |
+
import TipModal from "../app/TipModel";
|
| 11 |
+
import type { Lang } from "../lib/types";
|
| 12 |
+
|
| 13 |
+
export default function HomePage() {
|
| 14 |
+
|
| 15 |
+
const [language, setLanguage] = useState<Lang>("pl");
|
| 16 |
+
const [activeTip, setActiveTip] = useState<number | null>(null);
|
| 17 |
+
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
// Synchronizacja języka interfejsu (localStorage + zdarzenie globalne)
|
| 20 |
+
const stored = localStorage.getItem("lang");
|
| 21 |
+
if (stored === "pl" || stored === "en" || stored === "no") setLanguage(stored as Lang);
|
| 22 |
+
|
| 23 |
+
const onLangChange = (e: Event) => {
|
| 24 |
+
const detail = (e as CustomEvent).detail;
|
| 25 |
+
if (detail === "pl" || detail === "en" || detail === "no") setLanguage(detail as Lang);
|
| 26 |
+
};
|
| 27 |
+
window.addEventListener("app:langchange", onLangChange as EventListener);
|
| 28 |
+
|
| 29 |
+
return () => window.removeEventListener("app:langchange", onLangChange as EventListener);
|
| 30 |
+
}, []);
|
| 31 |
+
|
| 32 |
+
const t = locales[language];
|
| 33 |
+
const T = (pl: string, no: string, en: string) =>
|
| 34 |
+
language === "pl" ? pl : language === "no" ? no : en;
|
| 35 |
+
|
| 36 |
+
const tips = [
|
| 37 |
+
{
|
| 38 |
+
pl: "Sprawdź źródło artykułu",
|
| 39 |
+
no: "Sjekk kilden til artikkelen",
|
| 40 |
+
en: "Check the article source",
|
| 41 |
+
morePl: "Zwróć uwagę na autora i wydawcę. Sprawdź czy strona ma sekcję 'O nas', dane kontaktowe i politykę redakcyjną. Unikaj anonimowych blogów i stron bez informacji o zespole. Prawdziwe media zwykle podają swoje dane i mają przejrzystą strukturę własności.",
|
| 42 |
+
moreNo: "Vær oppmerksom på forfatteren og utgiveren. Sjekk om nettstedet har en 'Om oss'-seksjon, kontaktinformasjon og redaksjonell policy. Unngå anonyme blogger og nettsteder uten teaminformasjon. Legitime medier oppgir vanligvis sine data og har en gjennomsiktig eierskapsstruktur.",
|
| 43 |
+
moreEn: "Pay attention to the author and publisher. Check if the site has an 'About us' section, contact details and editorial policy. Avoid anonymous blogs and sites without team information. Legitimate media usually provide their data and have transparent ownership structure.",
|
| 44 |
+
link: { href: "https://demagog.org.pl/edukacja/", labelPl: "Demagog: Jak weryfikować źródła", labelNo: "Demagog: Slik verifiserer du kilder", labelEn: "Demagog: How to verify sources" },
|
| 45 |
+
icon: "🔍"
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
pl: "Analizuj emocjonalny język",
|
| 49 |
+
no: "Analyser emosjonelt språk",
|
| 50 |
+
en: "Analyze emotional language",
|
| 51 |
+
morePl: "Teksty pełne wykrzykników, CAPS LOCKA i ostrych epitetów często manipulują emocjami. Prawdziwe newsy starają się być neutralne. Zwróć uwagę na clickbaitowe nagłówki obiecujące szokujące rewelacje bez pokrycia w treści.",
|
| 52 |
+
moreNo: "Tekster fulle av utropstegn, STORE BOKSTAVER og harde epiteter manipulerer ofte følelser. Ekte nyheter prøver å være nøytrale. Vær oppmerksom på clickbait-overskrifter som lover sjokkerende avsløringer uten innhold.",
|
| 53 |
+
moreEn: "Texts full of exclamation marks, ALL CAPS and harsh epithets often manipulate emotions. Real news tries to be neutral. Pay attention to clickbait headlines promising shocking revelations without substance.",
|
| 54 |
+
link: { href: "https://www.snopes.com/collections/fact-checking-101/", labelPl: "Snopes: Podstawy fact-checkingu", labelNo: "Snopes: Grunnleggende fakta-sjekk", labelEn: "Snopes: Fact-checking basics" },
|
| 55 |
+
icon: "🎭"
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
pl: "Weryfikuj w wielu źródłach",
|
| 59 |
+
no: "Verifiser i flere kilder",
|
| 60 |
+
en: "Verify in multiple sources",
|
| 61 |
+
morePl: "Nie polegaj na jednym źródle. Sprawdź tę samą informację w co najmniej 2-3 wiarygodnych mediach. Użyj wyszukiwarki z operatorami jak 'site:.gov.pl' lub 'site:.edu'. Sprawdź datę publikacji - stare newsy często są udostępniane jako aktualne.",
|
| 62 |
+
moreNo: "Ikke stol på én kilde. Sjekk samme informasjon i minst 2-3 pålitelige medier. Bruk søkemotorer med operatorer som 'site:.gov.no' eller 'site:.edu'. Sjekk publiseringsdatoen - gamle nyheter deles ofte som nye.",
|
| 63 |
+
moreEn: "Don't rely on one source. Check the same information in at least 2-3 reliable media. Use search engines with operators like 'site:.gov' or 'site:.edu'. Check the publication date - old news is often shared as current.",
|
| 64 |
+
link: { href: "https://factcheck.afp.com/", labelPl: "AFP Fact Check", labelNo: "AFP Fact Check", labelEn: "AFP Fact Check" },
|
| 65 |
+
icon: "📡"
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
pl: "Sprawdź dowody i cytaty",
|
| 69 |
+
no: "Sjekk bevis og sitater",
|
| 70 |
+
en: "Check evidence and quotes",
|
| 71 |
+
morePl: "Prawdziwe artykuły zawierają konkretne dane, statystyki, cytaty ekspertów i odnośniki do badań. Fake newsy operują ogólnikami i emocjami. Sprawdź czy cytowane osoby rzeczywiście istnieją i czy zostały poprawnie przytoczone.",
|
| 72 |
+
moreNo: "Ekte artikler inneholder spesifikke data, statistikk, ekspertsitater og henvisninger til forskning. Falske nyheter opererer med generaliseringer og følelser. Sjekk om siterte personer faktisk eksisterer og ble korrekt sitert.",
|
| 73 |
+
moreEn: "Real articles contain specific data, statistics, expert quotes and references to research. Fake news operates on generalizations and emotions. Check if quoted people actually exist and were correctly cited.",
|
| 74 |
+
link: { href: "https://www.politifact.com/", labelPl: "PolitiFact: Weryfikacja faktów", labelNo: "PolitiFact: Faktaverifisering", labelEn: "PolitiFact: Fact verification" },
|
| 75 |
+
icon: "📊"
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
pl: "Uważaj na deepfakes i manipulacje",
|
| 79 |
+
no: "Vær forsiktig med deepfakes og manipulasjoner",
|
| 80 |
+
en: "Beware of deepfakes and manipulations",
|
| 81 |
+
morePl: "Coraz częściej spotykamy zmontowane zdjęcia, filmy i nagrania audio. Sprawdź oryginalne źródło materiału. Użyj narzędzi do odwrotnego wyszukiwania obrazów. Zwróć uwagę na nienaturalne ruchy twarzy w filmach.",
|
| 82 |
+
moreNo: "Vi møter stadig mer manipulerte bilder, videoer og lydopptak. Sjekk den originale kilden til materialet. Bruk verktøy for omvendt bildesøk. Vær oppmerksom på unaturlige ansiktsbevegelser i videoer.",
|
| 83 |
+
moreEn: "We increasingly encounter edited photos, videos and audio recordings. Check the original source of the material. Use reverse image search tools. Pay attention to unnatural facial movements in videos.",
|
| 84 |
+
link: { href: "https://euvsdisinfo.eu/", labelPl: "EU vs Disinfo: Walka z dezinformacją", labelNo: "EU vs Disinfo: Kamp mot desinformasjon", labelEn: "EU vs Disinfo: Fighting disinformation" },
|
| 85 |
+
icon: "🛸"
|
| 86 |
+
}
|
| 87 |
+
];
|
| 88 |
+
|
| 89 |
+
const sources = [
|
| 90 |
+
{ href: "https://demagog.org.pl", label: "Demagog", tag: "PL",},
|
| 91 |
+
{ href: "https://konkret24.tvn24.pl", label: "Konkret24", tag: "PL",},
|
| 92 |
+
{ href: "https://fakehunter.pap.pl", label: "FakeHunter", tag: "PL",},
|
| 93 |
+
{ href: "https://euvsdisinfo.eu", label: "EU vs Disinfo", tag: "EU",},
|
| 94 |
+
{ href: "https://www.snopes.com/", label: "Snopes", tag: "EN",},
|
| 95 |
+
{ href: "https://www.politifact.com/", label: "PolitiFact", tag: "EN",},
|
| 96 |
+
{ href: "https://factcheck.afp.com/", label: "AFP Fact Check", tag: "EN",},
|
| 97 |
+
{ href: "https://www.factcheck.org/", label: "FactCheck.org", tag: "EN", },
|
| 98 |
+
];
|
| 99 |
+
|
| 100 |
+
const features = [
|
| 101 |
+
{
|
| 102 |
+
icon: "🤖",
|
| 103 |
+
title: T("Analiza AI", "AI-analyse", "AI Analysis"),
|
| 104 |
+
description: T(
|
| 105 |
+
"Zaawansowane modele NLP do analizy sentymentu i wykrywania dezinformacji",
|
| 106 |
+
"Avanserte NLP-modeller for sentimentanalyse og oppdagelse av desinformasjon",
|
| 107 |
+
"Advanced NLP models for sentiment analysis and misinformation detection"
|
| 108 |
+
)
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
icon: "⚡",
|
| 112 |
+
title: T("Analiza w Czasie Rzeczywistym", "Sanntidsanalyse", "Real-time Analysis"),
|
| 113 |
+
description: T(
|
| 114 |
+
"Natychmiastowe przetwarzanie najnowszych wiadomości ze strumieni RSS",
|
| 115 |
+
"Umiddelbar behandling av siste nyheter fra RSS-strømmer",
|
| 116 |
+
"Instant processing of latest news from RSS feeds"
|
| 117 |
+
)
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
icon: "🌐",
|
| 121 |
+
title: T("Wiele Źródeł", "Flere kilder", "Multiple Sources"),
|
| 122 |
+
description: T(
|
| 123 |
+
"Porównuj wiadomości z 10+ zaufanych źródeł krajowych i międzynarodowych",
|
| 124 |
+
"Sammenlign nyheter fra 10+ pålitelige innenlandske og internasjonale kilder",
|
| 125 |
+
"Compare news from 10+ trusted domestic and international sources"
|
| 126 |
+
)
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
icon: "📊",
|
| 130 |
+
title: T("Dashboard z Wykresami", "Dashbord med diagrammer", "Chart Dashboard"),
|
| 131 |
+
description: T(
|
| 132 |
+
"Interaktywne wizualizacje danych i statystyki emocjonalne",
|
| 133 |
+
"Interaktive datavisualiseringer og emosjonell statistikk",
|
| 134 |
+
"Interactive data visualizations and emotional statistics"
|
| 135 |
+
)
|
| 136 |
+
}
|
| 137 |
+
];
|
| 138 |
+
|
| 139 |
+
return (
|
| 140 |
+
<div className="min-h-screen bg-gradient-to-b from-blue-200 via-purple-100/80 to-pink-100/30 dark:from-gray-900 dark:via-gray-800 dark:to-gray-700 text-gray-900 dark:text-gray-100 font-sans">
|
| 141 |
+
|
| 142 |
+
<section className="relative py-16 px-4 md:px-8 flex items-center justify-center">
|
| 143 |
+
<motion.div
|
| 144 |
+
initial={{ opacity: 0, y: 30 }}
|
| 145 |
+
animate={{ opacity: 1, y: 0 }}
|
| 146 |
+
transition={{ duration: 0.8 }}
|
| 147 |
+
className="relative z-10 text-center space-y-8 w-full max-w-4xl mx-auto"
|
| 148 |
+
>
|
| 149 |
+
<motion.h1
|
| 150 |
+
initial={{ opacity: 0, y: 20 }}
|
| 151 |
+
animate={{ opacity: 1, y: 0 }}
|
| 152 |
+
transition={{ delay: 0.2 }}
|
| 153 |
+
className="text-5xl md:text-6xl lg:text-7xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent"
|
| 154 |
+
>
|
| 155 |
+
TruthScan AI
|
| 156 |
+
</motion.h1>
|
| 157 |
+
|
| 158 |
+
<motion.p
|
| 159 |
+
initial={{ opacity: 0, y: 20 }}
|
| 160 |
+
animate={{ opacity: 1, y: 0 }}
|
| 161 |
+
transition={{ delay: 0.3 }}
|
| 162 |
+
className="text-xl md:text-2xl text-gray-700 dark:text-gray-300 max-w-2xl mx-auto"
|
| 163 |
+
>
|
| 164 |
+
{T(
|
| 165 |
+
"Inteligentny system do analizy wiarygodności newsów i wykrywania dezinformacji",
|
| 166 |
+
"Intelligent system for troverdighetanalyse av nyheter og oppdagelse av desinformasjon",
|
| 167 |
+
"Intelligent system for news credibility analysis and misinformation detection"
|
| 168 |
+
)}
|
| 169 |
+
</motion.p>
|
| 170 |
+
|
| 171 |
+
<motion.div
|
| 172 |
+
initial={{ opacity: 0, y: 20 }}
|
| 173 |
+
animate={{ opacity: 1, y: 0 }}
|
| 174 |
+
transition={{ delay: 0.4 }}
|
| 175 |
+
className="flex justify-center gap-8 py-4"
|
| 176 |
+
>
|
| 177 |
+
<div className="text-center">
|
| 178 |
+
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">10+</div>
|
| 179 |
+
<div className="text-sm text-gray-600 dark:text-gray-400">
|
| 180 |
+
{T("Źródeł", "Kilder", "Sources")}
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
<div className="text-center">
|
| 184 |
+
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">2s</div>
|
| 185 |
+
<div className="text-sm text-gray-600 dark:text-gray-400">
|
| 186 |
+
{T("Analiza", "Analyse", "Analysis")}
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
<div className="text-center">
|
| 190 |
+
<div className="text-2xl font-bold text-pink-600 dark:text-pink-400">AI</div>
|
| 191 |
+
<div className="text-sm text-gray-600 dark:text-gray-400">
|
| 192 |
+
{T("Modele NLP", "NLP-modeller", "NLP Models")}
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
</motion.div>
|
| 196 |
+
|
| 197 |
+
<motion.div
|
| 198 |
+
initial={{ opacity: 0, y: 20 }}
|
| 199 |
+
animate={{ opacity: 1, y: 0 }}
|
| 200 |
+
transition={{ delay: 0.5 }}
|
| 201 |
+
className="flex flex-col sm:flex-row gap-4 justify-center pt-4"
|
| 202 |
+
>
|
| 203 |
+
<a
|
| 204 |
+
href="/dashboard"
|
| 205 |
+
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white px-8 py-3 rounded-lg text-lg font-semibold transition-all hover:scale-105 shadow-lg"
|
| 206 |
+
>
|
| 207 |
+
{T("Przejdź do Dashboardu →", "Gå til dashbordet →", "Go to Dashboard →")}
|
| 208 |
+
</a>
|
| 209 |
+
|
| 210 |
+
</motion.div>
|
| 211 |
+
</motion.div>
|
| 212 |
+
</section>
|
| 213 |
+
|
| 214 |
+
<section id="features" className="py-12 px-4 md:px-8">
|
| 215 |
+
<div className="container mx-auto max-w-6xl">
|
| 216 |
+
<motion.div
|
| 217 |
+
initial={{ opacity: 0, y: 20 }}
|
| 218 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 219 |
+
viewport={{ once: true }}
|
| 220 |
+
className="text-center mb-12"
|
| 221 |
+
>
|
| 222 |
+
<h2 className="text-4xl font-bold mb-4 text-gray-800 dark:text-gray-100">
|
| 223 |
+
{T(" Dlaczego TruthScan AI?", " Hvorfor TruthScan AI?", " Why TruthScan AI?")}
|
| 224 |
+
</h2>
|
| 225 |
+
<p className="text-gray-700 dark:text-gray-300 max-w-2xl mx-auto">
|
| 226 |
+
{T(
|
| 227 |
+
"Nowoczesne narzędzie wspierające krytyczne myślenie",
|
| 228 |
+
"Moderne verktøy som støtter kritisk tenkning",
|
| 229 |
+
"Modern tool supporting critical thinking"
|
| 230 |
+
)}
|
| 231 |
+
</p>
|
| 232 |
+
</motion.div>
|
| 233 |
+
|
| 234 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 235 |
+
{features.map((feature, index) => (
|
| 236 |
+
<motion.div
|
| 237 |
+
key={feature.title}
|
| 238 |
+
initial={{ opacity: 0, y: 20 }}
|
| 239 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 240 |
+
viewport={{ once: true }}
|
| 241 |
+
transition={{ delay: index * 0.1 }}
|
| 242 |
+
className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm p-6 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50"
|
| 243 |
+
>
|
| 244 |
+
<div className="text-4xl mb-4">{feature.icon}</div>
|
| 245 |
+
<h3 className="text-lg font-bold mb-3 text-gray-800 dark:text-gray-100">
|
| 246 |
+
{feature.title}
|
| 247 |
+
</h3>
|
| 248 |
+
<p className="text-gray-700 dark:text-gray-300 text-sm">
|
| 249 |
+
{feature.description}
|
| 250 |
+
</p>
|
| 251 |
+
</motion.div>
|
| 252 |
+
))}
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
</section>
|
| 256 |
+
|
| 257 |
+
<section className="py-12 px-4 md:px-8">
|
| 258 |
+
<div className="container mx-auto max-w-6xl">
|
| 259 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
| 260 |
+
|
| 261 |
+
<div className="space-y-6">
|
| 262 |
+
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100">
|
| 263 |
+
{T("🔍 Jak rozpoznać fake newsy?", "🔍 Hvordan gjenkjenne falske nyheter?", "🔍 How to Spot Fake News")}
|
| 264 |
+
</h2>
|
| 265 |
+
|
| 266 |
+
<div className="space-y-3">
|
| 267 |
+
{tips.map((tip, i) => (
|
| 268 |
+
<button
|
| 269 |
+
key={i}
|
| 270 |
+
onClick={() => setActiveTip(i)}
|
| 271 |
+
className="w-full flex items-start gap-3 p-4 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-lg shadow hover:shadow-md transition-all text-left border border-gray-200/50 dark:border-gray-700/50"
|
| 272 |
+
>
|
| 273 |
+
<div className="text-xl">{tip.icon}</div>
|
| 274 |
+
<div className="flex-1">
|
| 275 |
+
<h3 className="font-semibold text-gray-800 dark:text-gray-100">
|
| 276 |
+
{language === "pl" ? tip.pl : language === "no" ? tip.no : tip.en}
|
| 277 |
+
</h3>
|
| 278 |
+
<p className="text-gray-600 dark:text-gray-400 text-sm mt-1">
|
| 279 |
+
{T("Kliknij, aby dowiedzieć się więcej", "Klikk for å lære mer", "Click to learn more")}
|
| 280 |
+
</p>
|
| 281 |
+
</div>
|
| 282 |
+
</button>
|
| 283 |
+
))}
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
|
| 287 |
+
<div className="space-y-6">
|
| 288 |
+
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100">
|
| 289 |
+
{T("📚 Polecane źródła", "📚 Anbefalte kilder", "📚 Recommended Sources")}
|
| 290 |
+
</h2>
|
| 291 |
+
|
| 292 |
+
<div className="grid grid-cols-1 gap-3">
|
| 293 |
+
{sources.map((source) => (
|
| 294 |
+
<a
|
| 295 |
+
key={source.href}
|
| 296 |
+
href={source.href}
|
| 297 |
+
target="_blank"
|
| 298 |
+
rel="noopener noreferrer"
|
| 299 |
+
className="flex items-center gap-3 p-3 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-lg shadow hover:shadow-md transition-all border border-gray-200/50 dark:border-gray-700/50"
|
| 300 |
+
>
|
| 301 |
+
<div className="flex-1">
|
| 302 |
+
<div className="flex items-center gap-2">
|
| 303 |
+
<span className="font-semibold text-gray-800 dark:text-gray-100">
|
| 304 |
+
{source.label}
|
| 305 |
+
</span>
|
| 306 |
+
<span className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 px-2 py-1 rounded">
|
| 307 |
+
{source.tag}
|
| 308 |
+
</span>
|
| 309 |
+
</div>
|
| 310 |
+
</div>
|
| 311 |
+
</a>
|
| 312 |
+
))}
|
| 313 |
+
</div>
|
| 314 |
+
</div>
|
| 315 |
+
</div>
|
| 316 |
+
</div>
|
| 317 |
+
</section>
|
| 318 |
+
<AnimatePresence>
|
| 319 |
+
{activeTip !== null && (
|
| 320 |
+
<TipModal
|
| 321 |
+
title={language === "pl" ? tips[activeTip].pl : language === "no" ? tips[activeTip].no : tips[activeTip].en}
|
| 322 |
+
body={language === "pl" ? tips[activeTip].morePl : language === "no" ? tips[activeTip].moreNo : tips[activeTip].moreEn}
|
| 323 |
+
linkHref={tips[activeTip].link.href}
|
| 324 |
+
linkLabel={language === "pl" ? tips[activeTip].link.labelPl : language === "no" ? tips[activeTip].link.labelNo : tips[activeTip].link.labelEn}
|
| 325 |
+
onClose={() => setActiveTip(null)}
|
| 326 |
+
/>
|
| 327 |
+
)}
|
| 328 |
+
</AnimatePresence>
|
| 329 |
+
</div>
|
| 330 |
+
);
|
| 331 |
+
}
|
TruthScan AI_frontend/app/saved/page.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Strona aplikacji prezentująca zapisane przez użytkownika artykuły.
|
| 3 |
+
*
|
| 4 |
+
* Stanowi prosty wrapper routingu Next.js dla komponentu SavedArticlesPage.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
"use client";
|
| 8 |
+
|
| 9 |
+
import SavedArticlesPage from "../../components/SavedArticlesPage";
|
| 10 |
+
|
| 11 |
+
export default function Page() {
|
| 12 |
+
return <SavedArticlesPage />;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
|
TruthScan AI_frontend/app/stores/newsCache.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Globalny cache danych newsowych po stronie frontendu.
|
| 3 |
+
* Implementuje prosty mechanizm TTL oraz synchronizację z localStorage.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { create } from "zustand";
|
| 7 |
+
|
| 8 |
+
type CacheEntry<T = any> = {
|
| 9 |
+
data: T;
|
| 10 |
+
ts: number;
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
type NewsCacheState = {
|
| 14 |
+
cache: Record<string, CacheEntry>;
|
| 15 |
+
ttlMs: number;
|
| 16 |
+
get: <T = any>(key: string) => T | null;
|
| 17 |
+
set: (key: string, data: any) => void;
|
| 18 |
+
del: (key: string) => void;
|
| 19 |
+
clear: () => void;
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const TTL_5_MIN = 5 * 60 * 1000;
|
| 23 |
+
|
| 24 |
+
const storageKey = "truthscan_news_cache_v1";
|
| 25 |
+
|
| 26 |
+
// Odczyt cache z localStorage (jeśli dostępny)
|
| 27 |
+
function loadFromStorage(): Record<string, CacheEntry> {
|
| 28 |
+
try {
|
| 29 |
+
const raw = localStorage.getItem(storageKey);
|
| 30 |
+
if (!raw) return {};
|
| 31 |
+
return JSON.parse(raw);
|
| 32 |
+
} catch {
|
| 33 |
+
return {};
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
// Zapis cache do localStorage
|
| 37 |
+
function saveToStorage(cache: Record<string, CacheEntry>) {
|
| 38 |
+
try {
|
| 39 |
+
localStorage.setItem(storageKey, JSON.stringify(cache));
|
| 40 |
+
} catch {
|
| 41 |
+
// ignorujemy błędy zapisu (np. quota exceeded)v
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export const useNewsCache = create<NewsCacheState>((set, get) => ({
|
| 46 |
+
cache: typeof window !== "undefined" ? loadFromStorage() : {},
|
| 47 |
+
ttlMs: TTL_5_MIN,
|
| 48 |
+
|
| 49 |
+
get: <T = any>(key: string) => {
|
| 50 |
+
const { cache, ttlMs } = get();
|
| 51 |
+
const entry = cache[key];
|
| 52 |
+
if (!entry) return null;
|
| 53 |
+
const expired = Date.now() - entry.ts > ttlMs;
|
| 54 |
+
// Automatyczne wygaszanie wpisów cache (TTL)
|
| 55 |
+
if (expired) {
|
| 56 |
+
|
| 57 |
+
const next = { ...cache };
|
| 58 |
+
delete next[key];
|
| 59 |
+
set({ cache: next });
|
| 60 |
+
saveToStorage(next);
|
| 61 |
+
return null;
|
| 62 |
+
}
|
| 63 |
+
return entry.data as T;
|
| 64 |
+
},
|
| 65 |
+
|
| 66 |
+
set: (key: string, data: any) => {
|
| 67 |
+
const { cache } = get();
|
| 68 |
+
const next = { ...cache, [key]: { data, ts: Date.now() } };
|
| 69 |
+
set({ cache: next });
|
| 70 |
+
saveToStorage(next);
|
| 71 |
+
},
|
| 72 |
+
|
| 73 |
+
del: (key: string) => {
|
| 74 |
+
const { cache } = get();
|
| 75 |
+
const next = { ...cache };
|
| 76 |
+
delete next[key];
|
| 77 |
+
set({ cache: next });
|
| 78 |
+
saveToStorage(next);
|
| 79 |
+
},
|
| 80 |
+
|
| 81 |
+
clear: () => {
|
| 82 |
+
set({ cache: {} });
|
| 83 |
+
saveToStorage({});
|
| 84 |
+
},
|
| 85 |
+
}));
|
| 86 |
+
|
| 87 |
+
// helper: klucz cache dla /news
|
| 88 |
+
export const newsKey = (source: string, lang = "pl") => `news:${source}:${lang}`;
|
| 89 |
+
// helper: klucz cache dla /emotion-stats
|
| 90 |
+
export const statsKey = (source: string) => `stats:${source}`;
|
TruthScan AI_frontend/components/ArticleCard.tsx
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Komponent prezentujący pojedynczy artykuł informacyjny.
|
| 3 |
+
*
|
| 4 |
+
* Odpowiada za:
|
| 5 |
+
* - wyświetlanie metadanych artykułu (tytuł, źródło, data, sentyment),
|
| 6 |
+
* - obsługę akcji użytkownika (zapis, usunięcie, eksport do PDF),
|
| 7 |
+
* - integrację z hookami pomocniczymi (sentyment, favicona, treść artykułu),
|
| 8 |
+
* - renderowanie w trybie pełnym lub kompaktowym.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
"use client";
|
| 12 |
+
|
| 13 |
+
import React, { useState } from "react";
|
| 14 |
+
import { Article } from "../lib/fetchNews";
|
| 15 |
+
import { useArticleContent } from "../app/hooks/useArticleContent";
|
| 16 |
+
import { useFavicon } from "../app/hooks/useFavicon";
|
| 17 |
+
import { useSentiment } from "../app/hooks/useSentiment";
|
| 18 |
+
import PDFModal from "./PDFModal";
|
| 19 |
+
import type { Lang } from "../lib/types";
|
| 20 |
+
|
| 21 |
+
interface ArticleCardProps {
|
| 22 |
+
article: Article;
|
| 23 |
+
language: Lang;
|
| 24 |
+
locales: any;
|
| 25 |
+
onDelete?: (title: string) => void;
|
| 26 |
+
savedView?: boolean;
|
| 27 |
+
variant?: "full" | "compact";
|
| 28 |
+
onSave?: (article: Article) => void;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export default function ArticleCard({
|
| 32 |
+
article,
|
| 33 |
+
language,
|
| 34 |
+
locales,
|
| 35 |
+
onDelete,
|
| 36 |
+
savedView = false,
|
| 37 |
+
variant = "full",
|
| 38 |
+
onSave,
|
| 39 |
+
}: ArticleCardProps) {
|
| 40 |
+
const { label: sentimentLabel, color: sentimentColor } = useSentiment(article.sentiment || "", language);
|
| 41 |
+
const { faviconSrc, sourceInitial, hasFavicon, nextFavicon } = useFavicon(article);
|
| 42 |
+
const { fetchFullContent } = useArticleContent();
|
| 43 |
+
const [isExporting, setIsExporting] = useState(false);
|
| 44 |
+
const [isLoadingContent, setIsLoadingContent] = useState(false);
|
| 45 |
+
const [fullContent, setFullContent] = useState<string>("");
|
| 46 |
+
const [showPDF, setShowPDF] = useState(false);
|
| 47 |
+
const isCompact = variant === "compact";
|
| 48 |
+
const t = locales[language] ?? locales.pl;
|
| 49 |
+
|
| 50 |
+
const T = (pl: string, no: string, en: string) =>
|
| 51 |
+
language === "pl" ? pl : language === "no" ? no : en;
|
| 52 |
+
|
| 53 |
+
const handleExportPDF = async () => {
|
| 54 |
+
if (isExporting) return;
|
| 55 |
+
|
| 56 |
+
setIsExporting(true);
|
| 57 |
+
try {
|
| 58 |
+
let contentToExport = article.summary || article.description || "";
|
| 59 |
+
|
| 60 |
+
if (article.link && article.link.startsWith('http') && !fullContent) {
|
| 61 |
+
setIsLoadingContent(true);
|
| 62 |
+
try {
|
| 63 |
+
const fetchedContent = await fetchFullContent(article.link);
|
| 64 |
+
setFullContent(fetchedContent);
|
| 65 |
+
} catch (error) {
|
| 66 |
+
console.log("ℹ️ Używam dostępnej treści");
|
| 67 |
+
} finally {
|
| 68 |
+
setIsLoadingContent(false);
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
setShowPDF(true);
|
| 73 |
+
} catch (error) {
|
| 74 |
+
console.error("❌ Błąd przygotowania PDF:", error);
|
| 75 |
+
alert(T("Błąd podczas przygotowywania PDF", "Feil ved forberedelse av PDF", "Error preparing PDF"));
|
| 76 |
+
} finally {
|
| 77 |
+
setIsExporting(false);
|
| 78 |
+
}
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
const handlePDFClose = () => setShowPDF(false);
|
| 82 |
+
const handlePDFExportStart = () => console.log("🟡 Eksport PDF rozpoczęty");
|
| 83 |
+
const handlePDFExportEnd = () => {
|
| 84 |
+
console.log("🟢 Eksport PDF zakończony");
|
| 85 |
+
handlePDFClose();
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
const renderHeader = () => (
|
| 89 |
+
<div className="flex items-start gap-3 mb-2">
|
| 90 |
+
{hasFavicon ? (
|
| 91 |
+
<img
|
| 92 |
+
src={faviconSrc}
|
| 93 |
+
alt={`${article.source} favicon`}
|
| 94 |
+
width={24}
|
| 95 |
+
height={24}
|
| 96 |
+
className="w-6 h-6 rounded-sm mt-1 ring-1 ring-black/10 dark:ring-white/10 bg-white object-contain"
|
| 97 |
+
referrerPolicy="no-referrer"
|
| 98 |
+
onError={nextFavicon}
|
| 99 |
+
/>
|
| 100 |
+
) : (
|
| 101 |
+
<div className="w-6 h-6 rounded-sm mt-1 bg-gray-200 dark:bg-gray-700 grid place-items-center text-xs font-semibold">
|
| 102 |
+
{sourceInitial}
|
| 103 |
+
</div>
|
| 104 |
+
)}
|
| 105 |
+
|
| 106 |
+
<h2 className={[
|
| 107 |
+
"font-bold text-blue-600 dark:text-blue-400",
|
| 108 |
+
isCompact ? "text-base leading-snug" : "text-lg"
|
| 109 |
+
].join(" ")}>
|
| 110 |
+
{article.title}
|
| 111 |
+
</h2>
|
| 112 |
+
</div>
|
| 113 |
+
);
|
| 114 |
+
|
| 115 |
+
const renderDescription = () => (
|
| 116 |
+
<p className={[
|
| 117 |
+
"text-gray-700 dark:text-gray-300",
|
| 118 |
+
isCompact ? "text-sm line-clamp-3 mb-2" : "mb-3"
|
| 119 |
+
].join(" ")}>
|
| 120 |
+
{article.summary || article.description ||
|
| 121 |
+
T("Brak opisu", "Ingen beskrivelse", "No description")}
|
| 122 |
+
</p>
|
| 123 |
+
);
|
| 124 |
+
|
| 125 |
+
const renderMetadata = () => (
|
| 126 |
+
<div className={[
|
| 127 |
+
"text-gray-500 dark:text-gray-400",
|
| 128 |
+
isCompact ? "text-xs space-y-0.5 mb-2" : "text-sm space-y-1 mb-3"
|
| 129 |
+
].join(" ")}>
|
| 130 |
+
<p>
|
| 131 |
+
🕒 {T("Data", "Dato", "Date")}:{" "}
|
| 132 |
+
<time suppressHydrationWarning>
|
| 133 |
+
{article.published || T("Brak daty", "Ingen dato", "No date")}
|
| 134 |
+
</time>
|
| 135 |
+
</p>
|
| 136 |
+
<p>📰 {T("Źródło", "Kilde", "Source")}: {article.source || "-"}</p>
|
| 137 |
+
<p>
|
| 138 |
+
💬 {T("Sentyment", "Sentimentanalyse", "Sentiment")}:{" "}
|
| 139 |
+
<span className={sentimentColor}>{sentimentLabel}</span>
|
| 140 |
+
</p>
|
| 141 |
+
<p>
|
| 142 |
+
❓ {T("Prawdopodobieństwo Fake News", "Sannsynlighet for falske nyheter", "Fake News Probability")}:{" "}
|
| 143 |
+
{typeof article.fake_probability === "number"
|
| 144 |
+
? `${article.fake_probability.toFixed(2)}%`
|
| 145 |
+
: T("Brak danych", "Ingen data", "No data")}
|
| 146 |
+
</p>
|
| 147 |
+
</div>
|
| 148 |
+
);
|
| 149 |
+
|
| 150 |
+
const renderActions = () => (
|
| 151 |
+
<div className={[
|
| 152 |
+
"flex items-center gap-6 pt-2 border-t border-gray-300 dark:border-gray-700",
|
| 153 |
+
isCompact ? "mt-2" : "mt-3"
|
| 154 |
+
].join(" ")}>
|
| 155 |
+
{article.link && (
|
| 156 |
+
<a
|
| 157 |
+
href={article.link.startsWith("http") ? article.link : `https://${article.link}`}
|
| 158 |
+
target="_blank"
|
| 159 |
+
rel="noopener noreferrer"
|
| 160 |
+
className="text-blue-500 hover:text-blue-400 font-medium transition"
|
| 161 |
+
>
|
| 162 |
+
🔗 {T("Pokaż artykuł", "Vis artikkel", "View article")}
|
| 163 |
+
</a>
|
| 164 |
+
)}
|
| 165 |
+
|
| 166 |
+
{savedView && (
|
| 167 |
+
<button
|
| 168 |
+
onClick={handleExportPDF}
|
| 169 |
+
disabled={isExporting || isLoadingContent}
|
| 170 |
+
className={`text-purple-600 hover:text-purple-500 font-medium transition ${
|
| 171 |
+
(isExporting || isLoadingContent) ? 'opacity-50 cursor-not-allowed' : ''
|
| 172 |
+
}`}
|
| 173 |
+
>
|
| 174 |
+
{isLoadingContent ? '⏳' : isExporting ? '📄' : '📄'}
|
| 175 |
+
{isLoadingContent
|
| 176 |
+
? T(" Pobieranie...", " Laster inn...", " Loading...")
|
| 177 |
+
: T(" Eksportuj PDF", " Eksporter til PDF", " Export PDF")
|
| 178 |
+
}
|
| 179 |
+
</button>
|
| 180 |
+
)}
|
| 181 |
+
|
| 182 |
+
{savedView ? (
|
| 183 |
+
<button
|
| 184 |
+
onClick={() => {
|
| 185 |
+
if (confirm(T(
|
| 186 |
+
"Czy na pewno chcesz usunąć ten artykuł?",
|
| 187 |
+
"Er du sikker på at du vil slette denne artikkelen?",
|
| 188 |
+
"Are you sure you want to delete this article?"
|
| 189 |
+
))) {
|
| 190 |
+
onDelete?.(article.title);
|
| 191 |
+
}
|
| 192 |
+
}}
|
| 193 |
+
className="text-red-500 hover:text-red-400 font-medium transition"
|
| 194 |
+
>
|
| 195 |
+
🗑️ {T("Usuń", "Slett", "Delete")}
|
| 196 |
+
</button>
|
| 197 |
+
) : onSave ? (
|
| 198 |
+
<button
|
| 199 |
+
onClick={() => onSave(article)}
|
| 200 |
+
className="text-green-600 hover:text-green-500 font-medium transition"
|
| 201 |
+
>
|
| 202 |
+
💾 {T("Zapisz", "Lagre", "Save")}
|
| 203 |
+
</button>
|
| 204 |
+
) : null}
|
| 205 |
+
</div>
|
| 206 |
+
);
|
| 207 |
+
|
| 208 |
+
return (
|
| 209 |
+
<>
|
| 210 |
+
<article className={[
|
| 211 |
+
"rounded-2xl border bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 transition-all duration-300",
|
| 212 |
+
isCompact ? "p-4 shadow-sm hover:shadow-md" : "p-5 shadow-md hover:shadow-lg hover:-translate-y-0.5"
|
| 213 |
+
].join(" ")}>
|
| 214 |
+
{renderHeader()}
|
| 215 |
+
{renderDescription()}
|
| 216 |
+
{renderMetadata()}
|
| 217 |
+
{renderActions()}
|
| 218 |
+
</article>
|
| 219 |
+
|
| 220 |
+
<PDFModal
|
| 221 |
+
article={article}
|
| 222 |
+
language={language}
|
| 223 |
+
sentimentLabel={sentimentLabel}
|
| 224 |
+
sentimentColor={sentimentColor}
|
| 225 |
+
fullContent={fullContent}
|
| 226 |
+
isLoadingContent={isLoadingContent}
|
| 227 |
+
showPDF={showPDF}
|
| 228 |
+
onClose={handlePDFClose}
|
| 229 |
+
onExportStart={handlePDFExportStart}
|
| 230 |
+
onExportEnd={handlePDFExportEnd}
|
| 231 |
+
/>
|
| 232 |
+
</>
|
| 233 |
+
);
|
| 234 |
+
}
|
TruthScan AI_frontend/components/ArticleList.tsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Komponent prezentujący pojedynczy artykuł informacyjny.
|
| 3 |
+
*
|
| 4 |
+
* Odpowiada za:
|
| 5 |
+
* - wyświetlanie metadanych artykułu (tytuł, źródło, data, sentyment),
|
| 6 |
+
* - obsługę akcji użytkownika (zapis, usunięcie, eksport do PDF),
|
| 7 |
+
* - integrację z hookami pomocniczymi (sentyment, favicona, treść artykułu),
|
| 8 |
+
* - renderowanie w trybie pełnym lub kompaktowym.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
"use client";
|
| 12 |
+
|
| 13 |
+
import React from "react";
|
| 14 |
+
import ArticleCard from "./ArticleCard";
|
| 15 |
+
import locales from "../lib/locales";
|
| 16 |
+
import { Article } from "../lib/fetchNews";
|
| 17 |
+
import type { Lang } from "../lib/types";
|
| 18 |
+
|
| 19 |
+
type ArticleListProps = {
|
| 20 |
+
articles: Article[];
|
| 21 |
+
language: Lang;
|
| 22 |
+
locales: typeof locales;
|
| 23 |
+
savedView?: boolean;
|
| 24 |
+
onDelete?: (title: string) => void;
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
export default function ArticleList({
|
| 28 |
+
articles,
|
| 29 |
+
language,
|
| 30 |
+
locales,
|
| 31 |
+
savedView = false,
|
| 32 |
+
onDelete,
|
| 33 |
+
}: ArticleListProps) {
|
| 34 |
+
if (!articles || articles.length === 0) {
|
| 35 |
+
return (
|
| 36 |
+
<p className="text-gray-500">
|
| 37 |
+
{language === "pl" ? "Brak artykułów do wyświetlenia." : language === "no" ? "Ingen artikler å vise." : "No articles to display."}
|
| 38 |
+
</p>
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<div className="space-y-4">
|
| 44 |
+
{articles.map((article, idx) => (
|
| 45 |
+
<ArticleCard
|
| 46 |
+
key={idx}
|
| 47 |
+
article={article}
|
| 48 |
+
language={language}
|
| 49 |
+
locales={locales}
|
| 50 |
+
savedView={savedView}
|
| 51 |
+
onDelete={onDelete ? () => onDelete(article.title) : undefined}
|
| 52 |
+
/>
|
| 53 |
+
))}
|
| 54 |
+
</div>
|
| 55 |
+
);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
|
TruthScan AI_frontend/components/DarkModeToggle.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Komponent przełącznika trybu jasnego i ciemnego (dark mode).
|
| 3 |
+
*
|
| 4 |
+
* Odpowiada za:
|
| 5 |
+
* - zarządzanie stanem trybu kolorystycznego interfejsu,
|
| 6 |
+
* - synchronizację ustawienia z localStorage,
|
| 7 |
+
* - dynamiczne dodawanie klasy `dark` do elementu <html>.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
"use client";
|
| 11 |
+
|
| 12 |
+
import { useEffect, useState } from "react";
|
| 13 |
+
import { Moon, Sun } from "lucide-react";
|
| 14 |
+
|
| 15 |
+
export default function DarkModeToggle() {
|
| 16 |
+
const [darkMode, setDarkMode] = useState<boolean>(false);
|
| 17 |
+
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
const saved = localStorage.getItem("darkMode");
|
| 20 |
+
if (saved === "true") {
|
| 21 |
+
setDarkMode(true);
|
| 22 |
+
document.documentElement.classList.add("dark");
|
| 23 |
+
}
|
| 24 |
+
}, []);
|
| 25 |
+
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
if (darkMode) {
|
| 28 |
+
document.documentElement.classList.add("dark");
|
| 29 |
+
} else {
|
| 30 |
+
document.documentElement.classList.remove("dark");
|
| 31 |
+
}
|
| 32 |
+
localStorage.setItem("darkMode", String(darkMode));
|
| 33 |
+
}, [darkMode]);
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<button
|
| 37 |
+
onClick={() => setDarkMode(!darkMode)}
|
| 38 |
+
className="p-2 rounded-md bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
| 39 |
+
aria-label={darkMode ? "Przełącz na tryb jasny" : "Przełącz na tryb ciemny"}
|
| 40 |
+
title={darkMode ? "Tryb jasny" : "Tryb ciemny"}
|
| 41 |
+
>
|
| 42 |
+
{darkMode ? (
|
| 43 |
+
<Sun className="w-5 h-5 text-yellow-400" />
|
| 44 |
+
) : (
|
| 45 |
+
<Moon className="w-5 h-5 text-gray-800 dark:text-white" />
|
| 46 |
+
)}
|
| 47 |
+
</button>
|
| 48 |
+
);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
|
TruthScan AI_frontend/components/EmotionalPieChart.tsx
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Komponent wizualizujący rozkład emocji w artykułach w formie wykresu kołowego.
|
| 3 |
+
*
|
| 4 |
+
* Odpowiada za:
|
| 5 |
+
* - normalizację i agregację danych sentymentu,
|
| 6 |
+
* - tłumaczenie etykiet w zależności od języka interfejsu,
|
| 7 |
+
* - prezentację danych z wykorzystaniem biblioteki Recharts,
|
| 8 |
+
* - obsługę tooltipów i etykiet procentowych.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
"use client";
|
| 12 |
+
|
| 13 |
+
import { useMemo } from "react";
|
| 14 |
+
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts";
|
| 15 |
+
import { useLanguage } from "../app/hooks/useLanguage";
|
| 16 |
+
import locales from "../lib/locales";
|
| 17 |
+
|
| 18 |
+
interface EmotionalPieChartProps {
|
| 19 |
+
data: { name: string; value: number }[];
|
| 20 |
+
title?: string;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
type Canonical = "POSITIVE" | "NEGATIVE" | "NEUTRAL";
|
| 24 |
+
|
| 25 |
+
const EMOTION_COLORS = {
|
| 26 |
+
POSITIVE: "#22c55e", // green
|
| 27 |
+
NEGATIVE: "#ef4444", // red
|
| 28 |
+
NEUTRAL: "#3b82f6", // blue
|
| 29 |
+
} as const;
|
| 30 |
+
|
| 31 |
+
const EMOTION_LABELS = {
|
| 32 |
+
pl: {
|
| 33 |
+
POSITIVE: "Pozytywne",
|
| 34 |
+
NEGATIVE: "Negatywne",
|
| 35 |
+
NEUTRAL: "Neutralne",
|
| 36 |
+
},
|
| 37 |
+
en: {
|
| 38 |
+
POSITIVE: "Positive",
|
| 39 |
+
NEGATIVE: "Negative",
|
| 40 |
+
NEUTRAL: "Neutral",
|
| 41 |
+
},
|
| 42 |
+
no: {
|
| 43 |
+
POSITIVE: "Positivt",
|
| 44 |
+
NEGATIVE: "Negativt",
|
| 45 |
+
NEUTRAL: "Nøytralt",
|
| 46 |
+
},
|
| 47 |
+
} as const;
|
| 48 |
+
|
| 49 |
+
export default function EmotionalPieChart({ data, title }: EmotionalPieChartProps) {
|
| 50 |
+
const { language } = useLanguage();
|
| 51 |
+
const t = locales[language];
|
| 52 |
+
|
| 53 |
+
const toCanonical = (raw: string): Canonical | null => {
|
| 54 |
+
const u = (raw || "").trim().toUpperCase();
|
| 55 |
+
if (u === "POSITIVE" || u === "POZYTYWNE") return "POSITIVE";
|
| 56 |
+
if (u === "NEGATIVE" || u === "NEGATYWNE") return "NEGATIVE";
|
| 57 |
+
if (u === "NEUTRAL" || u === "NEUTRALNE") return "NEUTRAL";
|
| 58 |
+
return null;
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
const translatedData = useMemo(() => {
|
| 62 |
+
const initialData: Record<Canonical, number> = {
|
| 63 |
+
POSITIVE: 0, NEGATIVE: 0, NEUTRAL: 0
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
for (const item of data) {
|
| 67 |
+
const c = toCanonical(item.name);
|
| 68 |
+
if (!c) continue;
|
| 69 |
+
initialData[c] = (initialData[c] ?? 0) + (item.value ?? 0);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
return (["POSITIVE", "NEUTRAL", "NEGATIVE"] as Canonical[])
|
| 73 |
+
.map(canonical => ({
|
| 74 |
+
canonical,
|
| 75 |
+
name: EMOTION_LABELS[language][canonical],
|
| 76 |
+
value: initialData[canonical],
|
| 77 |
+
color: EMOTION_COLORS[canonical]
|
| 78 |
+
}))
|
| 79 |
+
.filter(item => item.value > 0);
|
| 80 |
+
}, [data, language]);
|
| 81 |
+
|
| 82 |
+
const totalValue = useMemo(() =>
|
| 83 |
+
translatedData.reduce((sum, item) => sum + item.value, 0),
|
| 84 |
+
[translatedData]
|
| 85 |
+
);
|
| 86 |
+
|
| 87 |
+
const RAD = Math.PI / 180;
|
| 88 |
+
|
| 89 |
+
const renderLabel = ({ cx, cy, midAngle, outerRadius, name, value, payload }: any) => {
|
| 90 |
+
const r = (outerRadius ?? 0) + 14;
|
| 91 |
+
const x = cx + r * Math.cos(-midAngle * RAD);
|
| 92 |
+
const y = cy + r * Math.sin(-midAngle * RAD);
|
| 93 |
+
const anchor: "start" | "end" = x > cx ? "start" : "end";
|
| 94 |
+
|
| 95 |
+
const fill = EMOTION_COLORS[(payload?.canonical as Canonical) ?? "NEUTRAL"];
|
| 96 |
+
return (
|
| 97 |
+
<text x={x} y={y} fill={fill} textAnchor={anchor} dominantBaseline="central"
|
| 98 |
+
className="text-[12px] select-none font-medium">
|
| 99 |
+
{`${name}: ${value}`}
|
| 100 |
+
</text>
|
| 101 |
+
);
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
const CustomTooltip = ({ active, payload }: any) => {
|
| 105 |
+
if (active && payload && payload.length) {
|
| 106 |
+
const data = payload[0].payload;
|
| 107 |
+
const percentage = totalValue > 0 ? (data.value / totalValue) * 100 : 0;
|
| 108 |
+
|
| 109 |
+
return (
|
| 110 |
+
<div className="bg-gray-900 dark:bg-gray-800 p-3 rounded-lg border border-gray-700 shadow-lg">
|
| 111 |
+
<p className="text-white font-medium">{data.name}</p>
|
| 112 |
+
<p className="text-gray-300">
|
| 113 |
+
{language === "pl" ? "Liczba artykułów: " : language === "no" ? "Antall artikler: " : "Number of articles: "}
|
| 114 |
+
<span className="text-white font-semibold">{data.value}</span>
|
| 115 |
+
</p>
|
| 116 |
+
<p className="text-gray-300">
|
| 117 |
+
{language === "pl" ? "Procent: " : language === "no" ? "Prosent: " : "Percentage: "}
|
| 118 |
+
<span className="text-white font-semibold">{percentage.toFixed(1)}%</span>
|
| 119 |
+
</p>
|
| 120 |
+
</div>
|
| 121 |
+
);
|
| 122 |
+
}
|
| 123 |
+
return null;
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
+
if (translatedData.length === 0) {
|
| 127 |
+
return (
|
| 128 |
+
<div className="w-full">
|
| 129 |
+
<h3 className="text-m font-semibold mb-2 text-gray-800 dark:text-gray-100">
|
| 130 |
+
{title || t.emotionsInArticles}
|
| 131 |
+
</h3>
|
| 132 |
+
<div className="h-64 flex items-center justify-center border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
| 133 |
+
<p className="text-gray-500 dark:text-gray-400 text-center">
|
| 134 |
+
{language === "pl" ? "Brak danych do wyświetlenia" : language === "no" ? "Ingen data å vise" : "No data to display"}
|
| 135 |
+
</p>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
return (
|
| 142 |
+
<div className="w-full">
|
| 143 |
+
<h3 className="text-m font-semibold mb-2 text-gray-800 dark:text-gray-100">
|
| 144 |
+
{title || t.emotionsInArticles}
|
| 145 |
+
</h3>
|
| 146 |
+
|
| 147 |
+
<div className="h-64">
|
| 148 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 149 |
+
<PieChart margin={{ top: 8, right: 28, left: 28, bottom: 0 }}>
|
| 150 |
+
<Pie
|
| 151 |
+
data={translatedData}
|
| 152 |
+
dataKey="value"
|
| 153 |
+
nameKey="name"
|
| 154 |
+
cx="50%"
|
| 155 |
+
cy="50%"
|
| 156 |
+
outerRadius="70%"
|
| 157 |
+
label={renderLabel}
|
| 158 |
+
labelLine={false}
|
| 159 |
+
isAnimationActive={true}
|
| 160 |
+
animationDuration={500}
|
| 161 |
+
>
|
| 162 |
+
{translatedData.map((item, idx) => (
|
| 163 |
+
<Cell key={idx} fill={item.color} stroke="#1f2937" strokeWidth={1} />
|
| 164 |
+
))}
|
| 165 |
+
</Pie>
|
| 166 |
+
|
| 167 |
+
<Tooltip content={<CustomTooltip />} />
|
| 168 |
+
|
| 169 |
+
<Legend
|
| 170 |
+
verticalAlign="bottom"
|
| 171 |
+
height={36}
|
| 172 |
+
formatter={(value, entry: any) => (
|
| 173 |
+
<span style={{ color: entry.color, fontSize: '12px' }}>{value}</span>
|
| 174 |
+
)}
|
| 175 |
+
wrapperStyle={{ paddingTop: '10px' }}
|
| 176 |
+
/>
|
| 177 |
+
</PieChart>
|
| 178 |
+
</ResponsiveContainer>
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
<p className="text-s text-center text-gray-500 dark:text-gray-400 mt-4">
|
| 182 |
+
{language === "pl"
|
| 183 |
+
? "Wykres pokazuje rozkład emocji w analizowanych artykułach."
|
| 184 |
+
: language === "no"
|
| 185 |
+
? "Diagrammet viser fordelingen av følelser i analyserte artikler."
|
| 186 |
+
: "This chart shows the distribution of detected emotions in articles."}
|
| 187 |
+
</p>
|
| 188 |
+
</div>
|
| 189 |
+
);
|
| 190 |
+
}
|