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
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:
parent
2b0acbc35f
commit
683df8f43d
@ -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)
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user