feat(audit): Add previous vs current AI analysis comparison
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

Store previous analysis before regeneration and show comparison table
with priority breakdown, new/removed actions diff.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-07 18:05:17 +01:00
parent 3307d99729
commit 7197af3933
7 changed files with 192 additions and 2 deletions

View File

@ -600,12 +600,19 @@ def generate_analysis(company_id: int, audit_type: str, user_id: int = None, for
if cache and cache.audit_data_hash == data_hash and cache.expires_at and cache.expires_at > datetime.now(): if cache and cache.audit_data_hash == data_hash and cache.expires_at and cache.expires_at > datetime.now():
logger.info(f"AI analysis cache hit for company {company_id} audit_type={audit_type}") logger.info(f"AI analysis cache hit for company {company_id} audit_type={audit_type}")
return { result = {
'summary': cache.analysis_summary, 'summary': cache.analysis_summary,
'actions': cache.actions_json or [], 'actions': cache.actions_json or [],
'cached': True, 'cached': True,
'generated_at': cache.generated_at.isoformat() if cache.generated_at else None, 'generated_at': cache.generated_at.isoformat() if cache.generated_at else None,
} }
if cache.previous_summary or cache.previous_actions_json:
result['previous'] = {
'summary': cache.previous_summary,
'actions': cache.previous_actions_json or [],
'generated_at': cache.previous_generated_at.isoformat() if cache.previous_generated_at else None,
}
return result
# Build prompt # Build prompt
prompt_builders = { prompt_builders = {
@ -656,6 +663,10 @@ def generate_analysis(company_id: int, audit_type: str, user_id: int = None, for
).first() ).first()
if cache: if cache:
# Preserve previous analysis for comparison
cache.previous_summary = cache.analysis_summary
cache.previous_actions_json = cache.actions_json
cache.previous_generated_at = cache.generated_at
cache.analysis_summary = summary cache.analysis_summary = summary
cache.actions_json = actions cache.actions_json = actions
cache.audit_data_hash = data_hash cache.audit_data_hash = data_hash
@ -693,12 +704,19 @@ def generate_analysis(company_id: int, audit_type: str, user_id: int = None, for
db.commit() db.commit()
return { result = {
'summary': summary, 'summary': summary,
'actions': actions, 'actions': actions,
'cached': False, 'cached': False,
'generated_at': datetime.now().isoformat(), 'generated_at': datetime.now().isoformat(),
} }
if cache and (cache.previous_summary or cache.previous_actions_json):
result['previous'] = {
'summary': cache.previous_summary,
'actions': cache.previous_actions_json or [],
'generated_at': cache.previous_generated_at.isoformat() if cache.previous_generated_at else None,
}
return result
except Exception as e: except Exception as e:
db.rollback() db.rollback()

View File

@ -5135,6 +5135,9 @@ class AuditAICache(Base):
audit_data_hash = Column(String(64)) audit_data_hash = Column(String(64))
generated_at = Column(DateTime, default=datetime.now) generated_at = Column(DateTime, default=datetime.now)
expires_at = Column(DateTime) expires_at = Column(DateTime)
previous_summary = Column(Text)
previous_actions_json = Column(JSONB)
previous_generated_at = Column(DateTime)
# Relationships # Relationships
company = relationship('Company', backref='audit_ai_caches') company = relationship('Company', backref='audit_ai_caches')

View File

@ -0,0 +1,6 @@
-- Migration 057: Add previous analysis columns to audit_ai_cache
-- Stores the previous AI analysis before regeneration for comparison
ALTER TABLE audit_ai_cache ADD COLUMN IF NOT EXISTS previous_summary TEXT;
ALTER TABLE audit_ai_cache ADD COLUMN IF NOT EXISTS previous_actions_json JSONB;
ALTER TABLE audit_ai_cache ADD COLUMN IF NOT EXISTS previous_generated_at TIMESTAMP;

View File

@ -2049,6 +2049,9 @@ function renderAIResults(data) {
actionsList.appendChild(card); actionsList.appendChild(card);
}); });
// Render comparison with previous analysis if available
if (typeof renderAIComparison === 'function') renderAIComparison(data);
results.style.display = 'block'; results.style.display = 'block';
document.getElementById('aiActionsSection').scrollIntoView({behavior: 'smooth', block: 'start'}); document.getElementById('aiActionsSection').scrollIntoView({behavior: 'smooth', block: 'start'});
window._aiActions = actions; window._aiActions = actions;

View File

@ -55,6 +55,30 @@
</div> </div>
</div> </div>
<!-- Comparison with previous analysis -->
<div id="aiComparison" style="display: none; margin-bottom: var(--spacing-lg);">
<div style="display: flex; align-items: center; gap: var(--spacing-sm); margin-bottom: var(--spacing-sm); cursor: pointer;" onclick="toggleComparison()">
<svg id="aiComparisonArrow" width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="transition: transform 0.2s;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<span style="font-size: var(--font-size-sm); font-weight: 600; color: var(--text-secondary);">Porownanie z poprzednia analiza</span>
<span id="aiComparisonDate" style="font-size: var(--font-size-xs); color: var(--text-tertiary);"></span>
</div>
<div id="aiComparisonBody" style="display: none;">
<table class="ai-comparison-table">
<thead>
<tr>
<th>Aspekt</th>
<th>Poprzednia</th>
<th>Obecna</th>
</tr>
</thead>
<tbody id="aiComparisonRows"></tbody>
</table>
<div id="aiComparisonDiff" style="margin-top: var(--spacing-sm);"></div>
</div>
</div>
<!-- Actions List --> <!-- Actions List -->
<div style="font-size: var(--font-size-lg); font-weight: 600; color: var(--text-primary); margin-bottom: var(--spacing-md);"> <div style="font-size: var(--font-size-lg); font-weight: 600; color: var(--text-primary); margin-bottom: var(--spacing-md);">
Priorytetowe akcje Priorytetowe akcje
@ -160,6 +184,48 @@
.ai-action-card.dismissed { .ai-action-card.dismissed {
display: none; display: none;
} }
.ai-comparison-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
}
.ai-comparison-table th {
background: var(--bg-tertiary);
padding: 8px 12px;
text-align: left;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--border);
}
.ai-comparison-table td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
vertical-align: top;
}
.ai-comparison-table tr:last-child td {
border-bottom: none;
}
.ai-diff-added {
display: inline-block;
background: #dcfce7;
color: #166534;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
margin: 2px;
}
.ai-diff-removed {
display: inline-block;
background: #fee2e2;
color: #991b1b;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
margin: 2px;
text-decoration: line-through;
}
</style> </style>
<script> <script>
@ -188,4 +254,92 @@ document.addEventListener('DOMContentLoaded', function() {
}) })
.catch(function() {}); .catch(function() {});
}); });
function toggleComparison() {
var body = document.getElementById('aiComparisonBody');
var arrow = document.getElementById('aiComparisonArrow');
if (body.style.display === 'none') {
body.style.display = 'block';
arrow.style.transform = 'rotate(90deg)';
} else {
body.style.display = 'none';
arrow.style.transform = '';
}
}
function renderAIComparison(data) {
var section = document.getElementById('aiComparison');
if (!data.previous) {
section.style.display = 'none';
return;
}
var prev = data.previous;
var prevActions = prev.actions || [];
var currActions = data.actions || [];
var priorityOrder = ['critical', 'high', 'medium', 'low'];
function countPriority(actions, p) {
return actions.filter(function(a) { return a.priority === p; }).length;
}
var rows = document.getElementById('aiComparisonRows');
var html = '';
// Summary row
var prevSummaryShort = (prev.summary || '').substring(0, 120) + ((prev.summary || '').length > 120 ? '...' : '');
var currSummaryShort = (data.summary || '').substring(0, 120) + ((data.summary || '').length > 120 ? '...' : '');
html += '<tr><td><strong>Podsumowanie</strong></td><td style="color: var(--text-secondary);">' + escapeHtml(prevSummaryShort) + '</td><td>' + escapeHtml(currSummaryShort) + '</td></tr>';
// Count row
html += '<tr><td><strong>Liczba akcji</strong></td><td>' + prevActions.length + '</td><td>' + currActions.length + '</td></tr>';
// Priority breakdown
priorityOrder.forEach(function(p) {
var labels = {critical: 'Krytyczne', high: 'Wysokie', medium: 'Srednie', low: 'Niskie'};
var pc = countPriority(prevActions, p);
var cc = countPriority(currActions, p);
if (pc > 0 || cc > 0) {
var diff = cc - pc;
var diffStr = diff > 0 ? ' <span style="color:#16a34a;">(+' + diff + ')</span>' : diff < 0 ? ' <span style="color:#dc2626;">(' + diff + ')</span>' : '';
html += '<tr><td>' + labels[p] + '</td><td>' + pc + '</td><td>' + cc + diffStr + '</td></tr>';
}
});
// Date row
if (prev.generated_at) {
var pd = new Date(prev.generated_at);
html += '<tr><td><strong>Data analizy</strong></td><td>' + pd.toLocaleDateString('pl-PL') + ' ' + pd.toLocaleTimeString('pl-PL', {hour:'2-digit', minute:'2-digit'}) + '</td><td>teraz</td></tr>';
}
rows.innerHTML = html;
// Diff: new/removed actions
var prevTitles = prevActions.map(function(a) { return a.title; });
var currTitles = currActions.map(function(a) { return a.title; });
var added = currTitles.filter(function(t) { return prevTitles.indexOf(t) === -1; });
var removed = prevTitles.filter(function(t) { return currTitles.indexOf(t) === -1; });
var diffEl = document.getElementById('aiComparisonDiff');
var diffHtml = '';
if (added.length > 0) {
diffHtml += '<div style="margin-bottom: var(--spacing-xs);"><strong style="font-size: var(--font-size-xs); color: var(--text-secondary);">Nowe akcje:</strong> ';
added.forEach(function(t) { diffHtml += '<span class="ai-diff-added">' + escapeHtml(t) + '</span>'; });
diffHtml += '</div>';
}
if (removed.length > 0) {
diffHtml += '<div><strong style="font-size: var(--font-size-xs); color: var(--text-secondary);">Usuniete akcje:</strong> ';
removed.forEach(function(t) { diffHtml += '<span class="ai-diff-removed">' + escapeHtml(t) + '</span>'; });
diffHtml += '</div>';
}
diffEl.innerHTML = diffHtml;
// Show date in header
if (prev.generated_at) {
var pd2 = new Date(prev.generated_at);
document.getElementById('aiComparisonDate').textContent = '(z ' + pd2.toLocaleDateString('pl-PL') + ')';
}
section.style.display = 'block';
}
</script> </script>

View File

@ -1099,6 +1099,9 @@ function renderAIResults(data) {
actionsList.appendChild(card); actionsList.appendChild(card);
}); });
// Render comparison with previous analysis if available
if (typeof renderAIComparison === 'function') renderAIComparison(data);
results.style.display = 'block'; results.style.display = 'block';
document.getElementById('aiActionsSection').scrollIntoView({behavior: 'smooth', block: 'start'}); document.getElementById('aiActionsSection').scrollIntoView({behavior: 'smooth', block: 'start'});

View File

@ -1499,6 +1499,9 @@ function renderAIResults(data) {
actionsList.appendChild(card); actionsList.appendChild(card);
}); });
// Render comparison with previous analysis if available
if (typeof renderAIComparison === 'function') renderAIComparison(data);
results.style.display = 'block'; results.style.display = 'block';
document.getElementById('aiActionsSection').scrollIntoView({behavior: 'smooth', block: 'start'}); document.getElementById('aiActionsSection').scrollIntoView({behavior: 'smooth', block: 'start'});
window._aiActions = actions; window._aiActions = actions;