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
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:
parent
0b8abe8bc3
commit
99dd628d4a
@ -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)
|
||||
|
||||
@ -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;">×</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>
|
||||
→
|
||||
<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 %}
|
||||
|
||||
@ -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)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user