Add backend, frontend, and project files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Džubara 2026-03-26 15:27:31 +01:00
parent 92022903ad
commit 2533f75f2c
22 changed files with 5588 additions and 0 deletions

15
.claude/settings.local.json Executable file
View File

@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(dir C:UsersviliaOneDrivePočítač*thesis*)",
"Bash(dir:*)",
"Bash(source venv/Scripts/activate)",
"Bash(python -m pytest test_app.py -v)",
"Bash(python -c \"import flask; import transformers; import torch; print\\(''Dependencies OK''\\)\")",
"Bash(curl -s http://localhost:5000/api/stats)",
"Bash(curl:*)",
"Bash(python -c \":*)",
"Bash(python view_db.py)"
]
}
}

8
.gitignore vendored Executable file
View File

@ -0,0 +1,8 @@
venv/
.venv/
__pycache__/
*.pyc
.env
factchecker.db
node_modules/
dist/

View File

@ -0,0 +1,90 @@
# README.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
AI Fact Checker - a full-stack web application that verifies claims using Natural Language Inference (NLI) models. The system searches the web via SerpAPI, analyzes snippets against claims, and returns verdicts (True/False/Ambiguous) with evidence.
## Tech Stack
**Backend (Python/Flask):**
- Flask REST API with CORS
- ML: HuggingFace Transformers (RoBERTa, mDeBERTa-v3)
- Translation: deep_translator (Google Translate)
- Database: SQLite with caching layer
- Search: SerpAPI for Google search results
**Frontend (React/Vite):**
- React 19 with Vite 7
- React Router for navigation
- Axios for HTTP requests
- CSS custom properties for theming
## Architecture
```
factchecker/
├── backend/
│ ├── app.py # Flask API, NLI logic, model switching
│ ├── database.py # SQLite cache, verified facts, stats
│ ├── .env # SERPAPI_API_KEY (required)
│ └── venv/ # Python virtual environment
├── frontend/
│ ├── src/
│ │ ├── App.jsx # Router setup
│ │ ├── main.jsx # React entry point
│ │ ├── components/
│ │ │ └── Layout.jsx
│ │ └── pages/
│ │ ├── Home.jsx # Main fact-check UI
│ │ └── About.jsx
│ └── package.json
└── factchecker.db # SQLite database (auto-created)
```
## Key Commands
### Backend
```bash
cd backend
source venv/bin/activate # or: venv\Scripts\activate on Windows
python app.py # Start Flask on port 5000
python clear_cache.py # Clear cache
python view_db.py # View database contents
```
### Frontend
```bash
cd frontend
npm install
npm run dev # Vite dev server
npm run build # Production build
npm run lint # ESLint
```
## API Endpoints
- `POST /api/check` - Verify a claim (body: claim, language, dateFrom, dateTo, selectedSource, model)
- `GET /api/history` - Get check history (query: limit)
- `GET /api/stats` - Get database statistics
- `POST /api/admin/add-fact` - Add manually verified fact (admin)
## Models
Dva NLI modely dostupné s rôznymi компромismi:
1. **RoBERTa** (`ynie/roberta-large-snli_mnli_fever_anli_R1_R2_R3-nli`) - Rýchly, vyžaduje preklad do angličtiny
2. **mDeBERTa** (`MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7`) - Najlepší pre slovenčinu, multilingválny
## Database Schema
- `fact_checks` - Cached results with claim hash, verdict, NLI votes, evidence, sources
- `verified_facts` - Manually verified facts (admin-added)
## Important Notes
- The `.env` file contains the SerpAPI key - do not commit
- Models are loaded on-demand; switching triggers reload with cache clearing
- Slovak claims are auto-translated for RoBERTa; mDeBERTa handles Slovak natively
- Domain whitelist/blacklist filters search results for quality
- Results are cached by claim hash to avoid redundant API calls

563
backend/test_app.py Executable file
View File

@ -0,0 +1,563 @@
"""
Testy pre AI Fact Checker aplikáciu
Spustenie: python -m pytest test_app.py -v
Alebo: python test_app.py
"""
import pytest
import json
import os
import sys
from datetime import datetime, timedelta
# Nastavíme prostredie pre testovanie
os.environ['TESTING'] = 'True'
# Importujeme aplikáciu
from app import app, load_model, MODELS
from database import (
get_db_connection,
get_cached_result,
save_to_cache,
get_history,
get_stats,
add_verified_fact,
hash_claim,
init_db
)
# =============================================================================
# FIXTURES - Pomocné funkcie pre testy
# =============================================================================
@pytest.fixture
def client():
"""Vytvorí testovacieho klienta pre Flask aplikáciu"""
app.config['TESTING'] = True
with app.test_client() as client:
yield client
@pytest.fixture
def init_test_db():
"""Inicializuje čistú databázu pre testy"""
init_db()
yield
# Cleanup - vymažeme testovacie dáta
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM fact_checks")
cursor.execute("DELETE FROM verified_facts")
conn.commit()
conn.close()
# =============================================================================
# TESTY DATABÁZY
# =============================================================================
class TestDatabase:
"""Testy pre databázové operácie"""
def test_hash_claim_consistency(self):
"""Test: Hash toho istého výroku je vždy rovnaký"""
claim = "Bratislava je hlavné mesto Slovenska"
hash1 = hash_claim(claim)
hash2 = hash_claim(claim)
assert hash1 == hash2, "Hash by mal byť konzistentný"
def test_hash_claim_case_insensitive(self):
"""Test: Hash je case-insensitive"""
claim1 = "Bratislava je hlavné mesto Slovenska"
claim2 = "bratislava je hlavné mesto slovenska"
hash1 = hash_claim(claim1)
hash2 = hash_claim(claim2)
assert hash1 == hash2, "Hash by mal byť case-insensitive"
def test_hash_claim_different_claims(self):
"""Test: Rôzne výroky majú rôzny hash"""
claim1 = "Bratislava je hlavné mesto Slovenska"
claim2 = "Košice sú druhé najväčšie mesto"
hash1 = hash_claim(claim1)
hash2 = hash_claim(claim2)
assert hash1 != hash2, "Rôzne výroky by mali mať rôzny hash"
def test_save_and_get_cached_result(self, init_test_db):
"""Test: Uloženie a získanie cachovaného výsledku"""
claim = "Testovací výrok pre cache"
result = {
"verdict": "✅ Pravda",
"confidence": 0.85,
"nli_votes": {"entailment": 3, "contradiction": 1},
"evidence_for": ["Dôkaz 1"],
"evidence_against": [],
"sources": ["https://example.com"]
}
# Uložíme do cache
saved = save_to_cache(claim, result, model_name="roberta")
assert saved == True, "Uloženie do cache zlyhalo"
# Získame z cache
cached = get_cached_result(claim)
assert cached is not None, "Cache by nemala byť prázdna"
assert cached["verdict"] == "✅ Pravda"
assert cached["cached"] == True
assert cached["model_name"] == "roberta"
def test_cache_miss(self, init_test_db):
"""Test: Získanie neexistujúceho cachovaného výsledku"""
claim = "Tento výrok ešte nebol overený"
cached = get_cached_result(claim)
assert cached is None, "Cache by mala byť prázdna pre nový výrok"
def test_get_history(self, init_test_db):
"""Test: Získanie histórie overení"""
# Pridáme niekoľko záznamov
for i in range(5):
claim = f"Testovací výrok {i}"
result = {
"verdict": "✅ Pravda" if i % 2 == 0 else "❌ Nepravda",
"sources": [f"https://example{i}.com"]
}
save_to_cache(claim, result, model_name="roberta")
history = get_history(limit=10)
assert len(history) == 5, "História by mala obsahovať 5 záznamov"
def test_get_stats(self, init_test_db):
"""Test: Získanie štatistík"""
# Pridáme testovacie dáta
for i in range(3):
claim = f"Štatistický výrok {i}"
result = {"verdict": "✅ Pravda", "sources": []}
save_to_cache(claim, result, model_name="roberta")
# Pridáme manuálne overený fakt
add_verified_fact("Manuálne overený fakt", "TRUE", "Vysvetlenie")
stats = get_stats()
assert stats["unique_claims"] == 3
assert stats["verified_facts"] == 1
assert stats["total_checks"] >= 3
def test_add_verified_fact_duplicate(self, init_test_db):
"""Test: Pridanie duplicitného manuálne overeného faktu"""
claim = "Duplicitný fakt"
result1 = add_verified_fact(claim, "TRUE", "Prvé vysvetlenie")
result2 = add_verified_fact(claim, "FALSE", "Druhé vysvetlenie")
assert result1 == True, "Prvý fakt by sa mal pridať"
assert result2 == False, "Duplicitný fakt by sa nemal pridať"
# =============================================================================
# TESTY API ENDPOINTOV
# =============================================================================
class TestAPIEndpoints:
"""Testy pre REST API endpointy"""
def test_empty_claim_validation(self, client):
"""Test: Validácia prázdneho výroku"""
response = client.post('/api/check',
data=json.dumps({"claim": ""}),
content_type='application/json')
assert response.status_code == 400
data = json.loads(response.data)
assert "error" in data
def test_whitespace_only_claim(self, client):
"""Test: Validácia výroku s iba medzerami"""
response = client.post('/api/check',
data=json.dumps({"claim": " "}),
content_type='application/json')
assert response.status_code == 400
def test_missing_claim_field(self, client):
"""Test: Chýbajúce pole claim"""
response = client.post('/api/check',
data=json.dumps({"language": "sk"}),
content_type='application/json')
assert response.status_code == 400
def test_history_endpoint_empty(self, client, init_test_db):
"""Test: História keď je prázdna"""
response = client.get('/api/history')
assert response.status_code == 200
data = json.loads(response.data)
assert "history" in data
assert "count" in data
assert data["count"] == 0
def test_history_endpoint_with_data(self, client, init_test_db):
"""Test: História s dátami"""
# Pridáme dáta cez cache
for i in range(3):
claim = f"História test {i}"
result = {"verdict": "✅ Pravda", "sources": []}
save_to_cache(claim, result, model_name="roberta")
response = client.get('/api/history?limit=10')
assert response.status_code == 200
data = json.loads(response.data)
assert data["count"] == 3
def test_stats_endpoint(self, client):
"""Test: Štatistiky endpoint"""
response = client.get('/api/stats')
assert response.status_code == 200
data = json.loads(response.data)
assert "unique_claims" in data
assert "total_checks" in data
assert "verified_facts" in data
def test_admin_add_fact_success(self, client, init_test_db):
"""Test: Pridanie manuálne overeného faktu (admin)"""
response = client.post('/api/admin/add-fact',
data=json.dumps({
"claim": "Manuálne pridaný fakt",
"verdict": "TRUE",
"explanation": "Toto je vysvetlenie",
"source_url": "https://overenie.sk"
}),
content_type='application/json')
assert response.status_code == 200
data = json.loads(response.data)
assert "message" in data
assert data["message"] == "Overený fakt pridaný"
def test_admin_add_fact_missing_fields(self, client):
"""Test: Chýbajúce povinné polia pre admin endpoint"""
response = client.post('/api/admin/add-fact',
data=json.dumps({
"claim": "Neúplný fakt"
# Chýba verdict
}),
content_type='application/json')
assert response.status_code == 400
def test_admin_add_fact_duplicate(self, client, init_test_db):
"""Test: Pridanie duplicitného faktu"""
# Prvýkrát úspech
response1 = client.post('/api/admin/add-fact',
data=json.dumps({
"claim": "Duplicitný admin fakt",
"verdict": "TRUE"
}),
content_type='application/json')
assert response1.status_code == 200
# Druhýkrát chyba (duplicate)
response2 = client.post('/api/admin/add-fact',
data=json.dumps({
"claim": "Duplicitný admin fakt",
"verdict": "FALSE"
}),
content_type='application/json')
assert response2.status_code == 409
# =============================================================================
# TESTY VALIDÁCIE VSTUPU
# =============================================================================
class TestInputValidation:
"""Testy pre validáciu vstupov a filtre"""
def test_forbidden_word_politician(self, client):
"""Test: Zakázané slová - politici"""
response = client.post('/api/check',
data=json.dumps({
"claim": "Fico je politik"
}),
content_type='application/json')
assert response.status_code == 400
data = json.loads(response.data)
assert "forbidden_words" in data
def test_forbidden_word_vulgar(self, client):
"""Test: Zakázané slová - vulgárnosti"""
response = client.post('/api/check',
data=json.dumps({
"claim": "Toto je kokotina"
}),
content_type='application/json')
assert response.status_code == 400
def test_forbidden_word_covid(self, client):
"""Test: Zakázané slová - citlivé témy"""
response = client.post('/api/check',
data=json.dumps({
"claim": "Vakcína spôsobuje neplodnosť"
}),
content_type='application/json')
assert response.status_code == 400
def test_forbidden_word_english(self, client):
"""Test: Zakázané slová - anglické výrazy"""
response = client.post('/api/check',
data=json.dumps({
"claim": "This is bullshit"
}),
content_type='application/json')
assert response.status_code == 400
def test_clean_claim_passes(self, client):
"""Test: Čistý výrok prejde"""
# Tento test vyžaduje funkčný SerpAPI kľúč
# Ak nie je nastavený, vráti 500
if not os.getenv('SERPAPI_API_KEY'):
pytest.skip("SERPAPI_API_KEY nie je nastavený")
response = client.post('/api/check',
data=json.dumps({
"claim": "Bratislava je hlavné mesto Slovenska"
}),
content_type='application/json')
# Môže byť 200 (úspech) alebo 429 (limit API)
assert response.status_code in [200, 429]
# =============================================================================
# TESTY MODELOV
# =============================================================================
class TestModels:
"""Testy pre AI modely"""
def test_model_config_exists(self):
"""Test: Konfigurácia modelov existuje"""
assert "roberta" in MODELS
assert "mdeberta" in MODELS
def test_roberta_config(self):
"""Test: RoBERTa konfigurácia"""
config = MODELS["roberta"]
assert config["needs_translation"] == True
assert "roberta" in config["name"].lower()
def test_mdeberta_config(self):
"""Test: mDeBERTa konfigurácia"""
config = MODELS["mdeberta"]
assert config["needs_translation"] == False
assert "mdeberta" in config["name"].lower() or "DeBERTa" in config["name"]
def test_model_loading(self):
"""Test: Načítanie modelu"""
# Testujeme že funkcia load_model existuje a nespôsobí chybu
try:
load_model("roberta")
assert True
except Exception as e:
pytest.fail(f"Načítanie modelu zlyhalo: {e}")
# =============================================================================
# TESTY CACHE LOGIKY
# =============================================================================
class TestCacheLogic:
"""Testy pre cachovaciu logiku"""
def test_cache_increments_count(self, init_test_db):
"""Test: Cache inkrementuje počet overení"""
claim = "Inkrementácia test"
result = {"verdict": "✅ Pravda", "sources": []}
# Prvé uloženie
save_to_cache(claim, result, model_name="roberta")
cached1 = get_cached_result(claim)
count1 = cached1["check_count"]
# Druhé získanie (inkrementuje)
cached2 = get_cached_result(claim)
count2 = cached2["check_count"]
assert count2 == count1 + 1, "Počet overení by sa mal inkrementovať"
def test_cache_updates_timestamp(self, init_test_db):
"""Test: Cache aktualizuje časovú pečiatku"""
claim = "Timestamp test"
result = {"verdict": "✅ Pravda", "sources": []}
save_to_cache(claim, result, model_name="roberta")
cached1 = get_cached_result(claim)
timestamp1 = cached1["checked_at"]
import time
time.sleep(1) # Počkáme sekundu
cached2 = get_cached_result(claim)
timestamp2 = cached2["checked_at"]
# Timestamp by mal byť novší
assert timestamp2 >= timestamp1
def test_cache_serialization(self, init_test_db):
"""Test: Serializácia JSON polí v cache"""
claim = "JSON serializácia test"
result = {
"verdict": "✅ Pravda",
"nli_votes": {"entailment": 0.8, "contradiction": 0.2},
"evidence_for": [{"text": "Dôkaz 1", "confidence": 0.9}],
"evidence_against": [],
"sources": [{"url": "https://example.com", "label": "entailment"}]
}
save_to_cache(claim, result, model_name="mdeberta")
cached = get_cached_result(claim)
assert isinstance(cached["nli_votes"], dict)
assert isinstance(cached["evidence_for"], list)
assert isinstance(cached["evidence_against"], list)
# =============================================================================
# INTEGRÁCNE TESTY
# =============================================================================
class TestIntegration:
"""Integračné testy celého systému"""
def test_full_request_cycle(self, client, init_test_db):
"""Test: Celý cyklus požiadavky (bez SerpAPI)"""
# Testujeme validáciu a štruktúru odpovede
# Bez SerpAPI kľúča očakávame chybu 500
response = client.post('/api/check',
data=json.dumps({
"claim": "Testovací výrok",
"language": "sk",
"model": "roberta"
}),
content_type='application/json')
# Bez SerpAPI: 500, so SerpAPI: 200 alebo 429
if os.getenv('SERPAPI_API_KEY'):
assert response.status_code in [200, 429]
else:
assert response.status_code == 500
def test_model_switching(self, client):
"""Test: Prepínanie medzi modelmi"""
# Overíme že endpoint akceptuje parameter model
response = client.post('/api/check',
data=json.dumps({
"claim": "", # Prázdne pre rýchly test
"model": "mdeberta"
}),
content_type='application/json')
# Očakávame 400 (validation error) nie 500 (model error)
assert response.status_code == 400
def test_verified_fact_overrides_cache(self, client, init_test_db):
"""Test: Manuálne overený fakt má prioritu pred cache"""
claim = "Prioritný fakt"
# Najprv pridáme manuálne overený fakt
add_verified_fact(claim, "TRUE", "Oficiálne overené", "https://overenie.sk")
# Potom pridáme do cache iný výsledok
cache_result = {
"verdict": "❌ Nepravda",
"sources": ["https://ine-zdroj.sk"]
}
save_to_cache(claim, cache_result, model_name="roberta")
# Získanie by malo vrátiť manuálne overený fakt
cached = get_cached_result(claim)
assert cached is not None
assert cached["verified"] == True
assert "Overené" in cached["verdict"]
# =============================================================================
# HRANIČNÉ PRÍPADY
# =============================================================================
class TestEdgeCases:
"""Testy hraničných prípadov"""
def test_very_long_claim(self, client):
"""Test: Veľmi dlhý výrok"""
long_claim = "A" * 10000 # 10k znakov
response = client.post('/api/check',
data=json.dumps({"claim": long_claim}),
content_type='application/json')
# Aplikácia by mala zvládnuť dlhý vstup (vráti "nedostatok zdrojov" alebo spracuje)
assert response.status_code == 200 # Očakávame úspešné spracovanie
data = json.loads(response.data)
assert "verdict" in data # Mala by vrátiť verdikt
def test_unicode_claim(self, client):
"""Test: Výrok s Unicode znakmi"""
unicode_claim = "🌍 je guľatá 🌍"
response = client.post('/api/check',
data=json.dumps({"claim": unicode_claim}),
content_type='application/json')
# Aplikácia by mala správne spracovať Unicode (emoji, diakritika)
assert response.status_code == 200
data = json.loads(response.data)
assert "verdict" in data # Mala by vrátiť verdikt
def test_sql_injection_attempt(self, client):
"""Test: Pokus o SQL injection"""
malicious_claim = "'; DROP TABLE fact_checks; --"
response = client.post('/api/check',
data=json.dumps({"claim": malicious_claim}),
content_type='application/json')
# Aplikácia by mala bezpečne spracovať vstup (parametrované dotazy)
assert response.status_code == 200 # Nemalo by to zhodiť server
# Overíme že databáza stále existuje
from database import get_stats
stats = get_stats() # Ak toto prejde, SQL injection zlyhal
assert stats is not None
def test_special_characters_claim(self, client):
"""Test: Výrok so špeciálnymi znakmi"""
special_claim = "Cena je 100€ (zľava 50%!) <script>alert('x')</script>"
response = client.post('/api/check',
data=json.dumps({"claim": special_claim}),
content_type='application/json')
assert response.status_code in [400, 500, 429]
def test_empty_json_body(self, client):
"""Test: Prázdne JSON telo"""
response = client.post('/api/check',
data=json.dumps({}),
content_type='application/json')
assert response.status_code == 400
def test_invalid_json(self, client):
"""Test: Neplatné JSON"""
response = client.post('/api/check',
data="nie je json",
content_type='application/json')
assert response.status_code == 400
def test_history_limit_zero(self, client, init_test_db):
"""Test: História s limitom 0"""
# Pridáme dáta
save_to_cache("Test", {"verdict": "", "sources": []}, "roberta")
response = client.get('/api/history?limit=0')
data = json.loads(response.data)
assert data["count"] == 0
assert len(data["history"]) == 0
def test_history_large_limit(self, client, init_test_db):
"""Test: História s veľkým limitom"""
response = client.get('/api/history?limit=999999')
assert response.status_code == 200
# =============================================================================
# SPUSTENIE TESTOV
# =============================================================================
if __name__ == '__main__':
# Spustenie priamym zavolaním: python test_app.py
pytest.main([__file__, '-v', '--tb=short'])

123
backend/view_db.py Executable file
View File

@ -0,0 +1,123 @@
"""
Jednoduchý skript na prezeranie databázy factchecker.db
"""
import sqlite3
import json
from datetime import datetime
DB_NAME = "factchecker.db"
def print_separator():
print("=" * 80)
def view_fact_checks():
"""Zobrazí všetky fact-checky"""
conn = sqlite3.connect(DB_NAME)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('SELECT * FROM fact_checks ORDER BY checked_at DESC')
rows = cursor.fetchall()
print_separator()
print(f"📊 FACT CHECKS (celkom: {len(rows)})")
print_separator()
for row in rows:
print(f"\n🔍 ID: {row['id']}")
print(f" Tvrdenie: {row['claim']}")
print(f" Verdikt: {row['verdict']}")
# Zobraziť model (ak existuje stĺpec model_name)
try:
model = row['model_name']
if model:
print(f" Model: {model}")
else:
print(f" Model: neznámy (starý záznam)")
except IndexError:
pass # Stĺpec neexistuje v starej databáze
print(f" Počet kontrol: {row['check_count']}")
print(f" Posledná kontrola: {row['checked_at']}")
if row['nli_votes']:
votes = json.loads(row['nli_votes'])
print(f" NLI hlasy: {votes}")
if row['sources']:
sources = json.loads(row['sources'])
if sources:
print(f" Zdroje: {sources[0] if sources else 'žiadne'}")
print()
conn.close()
def view_verified_facts():
"""Zobrazí manuálne overené fakty"""
conn = sqlite3.connect(DB_NAME)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('SELECT * FROM verified_facts ORDER BY added_at DESC')
rows = cursor.fetchall()
print_separator()
print(f"✅ OVERENÉ FAKTY (celkom: {len(rows)})")
print_separator()
if not rows:
print("\n⚠️ Žiadne manuálne overené fakty\n")
else:
for row in rows:
print(f"\n✓ ID: {row['id']}")
print(f" Tvrdenie: {row['claim']}")
print(f" Verdikt: {row['verdict']}")
if row['explanation']:
print(f" Vysvetlenie: {row['explanation']}")
if row['source_url']:
print(f" Zdroj: {row['source_url']}")
print(f" Pridané: {row['added_at']} ({row['added_by']})")
print()
conn.close()
def view_stats():
"""Zobrazí štatistiky"""
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
cursor.execute('SELECT COUNT(*) as total FROM fact_checks')
total_claims = cursor.fetchone()[0]
cursor.execute('SELECT SUM(check_count) as total FROM fact_checks')
total_checks = cursor.fetchone()[0] or 0
cursor.execute('SELECT COUNT(*) as total FROM verified_facts')
verified = cursor.fetchone()[0]
print_separator()
print("📈 ŠTATISTIKY")
print_separator()
print(f" Unikátne tvrdenia: {total_claims}")
print(f" Celkový počet kontrol: {total_checks}")
print(f" Overené fakty: {verified}")
print_separator()
print()
conn.close()
def main():
print("\n" + "🔎 DATABÁZA FACT-CHECKERA".center(80))
try:
view_stats()
view_fact_checks()
view_verified_facts()
except sqlite3.OperationalError as e:
print(f"❌ Chyba: {e}")
print(" Skontroluj či existuje factchecker.db")
if __name__ == "__main__":
main()

24
frontend/.gitignore vendored Executable file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

16
frontend/README.md Executable file
View File

@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

29
frontend/eslint.config.js Executable file
View File

@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
frontend/index.html Executable file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3156
frontend/package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

29
frontend/package.json Executable file
View File

@ -0,0 +1,29 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^6.30.3"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"vite": "^7.2.2"
}
}

BIN
frontend/public/KEMT_logo.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

1
frontend/public/vite.svg Executable file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

903
frontend/src/App.css Executable file
View File

@ -0,0 +1,903 @@
:root{
--kemt-orange:#ffa900;
--kemt-blue:#0459b6;
--text-dark:#05263d;
--muted:#657786;
--bg:#f6f8fb;
--card:#ffffff;
--max-w:1100px;
}
.container{
max-width:var(--max-w);
margin:0 auto;
padding:0 1rem;
}
.site-header{
background:white;
border-bottom:1px solid #e6edf6;
}
.header-inner{
max-width:var(--max-w);
margin:0 auto;
padding:14px 0;
display:flex;
justify-content:space-between;
align-items:center;
}
.brand{
display:flex;
align-items:center;
gap:12px;
}
.logo{
height:64px;
}
.brand-text h1{
margin:0;
font-size:1.25rem;
color:var(--text-dark);
}
.brand-text .muted{
margin:0;
font-size:0.9rem;
color:#444;
}
.nav-bar{
background:var(--kemt-orange);
}
.nav-inner{
max-width:var(--max-w);
margin:0 auto;
padding:0.6rem 1rem;
}
.main-nav{
display:flex;
gap:1.5rem;
}
.main-nav a{
color:white;
text-decoration:none;
font-weight:600;
text-transform:uppercase;
letter-spacing:0.3px;
padding:6px 0;
}
.main-nav .btn-outline{
padding:6px 12px;
background:transparent;
border:1px solid rgba(255,255,255,0.7);
border-radius:6px;
}
.main-nav a:hover{opacity:0.9;}
.main-nav .btn-outline:hover{background:rgba(255,255,255,0.15);}
.mobile-menu{
display:none;
font-size:28px;
background:transparent;
border:0;
cursor:pointer;
}
.hero {
padding: 4rem 0;
/* Gradient adjusted to match "About" page density */
background: linear-gradient(180deg, var(--kemt-orange) 0%, #ffffff 35%);
min-height: 80vh;
}
.hero-inner {
max-width: var(--max-w);
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 3rem;
}
.hero-content.centered {
flex: 1;
width: 100%;
max-width: 800px;
text-align: center;
}
.hero-content h2 {
font-size: 2.5rem;
margin-bottom: 1rem;
color: var(--text-dark);
letter-spacing: -0.5px;
}
.hero-content .lead {
font-size: 1.2rem;
max-width: 600px;
margin: 0 auto 2.5rem auto;
}
/* ABOUT PAGE HEADER */
.about-hero {
padding: 4rem 0 2rem 0;
background: linear-gradient(180deg, var(--kemt-orange) 0%, #ffffff 100%);
text-align: center;
margin-bottom: 0;
}
.about-hero-content {
max-width: 800px;
margin: 0 auto;
padding: 0 1rem;
}
.about-hero h2 {
font-size: 2.5rem;
margin-bottom: 1rem;
color: var(--text-dark);
}
.about-hero p {
font-size: 1.2rem;
line-height: 1.6;
color: var(--text-dark);
}
/* FORM STYLES REFRESH */
.claim-form.shadow-depth {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.08); /* Deep shadow */
border: 2px solid #ffa900; /* Visible border */
text-align: left;
position: relative;
z-index: 10;
}
textarea#claim-input {
width: 100%;
padding: 16px;
font-size: 1.1rem;
border: 1px solid var(--kemt-blue);
border-radius: 8px;
resize: vertical;
font-family: inherit;
margin-bottom: 1rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
background: #fcfcfc;
}
textarea#claim-input:focus {
border-color: var(--kemt-blue);
box-shadow: 0 0 0 4px rgba(4,89,182, 0.1);
background: white;
}
.form-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
flex-wrap: wrap;
gap: 1rem;
}
/* Advanced Filters & Buttons */
.btn-text {
background: none;
border: none;
color: var(--muted);
font-weight: 500;
cursor: pointer;
padding: 6px 12px;
font-size: 0.9rem;
border: var(--kemt-blue) 1px solid;
border-radius: 6px;
transition: all 0.2s;
background-color: #f1f3f5;
}
.btn-text:hover {
background-color: #e9ecef;
color: var(--kemt-blue);
}
.advanced-wrapper {
max-height: 0;
overflow: hidden;
opacity: 0;
transform: translateY(-10px);
transition: max-height 0.4s ease-in-out, opacity 0.3s ease, transform 0.3s ease;
margin-top: 0;
}
.advanced-wrapper.open {
max-height: 500px; /* Adjust if content is very long */
opacity: 1;
transform: translateY(0);
margin-top: 1.5rem;
}
.advanced-panel {
margin: 0 -2rem -2rem -2rem;
padding: 1.5rem 2rem 2rem 2rem;
background-color: #f1f5f9; /* Light contrast background */
border-top: 1px solid #e2e8f0;
border-radius: 0 0 12px 12px;
}
.filter-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.25rem;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: left;
}
.filter-group label {
font-size: 0.8rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-left: 2px;
}
.filter-group select,
.filter-group input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--kemt-blue);
border-radius: 8px;
font-size: 0.95rem;
background-color: #f8fafc;
color: var(--text-color);
transition: all 0.2s ease;
outline: none;
font-family: inherit;
}
.filter-group select:hover,
.filter-group input:hover {
border-color: #cbd5e1;
}
.filter-group select:focus,
.filter-group input:focus {
border-color: var(--kemt-blue);
background-color: white;
box-shadow: 0 0 0 3px rgba(4, 89, 182, 0.1);
}
/* Custom Calendar Icon fix for inputs */
.filter-group input[type="date"]::-webkit-calendar-picker-indicator {
opacity: 0.6;
cursor: pointer;
transition: opacity 0.2s;
}
.filter-group input[type="date"]::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
.filter-actions {
margin-top: 1.5rem;
display: flex;
justify-content: flex-end;
}
.btn-link {
background: white;
border: 1px solid #e03131;
padding: 8px 18px;
color: #e03131;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-primary-lg {
background-color: var(--kemt-blue);
color: white;
padding: 12px 32px;
font-size: 1.05rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(4, 89, 182, 0.3);
}
.btn-primary-lg:hover {
background-color: #034691;
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(4, 89, 182, 0.4);
}
.btn-primary-lg:disabled {
background-color: #a0bfe0;
cursor: wait;
transform: none;
}
/* HOW IT WORKS STEPS NEW */
.how-it-works-row {
display: flex;
justify-content: center;
align-items: center;
border: var(--kemt-orange) 2px solid;
gap: 1rem;
margin-top: 1rem;
width: 100%;
max-width: 900px;
flex-wrap: wrap;
}
.step-card {
flex: 1;
min-width: 140px;
text-align: center;
padding: 1rem;
}
.step-num {
background: white;
color: var(--kemt-blue);
font-weight: 800;
width: 36px;
height: 36px;
line-height: 36px;
border-radius: 50%;
margin: 0 auto 0.5rem auto;
border: 2px solid var(--kemt-blue);
font-size: 1.1rem;
}
.step-card h4 {
margin: 0.5rem 0 0.25rem 0;
font-size: 1rem;
color: var(--text-dark);
font-weight: 700;
}
.step-card p {
font-size: 0.85rem;
color: var(--muted);
font-weight: 500;
margin: 0;
line-height: 1.4;
}
.step-arrow {
color: var(--muted);
font-size: 1.5rem;
opacity: 0.5;
}
@media (max-width: 768px) {
.step-arrow { display: none; }
.how-it-works-row { flex-direction: column; gap: 2rem; }
}
/* RESULTS REDESIGN */
.result-card {
margin-top: 2rem;
background: white;
border-radius: 12px;
padding: 2rem;
text-align: left;
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
border-left: 6px solid #ccc;
animation: fadeIn 0.5s ease-out;
}
.verdict-success { border-left-color: #2e7d32; background: #f1f8f1; }
.verdict-danger { border-left-color: #d32f2f; background: #fff5f5; }
.verdict-warning { border-left-color: #ed6c02; background: #fff8e1; }
.verdict-main {
font-size: 1.5rem;
font-weight: bold;
margin: 1rem 0 1.5rem 0;
color: var(--text-dark);
}
.verdict-success .verdict-main { color: #1b5e20; }
.verdict-danger .verdict-main { color: #c62828; }
.verdict-warning .verdict-main { color: #e65100; }
.sources-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.source-item {
display: flex;
align-items: center;
gap: 10px;
background: white;
padding: 10px 15px;
border-radius: 6px;
border: 1px solid rgba(0,0,0,0.08);
text-decoration: none;
color: var(--text-dark);
transition: hover 0.2s;
}
.source-item:hover {
border-color: var(--kemt-blue);
background: #fbfdff;
}
.source-icon { font-size: 1.2rem; }
.source-url { font-weight: 500; color: var(--kemt-blue); }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.card{
background:white;
padding:16px;
border-radius:8px;
border:1px solid #e6edf6;
}
.features{
margin-top:1.5rem;
padding-bottom:2rem;
}
.grid{
display:grid;
grid-template-columns:repeat(auto-fit,minmax(260px,1fr));
gap:1rem;
}
.feature{
background:white;
padding:18px;
border-radius:8px;
border:1px solid #e6edf6;
}
.site-footer{
border-top:1px solid #e6edf6;
margin-top:2rem;
padding:14px 0;
}
.footer-inner{
max-width:var(--max-w);
margin:0 auto;
display:flex;
justify-content:space-between;
align-items:center;
}
.tiny-nav a{
margin-left:1rem;
color:var(--kemt-blue);
text-decoration:none;
}
@media (max-width:900px){
.hero-inner{
flex-direction:column;
}
.hero-aside{
flex:none;
max-width:100%;
width:100%;
}
}
@media (max-width:700px){
.mobile-menu{
display:block;
color:var(--kemt-blue);
}
.main-nav{
display:none;
flex-direction:column;
}
.main-nav.open{
display:flex;
background:var(--kemt-blue);
position:absolute;
left:0;
right:0;
top:110px;
padding:1rem;
z-index:1000;
}
.main-nav.open a{
padding:10px 0;
border-bottom:1px solid rgba(255,255,255,0.15);
}
}
/* About page explanation styles */
.explanation-block, .features-section {
margin-top: 3rem;
margin-bottom: 3rem;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.about-context-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
margin-bottom: 3rem;
}
.context-card {
background: transparent;
border-left: 4px solid var(--kemt-orange);
padding: 1rem 1.5rem;
}
.context-card h4 {
color: var(--text-dark);
margin-top: 0;
margin-bottom: 0.8rem;
font-size: 1.15rem;
}
.context-card p {
margin: 0;
font-size: 0.95rem;
line-height: 1.6;
color: #444;
}
.feature-card {
background: #fcfdfe;
padding: 2rem;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
text-align: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
border: 1px solid #e1e8ed;
}
.feature-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 30px rgba(0,0,0,0.1);
background: white;
}
.feature-icon {
font-size: 3rem;
margin-bottom: 1rem;
background: #edf5fc;
width: 80px;
height: 80px;
line-height: 80px;
border-radius: 50%;
margin-left: auto;
margin-right: auto;
}
.feature-card h4 {
color: var(--text-dark);
margin: 0.5rem 0;
font-size: 1.3rem;
}
.feature-card p {
color: var(--muted);
font-size: 0.95rem;
line-height: 1.5;
}
/* Steps bubbles */
.steps-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-top: 1.5rem;
}
@media (min-width: 768px) {
.steps-container {
flex-direction: row;
align-items: stretch;
}
}
.step.bubble-step {
flex: 1;
background: #f8fafc;
padding: 2rem;
border-radius: 20px; /* Bubble look */
border: 2px solid #eef3f7;
position: relative;
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
transition: all 0.2s ease;
}
.step-number {
background: var(--kemt-orange);
color: white;
font-weight: bold;
font-size: 1.5rem;
width: 50px;
height: 50px;
line-height: 50px;
border-radius: 50%;
margin-bottom: 1rem;
box-shadow: 0 4px 10px rgba(255, 169, 0, 0.3);
}
.step-content h4 {
color: var(--kemt-blue);
font-size: 1.2rem;
margin-bottom: 1rem;
border-bottom: 2px solid transparent;
padding-bottom: 0.5rem;
display: inline-block;
}
.step.bubble-step:hover {
border-color: var(--kemt-orange);
background: white;
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(0,0,0,0.08);
}
/* Accordion / Detailed Info Section */
.detailed-info-section {
margin-top: 4rem;
text-align: center;
}
.accordion-btn {
background: white;
border: 2px solid var(--kemt-blue);
color: var(--kemt-blue);
padding: 12px 24px;
font-size: 1rem;
font-weight: 600;
border-radius: 30px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 10px;
transition: all 0.3s ease;
}
.accordion-btn:hover, .accordion-btn.active {
background: var(--kemt-blue);
color: white;
}
.accordion-icon {
font-size: 1.2rem;
line-height: 1;
}
.detailed-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.4s ease-out, opacity 0.4s ease;
opacity: 0;
margin-top: 1rem;
text-align: left;
}
.detailed-content.expanded {
max-height: 2000px; /* arbitrary large value */
opacity: 1;
transition: max-height 0.8s ease-in, opacity 0.4s ease;
}
.detail-inner {
background: #f1f8ff;
padding: 2.5rem;
border-radius: 16px;
border-left: 5px solid var(--kemt-blue);
}
.detail-inner h3 {
margin-top: 0;
color: var(--text-dark);
}
.detail-inner h4 {
color: var(--kemt-blue);
margin-top: 1.5rem;
margin-bottom: 0.5rem;
font-size: 1.1rem;
}
.detail-inner code {
background: rgba(0,0,0,0.05);
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
color: #cc0000;
}
.math-block {
background: white;
padding: 1rem;
border-radius: 8px;
border: 1px solid #ddd;
margin: 1rem 0;
font-family: monospace;
}
.math-block p {
margin: 0.5rem 0;
color: #333;
}
.limitations-list li {
margin-bottom: 0.8rem;
padding-left: 0.5rem;
}
.limitations-list strong {
color: #d32f2f; /* Red standard for limitations/warnings */
}
/* Source Badges */
.source-content {
display: flex;
align-items: center;
gap: 10px;
}
.source-badge {
margin-left: auto;
font-size: 0.8em;
padding: 4px 10px;
border-radius: 12px;
font-weight: 600;
white-space: nowrap;
}
.badge-success {
background-color: #e6f4ea;
color: #1e7e34;
border: 1px solid #cce8d0;
}
.badge-danger {
background-color: #fce8e6;
color: #d93025;
border: 1px solid #fad2cf;
}
.badge-neutral {
background-color: #f1f3f4;
color: #5f6368;
border: 1px solid #dcdcdc;
}
/* Model Selector Buttons */
.model-selector-container {
padding: 0 0 1rem 0;
}
.model-options {
display: flex;
gap: 12px;
justify-content: space-between;
}
.model-option {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 10px;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
position: relative;
user-select: none;
min-width: 100px; /* Ensure they don't shrink too much */
}
.model-option:hover {
background-color: #e2e6ea;
border-color: #ced4da;
transform: translateY(-2px);
}
.model-option.active {
background-color: #ffffff;
border-color: #ffa900;
box-shadow: 0 4px 10px rgba(255, 169, 0, 0.15);
z-index: 2;
}
.model-option .model-name {
font-weight: 700;
color: var(--text-dark);
font-size: 0.95rem;
margin-bottom: 2px;
display: block;
}
.model-option .model-desc {
font-size: 0.75rem;
color: #6c757d;
letter-spacing: 0.02em;
display: block;
}
.model-option.active .model-name {
color: #ffa900;
}
.model-option.active::after {
display: none; /* No checkmark needed if highlighted well */
}
@media (max-width: 600px) {
.model-options {
flex-direction: column;
gap: 8px;
}
.model-option {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
text-align: left;
}
.model-option .model-name { margin-bottom: 0; }
}

21
frontend/src/App.jsx Executable file
View File

@ -0,0 +1,21 @@
import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Layout from "./components/Layout";
import Home from "./pages/Home";
import About from "./pages/About";
import "./App.css";
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
</Route>
</Routes>
</Router>
);
}
export default App;

1
frontend/src/assets/react.svg Executable file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,60 @@
import React, { useState } from "react";
import { Link, Outlet } from "react-router-dom";
import "../App.css";
const Layout = () => {
const [menuOpen, setMenuOpen] = useState(false);
return (
<>
<header className="site-header">
<div className="container header-inner">
<div className="brand">
<img src="/KEMT_logo.jpg" alt="KEMT logo" className="logo" />
<div className="brand-text">
<h1>AI Fact Checker</h1>
<p className="muted">Demo KEMT</p>
</div>
</div>
<button
id="menu-toggle"
className={`mobile-menu ${menuOpen ? 'open' : ''}`}
aria-label="Otvoriť menu"
onClick={() => setMenuOpen(!menuOpen)}
>
</button>
</div>
</header>
<div className="nav-bar">
<div className="nav-inner">
<nav className={`main-nav ${menuOpen ? 'open' : ''}`} id="main-nav">
<a href="https://kps.fei.tuke.sk/" target="_blank" rel="noopener noreferrer">
Domov
</a>
<Link to="/">Fact-checker</Link>
<Link to="/about">O projekte</Link>
<a href="#contact" className="btn-outline">
Kontakt
</a>
</nav>
</div>
</div>
<Outlet />
<footer className="site-footer">
<div className="container footer-inner">
<p>© 2025 Fakulta elektrotechniky a informatiky Demo</p>
<nav className="tiny-nav">
<a href="#">Súkromie</a>
<a href="#">Kontakt</a>
</nav>
</div>
</footer>
</>
);
};
export default Layout;

10
frontend/src/index.css Executable file
View File

@ -0,0 +1,10 @@
*{box-sizing:border-box;}
body{
font-family:Inter, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
margin:0;
background:#f6f8fb;
color:#000;
line-height: 1.5;
}

10
frontend/src/main.jsx Executable file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

227
frontend/src/pages/About.jsx Executable file
View File

@ -0,0 +1,227 @@
import React, { useState } from "react";
import "../App.css";
const About = () => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<main>
<section className="about-hero">
<div className="about-hero-content">
<h2>O projekte</h2>
<p>
Tento fact-checker využíva pokročilé metódy umelej inteligencie na
overovanie pravdivosti tvrdení. Systém kombinuje vyhľadávanie
informácií v reálnom čase s analytickými schopnosťami neurónových
sietí.
</p>
</div>
</section>
<div className="container">
<div className="about-context-grid">
<div className="context-card">
<h4>O nástroji</h4>
<p>
Tento demo nástroj kombinuje model prirodzeného jazykového inferovania (NLI) s vyhľadávaním na webe. Zadáš tvrdenie, backend získa výsledky vyhľadávania, extrahuje relevantné úryvky a model vyhodnotí, či dôkazy tvrdenie podporujú, odporujú alebo nestačia.
</p>
</div>
<div className="context-card">
<h4>Prečo je to dôležité?</h4>
<p>
Fact-checking pomáha odhaľovať nepravdivé a zavádzajúce tvrdenia, ktoré sa šíria najmä na internete. Chráni ľudí pred manipuláciou, posilňuje dôveru v spoľahlivé zdroje a podporuje kritické myslenie.
</p>
</div>
<div className="context-card">
<h4>Open-source</h4>
<p>
Projekt využíva open-source technológie z oblasti NLP Flask, Transformers, Sentence-Transformers a verejné fact-checking datasety. Kód je voľne dostupný a pripravený na ďalšie vylepšovanie.
</p>
</div>
</div>
<div className="features-section">
<h3>Kľúčové funkcie</h3>
<div className="features-grid">
<div className="feature-card">
<div className="feature-icon"></div>
<h4>Rýchle overenie</h4>
<p>
Okamžitá analýza tvrdení pomocou moderných AI modelov a
vyhľadávania v reálnom čase.
</p>
</div>
<div className="feature-card">
<div className="feature-icon">🌍</div>
<h4>Podpora slovenčiny</h4>
<p>
Plne lokalizované rozhranie a schopnosť spracovať slovenské
tvrdenia s prekladom na pozadí.
</p>
</div>
<div className="feature-card">
<div className="feature-icon">🔍</div>
<h4>Transparentné zdroje</h4>
<p>
Každé overenie obsahuje odkazy na použité články a zdroje, z
ktorých systém čerpal.
</p>
</div>
</div>
</div>
<div className="explanation-block">
<h3>Ako to funguje?</h3>
<p>Proces overovania sa skladá z troch hlavných krokov:</p>
<div className="steps-container">
<article className="step bubble-step">
<div className="step-number">1</div>
<div className="step-content">
<h4>Analýza a vyhľadávanie</h4>
<p>
Systém analyzuje text a vyhľadá relevantné články na internete.
Používa preklad do angličtiny pre lepšie výsledky a získanie
najnovších faktov.
</p>
</div>
</article>
<article className="step bubble-step">
<div className="step-number">2</div>
<div className="step-content">
<h4>Overovanie (NLI)</h4>
<p>
AI model (RoBERTa) porovnáva vaše tvrdenie s nájdenými textami.
Určuje, či dôkazy potvrdzujú alebo vyvracajú hypotézu.
</p>
</div>
</article>
<article className="step bubble-step">
<div className="step-number">3</div>
<div className="step-content">
<h4>Vyhodnotenie</h4>
<p>
Agregácia výsledkov z viacerých zdrojov. Systém vypočíta skóre
dôveryhodnosti a zobrazí finálny verdikt s dôkazmi.
</p>
</div>
</article>
</div>
</div>
<div className="detailed-info-section">
<button
className={`accordion-btn ${isExpanded ? 'active' : ''}`}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? 'Skryť technické detaily' : 'Zobraziť technické detaily (Viac o technológii)'}
<span className="accordion-icon">{isExpanded ? '' : '+'}</span>
</button>
<div className={`detailed-content ${isExpanded ? 'expanded' : ''}`}>
<div className="detail-inner">
<h3>Deep Dive: Architektúra systému</h3>
<h4>1. Získavanie informácií</h4>
<p>
Na rozdiel od generatívnych modelov (ako GPT), ktoré "halucinujú" odpovede zo svojej pamäte,
náš systém funguje na princípe <strong>Retrieval-Augmented Verification</strong>.
Najprv sa vykoná sémantický preklad dopytu do angličtiny (<code>GoogleTranslator</code>),
čím sa sprístupní globálna báza vedomostí. Následne sa cez SerpAPI vykoná cielené
vyhľadávanie relevantných úryvkov (snippetov) z dôveryhodných zdrojov.
</p>
<h4>2. NLI Klasifikátor (Natural Language Inference)</h4>
<p>
Srdcom systému je model <code>unie/roberta-large-snli_mnli_fever_anli_R1_R2_R3-nli</code>.
Ide o model architektúry Transformer (RoBERTa Large), ktorý bol jemne doladený (fine-tuned)
na masívnych datasetoch pre úlohu NLI (SNLI, MNLI, FEVER, ANLI).
</p>
<p>
Tento model nepredikuje ďalšie slovo, ale vykonáva <strong>troj-triednu klasifikáciu </strong>
pre dvojicu textov (Tvrdenie vs. Dôkaz):
</p>
<ul>
<li><strong>Entailment (Vyplýva):</strong> Dôkaz potvrdzuje tvrdenie (Pravda).</li>
<li><strong>Contradiction (Rozpor):</strong> Dôkaz je v priamom rozpore s tvrdením (Nepravda).</li>
<li><strong>Neutral (Neutrálne):</strong> Dôkaz s tvrdením nesúvisí alebo ho nepotvrdzuje.</li>
</ul>
<h4>3. Agregácia a Vyhodnotenie (Soft Scoring)</h4>
<p>
Webové vyhľadávanie vráti desiatky fragmentov, z ktorých niektoré môžu byť irelevantné.
Preto systém nepoužíva jednoduchú väčšinu ("koľko článkov súhlasí"), ale <strong>váženú agregáciu (Soft Scoring)</strong>.
Zohľadňuje sa miera istoty (Confidence Score) modelu ak model s vysokou istotou (napr. 99%) nájde rozpor v dôveryhodnom zdroji,
tento silný signál preváži nad množstvom neutrálnych výsledkov.
</p>
<p>
Finálne skóre sa počíta sčítaním pravdepodobností:
</p>
<div className="math-block">
<p><code>Skóre_Pravda = &sum; P(entailment)</code></p>
<p><code>Skóre_Nepravda = &sum; P(contradiction)</code></p>
<p><code>Celkové_Skóre = Skóre_Pravda + Skóre_Nepravda</code></p>
</div>
<p>
Verdikt sa určí na základe pomeru:
</p>
<ul>
<li>Ak <code>Skóre_Pravda / Celkové_Skóre &gt; 0.5</code> &rarr; <strong> Pravdepodobne pravda</strong></li>
<li>Ak <code>Skóre_Nepravda / Celkové_Skóre &gt; 0.5</code> &rarr; <strong> Pravdepodobne nepravda</strong></li>
<li>Inak &rarr; <strong> Nejednoznačné</strong> (ak dôkazy protichodné alebo slabé)</li>
</ul>
<h4>4. Transparentnosť a vysvetliteľnosť </h4>
<p>
Kľúčovou vlastnosťou systému je možnosť overiť si prácu AI. Toto nie je "Black Box".
Pri každom rozhodnutí systém zobrazí:
</p>
<ul>
<li><strong>Presné zdroje:</strong> URL odkazy na články, ktoré boli použité pre analýzu.</li>
<li><strong>Konkrétne úryvky:</strong> Text, ktorý AI našla a na základe ktorého rozhodla.</li>
<li><strong>Skóre istoty:</strong> Ako veľmi si je model istý svojím tvrdením.</li>
</ul>
<h4>5. Prečo je tento prístup spoľahlivejší než LLM (GPT)?</h4>
<p>
Bežné generatívne modely (LLMs) ako ChatGPT fungujú ako prediktor ďalšieho slova a často "halucinujú" (vymýšľajú si fakty).
Tento <strong>Fact-Checking Pipeline</strong> je robustnejší pretože:
</p>
<ul>
<li><strong>Nehalucinuje:</strong> Negeneruje text z pamäte, ale overuje ho voči reálnym zdrojom.</li>
<li><strong>Aktuálnosť:</strong> Pracuje s informáciami z dnešného dňa (cez Google Search), nie s dátami spred rokov.</li>
<li><strong>Objektivita:</strong> Model je trénovaný na logiku (Dôkaz vs. Tvrdenie), nie na kreatívne písanie.</li>
</ul>
<h4>6. Obmedzenia systému </h4>
<p>
Napriek pokročilej technológii systém svoje hranice a etické obmedzenia:
</p>
<ul className="limitations-list">
<li>
<strong>Zákaz politických tvrdení:</strong> Systém je navrhnutý ako nástroj na overovanie faktov, nie na politický boj.
Aplikácia obsahuje filter, ktorý automaticky <strong>odmieta tvrdenia týkajúce sa aktívnych politikov, politických strán
alebo citlivých spoločenských tém</strong> (napr. voľby), aby sa predišlo šíreniu zaujatosti alebo manipulácii.
</li>
<li>
<strong>Závislosť na zdrojoch:</strong> Ak Google nenájde k téme žiadne dôveryhodné články, systém nedokáže tvrdenie overiť
(vráti status "Neoveriteľné").
</li>
<li>
<strong>Jazyková bariéra:</strong> Hoci systém používa prekladač, pri veľmi špecifických slovenských slangových výrazoch
alebo lokálnych kauzách môže byť presnosť nižšia než pri globálnych témach.
</li>
</ul>
</div>
</div>
</div>
</div>
</main>
);
};
export default About;

274
frontend/src/pages/Home.jsx Executable file
View File

@ -0,0 +1,274 @@
import React, { useState } from "react";
import "../App.css";
function Home() {
const [claim, setClaim] = useState("");
// Advanced settings state
const [showAdvanced, setShowAdvanced] = useState(false);
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [language, setLanguage] = useState("all");
const [selectedSource, setSelectedSource] = useState("all");
const [model, setModel] = useState("roberta");
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false);
// Note: menuOpen state was moved to Layout.jsx
const handleCheck = async () => {
if (!claim.trim()) return;
setLoading(true);
setResult({ verdict: "Overujem…", sources: [] });
try {
const res = await fetch("http://localhost:5000/api/check", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
claim,
language,
dateFrom,
dateTo,
selectedSource,
model
}),
});
const data = await res.json();
// Spracuj chyby z backendu
if (!res.ok) {
setResult({
verdict: data.message || data.error || "Chyba servera",
sources: []
});
} else {
setResult({
verdict: data.verdict || "Neznámy výsledok",
sources: data.sources || [],
});
}
} catch (e) {
setResult({ verdict: "Backend nedostupný", sources: [] });
}
setLoading(false);
};
return (
<main>
<section className="hero">
<div className="container hero-inner">
<div className="hero-content centered">
<h2>Rýchle overenie tvrdení pomocou NLI</h2>
<p className="lead">
Zadaj výrok a systém prehľadá web, vyhodnotí dôkazy a vráti odhad - pravda, nepravda alebo neoveriteľné.
</p>
<form className="claim-form shadow-depth" onSubmit={(e) => e.preventDefault()}>
<div className="model-selector-container">
<div className="model-options">
<button
type="button"
className={`model-option ${model === 'roberta' ? 'active' : ''}`}
onClick={() => setModel('roberta')}
title="Základný model "
>
<span className="model-name">RoBERTa</span>
<span className="model-desc">Rýchly </span>
</button>
<button
type="button"
className={`model-option ${model === 'mdeberta' ? 'active' : ''}`}
onClick={() => setModel('mdeberta')}
title="Najlepší pre slovenčinu "
>
<span className="model-name">mDeBERTa</span>
<span className="model-desc">Najpresnejší </span>
</button>
</div>
</div>
<textarea
id="claim-input"
placeholder="Sem vložte výrok alebo tvrdenie, ktoré chcete overiť..."
value={claim}
onChange={(e) => setClaim(e.target.value)}
rows={4}
/>
<div className="form-footer">
<div className="advanced-toggle-wrapper">
<button
type="button"
className="btn-text toggle-advanced"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? "▲ Skryť filtre" : "⚙ Rozšírené nastavenia"}
</button>
</div>
<button id="check-btn" type="button" onClick={handleCheck} disabled={loading} className="btn-primary-lg">
{loading ? (
<span className="spinner"> Overujem...</span>
) : (
"Overiť tvrdenie"
)}
</button>
</div>
<div className={`advanced-wrapper ${showAdvanced ? 'open' : ''}`}>
<div className="advanced-panel">
<div className="filter-row">
<div className="filter-group">
<label>Dátum od:</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
</div>
<div className="filter-group">
<label>Dátum do:</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
<div className="filter-group">
<label>Jazyk:</label>
<select value={language} onChange={(e) => setLanguage(e.target.value)}>
<option value="all">Všetky jazyky</option>
<option value="en">Angličtina</option>
<option value="sk">Slovenčina</option>
</select>
</div>
</div>
<div className="filter-row">
<div className="filter-group full-width">
<label>Preferované zdroje:</label>
<select value={selectedSource} onChange={(e) => setSelectedSource(e.target.value)}>
<option value="all">Všetky zdroje</option>
<option value="demagog">Demagog.sk</option>
<option value="afp">AFP Fact Check</option>
<option value="reuters">Reuters</option>
</select>
</div>
</div>
<div className="filter-actions">
<button
type="button"
className="btn-link"
onClick={() => {
setDateFrom("");
setDateTo("");
setLanguage("all");
setSelectedSource("all");
}}
>
Resetovať filtre
</button>
</div>
</div>
</div>
</form>
{result && (
<div id="result" className={`result-card fade-in ${result.verdict.includes("Pravda") || result.verdict.includes("pravda") ? "verdict-success" : result.verdict.includes("Nepravda") || result.verdict.includes("nepravda") ? "verdict-danger" : "verdict-warning"}`}>
<h3>Výsledok analýzy</h3>
<div className="verdict-main">{result.verdict}</div>
<h4>Použité zdroje:</h4>
<div className="sources-list">
{result.sources.length > 0 ? result.sources.slice(0, 5).map((s, i) => {
const url = typeof s === 'string' ? s : s.url;
const isObject = typeof s === 'object';
let badge = null;
if (isObject) {
const entailment = s.entailment_prob || 0;
const contradiction = s.contradiction_prob || 0;
const neutral = s.neutral_prob || 0;
const entPct = Math.round(entailment * 100);
const conPct = Math.round(contradiction * 100);
const neuPct = Math.round(neutral * 100);
if (s.label === 'entailment') {
badge = <span className="source-badge badge-success">{entPct}% Pravda</span>;
} else if (s.label === 'contradiction') {
badge = <span className="source-badge badge-danger">{conPct}% Nepravda</span>;
} else {
badge = <span className="source-badge badge-neutral">Neutrálne ({neuPct}%)</span>;
}
}
return (
<a
key={i}
href={url}
target="_blank"
rel="noopener noreferrer"
className="source-item"
>
<div className="source-content">
<span className="source-icon"></span>
<span className="source-url">{new URL(url).hostname.replace('www.', '')}</span>
</div>
{badge}
</a>
);
}) : <p>Žiadne zdroje neboli nájdené.</p>}
</div>
</div>
)}
</div>
<div className="how-it-works-section">
<h3>Ako to funguje?</h3>
<div className="how-it-works-row">
<div className="step-card">
<div className="step-num">1</div>
<h4>Zadanie</h4>
<p>Zadajte výrok, ktorý chcete overiť</p>
</div>
<div className="step-arrow"></div>
<div className="step-card">
<div className="step-num">2</div>
<h4>Vyhľadávanie</h4>
<p>AI vyhľadá dôveryhodné zdroje</p>
</div>
<div className="step-arrow"></div>
<div className="step-card">
<div className="step-num">3</div>
<h4>Analýza</h4>
<p>Model porovná fakty s tvrdením</p>
</div>
<div className="step-arrow"></div>
<div className="step-card">
<div className="step-num">4</div>
<h4>Výsledok</h4>
<p>Získate verdikt a zdroje</p>
</div>
</div>
</div>
</div>
</section>
</main>
);
}
export default Home;

15
frontend/vite.config.js Executable file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
}
}
}
})