nordabiz/templates/admin/zopk_dashboard.html
Maciej Pienczyn cebe52f303 refactor: Rebranding i aktualizacja modelu AI
- Zmiana nazwy: "Norda Biznes Hub" → "Norda Biznes Partner"
- Aktualizacja modelu AI: Gemini 2.0 Flash → Gemini 3 Flash
- Zachowano historyczne odniesienia w timeline i dokumentacji

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:08:39 +01:00

3073 lines
107 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}ZOP Kaszubia - Panel Admina - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.admin-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
}
.admin-actions {
display: flex;
gap: var(--spacing-sm);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: var(--surface);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
cursor: pointer;
transition: var(--transition);
border: 2px solid transparent;
text-decoration: none;
display: block;
color: inherit;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.stat-card.info-only {
cursor: default;
opacity: 0.85;
}
.stat-card.info-only:hover {
transform: none;
box-shadow: var(--shadow);
}
.stat-card.active {
border-color: var(--primary);
background: var(--primary-light, #f0fdf4);
}
.stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--primary);
}
.stat-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.stat-card.warning .stat-value {
color: var(--warning);
}
.stat-card.success .stat-value {
color: var(--success);
}
.stat-card.danger .stat-value {
color: var(--danger);
}
/* Stats sections (two-row layout) */
.stats-section {
margin-bottom: var(--spacing-lg);
}
.stats-section-title {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--spacing-md);
}
.stats-section-title small {
font-weight: 400;
text-transform: none;
letter-spacing: normal;
opacity: 0.8;
}
.stats-grid-small {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-md);
max-width: 600px;
}
.filter-card {
cursor: pointer;
}
.filter-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
/* AI Action button */
.stat-card.ai-action {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
color: white;
border: none;
font-family: inherit;
}
.stat-card.ai-action .stat-value {
color: white;
}
.stat-card.ai-action .stat-label {
color: rgba(255,255,255,0.9);
}
.stat-card.ai-action:hover {
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
}
.stat-card.ai-action:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.stat-card.ai-action:disabled:hover {
box-shadow: var(--shadow);
}
/* AI evaluation result */
.ai-result {
background: #f3e8ff;
border: 1px solid #c4b5fd;
color: #6b21a8;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
font-size: var(--font-size-sm);
}
.ai-result.success {
background: #dcfce7;
border-color: #86efac;
color: #166534;
}
.ai-result.error {
background: #fee2e2;
border-color: #fca5a5;
color: #991b1b;
}
/* AI badge in news list */
.ai-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.ai-badge.relevant {
background: #dcfce7;
color: #166534;
}
.ai-badge.not-relevant {
background: #fee2e2;
color: #991b1b;
}
/* Star rating display */
.ai-stars {
display: inline-flex;
gap: 1px;
font-size: 11px;
letter-spacing: -1px;
}
.ai-stars .star-filled { color: #f59e0b; }
.ai-stars .star-empty { color: #d1d5db; }
.ai-badge.score-5 { background: #dcfce7; color: #166534; }
.ai-badge.score-4 { background: #d1fae5; color: #047857; }
.ai-badge.score-3 { background: #fef3c7; color: #92400e; }
.ai-badge.score-2 { background: #fee2e2; color: #b91c1c; }
.ai-badge.score-1 { background: #fecaca; color: #991b1b; }
/* AI Evaluation Modal */
.modal-icon {
font-size: 48px;
text-align: center;
margin-bottom: var(--spacing-md);
}
.modal-icon.spinning {
animation: spin 2s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.modal-description {
text-align: center;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.modal-note {
text-align: center;
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-lg);
}
.ai-progress-bar {
width: 100%;
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
margin-bottom: var(--spacing-md);
}
.ai-progress-fill {
height: 100%;
background: linear-gradient(90deg, #8b5cf6, #7c3aed);
border-radius: 4px;
transition: width 0.5s ease;
width: 0%;
}
.ai-result-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.ai-stat-item {
text-align: center;
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
}
.ai-stat-item .value {
font-size: var(--font-size-2xl);
font-weight: 700;
}
.ai-stat-item .label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.ai-stat-item.success .value { color: var(--success); }
.ai-stat-item.danger .value { color: var(--danger); }
.ai-stat-item.warning .value { color: var(--warning); }
#aiResultIcon.success { color: var(--success); }
#aiResultIcon.error { color: var(--danger); }
/* Filters bar */
.filters-bar {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
align-items: center;
}
.filter-group {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.filter-group label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.filter-checkbox {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--background);
border-radius: var(--radius);
cursor: pointer;
font-size: var(--font-size-sm);
}
.filter-checkbox input {
margin: 0;
}
.old-news-warning {
background: #fef3c7;
color: #92400e;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
font-size: var(--font-size-sm);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border);
}
.pagination a,
.pagination span {
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius);
font-size: var(--font-size-sm);
text-decoration: none;
color: var(--text-primary);
}
.pagination a {
background: var(--background);
border: 1px solid var(--border);
}
.pagination a:hover {
background: var(--border);
}
.pagination .current {
background: var(--primary);
color: white;
font-weight: 600;
}
.pagination .disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-info {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* Old news badge */
.old-news-badge {
background: #fef3c7;
color: #92400e;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.panel-section {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.panel-section h2 {
font-size: var(--font-size-lg);
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border);
}
.pending-news-list {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.pending-news-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
gap: var(--spacing-md);
}
.news-info {
flex: 1;
min-width: 0;
}
.news-info h4 {
font-size: var(--font-size-base);
margin-bottom: var(--spacing-xs);
word-break: break-word;
}
.news-info h4 a {
color: inherit;
text-decoration: none;
}
.news-info h4 a:hover {
color: var(--primary);
}
.news-meta {
font-size: var(--font-size-xs);
color: var(--text-secondary);
display: flex;
gap: var(--spacing-md);
}
.news-actions {
display: flex;
gap: var(--spacing-xs);
flex-shrink: 0;
}
.btn-approve {
background: var(--success);
color: white;
}
.btn-approve:hover {
background: #059669;
}
.btn-reject {
background: var(--danger);
color: white;
}
.btn-reject:hover {
background: #dc2626;
}
.projects-table {
width: 100%;
border-collapse: collapse;
}
.projects-table th,
.projects-table td {
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.projects-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.status-planned { background: #fef3c7; color: #92400e; }
.status-in_progress { background: #dbeafe; color: #1e40af; }
.status-completed { background: #dcfce7; color: #166534; }
.search-section {
background: linear-gradient(135deg, #059669 0%, #047857 100%);
color: white;
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-xl);
}
.search-section h3 {
margin-bottom: var(--spacing-md);
}
.search-form {
display: flex;
gap: var(--spacing-sm);
}
.search-form input {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--radius);
font-size: var(--font-size-base);
}
.search-form button {
padding: var(--spacing-sm) var(--spacing-lg);
background: white;
color: var(--primary);
border: none;
border-radius: var(--radius);
font-weight: 600;
cursor: pointer;
transition: var(--transition);
}
.search-form button:hover {
background: #f0fdf4;
}
.search-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.fetch-jobs-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.fetch-job {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--background);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
}
.fetch-job-status {
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
}
.fetch-job-status.completed { background: #dcfce7; color: #166534; }
.fetch-job-status.failed { background: #fee2e2; color: #991b1b; }
.fetch-job-status.running { background: #dbeafe; color: #1e40af; }
/* Progress bar */
.progress-container {
display: none;
margin-top: var(--spacing-lg);
background: rgba(255,255,255,0.1);
border-radius: var(--radius);
padding: var(--spacing-md);
}
.progress-container.active {
display: block;
}
.progress-header {
display: flex;
justify-content: space-between;
margin-bottom: var(--spacing-sm);
font-size: var(--font-size-sm);
}
.progress-bar-container {
height: 8px;
background: rgba(255,255,255,0.2);
border-radius: 4px;
overflow: hidden;
margin-bottom: var(--spacing-md);
}
.progress-bar-fill {
height: 100%;
background: white;
border-radius: 4px;
transition: width 0.3s ease;
width: 0%;
}
.progress-steps {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
font-size: var(--font-size-xs);
max-height: 200px;
overflow-y: auto;
}
.progress-step {
display: flex;
align-items: center;
gap: var(--spacing-sm);
opacity: 0.6;
}
.progress-step.active {
opacity: 1;
font-weight: 600;
}
.progress-step.completed {
opacity: 1;
}
.progress-step-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.progress-step.active .progress-step-icon::before {
content: "⏳";
}
.progress-step.completed .progress-step-icon::before {
content: "✓";
}
.progress-step.pending .progress-step-icon::before {
content: "○";
}
.progress-step-count {
margin-left: auto;
background: rgba(255,255,255,0.2);
padding: 2px 6px;
border-radius: var(--radius-sm);
}
/* Source stats */
.source-stats {
display: none;
margin-top: var(--spacing-md);
padding: var(--spacing-md);
background: rgba(255,255,255,0.1);
border-radius: var(--radius);
}
.source-stats.active {
display: block;
}
.source-stats h4 {
margin-bottom: var(--spacing-sm);
font-size: var(--font-size-sm);
}
.source-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--spacing-sm);
}
.source-stat-item {
display: flex;
justify-content: space-between;
padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(255,255,255,0.1);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
}
.source-stat-item .count {
font-weight: 700;
}
/* News source badges */
.source-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.source-badge.brave { background: #fff3cd; color: #856404; }
.source-badge.rss_local_media { background: #d1ecf1; color: #0c5460; }
.source-badge.rss_government { background: #d4edda; color: #155724; }
.source-badge.rss_aggregator { background: #e2e3e5; color: #383d41; }
.source-badge.manual { background: #f8d7da; color: #721c24; }
.confidence-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.confidence-badge.high { background: #d4edda; color: #155724; }
.confidence-badge.medium { background: #fff3cd; color: #856404; }
.confidence-badge.low { background: #f8d7da; color: #721c24; }
.empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--text-secondary);
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal h3 {
margin-bottom: var(--spacing-lg);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-group label {
display: block;
margin-bottom: var(--spacing-xs);
font-weight: 500;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
}
@media (max-width: 768px) {
.admin-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-md);
}
.pending-news-item {
flex-direction: column;
}
.news-actions {
width: 100%;
justify-content: flex-end;
}
.search-form {
flex-direction: column;
}
}
/* Progress phases (search → filter → AI → save) */
.progress-phases {
display: flex;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-md);
flex-wrap: wrap;
}
.progress-phase {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: var(--radius);
font-size: var(--font-size-xs);
background: rgba(255,255,255,0.1);
opacity: 0.5;
transition: all 0.3s ease;
}
.progress-phase.active {
opacity: 1;
background: rgba(255,255,255,0.25);
animation: pulse 1.5s ease-in-out infinite;
}
.progress-phase.completed {
opacity: 1;
background: rgba(34, 197, 94, 0.3);
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); }
}
.progress-phase-icon {
font-size: 1em;
}
/* Search results container */
.search-results-container {
margin-top: var(--spacing-lg);
padding: var(--spacing-lg);
background: rgba(255,255,255,0.1);
border-radius: var(--radius-lg);
animation: fadeIn 0.5s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.search-results-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.summary-stat {
text-align: center;
padding: var(--spacing-md);
background: rgba(255,255,255,0.1);
border-radius: var(--radius);
}
.summary-stat .value {
font-size: var(--font-size-2xl);
font-weight: 700;
}
.summary-stat .label {
font-size: var(--font-size-xs);
opacity: 0.8;
}
.summary-stat.success .value { color: #86efac; }
.summary-stat.warning .value { color: #fde68a; }
.summary-stat.error .value { color: #fca5a5; }
.summary-stat.info .value { color: #93c5fd; }
/* Auto-approved articles section */
.auto-approved-section {
margin-top: var(--spacing-lg);
padding: var(--spacing-md);
background: rgba(34, 197, 94, 0.15);
border-radius: var(--radius);
border: 1px solid rgba(34, 197, 94, 0.3);
}
.auto-approved-section h4 {
margin-bottom: var(--spacing-md);
font-size: var(--font-size-sm);
}
.auto-approved-list {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
max-height: 200px;
overflow-y: auto;
}
.auto-approved-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(255,255,255,0.1);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
}
.auto-approved-item .stars {
color: #fbbf24;
flex-shrink: 0;
}
.auto-approved-item .title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.auto-approved-item .source {
color: rgba(255,255,255,0.6);
flex-shrink: 0;
font-size: 10px;
}
/* Refresh countdown */
.refresh-countdown {
margin-top: var(--spacing-lg);
padding: var(--spacing-md);
background: rgba(255,255,255,0.1);
border-radius: var(--radius);
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--font-size-sm);
}
.refresh-countdown strong {
font-size: var(--font-size-lg);
color: #fde68a;
}
</style>
{% endblock %}
{% block content %}
<div class="admin-header">
<div>
<h1>Zielony Okręg Przemysłowy Kaszubia</h1>
<p class="text-muted">Panel zarządzania bazą wiedzy</p>
</div>
<div class="admin-actions">
<a href="{{ url_for('zopk_index') }}" class="btn btn-secondary" target="_blank">Zobacz stronę publiczną</a>
<a href="{{ url_for('admin_zopk_news') }}" class="btn btn-secondary">Zarządzaj newsami</a>
<button class="btn btn-primary" onclick="openAddNewsModal()">+ Dodaj news</button>
</div>
</div>
<!-- Section 1: General stats (info only, not clickable) -->
<div class="stats-section">
<h3 class="stats-section-title">Baza wiedzy ZOP Kaszubia</h3>
<div class="stats-grid stats-grid-small">
<div class="stat-card info-only">
<div class="stat-value">{{ stats.total_projects }}</div>
<div class="stat-label">Projektów</div>
</div>
<div class="stat-card info-only">
<div class="stat-value">{{ stats.total_stakeholders }}</div>
<div class="stat-label">Interesariuszy</div>
</div>
<div class="stat-card info-only">
<div class="stat-value">{{ stats.total_resources }}</div>
<div class="stat-label">Materiałów</div>
</div>
</div>
</div>
<!-- Section 2: News filters (clickable) -->
<div class="stats-section">
<h3 class="stats-section-title">Filtruj newsy <small>(kliknij aby wybrać)</small></h3>
<div class="stats-grid stats-grid-small">
<a href="?status=pending" class="stat-card filter-card warning {{ 'active' if status_filter == 'pending' else '' }}">
<div class="stat-value">{{ stats.pending_news }}</div>
<div class="stat-label">Oczekujących</div>
</a>
<a href="?status=approved" class="stat-card filter-card success {{ 'active' if status_filter == 'approved' else '' }}">
<div class="stat-value">{{ stats.approved_news }}</div>
<div class="stat-label">Zatwierdzonych</div>
</a>
<a href="?status=rejected" class="stat-card filter-card danger {{ 'active' if status_filter == 'rejected' else '' }}">
<div class="stat-value">{{ stats.rejected_news }}</div>
<div class="stat-label">Odrzuconych</div>
</a>
</div>
</div>
<!-- Section 3: AI Evaluation filters -->
<div class="stats-section">
<h3 class="stats-section-title">Ocena AI (Gemini) <small>(kliknij aby wybrać)</small></h3>
<!-- AI Model Info Banner -->
<div class="ai-model-info" style="background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%); border: 1px solid #7dd3fc; border-radius: var(--radius); padding: var(--spacing-sm) var(--spacing-md); margin-bottom: var(--spacing-md); font-size: var(--font-size-sm);">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: var(--spacing-xs);">
<div>
<strong>🤖 Model:</strong> <code style="background: #fff; padding: 2px 6px; border-radius: 4px;">gemini-2.5-flash-lite</code>
</div>
<div>
<strong>📅 Prompt v2:</strong> 2026-01-15
</div>
<div title="Via Pomerania, S6, Droga Czerwona, Pakt Bezpieczeństwa, NORDA, Deklaracja Bałtycka">
<strong>✅ Nowe tematy:</strong> +7 projektów infrastrukturalnych
</div>
</div>
</div>
<div class="stats-grid" style="grid-template-columns: repeat(4, 1fr); max-width: 800px;">
<a href="?status=ai_relevant" class="stat-card filter-card success {{ 'active' if status_filter == 'ai_relevant' else '' }}">
<div class="stat-value">{{ stats.ai_relevant }}</div>
<div class="stat-label">Pasuje wg AI</div>
</a>
<a href="?status=ai_not_relevant" class="stat-card filter-card danger {{ 'active' if status_filter == 'ai_not_relevant' else '' }}">
<div class="stat-value">{{ stats.ai_not_relevant }}</div>
<div class="stat-label">Nie pasuje wg AI</div>
</a>
<a href="?status=ai_not_evaluated" class="stat-card filter-card warning {{ 'active' if status_filter == 'ai_not_evaluated' else '' }}">
<div class="stat-value">{{ stats.ai_not_evaluated }}</div>
<div class="stat-label">Nieocenione</div>
</a>
<button type="button" class="stat-card filter-card ai-action" onclick="evaluateWithAI()" id="aiEvalBtn" {% if stats.ai_not_evaluated == 0 %}disabled{% endif %} title="Gemini AI oceni {{ stats.ai_not_evaluated }} nieocenionych newsów">
<div class="stat-value" style="font-size: var(--font-size-xl);">🤖</div>
<div class="stat-label">AI: Oceń {{ stats.ai_not_evaluated }}</div>
</button>
</div>
{% if stats.ai_missing_score > 0 %}
<div class="stats-grid" style="grid-template-columns: repeat(2, 1fr); max-width: 400px; margin-top: var(--spacing-md);">
<div class="stat-card warning info-only">
<div class="stat-value">{{ stats.ai_missing_score }}</div>
<div class="stat-label">Brak gwiazdek</div>
</div>
<button type="button" class="stat-card filter-card ai-action" onclick="reevaluateScores()" id="aiRescoreBtn" title="Gemini AI przeoceni {{ stats.ai_missing_score }} newsów i doda oceny 1-5 gwiazdek">
<div class="stat-value" style="font-size: var(--font-size-xl);">🤖⭐</div>
<div class="stat-label">AI: Dodaj gwiazdki</div>
</button>
</div>
{% endif %}
<!-- Re-evaluate low score news with key topics (Via Pomerania, NORDA, etc.) -->
<div class="stats-grid" style="grid-template-columns: repeat(1, 1fr); max-width: 400px; margin-top: var(--spacing-md);">
<button type="button" class="stat-card filter-card ai-action" onclick="reevaluateLowScores()" id="aiReevalLowBtn" title="Re-ewaluacja newsów z oceną 1-2★ zawierających nowe tematy (Via Pomerania, NORDA, S6...)">
<div class="stat-value" style="font-size: var(--font-size-xl);">🔄⭐</div>
<div class="stat-label">Re-ewaluuj niskie oceny (Via Pomerania, NORDA...)</div>
</button>
</div>
<div id="aiEvalResultLegacy" style="margin-top: var(--spacing-md); display: none;"></div>
</div>
<!-- Old news warning -->
{% if stats.old_news > 0 and not show_old %}
<div class="old-news-warning">
<span>⚠️</span>
<span><strong>{{ stats.old_news }} newsów</strong> z przed 2024 roku zostało ukrytych (ZOP Kaszubia powstał w 2024).
<a href="?status={{ status_filter }}&show_old=true">Pokaż wszystkie</a> lub
<a href="#" onclick="rejectOldNews(); return false;">odrzuć wszystkie stare</a>.</span>
</div>
{% endif %}
<!-- Search Section -->
<div class="search-section">
<h3>Wyszukaj nowe artykuły</h3>
<p style="opacity: 0.9; margin-bottom: var(--spacing-md); font-size: var(--font-size-sm);">Multi-source search: Brave API, RSS (trojmiasto.pl, Dziennik Bałtycki), Google News, gov.pl</p>
<div class="search-form">
<input type="text" id="searchQuery" value="Zielony Okręg Przemysłowy Kaszubia" placeholder="Wpisz zapytanie...">
<button type="button" onclick="searchNews()" id="searchBtn">Szukaj artykułów</button>
</div>
<!-- Progress Container -->
<div class="progress-container" id="progressContainer">
<div class="progress-header">
<span id="progressStatus">Inicjalizacja...</span>
<span id="progressPercent">0%</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="progressBar"></div>
</div>
<div class="progress-phases" id="progressPhases">
<!-- Phases will be rendered by JS -->
</div>
<div class="progress-steps" id="progressSteps"></div>
</div>
<!-- Results Container (shown after completion) -->
<div class="search-results-container" id="searchResultsContainer" style="display: none;">
<!-- Summary Stats -->
<div class="search-results-summary" id="searchResultsSummary"></div>
<!-- Auto-approved articles list -->
<div class="auto-approved-section" id="autoApprovedSection" style="display: none;">
<h4>✅ Artykuły automatycznie zaakceptowane (3+★)</h4>
<div class="auto-approved-list" id="autoApprovedList"></div>
</div>
<!-- Detailed Statistics Section -->
<div class="detailed-stats-section" id="detailedStatsSection" style="margin-top: var(--spacing-lg); display: none;">
<h4 style="margin-bottom: var(--spacing-sm); font-size: var(--font-size-sm); color: var(--text-secondary);">
📊 Szczegóły procesu
</h4>
<div class="detailed-stats-grid" id="detailedStatsGrid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--spacing-sm);"></div>
</div>
<!-- AI Rejected Articles Section -->
<div class="ai-rejected-section" id="aiRejectedSection" style="display: none; margin-top: var(--spacing-lg);">
<h4 style="color: #ef4444;">❌ Artykuły odrzucone przez AI</h4>
<div class="ai-rejected-list" id="aiRejectedList" style="max-height: 200px; overflow-y: auto;"></div>
</div>
<!-- OK Button to close -->
<div class="results-actions" style="margin-top: var(--spacing-xl); text-align: center; padding-top: var(--spacing-lg); border-top: 1px solid var(--border);">
<button type="button" class="btn btn-primary btn-lg" onclick="location.reload()" style="padding: var(--spacing-md) var(--spacing-2xl); font-size: var(--font-size-lg);">
✓ OK - Zamknij i odśwież
</button>
<p style="margin-top: var(--spacing-sm); font-size: var(--font-size-xs); color: var(--text-secondary);">
Kliknij aby zamknąć okno wyników i odświeżyć listę newsów
</p>
</div>
</div>
<div id="searchResult" style="margin-top: var(--spacing-md); display: none;"></div>
</div>
<!-- News List -->
<div class="panel-section">
<h2>
{% if status_filter == 'pending' %}Newsy oczekujące na moderację
{% elif status_filter == 'approved' %}Newsy zatwierdzone
{% elif status_filter == 'rejected' %}Newsy odrzucone
{% elif status_filter == 'ai_relevant' %}🤖 Newsy pasujące wg AI
{% elif status_filter == 'ai_not_relevant' %}🤖 Newsy NIE pasujące wg AI
{% else %}Wszystkie newsy{% endif %}
({{ total_news_filtered }})
</h2>
{% if news_items %}
<div class="pending-news-list">
{% for news in news_items %}
<div class="pending-news-item" id="news-{{ news.id }}">
<div class="news-info">
<h4><a href="{{ news.url }}" target="_blank" rel="noopener">{{ news.title }}</a></h4>
<div class="news-meta">
{% if news.source_type %}
<span class="source-badge {{ news.source_type|replace(' ', '_') }}">
{% if news.source_type == 'brave' %}🔍 Brave
{% elif news.source_type == 'rss_local_media' %}📰 Media lokalne
{% elif news.source_type == 'rss_government' %}🏛️ Rząd
{% elif news.source_type == 'rss_aggregator' %}📡 Agregator
{% elif news.source_type == 'manual' %}✏️ Ręcznie
{% else %}{{ news.source_type }}{% endif %}
</span>
{% endif %}
<span>{{ news.source_name or news.source_domain or '-' }}</span>
{% set news_year = news.published_at.year if news.published_at else None %}
<span>{{ news.published_at.strftime('%d.%m.%Y') if news.published_at else (news.created_at.strftime('%d.%m.%Y') if news.created_at else '-') }}</span>
{% if news_year and news_year < min_year %}
<span class="old-news-badge">⚠️ Sprzed {{ min_year }}</span>
{% endif %}
{% if news.confidence_score %}
<span class="confidence-badge {{ 'high' if news.confidence_score >= 4 else ('medium' if news.confidence_score >= 2 else 'low') }}">
{% if news.source_count and news.source_count > 1 %}{{ news.source_count }} źródeł{% else %}1 źródło{% endif %}
</span>
{% endif %}
{% if news.status == 'approved' or news.status == 'auto_approved' %}
<span class="confidence-badge high">✓ {{ 'Auto' if news.status == 'auto_approved' else '' }}Zatwierdzony</span>
{% elif news.status == 'rejected' %}
<span class="confidence-badge low">✗ Odrzucony</span>
{% endif %}
{# AI Evaluation badge with star rating #}
{% if news.ai_relevant is not none %}
<span class="ai-badge score-{{ news.ai_relevance_score or 3 }}" title="{{ news.ai_evaluation_reason or '' }}">
🤖
<span class="ai-stars">
{% for i in range(1, 6) %}
<span class="{{ 'star-filled' if i <= (news.ai_relevance_score or 0) else 'star-empty' }}"></span>
{% endfor %}
</span>
</span>
{% endif %}
</div>
</div>
<div class="news-actions">
{% if news.status == 'pending' %}
<button class="btn btn-sm btn-approve" onclick="approveNews({{ news.id }})">Zatwierdź</button>
<button class="btn btn-sm btn-reject" onclick="rejectNews({{ news.id }})">Odrzuć</button>
{% elif news.status == 'rejected' %}
<button class="btn btn-sm btn-approve" onclick="approveNews({{ news.id }})">Przywróć</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<div class="pagination">
{% if current_page > 1 %}
<a href="?page={{ current_page - 1 }}&status={{ status_filter }}{% if show_old %}&show_old=true{% endif %}">← Poprzednia</a>
{% else %}
<span class="disabled">← Poprzednia</span>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == current_page %}
<span class="current">{{ p }}</span>
{% elif p == 1 or p == total_pages or (p >= current_page - 2 and p <= current_page + 2) %}
<a href="?page={{ p }}&status={{ status_filter }}{% if show_old %}&show_old=true{% endif %}">{{ p }}</a>
{% elif p == current_page - 3 or p == current_page + 3 %}
<span>...</span>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="?page={{ current_page + 1 }}&status={{ status_filter }}{% if show_old %}&show_old=true{% endif %}">Następna →</a>
{% else %}
<span class="disabled">Następna →</span>
{% endif %}
<span class="pagination-info">
({{ (current_page - 1) * per_page + 1 }}-{{ [current_page * per_page, total_news_filtered]|min }} z {{ total_news_filtered }})
</span>
</div>
{% endif %}
{% else %}
<div class="empty-state">
<p>Brak newsów{% if status_filter != 'all' %} o tym statusie{% endif %}.</p>
</div>
{% endif %}
</div>
<!-- AI Knowledge Base -->
<div class="panel-section">
<h2>🧠 Baza Wiedzy AI</h2>
<div id="knowledge-stats-loading" class="text-center py-4">
<span class="spinner"></span> Ładowanie statystyk...
</div>
<div id="knowledge-stats-content" style="display: none;">
<!-- Content Scraping Stats -->
<div class="stats-section">
<h3 class="stats-section-title">Scraping treści</h3>
<div class="stats-grid stats-grid-small">
<div class="stat-card info-only">
<div class="stat-value" id="kb-total-approved">-</div>
<div class="stat-label">Zatwierdzonych newsów</div>
</div>
<div class="stat-card info-only success">
<div class="stat-value" id="kb-scraped">-</div>
<div class="stat-label">Zescrapowanych</div>
</div>
<div class="stat-card info-only warning">
<div class="stat-value" id="kb-pending-scrape">-</div>
<div class="stat-label">Oczekuje na scraping</div>
</div>
<button class="stat-card filter-card ai-action" onclick="scrapeContent()" id="scrapeBtn" title="Scrapuj treść artykułów">
<div class="stat-value" style="font-size: var(--font-size-xl);">📄</div>
<div class="stat-label">Scrapuj treść</div>
</button>
</div>
</div>
<!-- Knowledge Extraction Stats -->
<div class="stats-section">
<h3 class="stats-section-title">Ekstrakcja wiedzy (Gemini AI)</h3>
<div class="stats-grid stats-grid-small">
<div class="stat-card info-only">
<div class="stat-value" id="kb-total-chunks">-</div>
<div class="stat-label">Chunks</div>
</div>
<div class="stat-card info-only">
<div class="stat-value" id="kb-total-facts">-</div>
<div class="stat-label">Faktów</div>
</div>
<div class="stat-card info-only">
<div class="stat-value" id="kb-total-entities">-</div>
<div class="stat-label">Encji</div>
</div>
<button class="stat-card filter-card ai-action" onclick="extractKnowledge()" id="extractBtn" title="Wyekstrahuj wiedzę z zescrapowanych artykułów">
<div class="stat-value" style="font-size: var(--font-size-xl);">🤖</div>
<div class="stat-label">Ekstrakcja AI</div>
</button>
</div>
</div>
<!-- Embeddings Stats -->
<div class="stats-section">
<h3 class="stats-section-title">Semantic Search (Embeddings)</h3>
<div class="stats-grid stats-grid-small">
<div class="stat-card info-only success">
<div class="stat-value" id="kb-with-embeddings">-</div>
<div class="stat-label">Z embeddingami</div>
</div>
<div class="stat-card info-only warning">
<div class="stat-value" id="kb-pending-embeddings">-</div>
<div class="stat-label">Bez embeddingów</div>
</div>
<button class="stat-card filter-card ai-action" onclick="generateEmbeddings()" id="embeddingsBtn" title="Wygeneruj embeddingi dla semantic search">
<div class="stat-value" style="font-size: var(--font-size-xl);">🔍</div>
<div class="stat-label">Generuj embeddingi</div>
</button>
</div>
</div>
<!-- Top Entities -->
<div class="stats-section" id="top-entities-section" style="display: none;">
<h3 class="stats-section-title">Najczęściej wymieniane encje</h3>
<div id="top-entities-list" class="entity-pills"></div>
</div>
<!-- Link to detailed dashboard -->
<div class="stats-section" style="text-align: center; padding-top: var(--spacing-lg);">
<a href="{{ url_for('admin_zopk_knowledge_dashboard') }}" class="btn btn-primary">
📊 Szczegółowy panel bazy wiedzy →
</a>
</div>
</div>
<div id="knowledge-stats-error" style="display: none;" class="alert alert-danger">
Błąd ładowania statystyk
</div>
</div>
<!-- Projects -->
<div class="panel-section">
<h2>Projekty strategiczne</h2>
{% if projects %}
<table class="projects-table">
<thead>
<tr>
<th>Nazwa</th>
<th>Typ</th>
<th>Status</th>
<th>Region</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr>
<td>
<strong>{{ project.name }}</strong>
<br><small class="text-muted">{{ project.slug }}</small>
</td>
<td>{{ project.project_type or '-' }}</td>
<td>
<span class="status-badge status-{{ project.status }}">
{% if project.status == 'planned' %}Planowany{% elif project.status == 'in_progress' %}W realizacji{% else %}{{ project.status }}{% endif %}
</span>
</td>
<td>{{ project.region or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>Brak projektów.</p>
</div>
{% endif %}
</div>
<!-- Recent Fetch Jobs -->
{% if fetch_jobs %}
<div class="panel-section">
<h2>Ostatnie wyszukiwania</h2>
<div class="fetch-jobs-list">
{% for job in fetch_jobs %}
<div class="fetch-job">
<div>
<strong>{{ job.search_query }}</strong>
<br><small class="text-muted">{{ job.created_at.strftime('%d.%m.%Y %H:%M') if job.created_at else '-' }}</small>
</div>
<div>
Znaleziono: {{ job.results_found or 0 }} | Nowych: {{ job.results_new or 0 }}
</div>
<span class="fetch-job-status {{ job.status }}">{{ job.status }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- AI Evaluation Modal -->
<div class="modal-overlay" id="aiEvalModal">
<div class="modal">
<div id="aiEvalConfirm">
<div class="modal-icon">🤖</div>
<h3>Ocena AI (Gemini)</h3>
<p class="modal-description">Czy chcesz uruchomić ocenę AI dla <strong>max 20</strong> z {{ stats.ai_not_evaluated }} nieocenionych newsów?</p>
<p class="modal-note">Gemini oceni każdy news pod kątem związku z ZOP Kaszubia.<br>
<strong>Uwaga:</strong> Proces trwa ok. 30-60 sekund (każdy news = osobne zapytanie do AI).</p>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeAiEvalModal()">Anuluj</button>
<button type="button" class="btn btn-primary" onclick="startAiEvaluation()">
<span>🚀</span> Rozpocznij ocenę
</button>
</div>
</div>
<div id="aiEvalProgress" style="display: none;">
<div class="modal-icon spinning">⚙️</div>
<h3>Trwa ocena AI...</h3>
<p class="modal-description" id="aiEvalStatusText">Analizowanie newsów przez Gemini...</p>
<div class="ai-progress-bar">
<div class="ai-progress-fill" id="aiProgressFill"></div>
</div>
<p class="modal-note" id="aiEvalCounter">Proszę czekać...</p>
</div>
<div id="aiEvalResult" style="display: none;">
<div class="modal-icon" id="aiResultIcon"></div>
<h3 id="aiResultTitle">Ocena zakończona</h3>
<div class="ai-result-stats" id="aiResultStats"></div>
<div class="modal-actions">
<button type="button" class="btn btn-primary" onclick="closeAiEvalModal(); location.reload();">Zamknij i odśwież</button>
</div>
</div>
</div>
</div>
<!-- AI Operations Progress Modal -->
<div class="modal-overlay" id="aiOpsModal">
<div class="modal modal-wide">
<div class="modal-header">
<div class="modal-icon" id="aiOpsIcon"></div>
<h3 id="aiOpsTitle">Operacja w toku...</h3>
<button type="button" class="modal-close-btn" id="aiOpsCloseBtn" style="display: none;" onclick="closeAiOpsModal()">×</button>
</div>
<!-- Progress Section -->
<div class="ai-ops-progress">
<div class="ai-progress-bar">
<div class="ai-progress-fill" id="aiOpsProgressFill" style="width: 0%;"></div>
</div>
<div class="ai-ops-stats">
<span id="aiOpsPercent">0%</span>
<span id="aiOpsCounter">0 / 0</span>
</div>
</div>
<!-- Current Operation -->
<div class="ai-ops-current" id="aiOpsCurrentOp">
<span class="spinner-small"></span>
<span id="aiOpsCurrentText">Inicjalizacja...</span>
</div>
<!-- Live Log (scrollable) -->
<div class="ai-ops-log-container">
<div class="ai-ops-log-header">
<span>📋 Log operacji</span>
<span id="aiOpsLogCount">0 wpisów</span>
</div>
<div class="ai-ops-log" id="aiOpsLog">
<!-- Log entries will be added here -->
</div>
</div>
<!-- Summary Stats (shown on complete) -->
<div class="ai-ops-summary" id="aiOpsSummary" style="display: none;">
<div class="summary-row">
<span class="summary-label">✓ Sukces:</span>
<span class="summary-value" id="aiOpsSummarySuccess">0</span>
</div>
<div class="summary-row">
<span class="summary-label">✗ Błędy:</span>
<span class="summary-value" id="aiOpsSummaryFailed">0</span>
</div>
<div class="summary-row" id="aiOpsSummarySkippedRow" style="display: none;">
<span class="summary-label">⊘ Pominięto:</span>
<span class="summary-value" id="aiOpsSummarySkipped">0</span>
</div>
<div class="summary-row">
<span class="summary-label">⏱️ Czas:</span>
<span class="summary-value" id="aiOpsSummaryTime">0s</span>
</div>
</div>
<!-- Actions -->
<div class="modal-actions" id="aiOpsActions" style="display: none;">
<button type="button" class="btn btn-primary" onclick="closeAiOpsModal(); loadKnowledgeStats();">Zamknij i odśwież</button>
</div>
</div>
</div>
<style>
/* AI Operations Modal Styles */
.modal-wide {
max-width: 700px;
width: 95%;
}
.modal-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
position: relative;
}
.modal-header h3 {
margin: 0;
flex: 1;
}
.modal-close-btn {
position: absolute;
right: 0;
top: 0;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text-muted);
padding: 5px 10px;
}
.modal-close-btn:hover {
color: var(--text-primary);
}
.ai-ops-progress {
margin-bottom: 15px;
}
.ai-ops-stats {
display: flex;
justify-content: space-between;
font-size: 14px;
margin-top: 8px;
color: var(--text-muted);
}
#aiOpsPercent {
font-weight: 600;
color: var(--color-primary);
}
.ai-ops-current {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 15px;
background: var(--bg-secondary);
border-radius: 8px;
margin-bottom: 15px;
font-size: 14px;
}
.ai-ops-log-container {
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
margin-bottom: 15px;
}
.ai-ops-log-header {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-tertiary);
font-size: 13px;
font-weight: 500;
border-bottom: 1px solid var(--border-color);
}
.ai-ops-log {
height: 250px;
overflow-y: auto;
padding: 10px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 12px;
background: var(--bg-primary);
}
.log-entry {
padding: 4px 8px;
margin: 2px 0;
border-radius: 4px;
display: flex;
align-items: flex-start;
gap: 8px;
}
.log-entry.processing {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.log-entry.success {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.log-entry.failed {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.log-entry.skipped {
background: rgba(234, 179, 8, 0.1);
color: #eab308;
}
.log-entry.complete {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
font-weight: 600;
}
.log-time {
color: var(--text-muted);
font-size: 11px;
white-space: nowrap;
}
.log-message {
flex: 1;
word-break: break-word;
}
.ai-ops-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
padding: 15px;
background: var(--bg-secondary);
border-radius: 8px;
margin-bottom: 15px;
}
.summary-row {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.summary-label {
font-size: 12px;
color: var(--text-muted);
}
.summary-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
}
/* Spinning icon animation */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.modal-icon.spinning {
animation: spin 2s linear infinite;
}
/* Responsive log height */
@media (max-height: 800px) {
.ai-ops-log {
height: 180px;
}
}
</style>
<!-- Add News Modal -->
<div class="modal-overlay" id="addNewsModal">
<div class="modal">
<h3>Dodaj news ręcznie</h3>
<form id="addNewsForm">
<div class="form-group">
<label for="newsTitle">Tytuł *</label>
<input type="text" id="newsTitle" name="title" required>
</div>
<div class="form-group">
<label for="newsUrl">URL artykułu *</label>
<input type="url" id="newsUrl" name="url" required placeholder="https://...">
</div>
<div class="form-group">
<label for="newsDescription">Opis</label>
<textarea id="newsDescription" name="description" placeholder="Krótki opis artykułu..."></textarea>
</div>
<div class="form-group">
<label for="newsSource">Źródło</label>
<input type="text" id="newsSource" name="source_name" placeholder="np. trojmiasto.pl">
</div>
<div class="form-group">
<label for="newsProject">Projekt</label>
<select id="newsProject" name="project_id">
<option value="">-- Brak --</option>
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeAddNewsModal()">Anuluj</button>
<button type="submit" class="btn btn-primary">Dodaj</button>
</div>
</form>
</div>
</div>
<!-- Universal Confirm/Alert Modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="max-width: 420px;">
<div style="text-align: center; margin-bottom: var(--spacing-lg);">
<div class="modal-icon" id="confirmModalIcon"></div>
<h3 id="confirmModalTitle" style="margin-bottom: var(--spacing-sm);">Potwierdzenie</h3>
<p class="modal-description" id="confirmModalMessage">Czy na pewno chcesz kontynuować?</p>
</div>
<!-- Optional input for prompt -->
<div class="form-group" id="confirmModalInputGroup" style="display: none;">
<label id="confirmModalInputLabel">Wprowadź wartość:</label>
<input type="text" id="confirmModalInput" placeholder="">
</div>
<div class="modal-actions" style="justify-content: center;">
<button type="button" class="btn btn-secondary" id="confirmModalCancel">Anuluj</button>
<button type="button" class="btn btn-primary" id="confirmModalOk">OK</button>
</div>
</div>
</div>
<!-- Toast notifications -->
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
<style>
/* Toast styles */
.toast {
padding: 12px 20px;
border-radius: var(--radius);
background: var(--surface);
border-left: 4px solid var(--primary);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
display: flex;
align-items: center;
gap: 10px;
animation: slideIn 0.3s ease;
max-width: 350px;
}
.toast.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--error); }
.toast.warning { border-left-color: #f59e0b; }
.toast-icon { font-size: 1.2em; }
.toast-message { flex: 1; }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* Spinner */
.spinner, .spinner-small {
display: inline-block;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner {
width: 24px;
height: 24px;
}
.spinner-small {
width: 16px;
height: 16px;
border-width: 2px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Entity Pills */
.entity-pills {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
}
.entity-pill {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-full, 9999px);
font-size: var(--font-size-sm);
background: var(--surface-secondary, #f5f5f5);
border: 1px solid var(--border);
}
.entity-pill small {
color: var(--text-muted);
}
.entity-pill.entity-company { background: #dbeafe; border-color: #93c5fd; }
.entity-pill.entity-person { background: #fef3c7; border-color: #fcd34d; }
.entity-pill.entity-place { background: #d1fae5; border-color: #6ee7b7; }
.entity-pill.entity-organization { background: #e0e7ff; border-color: #a5b4fc; }
.entity-pill.entity-project { background: #fce7f3; border-color: #f9a8d4; }
/* Text utilities */
.text-center { text-align: center; }
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
/* Alert */
.alert { padding: var(--spacing-md); border-radius: var(--radius); }
.alert-danger { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; }
</style>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
// ===========================================
// Universal Modal System
// ===========================================
let confirmModalResolve = null;
function showConfirm(message, options = {}) {
return new Promise((resolve) => {
confirmModalResolve = resolve;
const modal = document.getElementById('confirmModal');
const icon = document.getElementById('confirmModalIcon');
const title = document.getElementById('confirmModalTitle');
const msg = document.getElementById('confirmModalMessage');
const cancelBtn = document.getElementById('confirmModalCancel');
const okBtn = document.getElementById('confirmModalOk');
const inputGroup = document.getElementById('confirmModalInputGroup');
const input = document.getElementById('confirmModalInput');
icon.textContent = options.icon || '❓';
title.textContent = options.title || 'Potwierdzenie';
msg.innerHTML = message;
cancelBtn.textContent = options.cancelText || 'Anuluj';
okBtn.textContent = options.okText || 'OK';
okBtn.className = 'btn ' + (options.okClass || 'btn-primary');
// Show/hide input for prompt mode
if (options.showInput) {
inputGroup.style.display = 'block';
document.getElementById('confirmModalInputLabel').textContent = options.inputLabel || 'Wprowadź wartość:';
input.value = options.inputValue || '';
input.placeholder = options.inputPlaceholder || '';
} else {
inputGroup.style.display = 'none';
}
// Show/hide cancel button for alert mode
cancelBtn.style.display = options.alertOnly ? 'none' : '';
modal.classList.add('active');
if (options.showInput) {
setTimeout(() => input.focus(), 100);
}
});
}
function closeConfirmModal(result) {
document.getElementById('confirmModal').classList.remove('active');
if (confirmModalResolve) {
confirmModalResolve(result);
confirmModalResolve = null;
}
}
// Modal button handlers
document.getElementById('confirmModalOk').addEventListener('click', () => {
const inputGroup = document.getElementById('confirmModalInputGroup');
if (inputGroup.style.display !== 'none') {
closeConfirmModal(document.getElementById('confirmModalInput').value);
} else {
closeConfirmModal(true);
}
});
document.getElementById('confirmModalCancel').addEventListener('click', () => {
closeConfirmModal(false);
});
document.getElementById('confirmModal').addEventListener('click', (e) => {
if (e.target.id === 'confirmModal') {
closeConfirmModal(false);
}
});
// Keyboard support
document.getElementById('confirmModal').addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeConfirmModal(false);
if (e.key === 'Enter' && document.getElementById('confirmModalInputGroup').style.display === 'none') {
closeConfirmModal(true);
}
});
// Toast notification system
function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toastContainer');
const icons = { success: '✓', error: '✕', warning: '⚠', info: '' };
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<span class="toast-icon">${icons[type] || icons.info}</span>
<span class="toast-message">${message}</span>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease forwards';
setTimeout(() => toast.remove(), 300);
}, duration);
}
function openAddNewsModal() {
document.getElementById('addNewsModal').classList.add('active');
}
function closeAddNewsModal() {
document.getElementById('addNewsModal').classList.remove('active');
document.getElementById('addNewsForm').reset();
}
document.getElementById('addNewsForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = {
title: document.getElementById('newsTitle').value,
url: document.getElementById('newsUrl').value,
description: document.getElementById('newsDescription').value,
source_name: document.getElementById('newsSource').value,
project_id: document.getElementById('newsProject').value || null
};
try {
const response = await fetch('{{ url_for("admin_zopk_news_add") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (data.success) {
closeAddNewsModal();
showToast('News został dodany', 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia: ' + error.message, 'error');
}
});
async function approveNews(newsId) {
const confirmed = await showConfirm('Czy na pewno chcesz zatwierdzić ten news?', {
icon: '✓',
title: 'Zatwierdź news',
okText: 'Zatwierdź',
okClass: 'btn-success'
});
if (!confirmed) return;
try {
const response = await fetch(`/admin/zopk/news/${newsId}/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
document.getElementById(`news-${newsId}`).remove();
showToast('News został zatwierdzony', 'success');
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia: ' + error.message, 'error');
}
}
async function rejectNews(newsId) {
const reason = await showConfirm('Podaj powód odrzucenia (opcjonalnie):', {
icon: '✕',
title: 'Odrzuć news',
showInput: true,
inputLabel: 'Powód odrzucenia:',
inputPlaceholder: 'np. Nieistotny artykuł...',
okText: 'Odrzuć',
okClass: 'btn-danger'
});
if (reason === false) return; // User cancelled
try {
const response = await fetch(`/admin/zopk/news/${newsId}/reject`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ reason: reason })
});
const data = await response.json();
if (data.success) {
document.getElementById(`news-${newsId}`).remove();
showToast('News został odrzucony', 'success');
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia: ' + error.message, 'error');
}
}
async function rejectOldNews() {
const minYear = {{ min_year }};
const confirmed = await showConfirm(
`Czy na pewno chcesz odrzucić wszystkie newsy sprzed <strong>${minYear}</strong> roku?<br><br>` +
`<small style="color: var(--text-muted)">ZOP Kaszubia powstał w 2024 roku, więc starsze artykuły najprawdopodobniej nie dotyczą tego projektu.</small>`,
{
icon: '🗑️',
title: 'Odrzuć stare newsy',
okText: 'Odrzuć wszystkie',
okClass: 'btn-danger'
}
);
if (!confirmed) {
return;
}
try {
const response = await fetch('/admin/zopk/news/reject-old', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ min_year: minYear })
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success', 3000);
setTimeout(() => location.reload(), 1500);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia: ' + error.message, 'error');
}
}
// AI Evaluation Modal functions
function evaluateWithAI() {
// Open modal instead of confirm()
document.getElementById('aiEvalModal').classList.add('active');
// Reset modal state
document.getElementById('aiEvalConfirm').style.display = 'block';
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'none';
}
function closeAiEvalModal() {
document.getElementById('aiEvalModal').classList.remove('active');
}
async function startAiEvaluation() {
const btn = document.getElementById('aiEvalBtn');
const progressFill = document.getElementById('aiProgressFill');
const statusText = document.getElementById('aiEvalStatusText');
const counter = document.getElementById('aiEvalCounter');
// Switch to progress view
document.getElementById('aiEvalConfirm').style.display = 'none';
document.getElementById('aiEvalProgress').style.display = 'block';
// Disable button
btn.disabled = true;
btn.querySelector('.stat-label').textContent = 'AI pracuje...';
// Animated progress with better messaging
let progress = 0;
let seconds = 0;
const progressInterval = setInterval(() => {
progress = Math.min(progress + Math.random() * 8, 95);
seconds++;
progressFill.style.width = `${progress}%`;
// Show elapsed time and encouraging messages
if (seconds < 30) {
statusText.textContent = `Gemini analizuje newsy... ${Math.round(progress)}%`;
} else if (seconds < 60) {
statusText.textContent = `Prawie gotowe... ${Math.round(progress)}%`;
} else {
statusText.textContent = `Jeszcze chwilę... ${Math.round(progress)}%`;
}
counter.textContent = `Upłynęło: ${seconds}s (każdy news wymaga wywołania AI)`;
}, 1000);
try {
// Use AbortController for timeout (3 minutes max)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 180000);
const response = await fetch('/admin/zopk/news/evaluate-ai', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ limit: 20 }), // Reduced limit for faster response
signal: controller.signal
});
clearTimeout(timeoutId);
clearInterval(progressInterval);
progressFill.style.width = '100%';
const data = await response.json();
// Switch to result view
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'block';
const resultIcon = document.getElementById('aiResultIcon');
const resultTitle = document.getElementById('aiResultTitle');
const resultStats = document.getElementById('aiResultStats');
if (data.success) {
resultIcon.textContent = '✓';
resultIcon.className = 'modal-icon success';
resultTitle.textContent = 'Ocena zakończona!';
resultStats.innerHTML = `
<div class="ai-stat-item success">
<div class="value">${data.relevant_count}</div>
<div class="label">Pasuje do ZOP Kaszubia</div>
</div>
<div class="ai-stat-item danger">
<div class="value">${data.not_relevant_count}</div>
<div class="label">Nie pasuje</div>
</div>
<div class="ai-stat-item warning">
<div class="value">${data.total_evaluated}</div>
<div class="label">Ocenionych</div>
</div>
<p style="text-align: center; margin-top: var(--spacing-md); color: var(--text-secondary);">
Odświeżam stronę za 2 sekundy...
</p>
`;
// Auto-reload after showing results
setTimeout(() => location.reload(), 2000);
} else {
resultIcon.textContent = '✗';
resultIcon.className = 'modal-icon error';
resultTitle.textContent = 'Wystąpił błąd';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">${data.error}</p>`;
btn.disabled = false;
btn.querySelector('.stat-label').textContent = 'AI: Oceń ponownie';
}
} catch (error) {
clearInterval(progressInterval);
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'block';
const resultIcon = document.getElementById('aiResultIcon');
const resultTitle = document.getElementById('aiResultTitle');
const resultStats = document.getElementById('aiResultStats');
resultIcon.textContent = '✗';
resultIcon.className = 'modal-icon error';
// Check if timeout
if (error.name === 'AbortError') {
resultTitle.textContent = 'Przekroczono limit czasu';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">
Przetwarzanie trwało zbyt długo (>3 min).<br>
Spróbuj ponownie lub sprawdź logi serwera.
</p>`;
} else {
resultTitle.textContent = 'Błąd połączenia';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">${error.message}</p>`;
}
btn.disabled = false;
btn.querySelector('.stat-label').textContent = 'AI: Oceń ponownie';
}
}
// Re-evaluate items missing scores (upgrade to 1-5 stars)
async function reevaluateScores() {
const btn = document.getElementById('aiRescoreBtn');
if (!btn) return;
// Show modal for re-evaluation
document.getElementById('aiEvalModal').classList.add('active');
document.getElementById('aiEvalConfirm').style.display = 'none';
document.getElementById('aiEvalProgress').style.display = 'block';
document.getElementById('aiEvalResult').style.display = 'none';
const progressBar = document.getElementById('aiProgressFill');
const progressStatus = document.getElementById('aiProgressStatus');
btn.disabled = true;
btn.querySelector('.stat-label').textContent = 'AI pracuje...';
// Simulated progress for better UX
let progress = 0;
let startTime = Date.now();
const progressInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
if (progress < 90) {
progress += Math.random() * 3;
progressBar.style.width = Math.min(progress, 90) + '%';
}
progressStatus.textContent = `AI dodaje gwiazdki... (${elapsed}s)`;
}, 500);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 180000);
const response = await fetch('/admin/zopk/news/reevaluate-scores', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ limit: 30 }),
signal: controller.signal
});
clearTimeout(timeoutId);
clearInterval(progressInterval);
const data = await response.json();
progressBar.style.width = '100%';
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'block';
const resultIcon = document.getElementById('aiResultIcon');
const resultTitle = document.getElementById('aiResultTitle');
const resultStats = document.getElementById('aiResultStats');
if (data.success) {
resultIcon.textContent = '⭐';
resultIcon.className = 'modal-icon success';
resultTitle.textContent = 'Gwiazdki dodane!';
resultStats.innerHTML = `
<div style="display: flex; gap: 20px; justify-content: center; margin-bottom: var(--spacing-md);">
<div><strong>${data.total_evaluated}</strong><br><small>Ocenionych</small></div>
<div style="color: var(--success);"><strong>${data.relevant_count}</strong><br><small>Pasuje</small></div>
<div style="color: var(--danger);"><strong>${data.not_relevant_count}</strong><br><small>Nie pasuje</small></div>
</div>
<p style="text-align: center; color: var(--text-secondary);">${data.message}</p>
`;
setTimeout(() => location.reload(), 2000);
} else {
resultIcon.textContent = '✗';
resultIcon.className = 'modal-icon error';
resultTitle.textContent = 'Wystąpił błąd';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">${data.error}</p>`;
btn.disabled = false;
btn.querySelector('.stat-label').textContent = 'AI: Spróbuj ponownie';
}
} catch (error) {
clearInterval(progressInterval);
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'block';
const resultIcon = document.getElementById('aiResultIcon');
const resultTitle = document.getElementById('aiResultTitle');
const resultStats = document.getElementById('aiResultStats');
resultIcon.textContent = '✗';
resultIcon.className = 'modal-icon error';
resultTitle.textContent = 'Błąd połączenia';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">${error.message}</p>`;
btn.disabled = false;
btn.querySelector('.stat-label').textContent = 'AI: Spróbuj ponownie';
}
}
// Re-evaluate low score news (1-2★) with key topics (Via Pomerania, NORDA, etc.)
async function reevaluateLowScores() {
const btn = document.getElementById('aiReevalLowBtn');
if (!btn) return;
// Show modal for re-evaluation
document.getElementById('aiEvalModal').classList.add('active');
document.getElementById('aiEvalConfirm').style.display = 'none';
document.getElementById('aiEvalProgress').style.display = 'block';
document.getElementById('aiEvalResult').style.display = 'none';
const progressBar = document.getElementById('aiProgressFill');
const progressStatus = document.getElementById('aiProgressStatus');
btn.disabled = true;
btn.querySelector('.stat-label').textContent = 'Re-ewaluacja...';
// Simulated progress for better UX
let progress = 0;
let startTime = Date.now();
const progressInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
if (progress < 90) {
progress += Math.random() * 3;
progressBar.style.width = Math.min(progress, 90) + '%';
}
progressStatus.textContent = `Re-ewaluacja newsów z Via Pomerania, NORDA... (${elapsed}s)`;
}, 500);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 180000);
const response = await fetch('/admin/zopk/news/reevaluate-low-scores', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ limit: 50 }),
signal: controller.signal
});
clearTimeout(timeoutId);
clearInterval(progressInterval);
const data = await response.json();
progressBar.style.width = '100%';
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'block';
const resultIcon = document.getElementById('aiResultIcon');
const resultTitle = document.getElementById('aiResultTitle');
const resultStats = document.getElementById('aiResultStats');
if (data.success) {
resultIcon.textContent = '🔄';
resultIcon.className = 'modal-icon success';
resultTitle.textContent = 'Re-ewaluacja zakończona!';
// Build details list if available
let detailsHtml = '';
if (data.details && data.details.length > 0) {
detailsHtml = `
<div style="max-height: 200px; overflow-y: auto; margin-top: var(--spacing-md); text-align: left; font-size: var(--font-size-sm);">
${data.details.map(d => `
<div style="padding: 4px 0; border-bottom: 1px solid var(--border);">
<span style="color: ${d.change === 'upgraded' ? 'var(--success)' : d.change === 'downgraded' ? 'var(--danger)' : 'var(--text-secondary)'};">
${d.old_score}★ → ${d.new_score}★
</span>
${d.title}...
</div>
`).join('')}
</div>
`;
}
resultStats.innerHTML = `
<div style="display: flex; gap: 20px; justify-content: center; margin-bottom: var(--spacing-md);">
<div><strong>${data.total_evaluated}</strong><br><small>Ocenionych</small></div>
<div style="color: var(--success);"><strong>${data.upgraded}</strong><br><small>⬆️ Podwyższono</small></div>
<div style="color: var(--danger);"><strong>${data.downgraded}</strong><br><small>⬇️ Obniżono</small></div>
<div style="color: var(--text-secondary);"><strong>${data.unchanged}</strong><br><small>Bez zmian</small></div>
</div>
<p style="text-align: center; color: var(--text-secondary);">${data.message}</p>
${detailsHtml}
`;
if (data.upgraded > 0 || data.downgraded > 0) {
setTimeout(() => location.reload(), 3000);
}
} else {
resultIcon.textContent = '✗';
resultIcon.className = 'modal-icon error';
resultTitle.textContent = 'Wystąpił błąd';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">${data.error}</p>`;
btn.disabled = false;
btn.querySelector('.stat-label').textContent = 'Re-ewaluuj niskie oceny';
}
} catch (error) {
clearInterval(progressInterval);
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'block';
const resultIcon = document.getElementById('aiResultIcon');
const resultTitle = document.getElementById('aiResultTitle');
const resultStats = document.getElementById('aiResultStats');
resultIcon.textContent = '✗';
resultIcon.className = 'modal-icon error';
resultTitle.textContent = 'Błąd połączenia';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">${error.message}</p>`;
btn.disabled = false;
btn.querySelector('.stat-label').textContent = 'Re-ewaluuj niskie oceny';
}
}
// Close modal on click outside
document.getElementById('aiEvalModal').addEventListener('click', function(e) {
if (e.target === this) {
closeAiEvalModal();
}
});
// Source names mapping for progress display
const SOURCE_NAMES = {
'brave': '🔍 Brave Search API',
'trojmiasto': '📰 trojmiasto.pl',
'dziennik_baltycki': '📰 Dziennik Bałtycki',
'gov_mon': '🏛️ Ministerstwo Obrony Narodowej',
'gov_przemysl': '🏛️ Min. Rozwoju i Technologii',
'google_news_zopk': '📡 Google News (ZOP Kaszubia)',
'google_news_offshore': '📡 Google News (offshore)',
'google_news_nuclear': '📡 Google News (elektrownia jądrowa)',
'google_news_samsonowicz': '📡 Google News (Samsonowicz)',
'google_news_kongsberg': '📡 Google News (Kongsberg)',
// New local media sources
'google_news_norda_fm': '📻 Norda FM',
'google_news_ttm': '📺 Twoja Telewizja Morska',
'google_news_nadmorski24': '📰 Nadmorski24.pl',
'google_news_samsonowicz_fb': '👤 Facebook (Samsonowicz)',
'google_news_norda': '📡 Google News (Norda Biznes)',
'google_news_spoko': '📡 Google News (Spoko Gospodarcze)'
};
const ALL_SOURCES = Object.keys(SOURCE_NAMES);
async function searchNews() {
const btn = document.getElementById('searchBtn');
const progressContainer = document.getElementById('progressContainer');
const progressBar = document.getElementById('progressBar');
const progressStatus = document.getElementById('progressStatus');
const progressPercent = document.getElementById('progressPercent');
const progressPhases = document.getElementById('progressPhases');
const progressSteps = document.getElementById('progressSteps');
const resultsContainer = document.getElementById('searchResultsContainer');
const resultsSummary = document.getElementById('searchResultsSummary');
const autoApprovedSection = document.getElementById('autoApprovedSection');
const autoApprovedList = document.getElementById('autoApprovedList');
const query = document.getElementById('searchQuery').value;
// Process phases definition
const PHASES = [
{ id: 'search', icon: '🔍', label: 'Wyszukiwanie' },
{ id: 'filter', icon: '🚫', label: 'Filtrowanie' },
{ id: 'ai', icon: '🤖', label: 'Analiza AI' },
{ id: 'save', icon: '💾', label: 'Zapisywanie' }
];
// Reset UI
btn.disabled = true;
btn.textContent = 'Szukam...';
resultsContainer.style.display = 'none';
autoApprovedSection.style.display = 'none';
progressContainer.classList.add('active');
progressBar.style.width = '0%';
progressBar.style.background = ''; // Reset color
progressPercent.textContent = '0%';
// Build progress phases UI
progressPhases.innerHTML = PHASES.map(phase => `
<div class="progress-phase pending" id="phase-${phase.id}">
<span class="progress-phase-icon">${phase.icon}</span>
<span>${phase.label}</span>
</div>
`).join('');
// Build initial progress steps (will be populated from process_log)
progressSteps.innerHTML = '<div class="progress-step active"><span class="progress-step-icon"></span><span>Inicjalizacja...</span></div>';
// Simulate progress phases while waiting for API
let currentPhaseIdx = 0;
const phaseMessages = [
'Przeszukuję źródła (Brave API + RSS)...',
'Filtruję wyniki (blacklist, słowa kluczowe)...',
'Analiza AI (Gemini ocenia artykuły)...',
'Zapisuję do bazy wiedzy...'
];
const progressInterval = setInterval(() => {
if (currentPhaseIdx < PHASES.length) {
// Update phase UI
PHASES.forEach((phase, idx) => {
const el = document.getElementById(`phase-${phase.id}`);
if (el) {
el.classList.remove('pending', 'active', 'completed');
if (idx < currentPhaseIdx) el.classList.add('completed');
else if (idx === currentPhaseIdx) el.classList.add('active');
else el.classList.add('pending');
}
});
progressStatus.textContent = phaseMessages[currentPhaseIdx];
const percent = Math.round(((currentPhaseIdx + 1) / PHASES.length) * 80);
progressBar.style.width = `${percent}%`;
progressPercent.textContent = `${percent}%`;
currentPhaseIdx++;
}
}, 2500); // Each phase ~2.5s for realistic timing
try {
const response = await fetch('{{ url_for("api_zopk_search_news") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ query: query })
});
clearInterval(progressInterval);
const data = await response.json();
if (data.success) {
// Mark all phases as completed
PHASES.forEach(phase => {
const el = document.getElementById(`phase-${phase.id}`);
if (el) {
el.classList.remove('pending', 'active');
el.classList.add('completed');
}
});
progressBar.style.width = '100%';
progressPercent.textContent = '100%';
progressStatus.textContent = '✅ Wyszukiwanie zakończone!';
// Display process log as steps
if (data.process_log && data.process_log.length > 0) {
// Show last few important steps
const importantSteps = data.process_log.filter(log =>
log.step.includes('done') || log.step.includes('complete') || log.phase === 'complete'
).slice(-6);
progressSteps.innerHTML = importantSteps.map(log => `
<div class="progress-step completed">
<span class="progress-step-icon"></span>
<span>${log.message}</span>
${log.count > 0 ? `<span class="progress-step-count">${log.count}</span>` : ''}
</div>
`).join('');
}
// Hide progress container after a moment
setTimeout(() => {
progressContainer.classList.remove('active');
}, 1500);
// Show results container
resultsContainer.style.display = 'block';
// Build summary stats
resultsSummary.innerHTML = `
<div class="summary-stat info">
<div class="value">${data.total_found || 0}</div>
<div class="label">Znaleziono</div>
</div>
<div class="summary-stat warning">
<div class="value">${(data.blacklisted || 0) + (data.keyword_filtered || 0)}</div>
<div class="label">Odfiltrowano</div>
</div>
<div class="summary-stat error">
<div class="value">${data.ai_rejected || 0}</div>
<div class="label">AI odrzucił</div>
</div>
<div class="summary-stat success">
<div class="value">${data.ai_approved || 0}</div>
<div class="label">AI zaakceptował</div>
</div>
<div class="summary-stat success">
<div class="value">${data.saved_new || 0}</div>
<div class="label">Nowe w bazie</div>
</div>
`;
// Show auto-approved articles list
if (data.auto_approved_articles && data.auto_approved_articles.length > 0) {
autoApprovedSection.style.display = 'block';
autoApprovedList.innerHTML = data.auto_approved_articles.map(article => {
const stars = '★'.repeat(article.score) + '☆'.repeat(5 - article.score);
return `
<div class="auto-approved-item">
<span class="stars">${stars}</span>
<span class="title">${article.title}</span>
<span class="source">${article.source || ''}</span>
</div>
`;
}).join('');
}
// Show detailed statistics section
const detailedStatsSection = document.getElementById('detailedStatsSection');
const detailedStatsGrid = document.getElementById('detailedStatsGrid');
const aiRejectedSection = document.getElementById('aiRejectedSection');
const aiRejectedList = document.getElementById('aiRejectedList');
// Build detailed statistics
const detailedStats = [
{ label: 'Zapytanie', value: query || 'ZOP Kaszubia', icon: '🔍', type: 'info' },
{ label: 'Źródła przeszukane', value: `Brave API + RSS`, icon: '📡', type: 'info' },
{ label: 'Łącznie znaleziono', value: data.total_found || 0, icon: '📰', type: 'info' },
{ label: 'Zablokowane (blacklist)', value: data.blacklisted || 0, icon: '🚫', type: 'warning' },
{ label: 'Odfiltrowane (słowa kluczowe)', value: data.keyword_filtered || 0, icon: '🔤', type: 'warning' },
{ label: 'Przekazane do AI', value: data.sent_to_ai || 0, icon: '🤖', type: 'info' },
{ label: 'AI zaakceptował (3+★)', value: data.ai_approved || 0, icon: '✅', type: 'success' },
{ label: 'AI odrzucił (1-2★)', value: data.ai_rejected || 0, icon: '❌', type: 'error' },
{ label: 'Duplikaty (już w bazie)', value: data.duplicates || 0, icon: '🔄', type: 'info' },
{ label: 'Zapisano nowych', value: data.saved_new || 0, icon: '💾', type: 'success' },
{ label: 'Do bazy wiedzy', value: data.knowledge_entities_created || 0, icon: '🧠', type: 'success' },
{ label: 'Czas przetwarzania', value: data.processing_time ? `${data.processing_time.toFixed(1)}s` : '-', icon: '⏱️', type: 'info' }
];
detailedStatsGrid.innerHTML = detailedStats.map(stat => `
<div class="detailed-stat-item" style="
background: ${stat.type === 'success' ? '#dcfce7' : stat.type === 'error' ? '#fee2e2' : stat.type === 'warning' ? '#fef3c7' : '#f3f4f6'};
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
display: flex;
justify-content: space-between;
align-items: center;
">
<span style="display: flex; align-items: center; gap: var(--spacing-xs);">
<span>${stat.icon}</span>
<span style="font-size: var(--font-size-sm);">${stat.label}</span>
</span>
<strong style="color: ${stat.type === 'success' ? '#166534' : stat.type === 'error' ? '#991b1b' : stat.type === 'warning' ? '#92400e' : '#374151'};">
${stat.value}
</strong>
</div>
`).join('');
detailedStatsSection.style.display = 'block';
// Show AI rejected articles if any
if (data.ai_rejected_articles && data.ai_rejected_articles.length > 0) {
aiRejectedSection.style.display = 'block';
aiRejectedList.innerHTML = data.ai_rejected_articles.map(article => {
const stars = '★'.repeat(article.score || 1) + '☆'.repeat(5 - (article.score || 1));
return `
<div class="ai-rejected-item" style="
padding: var(--spacing-xs) var(--spacing-sm);
border-bottom: 1px solid #fee2e2;
font-size: var(--font-size-sm);
display: flex;
gap: var(--spacing-sm);
align-items: center;
">
<span style="color: #f59e0b;">${stars}</span>
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${article.title}</span>
<span style="color: var(--text-secondary); font-size: var(--font-size-xs);">${article.source || ''}</span>
</div>
`;
}).join('');
}
// Scroll to results for better visibility
resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
// Error handling
progressBar.style.width = '100%';
progressBar.style.background = '#fca5a5';
progressStatus.textContent = 'Błąd wyszukiwania';
PHASES.forEach(phase => {
const el = document.getElementById(`phase-${phase.id}`);
if (el) el.classList.remove('active');
});
progressSteps.innerHTML = `
<div class="progress-step" style="color: #fca5a5;">
<span class="progress-step-icon"></span>
<span>Błąd: ${data.error}</span>
</div>
`;
btn.disabled = false;
btn.textContent = 'Szukaj artykułów';
}
} catch (error) {
clearInterval(progressInterval);
progressBar.style.width = '100%';
progressBar.style.background = '#fca5a5';
progressStatus.textContent = 'Błąd połączenia';
progressSteps.innerHTML = `
<div class="progress-step" style="color: #fca5a5;">
<span class="progress-step-icon"></span>
<span>Błąd połączenia: ${error.message}</span>
</div>
`;
btn.disabled = false;
btn.textContent = 'Szukaj artykułów';
}
}
// Close modal on click outside
document.getElementById('addNewsModal').addEventListener('click', function(e) {
if (e.target === this) {
closeAddNewsModal();
}
});
// ===========================================
// AI Knowledge Base Functions
// ===========================================
async function loadKnowledgeStats() {
try {
const response = await fetch('/admin/zopk/knowledge/stats');
const data = await response.json();
if (data.success) {
document.getElementById('knowledge-stats-loading').style.display = 'none';
document.getElementById('knowledge-stats-content').style.display = 'block';
// Scraping stats
document.getElementById('kb-total-approved').textContent = data.articles?.total_approved || 0;
document.getElementById('kb-scraped').textContent = data.articles?.scraped || 0;
document.getElementById('kb-pending-scrape').textContent = data.articles?.pending_scrape || 0;
// Knowledge base stats
document.getElementById('kb-total-chunks').textContent = data.knowledge_base?.total_chunks || 0;
document.getElementById('kb-total-facts').textContent = data.knowledge_base?.total_facts || 0;
document.getElementById('kb-total-entities').textContent = data.knowledge_base?.total_entities || 0;
// Embeddings stats
document.getElementById('kb-with-embeddings').textContent = data.knowledge_base?.chunks_with_embeddings || 0;
document.getElementById('kb-pending-embeddings').textContent = data.knowledge_base?.chunks_without_embeddings || 0;
// Top entities
if (data.top_entities && data.top_entities.length > 0) {
document.getElementById('top-entities-section').style.display = 'block';
const list = document.getElementById('top-entities-list');
list.innerHTML = data.top_entities.map(e =>
`<span class="entity-pill entity-${e.type}">${e.name} <small>(${e.mentions})</small></span>`
).join('');
}
} else {
throw new Error(data.error || 'Unknown error');
}
} catch (error) {
console.error('Error loading knowledge stats:', error);
document.getElementById('knowledge-stats-loading').style.display = 'none';
document.getElementById('knowledge-stats-error').style.display = 'block';
document.getElementById('knowledge-stats-error').textContent = `Błąd: ${error.message}`;
}
}
// ===========================================
// AI Operations Modal Functions
// ===========================================
let aiOpsEventSource = null;
let aiOpsLogEntries = 0;
function openAiOpsModal(title, icon) {
const modal = document.getElementById('aiOpsModal');
document.getElementById('aiOpsTitle').textContent = title;
document.getElementById('aiOpsIcon').textContent = icon;
document.getElementById('aiOpsIcon').classList.add('spinning');
// Reset state
document.getElementById('aiOpsProgressFill').style.width = '0%';
document.getElementById('aiOpsPercent').textContent = '0%';
document.getElementById('aiOpsCounter').textContent = '0 / 0';
document.getElementById('aiOpsCurrentText').textContent = 'Inicjalizacja...';
document.getElementById('aiOpsLog').innerHTML = '';
document.getElementById('aiOpsSummary').style.display = 'none';
document.getElementById('aiOpsActions').style.display = 'none';
document.getElementById('aiOpsCloseBtn').style.display = 'none';
document.getElementById('aiOpsCurrentOp').style.display = 'flex';
aiOpsLogEntries = 0;
document.getElementById('aiOpsLogCount').textContent = '0 wpisów';
modal.classList.add('active');
}
function closeAiOpsModal() {
const modal = document.getElementById('aiOpsModal');
modal.classList.remove('active');
// Close SSE connection if active
if (aiOpsEventSource) {
aiOpsEventSource.close();
aiOpsEventSource = null;
}
}
function addAiOpsLogEntry(status, message) {
const log = document.getElementById('aiOpsLog');
const time = new Date().toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const entry = document.createElement('div');
entry.className = `log-entry ${status}`;
entry.innerHTML = `
<span class="log-time">${time}</span>
<span class="log-message">${message}</span>
`;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
aiOpsLogEntries++;
document.getElementById('aiOpsLogCount').textContent = `${aiOpsLogEntries} wpisów`;
}
function updateAiOpsProgress(data) {
// Update progress bar
if (data.percent !== undefined) {
document.getElementById('aiOpsProgressFill').style.width = `${data.percent}%`;
document.getElementById('aiOpsPercent').textContent = `${Math.round(data.percent)}%`;
}
// Update counter
if (data.current !== undefined && data.total !== undefined) {
document.getElementById('aiOpsCounter').textContent = `${data.current} / ${data.total}`;
}
// Update current operation text
if (data.message) {
document.getElementById('aiOpsCurrentText').textContent = data.message;
}
// Add log entry
if (data.status && data.message) {
addAiOpsLogEntry(data.status, data.message);
}
}
function completeAiOpsModal(data) {
// Stop spinning
document.getElementById('aiOpsIcon').classList.remove('spinning');
document.getElementById('aiOpsIcon').textContent = data.status === 'error' ? '❌' : '✅';
document.getElementById('aiOpsTitle').textContent = data.status === 'error' ? 'Błąd operacji' : 'Operacja zakończona';
// Hide current operation
document.getElementById('aiOpsCurrentOp').style.display = 'none';
// Show summary
const details = data.details || {};
document.getElementById('aiOpsSummarySuccess').textContent = details.success || details.scraped || 0;
document.getElementById('aiOpsSummaryFailed').textContent = details.failed || 0;
if (details.skipped !== undefined) {
document.getElementById('aiOpsSummarySkippedRow').style.display = 'flex';
document.getElementById('aiOpsSummarySkipped').textContent = details.skipped;
}
if (details.processing_time) {
document.getElementById('aiOpsSummaryTime').textContent = `${details.processing_time}s`;
}
document.getElementById('aiOpsSummary').style.display = 'grid';
document.getElementById('aiOpsActions').style.display = 'flex';
document.getElementById('aiOpsCloseBtn').style.display = 'block';
}
function startSSEOperation(endpoint, title, icon, limit) {
openAiOpsModal(title, icon);
const url = `${endpoint}?limit=${limit}`;
aiOpsEventSource = new EventSource(url);
aiOpsEventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.status === 'complete' || data.status === 'error') {
aiOpsEventSource.close();
aiOpsEventSource = null;
completeAiOpsModal(data);
} else {
updateAiOpsProgress(data);
}
};
aiOpsEventSource.onerror = function(event) {
console.error('SSE error:', event);
aiOpsEventSource.close();
aiOpsEventSource = null;
completeAiOpsModal({ status: 'error', message: 'Błąd połączenia', details: {} });
};
}
// ===========================================
// AI Knowledge Base Functions (with SSE)
// ===========================================
async function scrapeContent() {
const confirmed = await showConfirm(
'Czy chcesz rozpocząć scrapowanie treści artykułów?<br><br>' +
'<small>Proces pobierze pełną treść z zatwierdzonych newsów które jeszcze nie mają treści.<br>' +
'Postęp będzie wyświetlany na żywo.</small>',
{ icon: '📄', title: 'Scraping treści', okText: 'Rozpocznij', okClass: 'btn-primary' }
);
if (!confirmed) return;
startSSEOperation('/admin/zopk/news/scrape-content/stream', 'Scraping treści artykułów', '📄', 30);
}
async function extractKnowledge() {
const confirmed = await showConfirm(
'Czy chcesz uruchomić ekstrakcję wiedzy przez AI?<br><br>' +
'<small>Gemini AI przeanalizuje zescrapowane artykuły i wyekstrahuje:<br>' +
'• Chunks (fragmenty tekstu)<br>' +
'• Fakty (daty, liczby, decyzje)<br>' +
'• Encje (firmy, osoby, projekty)<br><br>' +
'Postęp będzie wyświetlany na żywo.</small>',
{ icon: '🤖', title: 'Ekstrakcja wiedzy', okText: 'Uruchom AI', okClass: 'btn-primary' }
);
if (!confirmed) return;
startSSEOperation('/admin/zopk/knowledge/extract/stream', 'Ekstrakcja wiedzy (Gemini AI)', '🤖', 10);
}
async function generateEmbeddings() {
const confirmed = await showConfirm(
'Czy chcesz wygenerować embeddingi dla semantic search?<br><br>' +
'<small>Google Text Embedding API przekształci tekst w wektory 768-wymiarowe.<br>' +
'Embeddingi umożliwiają inteligentne wyszukiwanie w bazie wiedzy.<br><br>' +
'Postęp będzie wyświetlany na żywo.</small>',
{ icon: '🔍', title: 'Generowanie embeddingów', okText: 'Generuj', okClass: 'btn-primary' }
);
if (!confirmed) return;
startSSEOperation('/admin/zopk/knowledge/embeddings/stream', 'Generowanie embeddingów', '🔍', 50);
}
// Keep old code for backward compatibility (non-SSE version - can be removed later)
async function scrapeContentOld() {
const btn = document.getElementById('scrapeBtn');
const originalContent = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-small"></span>';
try {
const response = await fetch('/admin/zopk/news/scrape-content', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ limit: 30 })
});
const data = await response.json();
if (data.success) {
showToast(data.message || `Zescrapowano ${data.scraped || 0} artykułów`, 'success');
loadKnowledgeStats();
} else {
showToast(data.error || 'Błąd scrapowania', 'error');
}
} catch (error) {
showToast(`Błąd: ${error.message}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalContent;
}
}
async function extractKnowledgeOld() {
const btn = document.getElementById('extractBtn');
const originalContent = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-small"></span>';
try {
const response = await fetch('/admin/zopk/knowledge/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ limit: 10 })
});
const data = await response.json();
if (data.success) {
showToast(data.message || 'Ekstrakcja zakończona', 'success');
loadKnowledgeStats();
} else {
showToast(data.error || 'Błąd ekstrakcji', 'error');
}
} catch (error) {
showToast(`Błąd: ${error.message}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalContent;
}
}
async function generateEmbeddingsOld() {
const btn = document.getElementById('embeddingsBtn');
const originalContent = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-small"></span>';
try {
const response = await fetch('/admin/zopk/knowledge/embeddings', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ limit: 50 })
});
const data = await response.json();
if (data.success) {
showToast(data.message || 'Embeddingi wygenerowane', 'success');
loadKnowledgeStats();
} else {
showToast(data.error || 'Błąd generowania', 'error');
}
} catch (error) {
showToast(`Błąd: ${error.message}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalContent;
}
}
// Load knowledge stats on page load
document.addEventListener('DOMContentLoaded', loadKnowledgeStats);
{% endblock %}