feat(zopk): Re-ewaluacja newsów z niską oceną zawierających kluczowe tematy

- Nowa funkcja reevaluate_low_score_news() szuka newsów z 1-2★
  zawierających Via Pomerania, NORDA, S6, Droga Czerwona, etc.
- Nowy endpoint POST /admin/zopk/news/reevaluate-low-scores
- Przycisk w UI "Re-ewaluuj niskie oceny" z szczegółowym raportem
- Automatyczne auto-approve jeśli nowa ocena >= 3★

Problem: Artykuły o Via Pomerania miały 1★ bo były ocenione
         przed dodaniem tego tematu do promptu AI.
Rozwiązanie: Re-ewaluacja nowym promptem podniesie ich oceny.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-15 05:38:20 +01:00
parent 0cbdcaaad6
commit 1193a2bf48
3 changed files with 308 additions and 0 deletions

43
app.py
View File

@ -10673,6 +10673,49 @@ def admin_zopk_reevaluate_scores():
db.close() db.close()
@app.route('/admin/zopk/news/reevaluate-low-scores', methods=['POST'])
@login_required
def admin_zopk_reevaluate_low_scores():
"""
Re-evaluate news with low AI scores (1-2) that contain key ZOPK topics.
Useful after updating AI prompt to include new topics like Via Pomerania, S6, NORDA.
Old articles scored low before these topics were recognized will be re-evaluated
and potentially upgraded.
"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
from zopk_news_service import reevaluate_low_score_news
db = SessionLocal()
try:
data = request.get_json() or {}
limit = data.get('limit', 50) # Max 50 to avoid API limits
# Run AI re-evaluation for low-score items with key topics
result = reevaluate_low_score_news(db, limit=limit, user_id=current_user.id)
return jsonify({
'success': True,
'total_evaluated': result.get('total_evaluated', 0),
'upgraded': result.get('upgraded', 0),
'downgraded': result.get('downgraded', 0),
'unchanged': result.get('unchanged', 0),
'errors': result.get('errors', 0),
'message': result.get('message', ''),
'details': result.get('details', [])
})
except Exception as e:
db.rollback()
logger.error(f"Error reevaluating low-score ZOPK news: {e}")
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas ponownej oceny'}), 500
finally:
db.close()
@app.route('/api/zopk/search-news', methods=['POST']) @app.route('/api/zopk/search-news', methods=['POST'])
@login_required @login_required
def api_zopk_search_news(): def api_zopk_search_news():

View File

@ -1072,6 +1072,14 @@
</div> </div>
{% endif %} {% endif %}
<!-- Re-evaluate low score news with key topics (Via Pomerania, NORDA, etc.) -->
<div class="stats-grid" style="grid-template-columns: repeat(1, 1fr); max-width: 400px; margin-top: var(--spacing-md);">
<button type="button" class="stat-card filter-card ai-action" onclick="reevaluateLowScores()" id="aiReevalLowBtn" title="Re-ewaluacja newsów z oceną 1-2★ zawierających nowe tematy (Via Pomerania, NORDA, S6...)">
<div class="stat-value" style="font-size: var(--font-size-xl);">🔄⭐</div>
<div class="stat-label">Re-ewaluuj niskie oceny (Via Pomerania, NORDA...)</div>
</button>
</div>
<div id="aiEvalResult" style="margin-top: var(--spacing-md); display: none;"></div> <div id="aiEvalResult" style="margin-top: var(--spacing-md); display: none;"></div>
</div> </div>
@ -1924,6 +1932,126 @@ async function reevaluateScores() {
} }
} }
// Re-evaluate low score news (1-2★) with key topics (Via Pomerania, NORDA, etc.)
async function reevaluateLowScores() {
const btn = document.getElementById('aiReevalLowBtn');
if (!btn) return;
// Show modal for re-evaluation
document.getElementById('aiEvalModal').classList.add('active');
document.getElementById('aiEvalConfirm').style.display = 'none';
document.getElementById('aiEvalProgress').style.display = 'block';
document.getElementById('aiEvalResult').style.display = 'none';
const progressBar = document.getElementById('aiProgressFill');
const progressStatus = document.getElementById('aiProgressStatus');
btn.disabled = true;
btn.querySelector('.stat-label').textContent = 'Re-ewaluacja...';
// Simulated progress for better UX
let progress = 0;
let startTime = Date.now();
const progressInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
if (progress < 90) {
progress += Math.random() * 3;
progressBar.style.width = Math.min(progress, 90) + '%';
}
progressStatus.textContent = `Re-ewaluacja newsów z Via Pomerania, NORDA... (${elapsed}s)`;
}, 500);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 180000);
const response = await fetch('/admin/zopk/news/reevaluate-low-scores', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ limit: 50 }),
signal: controller.signal
});
clearTimeout(timeoutId);
clearInterval(progressInterval);
const data = await response.json();
progressBar.style.width = '100%';
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'block';
const resultIcon = document.getElementById('aiResultIcon');
const resultTitle = document.getElementById('aiResultTitle');
const resultStats = document.getElementById('aiResultStats');
if (data.success) {
resultIcon.textContent = '🔄';
resultIcon.className = 'modal-icon success';
resultTitle.textContent = 'Re-ewaluacja zakończona!';
// Build details list if available
let detailsHtml = '';
if (data.details && data.details.length > 0) {
detailsHtml = `
<div style="max-height: 200px; overflow-y: auto; margin-top: var(--spacing-md); text-align: left; font-size: var(--font-size-sm);">
${data.details.map(d => `
<div style="padding: 4px 0; border-bottom: 1px solid var(--border);">
<span style="color: ${d.change === 'upgraded' ? 'var(--success)' : d.change === 'downgraded' ? 'var(--danger)' : 'var(--text-secondary)'};">
${d.old_score}★ → ${d.new_score}★
</span>
${d.title}...
</div>
`).join('')}
</div>
`;
}
resultStats.innerHTML = `
<div style="display: flex; gap: 20px; justify-content: center; margin-bottom: var(--spacing-md);">
<div><strong>${data.total_evaluated}</strong><br><small>Ocenionych</small></div>
<div style="color: var(--success);"><strong>${data.upgraded}</strong><br><small>⬆️ Podwyższono</small></div>
<div style="color: var(--danger);"><strong>${data.downgraded}</strong><br><small>⬇️ Obniżono</small></div>
<div style="color: var(--text-secondary);"><strong>${data.unchanged}</strong><br><small>Bez zmian</small></div>
</div>
<p style="text-align: center; color: var(--text-secondary);">${data.message}</p>
${detailsHtml}
`;
if (data.upgraded > 0 || data.downgraded > 0) {
setTimeout(() => location.reload(), 3000);
}
} else {
resultIcon.textContent = '✗';
resultIcon.className = 'modal-icon error';
resultTitle.textContent = 'Wystąpił błąd';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">${data.error}</p>`;
btn.disabled = false;
btn.querySelector('.stat-label').textContent = 'Re-ewaluuj niskie oceny';
}
} catch (error) {
clearInterval(progressInterval);
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'block';
const resultIcon = document.getElementById('aiResultIcon');
const resultTitle = document.getElementById('aiResultTitle');
const resultStats = document.getElementById('aiResultStats');
resultIcon.textContent = '✗';
resultIcon.className = 'modal-icon error';
resultTitle.textContent = 'Błąd połączenia';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">${error.message}</p>`;
btn.disabled = false;
btn.querySelector('.stat-label').textContent = 'Re-ewaluuj niskie oceny';
}
}
// Close modal on click outside // Close modal on click outside
document.getElementById('aiEvalModal').addEventListener('click', function(e) { document.getElementById('aiEvalModal').addEventListener('click', function(e) {
if (e.target === this) { if (e.target === this) {

View File

@ -1426,6 +1426,143 @@ def reevaluate_news_without_score(db_session, limit: int = 50, user_id: int = No
return stats return stats
def reevaluate_low_score_news(db_session, limit: int = 50, user_id: int = None) -> Dict:
"""
Re-evaluate news with low AI scores (1-2) that contain key ZOPK topics.
This is useful after updating the AI prompt to include new topics like
Via Pomerania, S6, NORDA, etc. Old articles may have been scored low
before these topics were added to the prompt.
Key topics that trigger re-evaluation:
- Via Pomerania (droga ekspresowa)
- S6, S7 (autostrady)
- Droga Czerwona
- NORDA, Izba Przedsiębiorców
- Pakt Bezpieczeństwa
- Deklaracja Bałtycka
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, upgraded, downgraded, unchanged, errors
"""
from database import ZOPKNews
from datetime import datetime
from sqlalchemy import or_, func
# Key topics that should trigger re-evaluation
KEY_TOPICS = [
'via pomerania', 'via-pomerania',
'droga czerwona',
'pakt bezpieczeństwa', 'pakt dla bezpieczeństwa',
'deklaracja bałtycka',
'norda biznes', 'izba przedsiębiorców norda', 'akademia biznesu norda',
's6 koszalin', 's6 słupsk',
's7 gdańsk', 's7 elbląg',
]
# Build filter for news with low scores containing key topics
topic_filters = []
for topic in KEY_TOPICS:
topic_filters.append(func.lower(ZOPKNews.title).contains(topic))
topic_filters.append(func.lower(ZOPKNews.description).contains(topic))
# Get news with score 1-2 that contain key topics
news_to_rescore = db_session.query(ZOPKNews).filter(
ZOPKNews.ai_relevance_score.isnot(None), # Has been evaluated
ZOPKNews.ai_relevance_score <= 2, # Low score (1-2★)
or_(*topic_filters) # Contains key topics
).order_by(ZOPKNews.created_at.desc()).limit(limit).all()
if not news_to_rescore:
return {
'total_evaluated': 0,
'upgraded': 0,
'downgraded': 0,
'unchanged': 0,
'errors': 0,
'message': 'Brak newsów z niską oceną zawierających kluczowe tematy'
}
# Get Gemini service once
try:
from gemini_service import get_gemini_service
gemini = get_gemini_service()
except Exception as e:
return {
'total_evaluated': 0,
'upgraded': 0,
'downgraded': 0,
'unchanged': 0,
'errors': 1,
'message': f'Gemini service error: {str(e)}'
}
stats = {
'total_evaluated': 0,
'upgraded': 0,
'downgraded': 0,
'unchanged': 0,
'errors': 0,
'details': [] # Track individual changes
}
for news in news_to_rescore:
old_score = news.ai_relevance_score
result = evaluate_news_relevance(news, gemini, user_id=user_id)
if result['evaluated']:
new_score = result.get('score', old_score)
# Update the news item
news.ai_relevant = result['relevant']
news.ai_relevance_score = new_score
news.ai_evaluation_reason = result['reason']
news.ai_evaluated_at = datetime.now()
news.ai_model = 'gemini-2.5-flash-lite' # Updated model name
# Track change
stats['total_evaluated'] += 1
if new_score > old_score:
stats['upgraded'] += 1
# If score is now >= 3, auto-approve
if new_score >= 3 and news.status in ('pending', 'rejected'):
news.status = 'auto_approved'
news.is_auto_verified = True
elif new_score < old_score:
stats['downgraded'] += 1
else:
stats['unchanged'] += 1
stats['details'].append({
'id': news.id,
'title': news.title[:50],
'old_score': old_score,
'new_score': new_score,
'change': 'upgraded' if new_score > old_score else ('downgraded' if new_score < old_score else 'unchanged')
})
logger.info(f"Re-evaluated news {news.id}: {old_score}★ → {new_score}")
else:
stats['errors'] += 1
logger.warning(f"Failed to re-evaluate news {news.id}: {result['reason']}")
# Commit all changes
try:
db_session.commit()
stats['message'] = f"Re-ewaluacja {stats['total_evaluated']} newsów: {stats['upgraded']} podwyższono, {stats['downgraded']} obniżono, {stats['unchanged']} bez zmian"
except Exception as e:
db_session.rollback()
stats['errors'] += 1
stats['message'] = f'Database error: {str(e)}'
return stats
def evaluate_pending_news(db_session, limit: int = 50, user_id: int = None) -> Dict: def evaluate_pending_news(db_session, limit: int = 50, user_id: int = None) -> Dict:
""" """
Evaluate multiple pending news items for ZOPK relevance. Evaluate multiple pending news items for ZOPK relevance.