feat(zopk): Add SSE streaming for real-time search progress
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

Replace simulated progress animation with Server-Sent Events streaming
that shows actual backend progress in real-time during ZOPK news search.
Prevents timeout errors by keeping the connection alive with heartbeat
events. Old POST endpoint preserved as automatic fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-09 14:10:55 +01:00
parent 2b0acbc35f
commit 683df8f43d
3 changed files with 379 additions and 136 deletions

View File

@ -6,7 +6,10 @@ Contains routes for ZOPK news management, scraping, and AI evaluation.
"""
import hashlib
import json
import logging
import queue
import threading
import uuid
from datetime import datetime
from urllib.parse import urlparse
@ -580,6 +583,107 @@ def api_zopk_search_news():
db.close()
@bp.route('/zopk-api/search-news-stream')
@login_required
@role_required(SystemRole.ADMIN)
def api_zopk_search_news_stream():
"""SSE endpoint for streaming ZOPK news search progress in real-time."""
from zopk_news_service import ZOPKNewsService
query = request.args.get('query', 'Zielony Okręg Przemysłowy Kaszubia')
def generate():
db = SessionLocal()
try:
# Create fetch job record
job_id = str(uuid.uuid4())[:8]
fetch_job = ZOPKNewsFetchJob(
job_id=job_id,
search_query=query,
search_api='multi_source_sse',
triggered_by='admin',
triggered_by_user=current_user.id,
status='running',
started_at=datetime.now()
)
db.add(fetch_job)
db.commit()
progress_queue = queue.Queue()
result_holder = [None]
error_holder = [None]
def on_progress(phase, message, current, total):
progress_queue.put((phase, message, current, total))
def run_search():
try:
service = ZOPKNewsService(db)
result_holder[0] = service.search_all_sources(
query, user_id=current_user.id, progress_callback=on_progress
)
except Exception as e:
error_holder[0] = e
finally:
progress_queue.put(None) # sentinel
thread = threading.Thread(target=run_search, daemon=True)
thread.start()
# Stream progress events
while True:
try:
item = progress_queue.get(timeout=1)
except queue.Empty:
yield f"data: {json.dumps({'type': 'heartbeat'})}\n\n"
continue
if item is None:
break
phase, message, current, total = item
yield f"data: {json.dumps({'type': 'progress', 'phase': phase, 'message': message, 'current': current, 'total': total})}\n\n"
thread.join(timeout=10)
if error_holder[0]:
# Update job on error
try:
fetch_job.status = 'failed'
fetch_job.error_message = str(error_holder[0])
fetch_job.completed_at = datetime.now()
db.commit()
except Exception:
pass
yield f"data: {json.dumps({'type': 'error', 'message': str(error_holder[0])})}\n\n"
else:
results = result_holder[0]
# Update fetch job
fetch_job.results_found = results['total_found']
fetch_job.results_new = results['saved_new']
fetch_job.results_approved = results['auto_approved']
fetch_job.status = 'completed'
fetch_job.completed_at = datetime.now()
db.commit()
yield f"data: {json.dumps({'type': 'complete', 'results': results})}\n\n"
except Exception as e:
logger.error(f"SSE stream error: {e}")
yield f"data: {json.dumps({'type': 'error', 'message': 'Wystąpił błąd podczas wyszukiwania'})}\n\n"
finally:
db.close()
return Response(
stream_with_context(generate()),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
'Connection': 'keep-alive'
}
)
@bp.route('/zopk/news/scrape-stats')
@login_required
@role_required(SystemRole.ADMIN)

View File

@ -2693,6 +2693,8 @@ async function searchNews() {
{ id: 'save', icon: '💾', label: 'Zapisywanie' }
];
const PHASE_ORDER = { 'search': 0, 'filter': 1, 'ai': 2, 'save': 3 };
// Reset UI
btn.disabled = true;
btn.textContent = 'Szukam...';
@ -2700,7 +2702,7 @@ async function searchNews() {
autoApprovedSection.style.display = 'none';
progressContainer.classList.add('active');
progressBar.style.width = '0%';
progressBar.style.background = ''; // Reset color
progressBar.style.background = '';
progressPercent.textContent = '0%';
// Build progress phases UI
@ -2711,10 +2713,249 @@ async function searchNews() {
</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
// Helper: update phase indicators based on current phase
function updatePhaseUI(currentPhase) {
const currentIdx = PHASE_ORDER[currentPhase] ?? -1;
PHASES.forEach((phase, idx) => {
const el = document.getElementById(`phase-${phase.id}`);
if (el) {
el.classList.remove('pending', 'active', 'completed');
if (idx < currentIdx) el.classList.add('completed');
else if (idx === currentIdx) el.classList.add('active');
else el.classList.add('pending');
}
});
}
// Helper: display final results (shared between SSE and fallback)
function displaySearchResults(data) {
// 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) {
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('');
}
setTimeout(() => { progressContainer.classList.remove('active'); }, 1500);
resultsContainer.style.display = 'block';
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>
`;
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('');
}
const detailedStatsSection = document.getElementById('detailedStatsSection');
const detailedStatsGrid = document.getElementById('detailedStatsGrid');
const aiRejectedSection = document.getElementById('aiRejectedSection');
const aiRejectedList = document.getElementById('aiRejectedList');
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';
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('');
}
resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
btn.disabled = false;
btn.textContent = 'Szukaj artykułów';
}
// Helper: display error
function displaySearchError(message) {
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>${message}</span>
</div>
`;
btn.disabled = false;
btn.textContent = 'Szukaj artykułów';
}
// Try SSE streaming first, fallback to old POST endpoint
try {
const sseUrl = `{{ url_for("admin.api_zopk_search_news_stream") }}?query=${encodeURIComponent(query)}`;
const source = new EventSource(sseUrl);
let sseWorking = false;
source.onmessage = function(event) {
sseWorking = true;
const data = JSON.parse(event.data);
if (data.type === 'progress') {
updatePhaseUI(data.phase);
progressStatus.textContent = data.message;
// Update progress steps with latest message
const stepHtml = `
<div class="progress-step active">
<span class="progress-step-icon"></span>
<span>${data.message}</span>
${data.total > 0 ? `<span class="progress-step-count">${data.current}/${data.total}</span>` : ''}
</div>
`;
// Keep last 4 steps visible
const existingSteps = progressSteps.querySelectorAll('.progress-step');
if (existingSteps.length >= 4) {
existingSteps[0].remove();
}
progressSteps.insertAdjacentHTML('beforeend', stepHtml);
// Calculate overall progress based on phase + sub-progress
const phaseIdx = PHASE_ORDER[data.phase] ?? 0;
let phasePct = 0;
if (data.total > 0) {
phasePct = data.current / data.total;
}
const overallPct = Math.round(((phaseIdx + phasePct) / PHASES.length) * 100);
progressBar.style.width = `${Math.min(overallPct, 95)}%`;
progressPercent.textContent = `${Math.min(overallPct, 95)}%`;
}
else if (data.type === 'heartbeat') {
// Keep alive, no UI update
}
else if (data.type === 'complete') {
source.close();
displaySearchResults(data.results);
}
else if (data.type === 'error') {
source.close();
displaySearchError(data.message);
}
};
source.onerror = function() {
source.close();
if (!sseWorking) {
// SSE never worked, fallback to old POST endpoint
searchNewsFallback(query, PHASES, btn, progressContainer, progressBar, progressStatus, progressPercent, progressPhases, progressSteps, resultsContainer, resultsSummary, autoApprovedSection, autoApprovedList);
} else {
displaySearchError('Połączenie ze streamem przerwane');
}
};
} catch (error) {
displaySearchError('Błąd: ' + error.message);
}
}
// Fallback: old synchronous POST endpoint (used when SSE fails)
async function searchNewsFallback(query, PHASES, btn, progressContainer, progressBar, progressStatus, progressPercent, progressPhases, progressSteps, resultsContainer, resultsSummary, autoApprovedSection, autoApprovedList) {
let currentPhaseIdx = 0;
const phaseMessages = [
'Przeszukuję źródła (Brave API + RSS)...',
@ -2725,7 +2966,6 @@ async function searchNews() {
const progressInterval = setInterval(() => {
if (currentPhaseIdx < PHASES.length) {
// Update phase UI
PHASES.forEach((phase, idx) => {
const el = document.getElementById(`phase-${phase.id}`);
if (el) {
@ -2735,15 +2975,13 @@ async function searchNews() {
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
}, 2500);
try {
const response = await fetch('{{ url_for("admin.api_zopk_search_news") }}', {
@ -2756,11 +2994,10 @@ async function searchNews() {
});
clearInterval(progressInterval);
const data = await response.json();
if (data.success) {
// Mark all phases as completed
// Reuse shared display function (need to reconstruct it inline since helpers are scoped)
PHASES.forEach(phase => {
const el = document.getElementById(`phase-${phase.id}`);
if (el) {
@ -2768,18 +3005,14 @@ async function searchNews() {
el.classList.add('completed');
}
});
progressBar.style.width = '100%';
progressPercent.textContent = '100%';
progressStatus.textContent = '✅ Wyszukiwanie zakończone!';
progressStatus.textContent = '✅ Wyszukiwanie zakończone (tryb klasyczny)';
// 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>
@ -2789,137 +3022,33 @@ async function searchNews() {
`).join('');
}
// Hide progress container after a moment
setTimeout(() => {
progressContainer.classList.remove('active');
}, 1500);
// Show results container
setTimeout(() => { progressContainer.classList.remove('active'); }, 1500);
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>
<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>
`;
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' });
btn.disabled = false;
btn.textContent = 'Szukaj artykułów';
} 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>
`;
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';
}
@ -2928,13 +3057,7 @@ async function searchNews() {
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>
`;
progressSteps.innerHTML = `<div class="progress-step" style="color: #fca5a5;"><span class="progress-step-icon"></span><span>Błąd: ${error.message}</span></div>`;
btn.disabled = false;
btn.textContent = 'Szukaj artykułów';
}

View File

@ -618,7 +618,7 @@ class ZOPKNewsService:
logger.error(f"Failed to load Gemini: {e}")
return self._gemini_service
def search_all_sources(self, query: str = None, user_id: int = None) -> Dict:
def search_all_sources(self, query: str = None, user_id: int = None, progress_callback=None) -> Dict:
"""
Search all sources with IMPROVED PIPELINE:
1. Multiple precise Brave queries
@ -629,6 +629,7 @@ class ZOPKNewsService:
Args:
query: Deprecated, ignored. Uses BRAVE_QUERIES instead.
user_id: User ID for tracking AI usage
progress_callback: Optional callback(phase, message, current, total) for SSE streaming
Returns:
Dict with search results, statistics, and detailed process log
@ -674,6 +675,9 @@ class ZOPKNewsService:
'message': f"Brave: {query_config['description']}",
'count': len(brave_items)
})
if progress_callback:
progress_callback('search', f"Brave: {query_config['description']} ({len(brave_items)})", i + 1, len(BRAVE_QUERIES))
else:
process_log.append({
'phase': 'search',
@ -717,6 +721,9 @@ class ZOPKNewsService:
'count': source_stats['rss_results']
})
if progress_callback:
progress_callback('search', f'RSS: {source_stats["rss_results"]} wyników', 0, 0)
logger.info(f"Total raw items: {len(all_items)}")
total_raw = len(all_items)
@ -799,6 +806,9 @@ class ZOPKNewsService:
'count': len(verified_items)
})
if progress_callback:
progress_callback('filter', f'Po filtrach: {len(verified_items)} artykułów do AI', 0, 0)
# 6. AI EVALUATION (before saving) - only if enabled
sent_to_ai = len(verified_items) # Track before AI modifies the list
@ -827,6 +837,9 @@ class ZOPKNewsService:
ai_evaluated_count += 1
if progress_callback:
progress_callback('ai', f'AI ocenia: {item["title"][:50]}...', ai_evaluated_count, sent_to_ai)
if ai_result.get('evaluated'):
ai_score = ai_result.get('score', 0)
if ai_score >= 3:
@ -897,6 +910,9 @@ class ZOPKNewsService:
'count': saved_count + updated_count
})
if progress_callback:
progress_callback('save', f'Zapisano {saved_count} nowych artykułów', saved_count, saved_count)
# Final summary
# Note: score >= 3 triggers auto-approve (verified 2026-01-15)
auto_approved_count = sum(1 for item in verified_items if item.get('auto_approve', False) or (item.get('ai_score') and item['ai_score'] >= 3))