diff --git a/app.py b/app.py index 3e405f1..54677f0 100644 --- a/app.py +++ b/app.py @@ -7348,19 +7348,32 @@ def admin_zopk(): 'old_news': db.query(ZOPKNews).filter( ZOPKNews.status == 'pending', ZOPKNews.published_at < datetime(min_year, 1, 1) - ).count() if not show_old else 0 + ).count() if not show_old else 0, + # AI evaluation stats + 'ai_relevant': db.query(ZOPKNews).filter(ZOPKNews.ai_relevant == True).count(), + 'ai_not_relevant': db.query(ZOPKNews).filter(ZOPKNews.ai_relevant == False).count(), + 'ai_not_evaluated': db.query(ZOPKNews).filter( + ZOPKNews.status == 'pending', + ZOPKNews.ai_relevant.is_(None) + ).count() } # Build news query with filters news_query = db.query(ZOPKNews) - # Status filter + # Status filter (including AI-based filters) if status_filter == 'pending': news_query = news_query.filter(ZOPKNews.status == 'pending') elif status_filter == 'approved': news_query = news_query.filter(ZOPKNews.status.in_(['approved', 'auto_approved'])) elif status_filter == 'rejected': news_query = news_query.filter(ZOPKNews.status == 'rejected') + elif status_filter == 'ai_relevant': + # AI evaluated as relevant (regardless of status) + news_query = news_query.filter(ZOPKNews.ai_relevant == True) + elif status_filter == 'ai_not_relevant': + # AI evaluated as NOT relevant + news_query = news_query.filter(ZOPKNews.ai_relevant == False) # 'all' - no status filter # Date filter - exclude old news by default @@ -7626,6 +7639,40 @@ def admin_zopk_reject_old_news(): db.close() +@app.route('/admin/zopk/news/evaluate-ai', methods=['POST']) +@login_required +def admin_zopk_evaluate_ai(): + """Evaluate pending news for ZOPK relevance using Gemini AI""" + if not current_user.is_admin: + return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 + + from zopk_news_service import evaluate_pending_news + + db = SessionLocal() + try: + data = request.get_json() or {} + limit = data.get('limit', 50) # Max 50 to avoid API limits + + # Run AI evaluation + result = evaluate_pending_news(db, limit=limit, user_id=current_user.id) + + return jsonify({ + 'success': True, + 'total_evaluated': result.get('total_evaluated', 0), + 'relevant_count': result.get('relevant_count', 0), + 'not_relevant_count': result.get('not_relevant_count', 0), + 'errors': result.get('errors', 0), + 'message': result.get('message', '') + }) + + except Exception as e: + db.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + finally: + db.close() + + @app.route('/api/zopk/search-news', methods=['POST']) @login_required def api_zopk_search_news(): diff --git a/database.py b/database.py index deb5d6d..c2dc520 100644 --- a/database.py +++ b/database.py @@ -1779,6 +1779,12 @@ class ZOPKNews(Base): title_hash = Column(String(64), index=True) # For fuzzy title matching (normalized) is_auto_verified = Column(Boolean, default=False) # True if 3+ sources confirmed + # AI Relevance Evaluation (Gemini) + ai_relevant = Column(Boolean) # True = relevant to ZOPK, False = not relevant, NULL = not evaluated + ai_evaluation_reason = Column(Text) # AI's explanation of relevance decision + ai_evaluated_at = Column(DateTime) # When AI evaluation was performed + ai_model = Column(String(100)) # Which AI model was used (e.g., gemini-2.0-flash) + # Moderation workflow status = Column(String(20), default='pending', index=True) # pending, approved, rejected, auto_approved moderated_by = Column(Integer, ForeignKey('users.id')) diff --git a/database/migrations/005_zopk_knowledge_base.sql b/database/migrations/005_zopk_knowledge_base.sql index 40475de..f1ee605 100644 --- a/database/migrations/005_zopk_knowledge_base.sql +++ b/database/migrations/005_zopk_knowledge_base.sql @@ -405,6 +405,50 @@ CREATE INDEX IF NOT EXISTS idx_zopk_news_title_hash ON zopk_news(title_hash); -- Index for confidence score (filtering high-confidence news) CREATE INDEX IF NOT EXISTS idx_zopk_news_confidence ON zopk_news(confidence_score); +-- ============================================================ +-- 12. ALTER TABLE - AI Relevance Evaluation columns +-- ============================================================ +-- These columns support AI-based relevance evaluation using Google Gemini + +-- AI relevance flag (True = relevant to ZOPK, False = not relevant, NULL = not evaluated) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'zopk_news' AND column_name = 'ai_relevant') THEN + ALTER TABLE zopk_news ADD COLUMN ai_relevant BOOLEAN; + END IF; +END $$; + +-- AI evaluation reason/explanation +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'zopk_news' AND column_name = 'ai_evaluation_reason') THEN + ALTER TABLE zopk_news ADD COLUMN ai_evaluation_reason TEXT; + END IF; +END $$; + +-- When AI evaluation was performed +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'zopk_news' AND column_name = 'ai_evaluated_at') THEN + ALTER TABLE zopk_news ADD COLUMN ai_evaluated_at TIMESTAMP; + END IF; +END $$; + +-- Which AI model was used for evaluation +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'zopk_news' AND column_name = 'ai_model') THEN + ALTER TABLE zopk_news ADD COLUMN ai_model VARCHAR(100); + END IF; +END $$; + +-- Index for AI relevance filtering +CREATE INDEX IF NOT EXISTS idx_zopk_news_ai_relevant ON zopk_news(ai_relevant); + -- ============================================================ -- MIGRATION COMPLETE -- ============================================================ diff --git a/templates/admin/zopk_dashboard.html b/templates/admin/zopk_dashboard.html index 5501ee5..07d3dbc 100644 --- a/templates/admin/zopk_dashboard.html +++ b/templates/admin/zopk_dashboard.html @@ -121,6 +121,79 @@ 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; + } + /* Filters bar */ .filters-bar { display: flex; @@ -705,6 +778,30 @@ + +
+

Ocena AI (Gemini) (kliknij aby wybrać)

+
+ +
{{ stats.ai_relevant }}
+
Pasuje wg AI
+
+ +
{{ stats.ai_not_relevant }}
+
Nie pasuje wg AI
+
+
+
{{ stats.ai_not_evaluated }}
+
Nieocenione
+
+ +
+ +
+ {% if stats.old_news > 0 and not show_old %}
@@ -751,6 +848,8 @@ {% 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 }}) @@ -788,6 +887,12 @@ {% elif news.status == 'rejected' %} ✗ Odrzucony {% endif %} + {# AI Evaluation badge #} + {% if news.ai_relevant is not none %} + + 🤖 {{ 'Pasuje' if news.ai_relevant else 'Nie pasuje' }} + + {% endif %}
@@ -1060,6 +1165,58 @@ async function rejectOldNews() { } } +// AI Evaluation function +async function evaluateWithAI() { + const btn = document.getElementById('aiEvalBtn'); + const resultDiv = document.getElementById('aiEvalResult'); + + if (!confirm('Czy chcesz uruchomić ocenę AI (Gemini) dla nieocenionych newsów?\n\nProces może potrwać kilka minut. Ocenionych zostanie max 50 newsów.')) { + return; + } + + btn.disabled = true; + btn.querySelector('.stat-label').textContent = 'Oceniam...'; + resultDiv.style.display = 'block'; + resultDiv.className = 'ai-result'; + resultDiv.innerHTML = '🤖 Trwa ocena newsów przez AI... Proszę czekać.'; + + try { + const response = await fetch('/admin/zopk/news/evaluate-ai', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ limit: 50 }) + }); + + const data = await response.json(); + + if (data.success) { + resultDiv.className = 'ai-result success'; + resultDiv.innerHTML = ` + ✓ ${data.message}
+ Pasuje: ${data.relevant_count} | Nie pasuje: ${data.not_relevant_count} | Błędy: ${data.errors} + `; + + // Refresh page after 3 seconds + setTimeout(() => { + location.reload(); + }, 3000); + } else { + resultDiv.className = 'ai-result error'; + resultDiv.innerHTML = `✗ Błąd: ${data.error}`; + btn.disabled = false; + btn.querySelector('.stat-label').textContent = 'Oceń przez AI'; + } + } catch (error) { + resultDiv.className = 'ai-result error'; + resultDiv.innerHTML = `✗ Błąd połączenia: ${error.message}`; + btn.disabled = false; + btn.querySelector('.stat-label').textContent = 'Oceń przez AI'; + } +} + // Source names mapping for progress display const SOURCE_NAMES = { 'brave': '🔍 Brave Search API', @@ -1072,6 +1229,11 @@ const SOURCE_NAMES = { '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)' }; diff --git a/zopk_news_service.py b/zopk_news_service.py index f7e16fd..8a6b058 100644 --- a/zopk_news_service.py +++ b/zopk_news_service.py @@ -109,6 +109,32 @@ RSS_SOURCES = { 'name': 'Google News', 'type': 'aggregator', 'keywords': [] + }, + # Regional media (via Google News - site-specific searches) + 'google_news_norda_fm': { + 'url': 'https://news.google.com/rss/search?q=site:nordafm.pl+OR+%22Norda+FM%22&hl=pl&gl=PL&ceid=PL:pl', + 'name': 'Norda FM', + 'type': 'local_media', + 'keywords': [] + }, + 'google_news_ttm': { + 'url': 'https://news.google.com/rss/search?q=site:ttm24.pl+OR+%22Twoja+Telewizja+Morska%22&hl=pl&gl=PL&ceid=PL:pl', + 'name': 'Twoja Telewizja Morska', + 'type': 'local_media', + 'keywords': [] + }, + 'google_news_nadmorski24': { + 'url': 'https://news.google.com/rss/search?q=site:nadmorski24.pl&hl=pl&gl=PL&ceid=PL:pl', + 'name': 'Nadmorski24.pl', + 'type': 'local_media', + 'keywords': [] + }, + # Facebook - Maciej Samsonowicz (via Google search - FB doesn't have RSS) + 'google_news_samsonowicz_fb': { + 'url': 'https://news.google.com/rss/search?q=%22Maciej+Samsonowicz%22+facebook&hl=pl&gl=PL&ceid=PL:pl', + 'name': 'Google News (Facebook Samsonowicz)', + 'type': 'aggregator', + 'keywords': [] } } @@ -513,3 +539,191 @@ def search_zopk_news(db_session, query: str = None) -> Dict: """ service = ZOPKNewsService(db_session) return service.search_all_sources(query or 'Zielony Okręg Przemysłowy Kaszubia') + + +# ============================================================ +# AI RELEVANCE EVALUATION (GEMINI) +# ============================================================ + +ZOPK_AI_EVALUATION_PROMPT = """Jesteś ekspertem ds. analizy wiadomości. Oceń, czy poniższy artykuł/news dotyczy projektu **Zielony Okręg Przemysłowy Kaszubia (ZOPK)** lub związanych z nim tematów. + +**ZOPK obejmuje:** +1. Morską energetykę wiatrową na Bałtyku (offshore wind) +2. Elektrownię jądrową w Lubiatowie-Kopalino (Choczewo) +3. Inwestycję Kongsberg w Rumi (przemysł obronny) +4. Centra danych i laboratoria wodorowe +5. Rozwój przemysłowy Kaszub (Wejherowo, Rumia, Gdynia) +6. Kluczowe osoby: Maciej Samsonowicz (koordynator ZOPK), minister Kosiniak-Kamysz + +**Artykuł do oceny:** +Tytuł: {title} +Opis: {description} +Źródło: {source} +Data: {date} + +**Twoje zadanie:** +1. Oceń czy artykuł dotyczy ZOPK lub powiązanych tematów +2. Odpowiedz TYLKO w formacie JSON (bez żadnego innego tekstu): + +{{"relevant": true/false, "reason": "krótkie uzasadnienie po polsku (max 100 znaków)"}} + +Przykłady odpowiedzi: +{{"relevant": true, "reason": "Dotyczy inwestycji Kongsberg w Rumi"}} +{{"relevant": false, "reason": "Artykuł o lokalnych wydarzeniach kulturalnych"}} +{{"relevant": true, "reason": "Informacje o farmach wiatrowych na Bałtyku"}} +{{"relevant": false, "reason": "News sportowy bez związku z przemysłem"}}""" + + +def evaluate_news_relevance(news_item, gemini_service=None) -> Dict: + """ + Evaluate a single news item for ZOPK relevance using Gemini AI. + + Args: + news_item: ZOPKNews object or dict with title, description, source_name, published_at + gemini_service: Optional GeminiService instance (uses global if not provided) + + Returns: + Dict with keys: relevant (bool), reason (str), evaluated (bool) + """ + import json + + # Get Gemini service + if gemini_service is None: + try: + from gemini_service import get_gemini_service + gemini_service = get_gemini_service() + except Exception as e: + logger.error(f"Failed to get Gemini service: {e}") + return {'relevant': None, 'reason': 'Gemini service unavailable', 'evaluated': False} + + if gemini_service is None: + return {'relevant': None, 'reason': 'Gemini service not initialized', 'evaluated': False} + + # Extract fields from news_item + if hasattr(news_item, 'title'): + title = news_item.title or '' + description = news_item.description or '' + source = news_item.source_name or news_item.source_domain or '' + date = news_item.published_at.strftime('%Y-%m-%d') if news_item.published_at else '' + else: + title = news_item.get('title', '') + description = news_item.get('description', '') + source = news_item.get('source_name', '') + date = news_item.get('published_at', '') + + # Build prompt + prompt = ZOPK_AI_EVALUATION_PROMPT.format( + title=title[:500], # Limit length + description=description[:1000] if description else 'Brak opisu', + source=source[:100], + date=date + ) + + try: + # Call Gemini with low temperature for consistent results + response = gemini_service.generate_text( + prompt, + temperature=0.1, + feature='zopk_news_evaluation' + ) + + # Parse JSON response + # Try to extract JSON from response (handle markdown code blocks) + json_match = re.search(r'\{[^{}]*\}', response) + if json_match: + result = json.loads(json_match.group()) + return { + 'relevant': bool(result.get('relevant', False)), + 'reason': str(result.get('reason', ''))[:255], + 'evaluated': True + } + else: + logger.warning(f"Could not parse Gemini response: {response[:200]}") + return {'relevant': None, 'reason': 'Invalid AI response format', 'evaluated': False} + + except json.JSONDecodeError as e: + logger.error(f"JSON decode error: {e}") + return {'relevant': None, 'reason': f'JSON parse error: {str(e)[:50]}', 'evaluated': False} + except Exception as e: + logger.error(f"Gemini evaluation error: {e}") + return {'relevant': None, 'reason': f'AI error: {str(e)[:50]}', 'evaluated': False} + + +def evaluate_pending_news(db_session, limit: int = 50, user_id: int = None) -> Dict: + """ + Evaluate multiple pending news items for ZOPK relevance. + + Args: + db_session: SQLAlchemy session + limit: Max number of items to evaluate (to avoid API limits) + user_id: User triggering the evaluation (for logging) + + Returns: + Dict with stats: total_evaluated, relevant_count, not_relevant_count, errors + """ + from database import ZOPKNews + from datetime import datetime + + # Get pending news that haven't been AI-evaluated yet + pending_news = db_session.query(ZOPKNews).filter( + ZOPKNews.status == 'pending', + ZOPKNews.ai_relevant.is_(None) # Not yet evaluated + ).order_by(ZOPKNews.created_at.desc()).limit(limit).all() + + if not pending_news: + return { + 'total_evaluated': 0, + 'relevant_count': 0, + 'not_relevant_count': 0, + 'errors': 0, + 'message': 'Brak newsów do oceny' + } + + # Get Gemini service once + try: + from gemini_service import get_gemini_service + gemini = get_gemini_service() + except Exception as e: + return { + 'total_evaluated': 0, + 'relevant_count': 0, + 'not_relevant_count': 0, + 'errors': 1, + 'message': f'Gemini service error: {str(e)}' + } + + stats = { + 'total_evaluated': 0, + 'relevant_count': 0, + 'not_relevant_count': 0, + 'errors': 0 + } + + for news in pending_news: + result = evaluate_news_relevance(news, gemini) + + if result['evaluated']: + news.ai_relevant = result['relevant'] + news.ai_evaluation_reason = result['reason'] + news.ai_evaluated_at = datetime.now() + news.ai_model = 'gemini-2.0-flash' + + stats['total_evaluated'] += 1 + if result['relevant']: + stats['relevant_count'] += 1 + else: + stats['not_relevant_count'] += 1 + else: + stats['errors'] += 1 + logger.warning(f"Failed to evaluate news {news.id}: {result['reason']}") + + # Commit all changes + try: + db_session.commit() + stats['message'] = f"Oceniono {stats['total_evaluated']} newsów: {stats['relevant_count']} pasuje, {stats['not_relevant_count']} nie pasuje" + except Exception as e: + db_session.rollback() + stats['errors'] += 1 + stats['message'] = f'Database error: {str(e)}' + + return stats