Add backend, frontend, and project files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
92022903ad
commit
2533f75f2c
15
.claude/settings.local.json
Executable file
15
.claude/settings.local.json
Executable 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
8
.gitignore
vendored
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
factchecker.db
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
90
README.md
90
README.md
@ -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
563
backend/test_app.py
Executable 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
123
backend/view_db.py
Executable 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
24
frontend/.gitignore
vendored
Executable 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
16
frontend/README.md
Executable 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
29
frontend/eslint.config.js
Executable 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
13
frontend/index.html
Executable 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
3156
frontend/package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Executable file
29
frontend/package.json
Executable 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
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
1
frontend/public/vite.svg
Executable 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
903
frontend/src/App.css
Executable 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
21
frontend/src/App.jsx
Executable 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
1
frontend/src/assets/react.svg
Executable 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 |
60
frontend/src/components/Layout.jsx
Executable file
60
frontend/src/components/Layout.jsx
Executable 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
10
frontend/src/index.css
Executable 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
10
frontend/src/main.jsx
Executable 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
227
frontend/src/pages/About.jsx
Executable 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 = ∑ P(entailment)</code></p>
|
||||||
|
<p><code>Skóre_Nepravda = ∑ 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 > 0.5</code> → <strong>✅ Pravdepodobne pravda</strong></li>
|
||||||
|
<li>Ak <code>Skóre_Nepravda / Celkové_Skóre > 0.5</code> → <strong>❌ Pravdepodobne nepravda</strong></li>
|
||||||
|
<li>Inak → <strong>⚠️ Nejednoznačné</strong> (ak sú 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 má 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
274
frontend/src/pages/Home.jsx
Executable 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
15
frontend/vite.config.js
Executable 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user