Files
WebChecker--Web-app-/templates/ai_summary.html
T

754 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}AI Summary — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1>🤖 AI Document Analysis</h1>
{% if is_admin %}
<a href="{{ url_for('admin_settings.settings') }}" class="btn btn-secondary btn-sm">⚙ API Settings</a>
{% endif %}
</div>
{% if not groq_key_set %}
<div class="alert alert-warning mb-2">⚠ Groq API key not configured. An admin must add it under Settings.</div>
{% endif %}
<div class="ai-layout">
<!-- ── Left panel ──────────────────────────────────────────── -->
<div class="ai-left">
<!-- Upload card -->
<div class="card">
<div class="card-header"><span class="card-title">Upload Documents</span></div>
<div class="card-body-pad">
{% if is_admin %}
<div class="form-group">
<label>Model</label>
<select id="ai-model">
{% for m in groq_models %}
<option {{ 'selected' if m == groq_model }}>{{ m }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="form-group">
<label>Documents <span class="text-muted">(PDF, DOCX, TXT, CSV, XLSX)</span></label>
<input type="file" id="ai-files" multiple
accept=".pdf,.docx,.doc,.txt,.csv,.xlsx,.xls,.md">
</div>
<button class="btn btn-primary w-full" id="btn-analyze" onclick="runAnalysis()">
🔍 Analyze with AI
</button>
</div>
</div>
<!-- Criteria card -->
<div class="card mt-2">
<div class="card-header">
<span class="card-title">Evaluation Criteria</span>
{% if is_admin %}
<button class="btn btn-secondary btn-sm" onclick="openModal('modal-add-criterion')"> Add</button>
{% endif %}
</div>
<div class="criteria-list">
{% for c in criteria %}
<div class="criterion-row {{ '' if c.is_active else 'criterion-inactive' }}"
data-title="{{ c.title }}"
data-desc="{{ c.description }}"
data-id="{{ c.id }}"
data-active="{{ '1' if c.is_active else '0' }}"
data-order="{{ c.sort_order }}">
<div class="criterion-header">
<strong class="criterion-title">{{ c.title }}</strong>
<div class="criterion-badges">
{% if not c.is_active %}<span class="badge badge-muted">Inactive</span>{% endif %}
{% if is_admin %}
<button class="btn btn-secondary btn-sm js-edit-criterion">✎ Edit</button>
<form method="post"
action="{{ url_for('ai_summary.delete_criterion_view', criterion_id=c.id) }}"
style="display:inline"
onsubmit="return confirm('Delete this criterion?')">
<button class="btn btn-danger btn-sm" type="submit"></button>
</form>
{% endif %}
</div>
</div>
<div class="criterion-desc">{{ c.description[:140] }}{% if c.description|length > 140 %}…{% endif %}</div>
<button class="btn-link-sm js-view-criterion">View full details </button>
</div>
{% else %}
<p class="text-muted text-center" style="padding:1rem">No criteria defined yet.</p>
{% endfor %}
</div>
</div>
</div>
<!-- ── Right panel ─────────────────────────────────────────── -->
<div class="ai-right">
<!-- Output card -->
<div class="card ai-output-card">
<div class="card-header">
<span class="card-title">Extracted Information</span>
<div class="flex gap-1">
<button class="btn btn-secondary btn-sm" id="btn-copy" onclick="copyOutput()" style="display:none">📋 Copy</button>
<button class="btn btn-secondary btn-sm" id="btn-save" onclick="saveOutput()" style="display:none">💾 Save as TXT</button>
<button class="btn btn-secondary btn-sm" id="btn-clear" onclick="clearOutput()" style="display:none">🗑 Clear</button>
</div>
</div>
<!-- Verdict banner — hidden until result arrives -->
<div id="verdict-banner" style="display:none"></div>
<!-- Output body -->
<div id="ai-output" class="ai-output-body">
<div class="ai-placeholder">
<p>Upload a document and click <strong>Analyze with AI</strong> to begin.</p>
<p class="text-muted text-sm mt-1">The AI will extract: solicitation number &amp; type, set-aside, scope of work, work site, pre-proposal conference, POC, square footage, driving distance &amp; travel time, due date, and key deadlines.</p>
</div>
</div>
</div>
<!-- History card -->
<div class="card mt-2">
<div class="card-header">
<span class="card-title">Analysis History</span>
<div class="flex gap-1" style="flex-wrap:wrap">
<button class="ai-hist-filter active" data-verdict="">All</button>
<button class="ai-hist-filter" data-verdict="PURSUE">PURSUE</button>
<button class="ai-hist-filter" data-verdict="PASS">PASS</button>
<button class="ai-hist-filter" data-verdict="UNCLEAR">UNCLEAR</button>
<button class="ai-hist-filter" data-verdict="none">No verdict</button>
</div>
</div>
{% if is_admin %}
<div class="ai-bulk-bar" id="bulk-bar">
<label style="display:flex;align-items:center;gap:.45rem;cursor:pointer">
<input type="checkbox" id="cb-all" title="Select all visible">
<span id="bulk-count" class="text-muted" style="font-size:.8rem">0 selected</span>
</label>
<button class="btn btn-danger btn-sm" id="btn-bulk-delete" disabled onclick="bulkDelete()">🗑 Delete Selected</button>
<button class="btn btn-ghost btn-sm" onclick="clearSelection()">✕ Clear</button>
</div>
{% endif %}
<div class="table-container">
<table>
<thead>
<tr>
{% if is_admin %}<th style="width:36px;padding-right:0"></th>{% endif %}
<th>Date</th>
<th>User</th>
<th>Files</th>
<th>Verdict</th>
{% if is_admin %}<th style="width:40px"></th>{% endif %}
</tr>
</thead>
<tbody>
{% for h in history %}
<tr class="history-row" data-id="{{ h.id }}" style="cursor:pointer" title="Click to view result">
{% if is_admin %}
<td onclick="event.stopPropagation()" style="padding-right:0">
<input type="checkbox" class="hist-cb" data-id="{{ h.id }}">
</td>
{% endif %}
<td class="nowrap text-muted">{{ h.analyzed_at.strftime('%Y-%m-%d %H:%M') if h.analyzed_at else '—' }}</td>
<td class="text-muted">{{ h.username or '—' }}</td>
<td class="text-muted">{{ h.file_names[:50] }}{% if h.file_names|length > 50 %}…{% endif %}</td>
<td>
{% if h.verdict %}
<span class="badge badge-{{ 'success' if h.verdict=='PURSUE' else 'danger' if h.verdict=='PASS' else 'warning' }}">
{{ h.verdict }}
</span>
{% else %}—{% endif %}
</td>
{% if is_admin %}
<td onclick="event.stopPropagation()">
<button class="btn-row-delete js-delete-row" data-id="{{ h.id }}" title="Delete this record">🗑</button>
</td>
{% endif %}
</tr>
{% else %}
<tr><td colspan="{{ 6 if is_admin else 4 }}" style="padding:2.5rem 1rem;text-align:center">
<div style="font-size:2rem;margin-bottom:.5rem">🤖</div>
<p class="text-muted" style="margin:0 0 .75rem">No analyses yet. Upload a document above to get started.</p>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('ai-files').scrollIntoView({behavior:'smooth'});document.getElementById('ai-files').focus()">Start Analyzing</button>
</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ── View Criterion Modal ─────────────────────────────────── -->
<div class="modal-overlay" id="modal-view-criterion">
<div class="modal">
<div class="modal-header">
<span class="modal-title" id="vc-title">Criterion Details</span>
<button class="modal-close" onclick="closeModal('modal-view-criterion')"></button>
</div>
<div class="modal-body">
<p id="vc-desc" style="white-space:pre-wrap;line-height:1.7;color:var(--text-secondary)"></p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-view-criterion')">Close</button>
</div>
</div>
</div>
{% if is_admin %}
<!-- ── Add Criterion Modal ──────────────────────────────────── -->
<div class="modal-overlay" id="modal-add-criterion">
<div class="modal">
<div class="modal-header">
<span class="modal-title">Add Criterion</span>
<button class="modal-close" onclick="closeModal('modal-add-criterion')"></button>
</div>
<form method="post" action="{{ url_for('ai_summary.create_criterion_view') }}">
<div class="modal-body">
<div class="form-group"><label>Title *</label><input name="title" required></div>
<div class="form-group"><label>Description *</label><textarea name="description" rows="5" required></textarea></div>
<div class="form-row">
<div class="form-group"><label>Sort Order</label><input type="number" name="sort_order" value="0"></div>
<div class="form-group"><label>Active</label>
<select name="is_active"><option value="1">Yes</option><option value="0">No</option></select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-add-criterion')">Cancel</button>
<button type="submit" class="btn btn-primary">Add Criterion</button>
</div>
</form>
</div>
</div>
<!-- ── Edit Criterion Modal ─────────────────────────────────── -->
<div class="modal-overlay" id="modal-edit-criterion">
<div class="modal">
<div class="modal-header">
<span class="modal-title">Edit Criterion</span>
<button class="modal-close" onclick="closeModal('modal-edit-criterion')"></button>
</div>
<form method="post" id="edit-criterion-form">
<div class="modal-body">
<div class="form-group"><label>Title *</label><input name="title" id="ec-title" required></div>
<div class="form-group"><label>Description *</label><textarea name="description" id="ec-desc" rows="5" required></textarea></div>
<div class="form-row">
<div class="form-group"><label>Sort Order</label><input type="number" name="sort_order" id="ec-order"></div>
<div class="form-group"><label>Active</label>
<select name="is_active" id="ec-active"><option value="1">Yes</option><option value="0">No</option></select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-edit-criterion')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
{% endif %}
<!-- ── History Detail Modal ─────────────────────────────────── -->
<div class="modal-overlay" id="modal-history">
<div class="modal" style="max-width:780px">
<div class="modal-header">
<span class="modal-title" id="hist-title">Analysis Result</span>
<button class="modal-close" onclick="closeModal('modal-history')"></button>
</div>
<div id="hist-verdict-banner" style="display:none"></div>
<div class="modal-body" style="padding:0">
<div class="ai-meta-bar" id="hist-meta"></div>
<div id="hist-criteria" style="display:none;padding:.6rem 1.5rem;font-size:.78rem;background:var(--bg-subtle);border-bottom:1px solid var(--border)"></div>
<div id="hist-body" class="ai-rendered-output" style="max-height:60vh;overflow-y:auto;padding:1.25rem 1.5rem"></div>
</div>
<div class="modal-footer">
<button class="btn btn-danger btn-sm" id="btn-delete-analysis" style="margin-right:auto;display:none" onclick="deleteAnalysis()">🗑 Delete</button>
<button class="btn btn-secondary" onclick="closeModal('modal-history')">Close</button>
</div>
</div>
</div>
<!-- marked.js for markdown rendering -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
<script>
var IS_ADMIN = {{ 'true' if is_admin else 'false' }};
/* ─── Markdown renderer config ──────────────────────────────── */
marked.setOptions({ breaks: true, gfm: true });
/* ─── Render AI text: markdown + escape safety ──────────────── */
function renderAI(text) {
// marked.parse handles markdown; it escapes HTML internally for safety
return marked.parse(String(text || ''));
}
/* ─── Verdict banner HTML ───────────────────────────────────── */
function verdictBannerHTML(verdict) {
if (!verdict) return '';
var cfg = {
'PURSUE': { bg:'#16a34a', icon:'✅', label:'PURSUE THIS OPPORTUNITY', sub:'Strong alignment with your evaluation criteria.' },
'PASS': { bg:'#dc2626', icon:'🚫', label:'PASS ON THIS OPPORTUNITY', sub:'Does not meet one or more key criteria.' },
'UNCLEAR': { bg:'#d97706', icon:'⚠️', label:'UNCLEAR — REVIEW MANUALLY', sub:'Insufficient information for a confident determination.' },
};
var c = cfg[verdict.toUpperCase()] || { bg:'#4b5563', icon:'•', label: verdict, sub:'' };
return '<div class="verdict-banner" style="background:' + c.bg + '">'
+ '<span class="verdict-icon">' + c.icon + '</span>'
+ '<div><div class="verdict-label">' + c.label + '</div>'
+ (c.sub ? '<div class="verdict-sub">' + c.sub + '</div>' : '')
+ '</div></div>';
}
/* ─── Show/clear output ─────────────────────────────────────── */
var _rawText = ''; // kept for copy/save
function showResult(data) {
_rawText = data.summary || '';
var fileList = (data.file_names || '').split(', ').map(function(f) {
return ' - ' + f;
}).join('\n');
var criteriaNote = data.criteria_count > 0
? 'Criteria evaluated: ' + data.criteria_count
: 'No evaluation criteria configured.';
var header = 'AI Extraction | Model: ' + (data.model || '')
+ '\nFiles analyzed (' + (data.file_count || 1) + '):\n' + fileList
+ '\n' + criteriaNote
+ '\n' + '='.repeat(60);
// Verdict banner
var banner = document.getElementById('verdict-banner');
if (data.verdict) {
banner.innerHTML = verdictBannerHTML(data.verdict);
banner.style.display = '';
} else {
banner.style.display = 'none';
banner.innerHTML = '';
}
// Render output: header as plain preformatted, then markdown body
document.getElementById('ai-output').innerHTML =
'<pre class="ai-header-block">' + escHtml(header) + '</pre>'
+ '<div class="ai-rendered-output">' + renderAI(_rawText) + '</div>';
// Show action buttons
document.getElementById('btn-copy').style.display = '';
document.getElementById('btn-save').style.display = '';
document.getElementById('btn-clear').style.display = '';
}
function clearOutput() {
_rawText = '';
document.getElementById('ai-output').innerHTML =
'<div class="ai-placeholder"><p>Upload a document and click <strong>Analyze with AI</strong> to begin.</p></div>';
document.getElementById('verdict-banner').style.display = 'none';
document.getElementById('verdict-banner').innerHTML = '';
document.getElementById('btn-copy').style.display = 'none';
document.getElementById('btn-save').style.display = 'none';
document.getElementById('btn-clear').style.display = 'none';
}
function copyOutput() {
if (!_rawText) return;
navigator.clipboard.writeText(_rawText).then(function() {
var btn = document.getElementById('btn-copy');
var orig = btn.textContent;
btn.textContent = '✓ Copied';
setTimeout(function() { btn.textContent = orig; }, 2000);
});
}
function saveOutput() {
if (!_rawText) return;
var blob = new Blob([_rawText], { type: 'text/plain' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'ai_extraction.txt';
a.click();
}
/* ─── Analyze ───────────────────────────────────────────────── */
async function runAnalysis() {
var files = document.getElementById('ai-files').files;
if (!files.length) { alert('Please select at least one file.'); return; }
var modelEl = document.getElementById('ai-model');
var model = modelEl ? modelEl.value : '';
var btn = document.getElementById('btn-analyze');
btn.disabled = true;
var _elapsed = 0;
var _elapsedTimer = setInterval(function() {
_elapsed++;
btn.textContent = '⏳ Analysing… ' + _elapsed + 's';
}, 1000);
btn.textContent = '⏳ Analysing… 0s';
document.getElementById('ai-output').innerHTML =
'<div class="ai-placeholder"><p class="text-muted">Running AI analysis… this may take a moment.</p></div>';
document.getElementById('verdict-banner').style.display = 'none';
document.getElementById('btn-copy').style.display = 'none';
document.getElementById('btn-save').style.display = 'none';
document.getElementById('btn-clear').style.display = 'none';
var fd = new FormData();
for (var i = 0; i < files.length; i++) fd.append('documents[]', files[i]);
if (model) fd.append('model', model);
try {
var resp = await fetch('/ai-summary/analyze', { method: 'POST', body: fd, headers: {'X-CSRFToken': getCsrfToken()} });
var data = await resp.json();
if (data.error) {
document.getElementById('ai-output').innerHTML =
'<div class="alert alert-danger" style="margin:1rem">' + escHtml(data.error) + '</div>';
} else {
// Attach metadata for header rendering
data.file_names = Array.from(files).map(function(f){ return f.name; }).join(', ');
data.file_count = files.length;
data.model = model || '{{ groq_model }}';
data.criteria_count = {{ criteria | selectattr('is_active') | list | length }};
showResult(data);
if (data.file_errors && data.file_errors.length) {
var warn = document.createElement('div');
warn.className = 'alert alert-warning';
warn.style.cssText = 'margin:.75rem 0 0';
warn.innerHTML = '⚠ ' + data.file_errors.length + ' file(s) skipped:<ul style="margin:.25rem 0 0 1.25rem;padding:0">'
+ data.file_errors.map(function(e){ return '<li>' + escHtml(e) + '</li>'; }).join('')
+ '</ul>';
document.getElementById('ai-output').prepend(warn);
}
}
} catch(e) {
document.getElementById('ai-output').innerHTML =
'<div class="alert alert-danger" style="margin:1rem">Request failed: ' + escHtml(String(e)) + '</div>';
} finally {
clearInterval(_elapsedTimer);
btn.disabled = false;
btn.textContent = '🔍 Analyze with AI';
}
}
/* ─── Criteria modals ───────────────────────────────────────── */
document.querySelectorAll('.js-view-criterion').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = btn.closest('.criterion-row');
document.getElementById('vc-title').textContent = row.dataset.title;
document.getElementById('vc-desc').textContent = row.dataset.desc;
openModal('modal-view-criterion');
});
});
document.querySelectorAll('.js-edit-criterion').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = btn.closest('.criterion-row');
document.getElementById('ec-title').value = row.dataset.title;
document.getElementById('ec-desc').value = row.dataset.desc;
document.getElementById('ec-order').value = row.dataset.order;
document.getElementById('ec-active').value = row.dataset.active;
document.getElementById('edit-criterion-form').action =
'/ai-summary/criteria/' + row.dataset.id + '/edit';
openModal('modal-edit-criterion');
});
});
/* ─── Bulk & row-level delete (admin only) ──────────────────── */
if (IS_ADMIN) {
// "Select all" checkbox
document.getElementById('cb-all').addEventListener('change', function() {
document.querySelectorAll('.hist-cb').forEach(function(cb) {
var row = cb.closest('.history-row');
if (row && row.style.display !== 'none') cb.checked = this.checked;
}, this);
_updateBulkBar();
});
// Individual checkboxes
document.addEventListener('change', function(e) {
if (e.target.classList.contains('hist-cb')) _updateBulkBar();
});
// Row delete icon — direct listeners (delegated won't work: td stopPropagation swallows the event)
document.querySelectorAll('.js-delete-row').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
var id = btn.dataset.id;
if (!confirm('Delete this analysis? This cannot be undone.')) return;
_sendDeletes([id], function(ok) {
if (!ok) return;
var row = document.querySelector('.history-row[data-id="' + id + '"]');
if (row) row.remove();
_updateBulkBar();
});
});
});
}
function _updateBulkBar() {
var cbs = document.querySelectorAll('.hist-cb:checked');
var count = cbs.length;
var barEl = document.getElementById('bulk-bar');
var countEl = document.getElementById('bulk-count');
var delBtn = document.getElementById('btn-bulk-delete');
if (!barEl) return;
countEl.textContent = count + ' selected';
delBtn.disabled = count === 0;
}
function clearSelection() {
document.querySelectorAll('.hist-cb').forEach(function(cb) { cb.checked = false; });
var cbAll = document.getElementById('cb-all');
if (cbAll) cbAll.checked = false;
_updateBulkBar();
}
function _sendDeletes(ids, callback) {
Promise.all(ids.map(function(id) {
return fetch('/ai-summary/history/' + id + '/delete', {
method: 'POST',
headers: { 'X-CSRFToken': getCsrfToken() },
}).then(function(r) { return r.json(); });
})).then(function(results) {
var failed = results.filter(function(r) { return r.error; });
if (failed.length) alert('Failed to delete ' + failed.length + ' item(s).');
if (callback) callback(true);
}).catch(function() {
alert('Delete failed. Please try again.');
if (callback) callback(false);
});
}
function bulkDelete() {
var checked = document.querySelectorAll('.hist-cb:checked');
if (!checked.length) return;
var ids = Array.from(checked).map(function(cb) { return cb.dataset.id; });
if (!confirm('Delete ' + ids.length + ' analysis record(s)? This cannot be undone.')) return;
_sendDeletes(ids, function(ok) {
if (!ok) return;
ids.forEach(function(id) {
var row = document.querySelector('.history-row[data-id="' + id + '"]');
if (row) row.remove();
});
clearSelection();
});
}
/* ─── History rows ──────────────────────────────────────────── */
var _currentAnalysisId = null;
document.querySelectorAll('.history-row').forEach(function(row) {
row.addEventListener('click', function() {
var id = row.dataset.id;
_currentAnalysisId = id;
document.getElementById('hist-title').textContent = 'Loading…';
document.getElementById('hist-meta').textContent = '';
document.getElementById('hist-body').innerHTML = '';
document.getElementById('hist-criteria').style.display = 'none';
document.getElementById('hist-verdict-banner').style.display = 'none';
document.getElementById('btn-delete-analysis').style.display = 'none';
openModal('modal-history');
fetch('/ai-summary/history/' + id)
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) {
document.getElementById('hist-body').textContent = d.error;
return;
}
document.getElementById('hist-title').textContent =
'Analysis — ' + (d.file_names || '');
document.getElementById('hist-meta').textContent =
(d.analyzed_at || '').slice(0, 16)
+ (d.username ? ' · ' + d.username : '')
+ ' · ' + (d.model || '');
if (d.verdict) {
var vb = document.getElementById('hist-verdict-banner');
vb.innerHTML = verdictBannerHTML(d.verdict);
vb.style.display = '';
}
// Criteria snapshot
var criteriaEl = document.getElementById('hist-criteria');
if (d.criteria_snapshot) {
try {
var snap = JSON.parse(d.criteria_snapshot);
if (snap && snap.length) {
var labels = snap.map(function(c) { return c.title; }).join(' · ');
criteriaEl.innerHTML = '<strong style="color:var(--text)">Criteria used:</strong> ' + escHtml(labels);
criteriaEl.style.display = '';
}
} catch(e) { /* ignore malformed snapshot */ }
}
document.getElementById('hist-body').innerHTML =
renderAI(d.summary_text || '');
if (IS_ADMIN) {
document.getElementById('btn-delete-analysis').style.display = '';
}
})
.catch(function() {
document.getElementById('hist-body').textContent = 'Failed to load analysis.';
});
});
});
function deleteAnalysis() {
if (!_currentAnalysisId) return;
if (!confirm('Delete this analysis? This cannot be undone.')) return;
fetch('/ai-summary/history/' + _currentAnalysisId + '/delete', {
method: 'POST',
headers: { 'X-CSRFToken': getCsrfToken() },
}).then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) { alert(d.error); return; }
closeModal('modal-history');
// Remove the row from the table
var row = document.querySelector('.history-row[data-id="' + _currentAnalysisId + '"]');
if (row) row.remove();
_currentAnalysisId = null;
})
.catch(function() { alert('Delete failed. Please try again.'); });
}
/* ─── History verdict filter ────────────────────────────────── */
document.querySelectorAll('.ai-hist-filter').forEach(function(btn) {
btn.addEventListener('click', function() {
document.querySelectorAll('.ai-hist-filter').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
var verdict = btn.dataset.verdict;
document.querySelectorAll('.history-row').forEach(function(row) {
if (!verdict) { row.style.display = ''; return; }
var badge = row.querySelector('.badge');
var rowVerdict = badge ? badge.textContent.trim() : '';
var show = verdict === 'none' ? !badge : rowVerdict === verdict;
row.style.display = show ? '' : 'none';
});
});
});
/* ─── Utility ───────────────────────────────────────────────── */
function escHtml(str) {
return String(str || '').replace(/[&<>"']/g, function(c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
</script>
<style>
/* ── Layout ────────────────────────────────────────────────── */
.ai-layout{display:grid;grid-template-columns:320px 1fr;gap:1.25rem;align-items:start}
.card-body-pad{padding:1.1rem 1.4rem 1.4rem}
.ai-output-card{display:flex;flex-direction:column;overflow:hidden}
/* ── Verdict banner ────────────────────────────────────────── */
.verdict-banner{
display:flex;align-items:center;gap:1rem;
padding:.9rem 1.4rem;color:#fff;
}
.verdict-icon{font-size:1.3rem;flex-shrink:0}
.verdict-label{font-size:1rem;font-weight:700;letter-spacing:.01em}
.verdict-sub{font-size:.8rem;opacity:.9;margin-top:2px}
/* ── Output area ───────────────────────────────────────────── */
.ai-output-body{
flex:1;overflow-y:auto;
min-height:300px;max-height:calc(100vh - 280px);
}
.ai-placeholder{
padding:2rem 1.5rem;color:var(--text-muted);
}
.ai-header-block{
background:var(--bg-subtle);border-bottom:1px solid var(--border);
padding:.85rem 1.4rem;font-family:'DM Mono',monospace;font-size:.78rem;
color:var(--text-secondary);margin:0;white-space:pre-wrap;line-height:1.6;
}
.ai-meta-bar{
padding:.6rem 1.5rem;font-size:.78rem;font-family:'DM Mono',monospace;
color:var(--text-muted);background:var(--bg-subtle);
border-bottom:1px solid var(--border);
}
/* ── Rendered markdown output ──────────────────────────────── */
.ai-rendered-output{
padding:1.1rem 1.4rem 1.5rem;
font-size:.9rem;line-height:1.8;color:var(--text-secondary);
}
.ai-rendered-output h1,.ai-rendered-output h2{
font-size:1rem;font-weight:700;color:var(--text);
margin:1.4rem 0 .5rem;padding-bottom:.3rem;
border-bottom:1px solid var(--border);
}
.ai-rendered-output h3,.ai-rendered-output h4{
font-size:.9rem;font-weight:700;color:var(--text);margin:1rem 0 .35rem;
}
.ai-rendered-output p{margin:.35rem 0}
.ai-rendered-output strong{color:var(--text);font-weight:700}
.ai-rendered-output ul,.ai-rendered-output ol{
padding-left:1.4rem;margin:.35rem 0;
}
.ai-rendered-output li{margin:.2rem 0}
.ai-rendered-output hr{
border:none;border-top:2px solid var(--border);margin:1.25rem 0;
}
.ai-rendered-output pre,.ai-rendered-output code{
font-family:'DM Mono',monospace;font-size:.82rem;
background:var(--bg-subtle);border-radius:var(--r-sm);
}
.ai-rendered-output pre{padding:.75rem 1rem;overflow-x:auto}
.ai-rendered-output code{padding:.1rem .3rem}
/* ── Criteria list ─────────────────────────────────────────── */
.criteria-list{display:flex;flex-direction:column}
.criterion-row{
padding:.8rem 1.2rem;border-bottom:1px solid var(--border);
transition:background var(--t);
}
.criterion-row:last-child{border-bottom:none}
.criterion-row:hover{background:var(--bg-subtle)}
.criterion-inactive{opacity:.55}
.criterion-header{display:flex;align-items:center;justify-content:space-between;
gap:.5rem;margin-bottom:.2rem}
.criterion-title{font-size:.875rem;font-weight:600;color:var(--text)}
.criterion-badges{display:flex;align-items:center;gap:.35rem;flex-shrink:0}
.criterion-desc{font-size:.78rem;color:var(--text-muted);line-height:1.5;margin-bottom:.3rem}
.btn-link-sm{
background:none;border:none;padding:0;font-size:.73rem;
color:var(--accent);cursor:pointer;font-family:inherit;
}
.btn-link-sm:hover{text-decoration:underline}
/* ── History ───────────────────────────────────────────────── */
.history-row:hover td{background:var(--bg-subtle)}
.ai-bulk-bar{
display:flex;align-items:center;gap:.75rem;
padding:.55rem 1rem;background:var(--bg-subtle);
border-bottom:1px solid var(--border);
}
.btn-row-delete{
background:none;border:none;padding:2px 6px;border-radius:var(--r-sm);
cursor:pointer;font-size:.85rem;color:var(--text-muted);
transition:color var(--t),background var(--t);opacity:0;
}
.history-row:hover .btn-row-delete{opacity:1}
.btn-row-delete:hover{color:var(--danger,#dc2626);background:var(--danger-bg,#fef2f2)}
.ai-hist-filter{
background:none;border:1px solid var(--border);
padding:.2rem .55rem;font-size:.72rem;border-radius:var(--r-sm);
cursor:pointer;color:var(--text-secondary);font-family:inherit;
transition:background var(--t),color var(--t);
}
.ai-hist-filter.active{background:var(--accent);border-color:var(--accent);color:#fff}
@media(max-width:960px){
.ai-layout{grid-template-columns:1fr}
.ai-output-body{max-height:60vh}
}
</style>
{% endblock %}