feat: Add 1-5 star rating to ZOPK news AI evaluation
- Add ai_relevance_score column (1-5) to zopk_news table - Update AI prompt to return score with detailed criteria: * 1 star = very weak (loose connection to region/industry) * 2 stars = weak (general industry news) * 3 stars = medium (relates to ZOPK industry but not directly) * 4 stars = strong (directly about ZOPK investments/companies) * 5 stars = perfect (main topic is ZOPK, Kongsberg, offshore Baltic) - Display star ratings in admin dashboard with color-coded badges - Score >= 3 marks news as relevant, < 3 as not relevant Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
8dda8e311f
commit
e399022223
@ -1781,6 +1781,7 @@ class ZOPKNews(Base):
|
||||
|
||||
# AI Relevance Evaluation (Gemini)
|
||||
ai_relevant = Column(Boolean) # True = relevant to ZOPK, False = not relevant, NULL = not evaluated
|
||||
ai_relevance_score = Column(Integer) # 1-5 stars: 1=weak match, 5=perfect match
|
||||
ai_evaluation_reason = Column(Text) # AI's explanation of relevance decision
|
||||
ai_evaluated_at = Column(DateTime) # When AI evaluation was performed
|
||||
ai_model = Column(String(100)) # Which AI model was used (e.g., gemini-2.0-flash)
|
||||
|
||||
@ -446,8 +446,18 @@ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- AI relevance score (1-5 stars: 1=weak, 5=perfect match)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'zopk_news' AND column_name = 'ai_relevance_score') THEN
|
||||
ALTER TABLE zopk_news ADD COLUMN ai_relevance_score INTEGER CHECK (ai_relevance_score >= 1 AND ai_relevance_score <= 5);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Index for AI relevance filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_zopk_news_ai_relevant ON zopk_news(ai_relevant);
|
||||
CREATE INDEX IF NOT EXISTS idx_zopk_news_ai_score ON zopk_news(ai_relevance_score);
|
||||
|
||||
-- ============================================================
|
||||
-- MIGRATION COMPLETE
|
||||
|
||||
@ -194,6 +194,23 @@
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* Star rating display */
|
||||
.ai-stars {
|
||||
display: inline-flex;
|
||||
gap: 1px;
|
||||
font-size: 11px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.ai-stars .star-filled { color: #f59e0b; }
|
||||
.ai-stars .star-empty { color: #d1d5db; }
|
||||
|
||||
.ai-badge.score-5 { background: #dcfce7; color: #166534; }
|
||||
.ai-badge.score-4 { background: #d1fae5; color: #047857; }
|
||||
.ai-badge.score-3 { background: #fef3c7; color: #92400e; }
|
||||
.ai-badge.score-2 { background: #fee2e2; color: #b91c1c; }
|
||||
.ai-badge.score-1 { background: #fecaca; color: #991b1b; }
|
||||
|
||||
/* AI Evaluation Modal */
|
||||
.modal-icon {
|
||||
font-size: 48px;
|
||||
@ -964,10 +981,15 @@
|
||||
{% elif news.status == 'rejected' %}
|
||||
<span class="confidence-badge low">✗ Odrzucony</span>
|
||||
{% endif %}
|
||||
{# AI Evaluation badge #}
|
||||
{# AI Evaluation badge with star rating #}
|
||||
{% if news.ai_relevant is not none %}
|
||||
<span class="ai-badge {{ 'relevant' if news.ai_relevant else 'not-relevant' }}" title="{{ news.ai_evaluation_reason or '' }}">
|
||||
🤖 {{ 'Pasuje' if news.ai_relevant else 'Nie pasuje' }}
|
||||
<span class="ai-badge score-{{ news.ai_relevance_score or 3 }}" title="{{ news.ai_evaluation_reason or '' }}">
|
||||
🤖
|
||||
<span class="ai-stars">
|
||||
{% for i in range(1, 6) %}
|
||||
<span class="{{ 'star-filled' if i <= (news.ai_relevance_score or 0) else 'star-empty' }}">★</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@ -563,15 +563,27 @@ Data: {date}
|
||||
|
||||
**Twoje zadanie:**
|
||||
1. Oceń czy artykuł dotyczy ZOPK lub powiązanych tematów
|
||||
2. Odpowiedz TYLKO w formacie JSON (bez żadnego innego tekstu):
|
||||
2. Przyznaj ocenę od 1 do 5 gwiazdek:
|
||||
- ⭐ 1 = Bardzo słabo powiązany (luźna styczność z regionem/przemysłem)
|
||||
- ⭐⭐ 2 = Słabo powiązany (ogólne wiadomości branżowe)
|
||||
- ⭐⭐⭐ 3 = Średnio powiązany (dotyczy branży ZOPK, ale nie bezpośrednio projektu)
|
||||
- ⭐⭐⭐⭐ 4 = Mocno powiązany (bezpośrednio dotyczy inwestycji lub kluczowych firm ZOPK)
|
||||
- ⭐⭐⭐⭐⭐ 5 = Doskonale pasuje (główny temat to ZOPK, Kongsberg, offshore Baltic, elektrownia Choczewo)
|
||||
|
||||
{{"relevant": true/false, "reason": "krótkie uzasadnienie po polsku (max 100 znaków)"}}
|
||||
3. Odpowiedz TYLKO w formacie JSON (bez żadnego innego tekstu):
|
||||
|
||||
{{"relevant": true/false, "score": 1-5, "reason": "krótkie uzasadnienie po polsku (max 100 znaków)"}}
|
||||
|
||||
Zasady:
|
||||
- relevant=true gdy score >= 3
|
||||
- relevant=false gdy score < 3
|
||||
|
||||
Przykłady odpowiedzi:
|
||||
{{"relevant": true, "reason": "Dotyczy inwestycji Kongsberg w Rumi"}}
|
||||
{{"relevant": false, "reason": "Artykuł o lokalnych wydarzeniach kulturalnych"}}
|
||||
{{"relevant": true, "reason": "Informacje o farmach wiatrowych na Bałtyku"}}
|
||||
{{"relevant": false, "reason": "News sportowy bez związku z przemysłem"}}"""
|
||||
{{"relevant": true, "score": 5, "reason": "Bezpośrednio o inwestycji Kongsberg w Rumi"}}
|
||||
{{"relevant": true, "score": 4, "reason": "Dotyczy farm wiatrowych Baltic Power"}}
|
||||
{{"relevant": true, "score": 3, "reason": "Ogólne informacje o offshore wind w Polsce"}}
|
||||
{{"relevant": false, "score": 2, "reason": "Artykuł o energetyce, ale nie dotyczy Bałtyku"}}
|
||||
{{"relevant": false, "score": 1, "reason": "News sportowy bez związku z przemysłem"}}"""
|
||||
|
||||
|
||||
def evaluate_news_relevance(news_item, gemini_service=None) -> Dict:
|
||||
@ -632,21 +644,25 @@ def evaluate_news_relevance(news_item, gemini_service=None) -> Dict:
|
||||
json_match = re.search(r'\{[^{}]*\}', response)
|
||||
if json_match:
|
||||
result = json.loads(json_match.group())
|
||||
# Extract score (1-5), default to 3 if not present
|
||||
score = int(result.get('score', 3))
|
||||
score = max(1, min(5, score)) # Clamp to 1-5 range
|
||||
return {
|
||||
'relevant': bool(result.get('relevant', False)),
|
||||
'relevant': bool(result.get('relevant', score >= 3)),
|
||||
'score': score,
|
||||
'reason': str(result.get('reason', ''))[:255],
|
||||
'evaluated': True
|
||||
}
|
||||
else:
|
||||
logger.warning(f"Could not parse Gemini response: {response[:200]}")
|
||||
return {'relevant': None, 'reason': 'Invalid AI response format', 'evaluated': False}
|
||||
return {'relevant': None, 'score': None, 'reason': 'Invalid AI response format', 'evaluated': False}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"JSON decode error: {e}")
|
||||
return {'relevant': None, 'reason': f'JSON parse error: {str(e)[:50]}', 'evaluated': False}
|
||||
return {'relevant': None, 'score': None, 'reason': f'JSON parse error: {str(e)[:50]}', 'evaluated': False}
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini evaluation error: {e}")
|
||||
return {'relevant': None, 'reason': f'AI error: {str(e)[:50]}', 'evaluated': False}
|
||||
return {'relevant': None, 'score': None, 'reason': f'AI error: {str(e)[:50]}', 'evaluated': False}
|
||||
|
||||
|
||||
def evaluate_pending_news(db_session, limit: int = 50, user_id: int = None) -> Dict:
|
||||
@ -704,6 +720,7 @@ def evaluate_pending_news(db_session, limit: int = 50, user_id: int = None) -> D
|
||||
|
||||
if result['evaluated']:
|
||||
news.ai_relevant = result['relevant']
|
||||
news.ai_relevance_score = result.get('score') # 1-5 stars
|
||||
news.ai_evaluation_reason = result['reason']
|
||||
news.ai_evaluated_at = datetime.now()
|
||||
news.ai_model = 'gemini-2.0-flash'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user