04/30 Web Checker web app ver. 1.0

This commit is contained in:
2026-04-30 17:15:56 -04:00
parent feb8e5051c
commit cd63d5a020
39 changed files with 8031 additions and 1 deletions
+542
View File
@@ -0,0 +1,542 @@
{% 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>
<div class="table-container">
<table>
<thead>
<tr>
<th>Date</th>
{% if is_admin %}<th>User</th>{% endif %}
<th>Files</th>
<th>Verdict</th>
</tr>
</thead>
<tbody>
{% for h in history %}
<tr class="history-row" data-id="{{ h.id }}" style="cursor:pointer" title="Click to view result">
<td class="nowrap text-muted">{{ h.analyzed_at.strftime('%Y-%m-%d %H:%M') if h.analyzed_at else '—' }}</td>
{% if is_admin %}<td class="text-muted">{{ h.username or '—' }}</td>{% endif %}
<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>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted" style="padding:1rem">No history yet.</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-body" class="ai-rendered-output" style="max-height:65vh;overflow-y:auto;padding:1.25rem 1.5rem"></div>
</div>
<div class="modal-footer">
<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>
/* ─── 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;
btn.textContent = '⏳ Analysing…';
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 });
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);
}
} catch(e) {
document.getElementById('ai-output').innerHTML =
'<div class="alert alert-danger" style="margin:1rem">Request failed: ' + escHtml(String(e)) + '</div>';
} finally {
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');
});
});
/* ─── History rows ──────────────────────────────────────────── */
document.querySelectorAll('.history-row').forEach(function(row) {
row.addEventListener('click', function() {
var id = row.dataset.id;
document.getElementById('hist-title').textContent = 'Loading…';
document.getElementById('hist-meta').textContent = '';
document.getElementById('hist-body').innerHTML = '';
document.getElementById('hist-verdict-banner').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 = '';
}
document.getElementById('hist-body').innerHTML =
renderAI(d.summary_text || '');
})
.catch(function() {
document.getElementById('hist-body').textContent = 'Failed to load analysis.';
});
});
});
/* ─── 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)}
@media(max-width:960px){
.ai-layout{grid-template-columns:1fr}
.ai-output-body{max-height:60vh}
}
</style>
{% endblock %}