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

991 lines
43 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 — Bid 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>
<button class="btn btn-primary btn-sm" id="btn-chat-live" onclick="openChat(_liveAnalysisId)" style="display:none">💬 Chat</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 btn-sm" onclick="openChat(_currentAnalysisId)">💬 Chat</button>
<button class="btn btn-secondary" onclick="closeModal('modal-history')">Close</button>
</div>
</div>
</div>
<!-- ── Chat Modal ────────────────────────────────────────────── -->
<div class="modal-overlay" id="modal-chat">
<div class="modal chat-modal">
<div class="modal-header">
<span class="modal-title">💬 Ask AI about this document</span>
<button class="modal-close" onclick="closeModal('modal-chat')"></button>
</div>
<div id="chat-messages" class="chat-messages"></div>
<div class="chat-input-row">
<input type="text" id="chat-input" class="chat-input" placeholder="Ask a question about this document…" autocomplete="off">
<button class="btn btn-primary" id="btn-chat-send" onclick="sendChatMessage()">Send</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' }};
var _liveAnalysisId = null; // set after a live analysis completes
/* ─── 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 || '';
// 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 = '';
}
// Meta pill bar (replaces old <pre> header block)
var files = (data.file_names || '').split(', ').filter(Boolean);
var metaHtml = '<div class="ai-meta-bar">'
+ '<span class="ai-meta-pill">' + escHtml(data.model || 'AI') + '</span>'
+ '<span class="ai-meta-pill">📄 ' + files.length + ' file' + (files.length !== 1 ? 's' : '')
+ (files.length <= 3 ? ': ' + files.map(escHtml).join(', ') : '') + '</span>'
+ (data.criteria_count > 0 ? '<span class="ai-meta-pill ai-meta-pill-green">✓ ' + data.criteria_count + ' criteria evaluated</span>' : '')
+ (data.truncated ? '<span class="ai-meta-pill ai-meta-pill-warn">⚠ Document truncated to first ~14 000 chars</span>' : '')
+ '</div>';
// Render markdown, then colorize evaluation keywords
var rendered = document.createElement('div');
rendered.className = 'ai-rendered-output';
rendered.innerHTML = renderAI(_rawText);
colorizeEval(rendered);
var outputEl = document.getElementById('ai-output');
outputEl.innerHTML = metaHtml;
outputEl.appendChild(rendered);
// Show action buttons
document.getElementById('btn-copy').style.display = '';
document.getElementById('btn-save').style.display = '';
document.getElementById('btn-clear').style.display = '';
// Show chat button and remember which analysis is loaded
if (data.analysis_id) {
_liveAnalysisId = data.analysis_id;
document.getElementById('btn-chat-live').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';
document.getElementById('btn-chat-live').style.display = 'none';
_liveAnalysisId = null;
}
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 */ }
}
var histBody = document.getElementById('hist-body');
histBody.innerHTML = renderAI(d.summary_text || '');
colorizeEval(histBody);
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];
});
}
function colorizeEval(container) {
container.querySelectorAll('li, td, p').forEach(function(el) {
if (el.querySelector('ul,ol,table,div')) return; // skip block containers
var h = el.innerHTML;
// Null-byte placeholders prevent cascading replacements
h = h.replace(/DOES NOT MEET/g, '\x00NM\x00');
h = h.replace(/PARTIALLY MEETS/g, '\x00PM\x00');
h = h.replace(/\bMEETS\b/g, '\x00M\x00');
h = h.replace(/CANNOT DETERMINE/g,'\x00CD\x00');
h = h.replace(/\x00NM\x00/g, '<span class="eval-badge eval-not-meet">DOES NOT MEET</span>');
h = h.replace(/\x00PM\x00/g, '<span class="eval-badge eval-partial">PARTIALLY MEETS</span>');
h = h.replace(/\x00M\x00/g, '<span class="eval-badge eval-meets">MEETS</span>');
h = h.replace(/\x00CD\x00/g, '<span class="eval-badge eval-unclear">CANNOT DETERMINE</span>');
h = h.replace(/Not specified in the document\./g,
'<span class="eval-missing">Not specified in the document.</span>');
el.innerHTML = h;
});
}
/* ─── Chat ──────────────────────────────────────────────────── */
var _chatAnalysisId = null;
var _chatMessages = [];
function openChat(analysisId) {
if (!analysisId) { alert('No analysis loaded.'); return; }
_chatAnalysisId = analysisId;
_chatMessages = [];
document.getElementById('chat-messages').innerHTML = '';
document.getElementById('chat-input').value = '';
// Close history modal if open, then open chat
closeModal('modal-history');
openModal('modal-chat');
_appendChatMsg('assistant',
'Hi! Ask me anything about this solicitation — dates, scope, contacts, requirements, and more.');
setTimeout(function() { document.getElementById('chat-input').focus(); }, 150);
}
function _appendChatMsg(role, content) {
var wrap = document.createElement('div');
wrap.className = 'chat-msg chat-msg-' + role;
var bubble = document.createElement('div');
bubble.className = 'chat-bubble';
bubble.innerHTML = renderAI(content);
wrap.appendChild(bubble);
var box = document.getElementById('chat-messages');
box.appendChild(wrap);
box.scrollTop = box.scrollHeight;
}
function _appendTyping() {
var wrap = document.createElement('div');
wrap.className = 'chat-msg chat-msg-assistant';
wrap.id = 'chat-typing';
wrap.innerHTML = '<div class="chat-bubble chat-typing"><span></span><span></span><span></span></div>';
var box = document.getElementById('chat-messages');
box.appendChild(wrap);
box.scrollTop = box.scrollHeight;
}
async function sendChatMessage() {
var input = document.getElementById('chat-input');
var text = input.value.trim();
if (!text || !_chatAnalysisId) return;
var sendBtn = document.getElementById('btn-chat-send');
input.value = '';
input.disabled = true;
sendBtn.disabled = true;
_chatMessages.push({ role: 'user', content: text });
_appendChatMsg('user', text);
_appendTyping();
try {
var resp = await fetch('/ai-summary/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
body: JSON.stringify({ analysis_id: _chatAnalysisId, messages: _chatMessages }),
});
var data = await resp.json();
var typing = document.getElementById('chat-typing');
if (typing) typing.remove();
if (data.error) {
_chatMessages.pop(); // discard the failed user message
_appendChatMsg('assistant', '⚠ ' + escHtml(data.error));
} else {
_chatMessages.push({ role: 'assistant', content: data.reply });
_appendChatMsg('assistant', data.reply);
}
} catch(e) {
var typing = document.getElementById('chat-typing');
if (typing) typing.remove();
_chatMessages.pop();
_appendChatMsg('assistant', '⚠ Request failed. Please try again.');
} finally {
input.disabled = false;
sendBtn.disabled = false;
input.focus();
}
}
document.getElementById('chat-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); }
});
</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);
}
/* ── Meta pill bar ─────────────────────────────────────────── */
.ai-meta-bar{
display:flex;flex-wrap:wrap;gap:.4rem;align-items:center;
padding:.7rem 1.4rem;background:var(--bg-subtle);
border-bottom:1px solid var(--border);
}
.ai-meta-pill{
display:inline-flex;align-items:center;
font-size:.72rem;font-family:'DM Mono',monospace;
background:var(--bg-card,#fff);color:var(--text-secondary);
border:1px solid var(--border);border-radius:999px;
padding:.18rem .65rem;white-space:nowrap;
}
.ai-meta-pill-green{border-color:#86efac;color:#15803d;background:#f0fdf4}
.ai-meta-pill-warn{border-color:#fcd34d;color:#92400e;background:#fffbeb}
/* ── Rendered markdown output ──────────────────────────────── */
.ai-rendered-output{
padding:1.4rem 1.75rem 2rem;
font-size:.9rem;line-height:1.85;color:var(--text);
}
.ai-rendered-output h1{
font-size:1.05rem;font-weight:800;color:var(--text);
margin:1.8rem 0 .6rem;padding-bottom:.4rem;
border-bottom:2px solid var(--accent);
}
.ai-rendered-output h2{
font-size:.97rem;font-weight:700;color:var(--text);
margin:1.5rem 0 .5rem;padding-bottom:.3rem;
border-bottom:1px solid var(--border);
}
.ai-rendered-output h3{
font-size:.8rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
color:var(--text-secondary);margin:1.25rem 0 .35rem;
}
.ai-rendered-output h4{font-size:.9rem;font-weight:700;color:var(--text);margin:.9rem 0 .25rem}
.ai-rendered-output p{margin:.4rem 0}
.ai-rendered-output strong{color:var(--text);font-weight:700}
.ai-rendered-output em{font-style:italic;color:var(--text-secondary)}
.ai-rendered-output ul,.ai-rendered-output ol{padding-left:1.5rem;margin:.45rem 0 .65rem}
.ai-rendered-output li{margin:.3rem 0;line-height:1.75}
.ai-rendered-output hr{border:none;border-top:2px solid var(--border);margin:1.75rem 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;border:1px solid var(--border)}
.ai-rendered-output code{padding:.15rem .35rem;border:1px solid var(--border)}
/* Tables ── */
.ai-rendered-output table{
width:100%;border-collapse:collapse;font-size:.845rem;
margin:.75rem 0 1.25rem;border:1px solid var(--border);
border-radius:var(--r-sm);overflow:hidden;display:table;
}
.ai-rendered-output th{
background:var(--bg-subtle);color:var(--text);font-weight:700;
padding:.5rem .9rem;border-bottom:2px solid var(--border);text-align:left;
font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;
}
.ai-rendered-output td{
padding:.45rem .9rem;border-bottom:1px solid var(--border);
vertical-align:top;color:var(--text-secondary);
}
.ai-rendered-output tr:last-child td{border-bottom:none}
.ai-rendered-output tbody tr:hover td{background:var(--bg-subtle)}
.ai-rendered-output td:first-child{font-weight:600;color:var(--text);width:38%;white-space:nowrap}
/* Evaluation badges ── */
.eval-badge{
display:inline-block;font-size:.72rem;font-weight:700;
padding:.1rem .5rem;border-radius:999px;border:1px solid;letter-spacing:.02em;
}
.eval-meets{background:#f0fdf4;color:#15803d;border-color:#86efac}
.eval-not-meet{background:#fef2f2;color:#dc2626;border-color:#fca5a5}
.eval-partial{background:#fffbeb;color:#92400e;border-color:#fcd34d}
.eval-unclear{background:var(--bg-subtle);color:var(--text-secondary);border-color:var(--border)}
.eval-missing{color:var(--text-muted);font-style:italic}
/* ── 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}
}
/* ── Chat modal ────────────────────────────────────────────── */
.chat-modal{
max-width:660px;width:100%;
display:flex;flex-direction:column;
height:75vh;max-height:600px;
}
.chat-messages{
flex:1;overflow-y:auto;
padding:.85rem 1.2rem;
display:flex;flex-direction:column;gap:.6rem;
background:var(--bg-subtle);
}
.chat-msg{display:flex}
.chat-msg-user{justify-content:flex-end}
.chat-msg-assistant{justify-content:flex-start}
.chat-bubble{
max-width:82%;padding:.55rem .85rem;
border-radius:1rem;font-size:.865rem;line-height:1.6;
}
.chat-msg-user .chat-bubble{
background:var(--accent);color:#fff;
border-bottom-right-radius:.25rem;
}
.chat-msg-assistant .chat-bubble{
background:var(--bg-card,#fff);color:var(--text);
border:1px solid var(--border);
border-bottom-left-radius:.25rem;
}
.chat-bubble p{margin:.2rem 0}
.chat-bubble p:first-child{margin-top:0}
.chat-bubble p:last-child{margin-bottom:0}
.chat-bubble ul,.chat-bubble ol{margin:.3rem 0;padding-left:1.2rem}
.chat-bubble li{margin:.15rem 0}
.chat-bubble strong{font-weight:700}
.chat-bubble table{border-collapse:collapse;font-size:.82rem;width:100%}
.chat-bubble th,.chat-bubble td{border:1px solid var(--border);padding:.2rem .5rem;text-align:left}
.chat-msg-user .chat-bubble strong{color:#fff}
/* Typing indicator */
.chat-typing{display:flex;align-items:center;gap:4px;padding:.55rem .75rem}
.chat-typing span{
display:inline-block;width:7px;height:7px;border-radius:50%;
background:var(--text-muted);animation:chatBounce 1.2s infinite;
}
.chat-typing span:nth-child(2){animation-delay:.2s}
.chat-typing span:nth-child(3){animation-delay:.4s}
@keyframes chatBounce{0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-6px)}}
/* Input row */
.chat-input-row{
display:flex;gap:.5rem;padding:.75rem 1rem;
border-top:1px solid var(--border);background:var(--bg-card,#fff);
border-radius:0 0 var(--r) var(--r);
}
.chat-input{
flex:1;padding:.45rem .75rem;border:1px solid var(--border);
border-radius:var(--r-sm);font-family:inherit;font-size:.875rem;
background:var(--bg);color:var(--text);
}
.chat-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-light,rgba(59,130,246,.15))}
</style>
{% endblock %}