feat(zopk): Add AI-powered roadmap analysis with status updates and gap detection
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

New analyze_roadmap_with_ai() function sends existing milestones and recent
knowledge facts to Gemini for comprehensive analysis. Returns new milestone
suggestions, status update recommendations, and identified roadmap gaps.
Adds PATCH endpoint for milestone status updates and tabbed UI modal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-09 17:10:28 +01:00
parent 0b8abe8bc3
commit 99dd628d4a
3 changed files with 515 additions and 0 deletions

View File

@ -145,6 +145,53 @@ def api_zopk_milestone_delete(milestone_id):
db.close()
@bp.route('/zopk-api/timeline/ai-analyze')
@login_required
@role_required(SystemRole.ADMIN)
def api_zopk_timeline_ai_analyze():
"""API - AI analysis of roadmap: new milestones, status updates, gaps."""
from zopk_knowledge_service import analyze_roadmap_with_ai
db = SessionLocal()
try:
result = analyze_roadmap_with_ai(db)
return jsonify(result)
except Exception as e:
logger.error(f"AI analyze error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/zopk-api/milestones/<int:milestone_id>/status', methods=['PATCH'])
@login_required
@role_required(SystemRole.ADMIN)
def api_zopk_milestone_update_status(milestone_id):
"""API - update milestone status."""
db = SessionLocal()
try:
milestone = db.query(ZOPKMilestone).get(milestone_id)
if not milestone:
return jsonify({'error': 'Not found'}), 404
data = request.get_json()
new_status = data.get('status')
if new_status not in ('planned', 'in_progress', 'completed', 'delayed', 'cancelled'):
return jsonify({'error': 'Invalid status'}), 400
milestone.status = new_status
db.commit()
return jsonify({'success': True, 'milestone_id': milestone_id, 'status': new_status})
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/zopk-api/timeline/suggestions')
@login_required
@role_required(SystemRole.ADMIN)

View File

@ -81,6 +81,19 @@
.suggestion-actions { display: flex; gap: var(--spacing-sm); align-items: center; }
.suggestion-input { flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); font-size: var(--font-size-sm); }
/* AI Analysis tabs */
.ai-tabs { display: flex; gap: 0; border-bottom: 2px solid var(--border); margin-bottom: var(--spacing-md); }
.ai-tab { padding: 10px 16px; cursor: pointer; font-size: var(--font-size-sm); font-weight: 500; color: var(--text-secondary); border-bottom: 2px solid transparent; margin-bottom: -2px; display: flex; align-items: center; gap: 6px; }
.ai-tab:hover { color: var(--text-primary); background: var(--background); }
.ai-tab.active { color: var(--primary); border-bottom-color: var(--primary); }
.ai-tab-badge { background: var(--primary); color: white; font-size: 11px; padding: 1px 7px; border-radius: 10px; min-width: 18px; text-align: center; }
.ai-tab-badge.zero { background: var(--border); color: var(--text-secondary); }
.ai-tab-content { display: none; }
.ai-tab-content.active { display: block; }
.update-card { background: var(--background); border-radius: var(--radius); padding: var(--spacing-md); margin-bottom: var(--spacing-sm); border-left: 4px solid #f59e0b; }
.update-card .status-arrow { font-weight: 600; color: var(--primary); }
.gap-card { background: var(--background); border-radius: var(--radius); padding: var(--spacing-md); margin-bottom: var(--spacing-sm); border-left: 4px solid #8b5cf6; }
/* Toast notifications */
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 2000; display: flex; flex-direction: column; gap: 10px; }
.toast { padding: 12px 20px; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow-lg); display: flex; align-items: center; gap: 10px; animation: slideIn 0.3s ease; }
@ -104,6 +117,7 @@
<h1>🗺️ Timeline ZOPK</h1>
<div class="btn-group">
<button class="btn btn-secondary" onclick="loadSuggestions()">🧠 Sugestie z bazy wiedzy</button>
<button class="btn btn-secondary" onclick="loadAIAnalysis()" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none;">🤖 AI Analiza roadmapy</button>
<button class="btn btn-primary" onclick="openAddModal()"> Dodaj kamień milowy</button>
</div>
</div>
@ -213,6 +227,55 @@
</div>
</div>
<!-- Modal AI Analysis -->
<div class="modal" id="aiAnalysisModal">
<div class="modal-content modal-large">
<div class="modal-header">
<h3>🤖 AI Analiza roadmapy</h3>
<button onclick="closeAIModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<div class="suggestions-stats" id="aiStats">
<div class="stat-item">
<div class="stat-value" id="aiStatMilestones">-</div>
<div class="stat-label">Kamieni milowych</div>
</div>
<div class="stat-item">
<div class="stat-value" id="aiStatFacts">-</div>
<div class="stat-label">Faktów przeanalizowanych</div>
</div>
<div class="stat-item">
<div class="stat-value" id="aiStatNew">-</div>
<div class="stat-label">Nowych sugestii</div>
</div>
<div class="stat-item">
<div class="stat-value" id="aiStatUpdates">-</div>
<div class="stat-label">Aktualizacji statusu</div>
</div>
<div class="stat-item">
<div class="stat-value" id="aiStatGaps">-</div>
<div class="stat-label">Braków w roadmapie</div>
</div>
</div>
<div class="ai-tabs">
<div class="ai-tab active" onclick="switchAITab('new')">Nowe kamienie milowe <span class="ai-tab-badge" id="badgeNew">0</span></div>
<div class="ai-tab" onclick="switchAITab('updates')">Aktualizacje statusu <span class="ai-tab-badge" id="badgeUpdates">0</span></div>
<div class="ai-tab" onclick="switchAITab('gaps')">Braki w roadmapie <span class="ai-tab-badge" id="badgeGaps">0</span></div>
</div>
<div class="ai-tab-content active" id="tabNew">
<div class="empty-state">Ładowanie...</div>
</div>
<div class="ai-tab-content" id="tabUpdates">
<div class="empty-state">Ładowanie...</div>
</div>
<div class="ai-tab-content" id="tabGaps">
<div class="empty-state">Ładowanie...</div>
</div>
</div>
</div>
<!-- Toast container -->
<div class="toast-container" id="toastContainer"></div>
{% endblock %}
@ -496,6 +559,273 @@ function showToast(type, message, duration = 4000) {
}, duration);
}
// ========== AI ANALYSIS ==========
let aiData = null;
async function loadAIAnalysis() {
document.getElementById('aiAnalysisModal').classList.add('active');
document.getElementById('tabNew').innerHTML = '<div class="empty-state">🤖 AI analizuje roadmapę... (może potrwać 10-15 sekund)</div>';
document.getElementById('tabUpdates').innerHTML = '<div class="empty-state">Oczekiwanie na wyniki...</div>';
document.getElementById('tabGaps').innerHTML = '<div class="empty-state">Oczekiwanie na wyniki...</div>';
try {
const response = await fetch('/admin/zopk-api/timeline/ai-analyze');
const data = await response.json();
if (data.success) {
aiData = data;
// Update stats
document.getElementById('aiStatMilestones').textContent = data.total_milestones || 0;
document.getElementById('aiStatFacts').textContent = data.total_facts_analyzed || 0;
document.getElementById('aiStatNew').textContent = (data.new_milestones || []).length;
document.getElementById('aiStatUpdates').textContent = (data.status_updates || []).length;
document.getElementById('aiStatGaps').textContent = (data.gaps || []).length;
// Update badges
updateBadge('badgeNew', (data.new_milestones || []).length);
updateBadge('badgeUpdates', (data.status_updates || []).length);
updateBadge('badgeGaps', (data.gaps || []).length);
renderAINewMilestones(data.new_milestones || []);
renderAIStatusUpdates(data.status_updates || []);
renderAIGaps(data.gaps || []);
} else {
document.getElementById('tabNew').innerHTML = '<div class="empty-state">Błąd: ' + (data.error || 'Nieznany') + '</div>';
}
} catch (error) {
document.getElementById('tabNew').innerHTML = '<div class="empty-state">Błąd połączenia: ' + error + '</div>';
}
}
function updateBadge(id, count) {
const badge = document.getElementById(id);
badge.textContent = count;
badge.className = count > 0 ? 'ai-tab-badge' : 'ai-tab-badge zero';
}
function switchAITab(tab) {
document.querySelectorAll('.ai-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.ai-tab-content').forEach(t => t.classList.remove('active'));
const tabs = {new: 0, updates: 1, gaps: 2};
document.querySelectorAll('.ai-tab')[tabs[tab]].classList.add('active');
document.getElementById('tab' + tab.charAt(0).toUpperCase() + tab.slice(1)).classList.add('active');
}
const statusLabelsAI = {planned: 'Planowane', in_progress: 'W trakcie', completed: 'Zakończone', delayed: 'Opóźnione'};
const categoryLabelsAI = {nuclear: 'Energia jądrowa', offshore: 'Offshore', infrastructure: 'Infrastruktura', defense: 'Obronność', other: 'Inne'};
function renderAINewMilestones(items) {
if (items.length === 0) {
document.getElementById('tabNew').innerHTML = '<div class="empty-state">AI nie znalazło nowych kandydatów na kamienie milowe</div>';
return;
}
const html = items.map((item, idx) => `
<div class="suggestion-card" id="ai-new-${idx}">
<div class="suggestion-text">${escapeHtml(item.full_text || item.reason || '')}</div>
<div class="suggestion-meta">
<span>Kategoria: ${categoryLabelsAI[item.category] || item.category}</span>
<span>Status: ${statusLabelsAI[item.status] || item.status}</span>
${item.target_date ? '<span>Data: ' + item.target_date + '</span>' : ''}
${item.news_title ? '<span>Źródło: ' + escapeHtml(item.news_title) + '</span>' : ''}
</div>
<div class="suggestion-meta" style="margin-bottom: var(--spacing-xs);"><em>AI: ${escapeHtml(item.reason || '')}</em></div>
<div class="suggestion-actions">
<input type="text" class="suggestion-input" id="ai-new-title-${idx}" value="${escapeHtml(item.title)}" placeholder="Tytuł">
<select id="ai-new-cat-${idx}" style="padding: 6px; border-radius: var(--radius);">
<option value="nuclear" ${item.category === 'nuclear' ? 'selected' : ''}>Energia jądrowa</option>
<option value="offshore" ${item.category === 'offshore' ? 'selected' : ''}>Offshore</option>
<option value="infrastructure" ${item.category === 'infrastructure' ? 'selected' : ''}>Infrastruktura</option>
<option value="defense" ${item.category === 'defense' ? 'selected' : ''}>Obronność</option>
<option value="other" ${item.category === 'other' ? 'selected' : ''}>Inne</option>
</select>
<button class="btn btn-success btn-sm" onclick="approveAINew(${idx})">Dodaj</button>
</div>
</div>
`).join('');
document.getElementById('tabNew').innerHTML = html;
}
function renderAIStatusUpdates(items) {
if (items.length === 0) {
document.getElementById('tabUpdates').innerHTML = '<div class="empty-state">AI nie znalazło potrzebnych aktualizacji statusu</div>';
return;
}
const html = items.map((item, idx) => `
<div class="update-card" id="ai-update-${idx}">
<div style="font-weight: 600; margin-bottom: var(--spacing-xs);">${escapeHtml(item.milestone_title || 'Milestone #' + item.milestone_id)}</div>
<div class="suggestion-meta">
<span class="status-arrow">
<span class="timeline-badge status-${item.current_status}">${statusLabelsAI[item.current_status]}</span>
&rarr;
<span class="timeline-badge status-${item.suggested_status}">${statusLabelsAI[item.suggested_status]}</span>
</span>
${item.milestone_category ? '<span>Kategoria: ' + (categoryLabelsAI[item.milestone_category] || item.milestone_category) + '</span>' : ''}
</div>
<div class="suggestion-meta"><em>AI: ${escapeHtml(item.reason || '')}</em></div>
<div class="suggestion-actions" style="margin-top: var(--spacing-sm);">
<button class="btn btn-success btn-sm" onclick="applyStatusUpdate(${idx})">Zastosuj zmianę</button>
<button class="btn btn-sm" onclick="skipUpdate(${idx})">Pomiń</button>
</div>
</div>
`).join('');
document.getElementById('tabUpdates').innerHTML = html;
}
function renderAIGaps(items) {
if (items.length === 0) {
document.getElementById('tabGaps').innerHTML = '<div class="empty-state">AI nie zidentyfikowało braków w roadmapie</div>';
return;
}
const html = items.map((item, idx) => `
<div class="gap-card" id="ai-gap-${idx}">
<div style="font-weight: 600; margin-bottom: var(--spacing-xs);">${escapeHtml(item.suggested_title)}</div>
<div class="suggestion-text">${escapeHtml(item.description)}</div>
<div class="suggestion-meta">
<span>Kategoria: ${categoryLabelsAI[item.category] || item.category}</span>
</div>
<div class="suggestion-meta"><em>AI: ${escapeHtml(item.reason || '')}</em></div>
<div class="suggestion-actions" style="margin-top: var(--spacing-sm);">
<input type="text" class="suggestion-input" id="ai-gap-title-${idx}" value="${escapeHtml(item.suggested_title)}" placeholder="Tytuł">
<button class="btn btn-success btn-sm" onclick="createFromGap(${idx})">Utwórz</button>
<button class="btn btn-sm" onclick="skipGap(${idx})">Pomiń</button>
</div>
</div>
`).join('');
document.getElementById('tabGaps').innerHTML = html;
}
async function approveAINew(idx) {
const item = aiData.new_milestones[idx];
if (!item) return;
const title = document.getElementById('ai-new-title-' + idx).value;
const category = document.getElementById('ai-new-cat-' + idx).value;
const body = {
title: title,
description: item.full_text || item.reason,
category: category,
target_date: item.target_date || null,
status: item.status || 'planned',
source_url: item.news_url || null,
source_news_id: item.source_news_id || null
};
// If fact_id exists, use suggestion approve endpoint
if (item.fact_id) {
body.fact_id = item.fact_id;
try {
const response = await fetch('/admin/zopk-api/timeline/suggestions/approve', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}'},
body: JSON.stringify(body)
});
const result = await response.json();
if (result.success) {
showToast('success', 'Dodano: ' + title);
document.getElementById('ai-new-' + idx).classList.add('approved');
loadMilestones();
} else {
showToast('error', 'Błąd: ' + result.error);
}
} catch (error) {
showToast('error', 'Błąd: ' + error);
}
} else {
// Create directly
try {
const response = await fetch('/admin/zopk-api/milestones', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}'},
body: JSON.stringify(body)
});
const result = await response.json();
if (result.success) {
showToast('success', 'Dodano: ' + title);
document.getElementById('ai-new-' + idx).classList.add('approved');
loadMilestones();
} else {
showToast('error', 'Błąd: ' + result.error);
}
} catch (error) {
showToast('error', 'Błąd: ' + error);
}
}
}
async function applyStatusUpdate(idx) {
const item = aiData.status_updates[idx];
if (!item) return;
try {
const response = await fetch('/admin/zopk-api/milestones/' + item.milestone_id + '/status', {
method: 'PATCH',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}'},
body: JSON.stringify({status: item.suggested_status})
});
const result = await response.json();
if (result.success) {
showToast('success', 'Status zmieniony: ' + (item.milestone_title || '#' + item.milestone_id));
document.getElementById('ai-update-' + idx).classList.add('approved');
loadMilestones();
} else {
showToast('error', 'Błąd: ' + result.error);
}
} catch (error) {
showToast('error', 'Błąd: ' + error);
}
}
function skipUpdate(idx) {
document.getElementById('ai-update-' + idx).remove();
}
async function createFromGap(idx) {
const item = aiData.gaps[idx];
if (!item) return;
const title = document.getElementById('ai-gap-title-' + idx).value;
try {
const response = await fetch('/admin/zopk-api/milestones', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}'},
body: JSON.stringify({
title: title,
description: item.description,
category: item.category || 'other',
status: 'planned'
})
});
const result = await response.json();
if (result.success) {
showToast('success', 'Utworzono: ' + title);
document.getElementById('ai-gap-' + idx).classList.add('approved');
loadMilestones();
} else {
showToast('error', 'Błąd: ' + result.error);
}
} catch (error) {
showToast('error', 'Błąd: ' + error);
}
}
function skipGap(idx) {
document.getElementById('ai-gap-' + idx).remove();
}
function closeAIModal() {
document.getElementById('aiAnalysisModal').classList.remove('active');
}
// Init
loadMilestones();
{% endblock %}

View File

@ -2710,3 +2710,141 @@ Odpowiedz TYLKO jako JSON array:
except Exception as e:
logger.warning(f"AI categorization failed: {e}")
return suggestions # Return original suggestions without AI enhancement
def analyze_roadmap_with_ai(db_session) -> Dict:
"""
AI-powered roadmap analysis: new milestones, status updates, and gap detection.
Uses Gemini to analyze existing milestones against recent knowledge facts.
Returns:
{
'success': True,
'new_milestones': [...],
'status_updates': [...],
'gaps': [...]
}
"""
import google.generativeai as genai
import json
from database import ZOPKMilestone
try:
# 1. Get existing milestones
milestones = db_session.query(ZOPKMilestone).order_by(ZOPKMilestone.target_date).all()
milestones_text = "\n".join([
f"ID:{m.id} | {m.title} | kategoria:{m.category} | status:{m.status} | data:{m.target_date}"
for m in milestones
]) or "(brak kamieni milowych)"
# 2. Get recent verified facts from knowledge base
from sqlalchemy import text
facts_query = text("""
SELECT f.id, f.full_text, f.fact_type, f.date_value, f.confidence_score,
f.source_news_id, n.title as news_title, n.url as news_url
FROM zopk_knowledge_facts f
LEFT JOIN zopk_news n ON n.id = f.source_news_id
WHERE f.confidence_score >= 0.5
ORDER BY f.created_at DESC
LIMIT 50
""")
facts = db_session.execute(facts_query).fetchall()
if not facts:
return {
'success': True,
'new_milestones': [],
'status_updates': [],
'gaps': [],
'message': 'Brak faktów w bazie wiedzy do analizy'
}
facts_text = "\n".join([
f"ID:{f.id} | typ:{f.fact_type} | data:{f.date_value} | {f.full_text[:250]}"
for f in facts
])
# 3. Send to Gemini
prompt = f"""Jesteś ekspertem ds. projektu ZOPK (Zielony Okręg Przemysłowy Kaszubia - Pomorze).
Projekty w regionie: elektrownia jądrowa (Lubiatowo-Kopalino), farmy wiatrowe offshore (Baltic Power, Baltica), infrastruktura (S6, porty), obronność (Kongsberg).
ISTNIEJĄCE KAMIENIE MILOWE ROADMAPY:
{milestones_text}
OSTATNIE FAKTY Z BAZY WIEDZY:
{facts_text}
Przeanalizuj i zwróć TYLKO JSON (bez markdown):
{{
"new_milestones": [
{{"fact_id": N, "title": "max 80 znaków", "category": "nuclear|offshore|infrastructure|defense|other", "target_date": "YYYY-MM-DD lub null", "status": "planned|in_progress|completed", "reason": "dlaczego to kamień milowy"}}
],
"status_updates": [
{{"milestone_id": N, "current_status": "...", "suggested_status": "...", "reason": "krótkie uzasadnienie", "supporting_fact_ids": [N]}}
],
"gaps": [
{{"description": "co brakuje w roadmapie", "suggested_title": "max 80 znaków", "category": "nuclear|offshore|infrastructure|defense|other", "reason": "dlaczego to ważne"}}
]
}}
Zasady:
- new_milestones: fakty które powinny być kamieniami milowymi, a NIE ma ich jeszcze w roadmapie
- status_updates: istniejące kamienie milowe, których status powinien się zmienić na podstawie nowych faktów
- gaps: ważne tematy dla regionu Kaszubia/Pomorze bez kamienia milowego, które warto dodać
- Jeśli nie ma sugestii w danej kategorii, zwróć pustą listę
- Tytuły pisz po polsku"""
model = genai.GenerativeModel("gemini-3-flash-preview")
response = model.generate_content(prompt)
response_text = response.text.strip()
# Strip markdown code blocks if present
if response_text.startswith('```'):
response_text = response_text.split('```')[1]
if response_text.startswith('json'):
response_text = response_text[4:]
if response_text.endswith('```'):
response_text = response_text[:-3]
ai_result = json.loads(response_text.strip())
# Enrich new_milestones with source info
facts_map = {f.id: f for f in facts}
for nm in ai_result.get('new_milestones', []):
fact = facts_map.get(nm.get('fact_id'))
if fact:
nm['full_text'] = fact.full_text
nm['news_url'] = fact.news_url
nm['news_title'] = fact.news_title
nm['source_news_id'] = fact.source_news_id
# Enrich status_updates with milestone info
milestones_map = {m.id: m for m in milestones}
for su in ai_result.get('status_updates', []):
ms = milestones_map.get(su.get('milestone_id'))
if ms:
su['milestone_title'] = ms.title
su['milestone_category'] = ms.category
logger.info(
f"AI roadmap analysis: {len(ai_result.get('new_milestones', []))} new, "
f"{len(ai_result.get('status_updates', []))} updates, "
f"{len(ai_result.get('gaps', []))} gaps"
)
return {
'success': True,
'new_milestones': ai_result.get('new_milestones', []),
'status_updates': ai_result.get('status_updates', []),
'gaps': ai_result.get('gaps', []),
'total_milestones': len(milestones),
'total_facts_analyzed': len(facts)
}
except json.JSONDecodeError as e:
logger.error(f"AI roadmap analysis JSON parse error: {e}")
return {'success': False, 'error': f'Błąd parsowania odpowiedzi AI: {e}'}
except Exception as e:
logger.error(f"AI roadmap analysis error: {e}")
return {'success': False, 'error': str(e)}