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:
parent
0cbdcaaad6
commit
1193a2bf48
43
app.py
43
app.py
@ -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():
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user