diff --git a/CLAUDE.md b/CLAUDE.md index b96986d..493269d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -788,6 +788,56 @@ AKTUALNOŚCI # 0 */6 * * * cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/fetch_company_news.py --all >> /var/log/nordabiznes/news_fetch.log 2>&1 ``` +## ZOP Kaszubia News (ZOPK) + +### Opis + +System monitoringu newsów związanych z projektem **Zielony Okręg Przemysłowy Kaszubia**. +Panel admina: `/admin/zopk/news` + +### Tematy ZOP Kaszubia (istotne) + +- **Zielony Okręg Przemysłowy Kaszubia** - główny projekt +- **Elektrownia jądrowa na Pomorzu** - Lubiatowo-Kopalino +- **Offshore wind Bałtyk** - farmy wiatrowe, Baltic Power, Baltica +- **Via Pomerania** - droga ekspresowa Ustka-Bydgoszcz +- **Droga Czerwona** - połączenie z Portem Gdynia +- **Kongsberg** - norweskie inwestycje zbrojeniowe w Rumi +- **Pakt Bezpieczeństwa Pomorze Środkowe** - MON +- **Izba Przedsiębiorców NORDA** - lokalne organizacje biznesowe + +### Tematy NIEZWIĄZANE (do odrzucenia) + +- Turystyka na Kaszubach (kuligi, lodowiska, hotele) +- Polityka ogólnopolska (Ziobro, polexit) +- Inne regiony Polski (Śląsk, Lubuskie, Małopolska) +- Wypadki i wydarzenia kryminalne +- Clickbait i lifestyle + +### Reguły auto-approve (WAŻNE!) + +**Próg auto-approve: score >= 3** (verified 2026-01-15) + +| Score | Status | Opis | +|-------|--------|------| +| 1-2 | `pending` | Wymaga ręcznej moderacji | +| 3-5 | `auto_approved` | Automatycznie zatwierdzony | + +**Plik:** `zopk_news_service.py` (linie 890, 1124, 1145) + +### Tabela zopk_news + +```sql +zopk_news ( + id, title, url, description, + source_name, source_domain, source_type, + ai_relevance_score INTEGER, -- 1-5 gwiazdek + status VARCHAR(20), -- pending, auto_approved, approved, rejected + confidence_score, source_count, + created_at, updated_at +) +``` + ## Social Media - Stan aktualny ### Statystyki (2025-12-29) diff --git a/app.py b/app.py index 61803f2..efe98de 100644 --- a/app.py +++ b/app.py @@ -10245,6 +10245,7 @@ def admin_zopk_news(): try: page = request.args.get('page', 1, type=int) status = request.args.get('status', 'all') + stars = request.args.get('stars', 'all') # 'all', '1'-'5', 'none' sort_by = request.args.get('sort', 'date') # 'date', 'score', 'title' sort_dir = request.args.get('dir', 'desc') # 'asc', 'desc' per_page = 50 @@ -10253,6 +10254,13 @@ def admin_zopk_news(): if status != 'all': query = query.filter(ZOPKNews.status == status) + # Filter by star rating + if stars == 'none': + query = query.filter(ZOPKNews.ai_relevance_score.is_(None)) + elif stars in ['1', '2', '3', '4', '5']: + query = query.filter(ZOPKNews.ai_relevance_score == int(stars)) + # 'all' - no filter + # Apply sorting sort_func = desc if sort_dir == 'desc' else asc if sort_by == 'score': @@ -10277,6 +10285,7 @@ def admin_zopk_news(): total_pages=total_pages, total=total, current_status=status, + current_stars=stars, current_sort=sort_by, current_dir=sort_dir ) @@ -10483,6 +10492,117 @@ def admin_zopk_reject_old_news(): db.close() +@app.route('/admin/zopk/news/star-counts') +@login_required +def admin_zopk_news_star_counts(): + """Get counts of pending news items grouped by star rating""" + if not current_user.is_admin: + return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 + + from database import ZOPKNews + from sqlalchemy import func + + db = SessionLocal() + try: + # Count pending news for each star rating (1-5 and NULL) + counts = {} + + # Count for each star 1-5 + for star in range(1, 6): + count = db.query(func.count(ZOPKNews.id)).filter( + ZOPKNews.status == 'pending', + ZOPKNews.ai_relevance_score == star + ).scalar() + counts[star] = count + + # Count for NULL (no AI evaluation) + count_null = db.query(func.count(ZOPKNews.id)).filter( + ZOPKNews.status == 'pending', + ZOPKNews.ai_relevance_score.is_(None) + ).scalar() + counts[0] = count_null + + return jsonify({ + 'success': True, + 'counts': counts + }) + + except Exception as e: + logger.error(f"Error getting ZOPK news star counts: {e}") + return jsonify({'success': False, 'error': 'Wystąpił błąd'}), 500 + + finally: + db.close() + + +@app.route('/admin/zopk/news/reject-by-stars', methods=['POST']) +@login_required +def admin_zopk_reject_by_stars(): + """Reject all pending news items with specified star ratings""" + if not current_user.is_admin: + return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 + + from database import ZOPKNews + + db = SessionLocal() + try: + data = request.get_json() or {} + stars = data.get('stars', []) # List of star ratings to reject (0 = no rating) + reason = data.get('reason', '') + + if not stars: + return jsonify({'success': False, 'error': 'Nie wybrano ocen do odrzucenia'}), 400 + + # Validate stars input + valid_stars = [s for s in stars if s in [0, 1, 2, 3, 4, 5]] + if not valid_stars: + return jsonify({'success': False, 'error': 'Nieprawidłowe oceny gwiazdkowe'}), 400 + + # Build query for pending news with specified stars + from sqlalchemy import or_ + conditions = [] + for star in valid_stars: + if star == 0: + conditions.append(ZOPKNews.ai_relevance_score.is_(None)) + else: + conditions.append(ZOPKNews.ai_relevance_score == star) + + news_to_reject = db.query(ZOPKNews).filter( + ZOPKNews.status == 'pending', + or_(*conditions) + ).all() + + count = len(news_to_reject) + + # Reject them all + default_reason = f"Masowo odrzucone - oceny: {', '.join(str(s) + '★' if s > 0 else 'brak oceny' for s in valid_stars)}" + final_reason = reason if reason else default_reason + + for news in news_to_reject: + news.status = 'rejected' + news.moderated_by = current_user.id + news.moderated_at = datetime.now() + news.rejection_reason = final_reason + + db.commit() + + logger.info(f"Admin {current_user.email} rejected {count} ZOPK news with stars {valid_stars}") + + return jsonify({ + 'success': True, + 'message': f'Odrzucono {count} artykułów', + 'count': count + }) + + except Exception as e: + db.rollback() + logger.error(f"Error rejecting ZOPK news by stars: {e}") + return jsonify({'success': False, 'error': 'Wystąpił błąd podczas odrzucania'}), 500 + + finally: + db.close() + + @app.route('/admin/zopk/news/evaluate-ai', methods=['POST']) @login_required def admin_zopk_evaluate_ai(): @@ -10620,7 +10740,13 @@ def api_zopk_search_news(): 'saved_new': results['saved_new'], 'updated_existing': results['updated_existing'], 'auto_approved': results['auto_approved'], - 'source_stats': results['source_stats'] + 'ai_approved': results.get('ai_approved', 0), + 'ai_rejected': results.get('ai_rejected', 0), + 'blacklisted': results.get('blacklisted', 0), + 'keyword_filtered': results.get('keyword_filtered', 0), + 'source_stats': results['source_stats'], + 'process_log': results.get('process_log', []), + 'auto_approved_articles': results.get('auto_approved_articles', []) }) except Exception as e: diff --git a/database.py b/database.py index 082dd82..112497a 100644 --- a/database.py +++ b/database.py @@ -2041,6 +2041,313 @@ class ZOPKNewsFetchJob(Base): user = relationship('User', foreign_keys=[triggered_by_user]) +# ============================================================ +# ZOPK KNOWLEDGE BASE (AI-powered, with pgvector) +# ============================================================ + +class ZOPKKnowledgeChunk(Base): + """ + Knowledge chunks extracted from approved ZOPK news articles. + Each chunk is a semantically coherent piece of text with embedding vector + for similarity search (RAG - Retrieval Augmented Generation). + + Best practices: + - Chunk size: 500-1000 tokens with ~100 token overlap + - Embedding model: text-embedding-004 (768 dimensions) + """ + __tablename__ = 'zopk_knowledge_chunks' + + id = Column(Integer, primary_key=True) + + # Source tracking + source_news_id = Column(Integer, ForeignKey('zopk_news.id'), nullable=False, index=True) + + # Chunk content + content = Column(Text, nullable=False) # The actual text chunk + content_clean = Column(Text) # Cleaned/normalized version for processing + chunk_index = Column(Integer) # Position in the original article (0, 1, 2...) + token_count = Column(Integer) # Approximate token count + + # Semantic embedding (pgvector) + # Using 768 dimensions for Google text-embedding-004 + # Will be stored as: embedding vector(768) + embedding = Column(Text) # Stored as JSON string, converted to vector for queries + + # AI-extracted metadata + chunk_type = Column(String(50)) # narrative, fact, quote, statistic, event, definition + summary = Column(Text) # 1-2 sentence summary + keywords = Column(PG_ARRAY(String(100)) if not IS_SQLITE else Text) # Extracted keywords + language = Column(String(10), default='pl') # pl, en + + # Context information + context_date = Column(Date) # Date the information refers to (not article date) + context_location = Column(String(255)) # Geographic location if mentioned + + # Quality & relevance + importance_score = Column(Integer) # 1-5, how important this information is + confidence_score = Column(Numeric(3, 2)) # 0.00-1.00, AI confidence in extraction + + # Moderation + is_verified = Column(Boolean, default=False) # Human verified + verified_by = Column(Integer, ForeignKey('users.id')) + verified_at = Column(DateTime) + + # Processing metadata + extraction_model = Column(String(100)) # gemini-2.0-flash, gpt-4, etc. + extracted_at = Column(DateTime, default=datetime.now) + + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # Relationships + source_news = relationship('ZOPKNews', backref='knowledge_chunks') + verifier = relationship('User', foreign_keys=[verified_by]) + + +class ZOPKKnowledgeEntity(Base): + """ + Named entities extracted from ZOPK knowledge base. + Entities are deduplicated and enriched across all sources. + + Types: company, person, place, organization, project, technology + """ + __tablename__ = 'zopk_knowledge_entities' + + id = Column(Integer, primary_key=True) + + # Entity identification + entity_type = Column(String(50), nullable=False, index=True) + name = Column(String(255), nullable=False) + normalized_name = Column(String(255), index=True) # Lowercase, no special chars (for dedup) + aliases = Column(PG_ARRAY(String(255)) if not IS_SQLITE else Text) # Alternative names + + # Description + description = Column(Text) # AI-generated description + short_description = Column(String(500)) # One-liner + + # Linking to existing data + company_id = Column(Integer, ForeignKey('companies.id')) # Link to Norda company if exists + zopk_project_id = Column(Integer, ForeignKey('zopk_projects.id')) # Link to ZOPK project + external_url = Column(String(1000)) # Wikipedia, company website, etc. + + # Entity metadata (JSONB for flexibility) + metadata = Column(PG_JSONB if not IS_SQLITE else Text) # {role: "CEO", founded: 2020, ...} + + # Statistics + mentions_count = Column(Integer, default=0) + first_mentioned_at = Column(DateTime) + last_mentioned_at = Column(DateTime) + + # Embedding for entity similarity + embedding = Column(Text) # Entity description embedding + + # Quality + is_verified = Column(Boolean, default=False) + merged_into_id = Column(Integer, ForeignKey('zopk_knowledge_entities.id')) # For deduplication + + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # Relationships + company = relationship('Company', foreign_keys=[company_id]) + zopk_project = relationship('ZOPKProject', foreign_keys=[zopk_project_id]) + merged_into = relationship('ZOPKKnowledgeEntity', remote_side=[id], foreign_keys=[merged_into_id]) + + +class ZOPKKnowledgeFact(Base): + """ + Structured facts extracted from knowledge chunks. + Facts are atomic, verifiable pieces of information. + + Examples: + - "ZOPK otrzymał 500 mln PLN dofinansowania w 2024" + - "Port Gdynia jest głównym partnerem projektu" + - "Projekt zakłada utworzenie 5000 miejsc pracy" + """ + __tablename__ = 'zopk_knowledge_facts' + + id = Column(Integer, primary_key=True) + + # Source + source_chunk_id = Column(Integer, ForeignKey('zopk_knowledge_chunks.id'), nullable=False, index=True) + source_news_id = Column(Integer, ForeignKey('zopk_news.id'), index=True) + + # Fact content + fact_type = Column(String(50), nullable=False) # statistic, event, statement, decision, milestone + subject = Column(String(255)) # Who/what the fact is about + predicate = Column(String(100)) # Action/relation type + object = Column(Text) # The actual information + full_text = Column(Text, nullable=False) # Complete fact as sentence + + # Structured data (for queryable facts) + numeric_value = Column(Numeric(20, 2)) # If fact contains number + numeric_unit = Column(String(50)) # PLN, EUR, jobs, MW, etc. + date_value = Column(Date) # If fact refers to specific date + + # Context + context = Column(Text) # Surrounding context for disambiguation + citation = Column(Text) # Original quote if applicable + + # Entities involved (denormalized for quick access) + entities_involved = Column(PG_JSONB if not IS_SQLITE else Text) # [{id: 1, name: "...", type: "company"}, ...] + + # Quality & verification + confidence_score = Column(Numeric(3, 2)) # AI confidence + is_verified = Column(Boolean, default=False) + contradicts_fact_id = Column(Integer, ForeignKey('zopk_knowledge_facts.id')) # If contradicted + + # Embedding for fact similarity + embedding = Column(Text) + + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # Relationships + source_chunk = relationship('ZOPKKnowledgeChunk', backref='facts') + source_news = relationship('ZOPKNews', backref='facts') + contradicted_by = relationship('ZOPKKnowledgeFact', remote_side=[id], foreign_keys=[contradicts_fact_id]) + + +class ZOPKKnowledgeEntityMention(Base): + """ + Links between knowledge chunks and entities. + Tracks where each entity is mentioned and in what context. + """ + __tablename__ = 'zopk_knowledge_entity_mentions' + + id = Column(Integer, primary_key=True) + + chunk_id = Column(Integer, ForeignKey('zopk_knowledge_chunks.id'), nullable=False, index=True) + entity_id = Column(Integer, ForeignKey('zopk_knowledge_entities.id'), nullable=False, index=True) + + # Mention details + mention_text = Column(String(500)) # Exact text that matched the entity + mention_type = Column(String(50)) # direct, reference, pronoun + start_position = Column(Integer) # Character position in chunk + end_position = Column(Integer) + + # Context + sentiment = Column(String(20)) # positive, neutral, negative + role_in_context = Column(String(100)) # subject, object, beneficiary, partner + + confidence = Column(Numeric(3, 2)) # Entity linking confidence + + created_at = Column(DateTime, default=datetime.now) + + # Relationships + chunk = relationship('ZOPKKnowledgeChunk', backref='entity_mentions') + entity = relationship('ZOPKKnowledgeEntity', backref='mentions') + + __table_args__ = ( + UniqueConstraint('chunk_id', 'entity_id', 'start_position', name='uq_chunk_entity_position'), + ) + + +class ZOPKKnowledgeRelation(Base): + """ + Relationships between entities discovered in the knowledge base. + Forms a knowledge graph of ZOPK ecosystem. + + Examples: + - Company A → "partner" → Company B + - Person X → "CEO of" → Company Y + - Project Z → "funded by" → Organization W + """ + __tablename__ = 'zopk_knowledge_relations' + + id = Column(Integer, primary_key=True) + + # Entities involved + entity_a_id = Column(Integer, ForeignKey('zopk_knowledge_entities.id'), nullable=False, index=True) + entity_b_id = Column(Integer, ForeignKey('zopk_knowledge_entities.id'), nullable=False, index=True) + + # Relation definition + relation_type = Column(String(100), nullable=False) # partner, investor, supplier, competitor, subsidiary, employs + relation_subtype = Column(String(100)) # More specific: strategic_partner, minority_investor + is_bidirectional = Column(Boolean, default=False) # True for "partners", False for "invests in" + + # Evidence + source_chunk_id = Column(Integer, ForeignKey('zopk_knowledge_chunks.id')) + source_fact_id = Column(Integer, ForeignKey('zopk_knowledge_facts.id')) + evidence_text = Column(Text) # Quote proving the relation + + # Temporal aspects + valid_from = Column(Date) # When relation started + valid_until = Column(Date) # When relation ended (NULL = still valid) + is_current = Column(Boolean, default=True) + + # Strength & confidence + strength = Column(Integer) # 1-5, how strong the relation is + confidence = Column(Numeric(3, 2)) # AI confidence in the relation + mention_count = Column(Integer, default=1) # How many times this relation was found + + # Quality + is_verified = Column(Boolean, default=False) + verified_by = Column(Integer, ForeignKey('users.id')) + + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # Relationships + entity_a = relationship('ZOPKKnowledgeEntity', foreign_keys=[entity_a_id], backref='relations_as_subject') + entity_b = relationship('ZOPKKnowledgeEntity', foreign_keys=[entity_b_id], backref='relations_as_object') + source_chunk = relationship('ZOPKKnowledgeChunk', backref='discovered_relations') + source_fact = relationship('ZOPKKnowledgeFact', backref='relation_evidence') + verifier = relationship('User', foreign_keys=[verified_by]) + + __table_args__ = ( + UniqueConstraint('entity_a_id', 'entity_b_id', 'relation_type', name='uq_entity_relation'), + ) + + +class ZOPKKnowledgeExtractionJob(Base): + """ + Tracks knowledge extraction jobs from approved articles. + One job per article, tracks progress and results. + """ + __tablename__ = 'zopk_knowledge_extraction_jobs' + + id = Column(Integer, primary_key=True) + job_id = Column(String(100), unique=True, nullable=False, index=True) + + # Source + news_id = Column(Integer, ForeignKey('zopk_news.id'), nullable=False, index=True) + + # Configuration + extraction_model = Column(String(100)) # gemini-2.0-flash + chunk_size = Column(Integer, default=800) # Target tokens per chunk + chunk_overlap = Column(Integer, default=100) # Overlap tokens + + # Results + chunks_created = Column(Integer, default=0) + entities_extracted = Column(Integer, default=0) + facts_extracted = Column(Integer, default=0) + relations_discovered = Column(Integer, default=0) + + # Costs + tokens_used = Column(Integer, default=0) + cost_cents = Column(Numeric(10, 4), default=0) + + # Status + status = Column(String(20), default='pending') # pending, running, completed, failed + error_message = Column(Text) + progress_percent = Column(Integer, default=0) + + # Timing + started_at = Column(DateTime) + completed_at = Column(DateTime) + + # Trigger + triggered_by = Column(String(50)) # auto (on approval), manual, batch + triggered_by_user = Column(Integer, ForeignKey('users.id')) + + created_at = Column(DateTime, default=datetime.now) + + # Relationships + news = relationship('ZOPKNews', backref='extraction_jobs') + user = relationship('User', foreign_keys=[triggered_by_user]) + + # ============================================================ # AI USAGE TRACKING MODELS # ============================================================ diff --git a/templates/admin/zopk_dashboard.html b/templates/admin/zopk_dashboard.html index e10fa25..a002801 100644 --- a/templates/admin/zopk_dashboard.html +++ b/templates/admin/zopk_dashboard.html @@ -818,6 +818,156 @@ 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; + } {% endblock %} @@ -938,13 +1088,28 @@
+
+ +
- -
-

Statystyki źródeł

-
+ + @@ -1758,64 +1923,77 @@ const ALL_SOURCES = Object.keys(SOURCE_NAMES); async function searchNews() { const btn = document.getElementById('searchBtn'); - const resultDiv = document.getElementById('searchResult'); 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 sourceStats = document.getElementById('sourceStats'); - const sourceStatsGrid = document.getElementById('sourceStatsGrid'); + 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...'; - resultDiv.style.display = 'none'; - sourceStats.classList.remove('active'); + 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 initial progress steps - progressSteps.innerHTML = ALL_SOURCES.map((src, idx) => ` -
- - ${SOURCE_NAMES[src]} - - + // Build progress phases UI + progressPhases.innerHTML = PHASES.map(phase => ` +
+ ${phase.icon} + ${phase.label}
`).join(''); - // Simulate progress while waiting for API - let currentStep = 0; - const totalSteps = ALL_SOURCES.length + 1; // +1 for cross-verification + // Build initial progress steps (will be populated from process_log) + progressSteps.innerHTML = '
Inicjalizacja...
'; + + // 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 (currentStep < ALL_SOURCES.length) { - // Mark previous step as completed - if (currentStep > 0) { - const prevStep = document.getElementById(`step-${ALL_SOURCES[currentStep - 1]}`); - if (prevStep) { - prevStep.classList.remove('active'); - prevStep.classList.add('completed'); + 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'); } - } + }); - // Mark current step as active - const currStep = document.getElementById(`step-${ALL_SOURCES[currentStep]}`); - if (currStep) { - currStep.classList.remove('pending'); - currStep.classList.add('active'); - } - - progressStatus.textContent = `Przeszukiwanie: ${SOURCE_NAMES[ALL_SOURCES[currentStep]]}`; - const percent = Math.round(((currentStep + 1) / totalSteps) * 80); + progressStatus.textContent = phaseMessages[currentPhaseIdx]; + const percent = Math.round(((currentPhaseIdx + 1) / PHASES.length) * 80); progressBar.style.width = `${percent}%`; progressPercent.textContent = `${percent}%`; - currentStep++; + currentPhaseIdx++; } - }, 800); + }, 2500); // Each phase ~2.5s for realistic timing try { const response = await fetch('{{ url_for("api_zopk_search_news") }}', { @@ -1831,65 +2009,112 @@ async function searchNews() { const data = await response.json(); - // Mark all steps as completed - ALL_SOURCES.forEach(src => { - const step = document.getElementById(`step-${src}`); - if (step) { - step.classList.remove('pending', 'active'); - step.classList.add('completed'); - } - }); - if (data.success) { - // Update counts from source_stats - if (data.source_stats) { - Object.entries(data.source_stats).forEach(([src, count]) => { - const countEl = document.getElementById(`count-${src}`); - if (countEl) { - countEl.textContent = count; - } - }); - } + // 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'); + } + }); - // Show cross-verification step - progressStatus.textContent = 'Weryfikacja krzyżowa zakończona'; progressBar.style.width = '100%'; progressPercent.textContent = '100%'; + progressStatus.textContent = '✅ Wyszukiwanie zakończone!'; - // Show source stats - if (data.source_stats && Object.keys(data.source_stats).length > 0) { - sourceStatsGrid.innerHTML = Object.entries(data.source_stats) - .filter(([src, count]) => count > 0) - .sort((a, b) => b[1] - a[1]) - .map(([src, count]) => ` -
- ${SOURCE_NAMES[src] || src} - ${count} -
- `).join(''); - sourceStats.classList.add('active'); + // 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 => ` +
+ + ${log.message} + ${log.count > 0 ? `${log.count}` : ''} +
+ `).join(''); } - // Show result message - resultDiv.style.display = 'block'; - resultDiv.innerHTML = ` -

- ✓ ${data.message}
- Auto-zatwierdzone (3+ źródeł): ${data.auto_approved || 0} -

+ // Hide progress container after a moment + setTimeout(() => { + progressContainer.classList.remove('active'); + }, 1500); + + // Show results container + resultsContainer.style.display = 'block'; + + // Build summary stats + resultsSummary.innerHTML = ` +
+
${data.total_found || 0}
+
Znaleziono
+
+
+
${(data.blacklisted || 0) + (data.keyword_filtered || 0)}
+
Odfiltrowano
+
+
+
${data.ai_rejected || 0}
+
AI odrzucił
+
+
+
${data.ai_approved || 0}
+
AI zaakceptował
+
+
+
${data.saved_new || 0}
+
Nowe w bazie
+
`; - // Auto-refresh after 3 seconds - setTimeout(() => { - progressStatus.textContent = 'Odświeżanie strony...'; - location.reload(); - }, 3000); + // 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 ` +
+ ${stars} + ${article.title} + ${article.source || ''} +
+ `; + }).join(''); + } + + // Start countdown to refresh (8 seconds) + let countdown = 8; + const countdownEl = document.getElementById('countdownSeconds'); + const countdownInterval = setInterval(() => { + countdown--; + countdownEl.textContent = countdown; + if (countdown <= 0) { + clearInterval(countdownInterval); + location.reload(); + } + }, 1000); + } else { + // Error handling progressBar.style.width = '100%'; progressBar.style.background = '#fca5a5'; progressStatus.textContent = 'Błąd wyszukiwania'; - resultDiv.style.display = 'block'; - resultDiv.innerHTML = `

Błąd: ${data.error}

`; + + PHASES.forEach(phase => { + const el = document.getElementById(`phase-${phase.id}`); + if (el) el.classList.remove('active'); + }); + + progressSteps.innerHTML = ` +
+ + Błąd: ${data.error} +
+ `; btn.disabled = false; btn.textContent = 'Szukaj artykułów'; } @@ -1898,8 +2123,13 @@ async function searchNews() { progressBar.style.width = '100%'; progressBar.style.background = '#fca5a5'; progressStatus.textContent = 'Błąd połączenia'; - resultDiv.style.display = 'block'; - resultDiv.innerHTML = `

Błąd połączenia: ${error.message}

`; + + progressSteps.innerHTML = ` +
+ + Błąd połączenia: ${error.message} +
+ `; btn.disabled = false; btn.textContent = 'Szukaj artykułów'; } diff --git a/templates/admin/zopk_news.html b/templates/admin/zopk_news.html index 3d177cd..cd96d6f 100644 --- a/templates/admin/zopk_news.html +++ b/templates/admin/zopk_news.html @@ -248,6 +248,60 @@ color: var(--text-primary); } + /* Star filter styling */ + .star-filter .star-icon { + font-size: 10px; + letter-spacing: -1px; + } + .star-filter.active .star-icon { + color: #f59e0b; + } + + /* Bulk actions */ + .bulk-actions { + background: var(--surface); + padding: var(--spacing-md); + border-radius: var(--radius); + border: 1px solid var(--border); + } + + /* Mass reject modal */ + .mass-reject-options { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + margin: var(--spacing-lg) 0; + } + .mass-reject-option { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: var(--transition); + } + .mass-reject-option:hover { + background: var(--background); + } + .mass-reject-option.selected { + background: #fee2e2; + border-color: #dc3545; + } + .mass-reject-option input[type="checkbox"] { + accent-color: #dc3545; + } + .mass-reject-stars { + color: #f59e0b; + font-size: 14px; + } + .mass-reject-count { + margin-left: auto; + font-size: var(--font-size-sm); + color: var(--text-secondary); + } + @media (max-width: 768px) { .news-table { display: block; @@ -260,6 +314,13 @@ width: 100%; margin-top: var(--spacing-md); } + .filters { + flex-direction: column; + align-items: flex-start; + } + .bulk-actions { + flex-wrap: wrap; + } } {% endblock %} @@ -275,10 +336,19 @@
Status: - Wszystkie - Oczekujące - Zatwierdzone - Odrzucone + Wszystkie + Oczekujące + Zatwierdzone + Odrzucone + + Gwiazdki: + Wszystkie + {% for star in [5, 4, 3, 2, 1] %} + + {{ '★' * star }}{{ '☆' * (5 - star) }} + + {% endfor %} + Brak oceny
Sortuj: @@ -293,6 +363,19 @@
+ +
+ Akcje masowe: + + {% if current_stars != 'all' and current_stars != 'none' and current_status == 'pending' %} + + {% endif %} +
+ {% if news_items %}
@@ -371,21 +454,21 @@ {% if total_pages > 1 %} {% endif %} @@ -415,6 +498,44 @@ + + +