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:
Maciej Pienczyn 2026-01-11 07:34:36 +01:00
parent 8dda8e311f
commit e399022223
4 changed files with 63 additions and 13 deletions

View File

@ -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)

View File

@ -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

View File

@ -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>

View File

@ -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'