Compare commits

..
10 Commits
5 changed files with 492 additions and 94 deletions
+18
View File
@@ -409,6 +409,24 @@ def initialize_database():
conn.commit()
logger.info("Migration: added idx_activity_log_time index to activity_log.")
# ── ai_analysis_log: add document_text column if missing ──────────────
cursor.execute(
"""
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ai_analysis_log'
AND COLUMN_NAME = 'document_text'
"""
)
(has_doc_text,) = cursor.fetchone()
if not has_doc_text:
cursor.execute(
"ALTER TABLE ai_analysis_log "
"ADD COLUMN document_text MEDIUMTEXT NULL AFTER criteria_snapshot"
)
conn.commit()
logger.info("Migration: added document_text column to ai_analysis_log.")
# ── Seed app_settings from environment variables (first-run bootstrap) ─
# Uses INSERT IGNORE so values already saved via the Admin UI are never
# overwritten — .env only fills in keys that are completely absent.
+6 -4
View File
@@ -1367,15 +1367,17 @@ def delete_criterion(admin_id: int, criterion_id: int):
def save_ai_analysis(user_id: int, file_names: str, model: str,
verdict, criteria_snapshot, summary_text: str) -> int:
verdict, criteria_snapshot, summary_text: str,
document_text: str = None) -> int:
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO ai_analysis_log (user_id, file_names, model, verdict, criteria_snapshot, summary_text) "
"VALUES (%s,%s,%s,%s,%s,%s)",
(user_id, file_names, model, verdict, criteria_snapshot, summary_text),
"INSERT INTO ai_analysis_log "
"(user_id, file_names, model, verdict, criteria_snapshot, document_text, summary_text) "
"VALUES (%s,%s,%s,%s,%s,%s,%s)",
(user_id, file_names, model, verdict, criteria_snapshot, document_text, summary_text),
)
conn.commit()
new_id = cur.lastrowid
+187 -46
View File
@@ -100,7 +100,8 @@ def analyze():
])
analysis_id = save_ai_analysis(
user["id"], ", ".join(file_names), model,
result.get("verdict"), criteria_snap, result.get("summary", "")
result.get("verdict"), criteria_snap, result.get("summary", ""),
document_text=combined,
)
log_action(user["id"], "AI_ANALYSIS", "ai_analysis_log", analysis_id,
f"AI analysis on {len(file_names)} file(s). Verdict: {result.get('verdict')}.")
@@ -182,48 +183,113 @@ def _extract_text(file_obj, ext: str) -> str:
# Office address used as origin for distance/travel-time estimates.
_OFFICE_ADDRESS = "2815 Hartland Road, Falls Church, VA 22043, USA"
# Stage 1 — extraction prompt (always sent)
_EXTRACTION_PROMPT = """You are an expert government procurement analyst.
The user has provided {n} document(s). Treat all provided content as a single combined source — do not analyze each file individually. Extract the following information once, consolidating details from all documents.
# Document text limit per API call.
# Stage 1 prompt template is ~1.8 KB overhead; 14 KB of doc text keeps the total
# JSON payload well under Groq's request size limit on all plan tiers.
_TEXT_LIMIT = 14_000
IMPORTANT — Our office is located at:
{office}
Use this as the ORIGIN address for all driving distance and travel time calculations in field #9 below.
# Analyst persona — injected as the system message in every API call.
_SYSTEM_PROMPT = (
"You are a senior government procurement analyst supporting a small business BD team. "
"Extract and organize solicitation information with precision. "
"Format all output in clean Markdown with tables and section headers. "
"Write \"Not specified\" for any field not found in the documents — never guess."
)
Extract and clearly label the following fields (write "N/A" if not found):
# Stage 1 — extraction prompt (always sent).
_EXTRACTION_PROMPT = """\
Analyze the {n} document(s) below as one combined source. Do not analyze files individually.
Write "Not specified in the document." for any field not found — never guess.
Our office address (use as origin for all travel estimates): {office}
1. Solicitation Number
2. Solicitation Type (e.g. RFP, RFQ, IFB, etc.)
3. Set-Aside (e.g. Small Business, 8(a), N/A)
4. Description / Scope of Work
5. Work Site / Location(s)
6. Pre-Proposal Conference / Site-Visit (date, time, full address)
7. Point of Contact (POC) (name, phone, email)
8. Total Square Footage (if applicable)
9. Driving Distance & Travel Time
- Origin: {office}
- Destination: Pre-Proposal Conference or primary Work Site address
- Provide your best estimate of driving distance (miles) and typical driving time using major highways
- Note that these are AI estimates; actual times may vary with traffic
10. Last Day to Submit Questions
11. Due Date & Time
12. Any other notable requirements or deadlines
---
Then provide a detailed OVERALL SUMMARY covering:
## Solicitation Details
A. Scope of Work
B. Contract Period
C. Proposal Submission Requirements
D. Key Deadlines & Action Items
| Field | Value |
|---|---|
| Solicitation Number | |
| Solicitation Type | (RFP / RFQ / IFB / IDIQ / BPA / etc.) |
| Set-Aside | (Small Business / 8(a) / SDVOSB / HUBZone / WOSB / Unrestricted / etc.) |
| Issuing Agency / Office | |
| Contract Type | (FFP / T&M / Cost-Plus / IDIQ / etc.) |
| Contract Period | |
| NAICS Code | |
| Estimated Value | |
| Work Site / Location(s) | |
| Total Square Footage | |
| Last Day to Submit Questions | |
| **Proposal Due Date & Time** | |
| Award / Period of Performance Start | |
Be precise, detailed, and use bullet points throughout.
If information is not explicitly stated in the documents, note it as "Not specified in the document."
---
## Pre-Proposal Conference / Site Visit
| Field | Value |
|---|---|
| Date & Time | |
| Full Address | |
| Attendance | (Mandatory / Optional / N/A) |
| RSVP Required | |
---
## Point of Contact
| Field | Value |
|---|---|
| Name & Title | |
| Phone | |
| Email | |
| Questions Submission Method | |
---
## Travel Estimate
| Field | Value |
|---|---|
| Origin | {office} |
| Destination | (Pre-Proposal Conference address or primary work site) |
| Estimated Driving Distance | |
| Estimated Drive Time | |
*(AI estimates — verify with a mapping service before scheduling.)*
---
## Scope of Work
*(Detail the required services, deliverables, and performance standards. Use bullet points.)*
---
## Notable Requirements
*(Security clearances, bonding, certifications, teaming restrictions, tight timelines, incumbent indicators, or other bid/no-bid risk factors. Use bullet points.)*
---
## Overall Summary
**A. Scope of Work:**
**B. Contract Period & Value:**
**C. Proposal Submission Requirements:**
**D. Key Deadlines & Action Items:**
---
DOCUMENTS:
{documents}"""
# Stage 2 — criteria evaluation suffix (appended only when active criteria exist)
_CRITERIA_PROMPT_SUFFIX = """
# Criteria evaluation appended to the extraction prompt in the same API call.
# A single call avoids the rate-limit (429) that two back-to-back calls trigger
# on Groq's free tier.
_CRITERIA_SUFFIX = """
================================================================================
OPPORTUNITY ALIGNMENT EVALUATION
@@ -255,23 +321,21 @@ in plain business language."""
def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict:
"""Call the Groq chat completions REST API directly (no SDK required)."""
import re
"""Single Groq API call: extraction + optional criteria evaluation in one request."""
truncated = len(text) > _TEXT_LIMIT
doc_text = text[:_TEXT_LIMIT]
n = doc_text.count("=== ") or 1
n = text.count("=== ") or 1 # count file separators for the prompt header
truncated = len(text) > 14000
# Build the two-stage prompt matching the desktop app exactly
prompt = _EXTRACTION_PROMPT.format(
n=n, office=_OFFICE_ADDRESS, documents=text[:14000]
n=n, office=_OFFICE_ADDRESS, documents=doc_text
)
if criteria:
criteria_list = "\n".join(
f" {i+1}. {c['title']}: {c['description']}"
f"{i+1}. **{c['title']}**: {c['description']}"
for i, c in enumerate(criteria)
)
prompt += _CRITERIA_PROMPT_SUFFIX.format(criteria_list=criteria_list)
prompt += _CRITERIA_SUFFIX.format(criteria_list=criteria_list)
response = http_requests.post(
"https://api.groq.com/openai/v1/chat/completions",
@@ -281,11 +345,14 @@ def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict:
},
json={
"model": model,
"messages": [{"role": "user", "content": prompt}],
"messages": [
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
"max_tokens": 4096,
"temperature": 0.2,
"temperature": 0.1,
},
timeout=90,
timeout=120,
)
if not response.ok:
logger.error(f"Groq API {response.status_code}: {response.text[:300]}")
@@ -293,7 +360,6 @@ def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict:
content = response.json()["choices"][0]["message"]["content"] or ""
# Parse the machine-readable RECOMMENDATION label (only present when criteria used)
verdict = None
if criteria:
match = re.search(
@@ -358,6 +424,81 @@ def delete_criterion_view(criterion_id):
return redirect(url_for("ai_summary.ai_summary"))
@ai_summary_bp.route("/chat", methods=["POST"])
@login_required
def chat():
"""Follow-up Q&A about a previously analyzed document."""
user = session["user"]
data = request.get_json(silent=True) or {}
analysis_id = data.get("analysis_id")
messages = data.get("messages") or []
if not analysis_id:
return jsonify({"error": "analysis_id is required."}), 400
if not messages or not isinstance(messages, list):
return jsonify({"error": "messages is required."}), 400
record = get_ai_analysis_detail(int(analysis_id))
if not record:
return jsonify({"error": "Analysis not found."}), 404
api_key = (os.environ.get("GROQ_API_KEY") or "").strip() or get_setting("groq.api_key", "").strip()
if not api_key:
return jsonify({"error": "Groq API key is not configured."}), 400
model = get_setting("groq.model", GROQ_MODELS[0])
# Prefer raw document text so the AI can answer questions from source material.
# Fall back to the stored summary for analyses run before this feature was added.
raw_text = (record.get("document_text") or "").strip()
if raw_text:
context_label = "ORIGINAL DOCUMENT CONTENT"
context_body = raw_text[:12000]
else:
context_label = "ANALYZED DOCUMENT SUMMARY"
context_body = (record.get("summary_text") or "")[:8000]
system_msg = (
"You are a government procurement analyst assistant. "
"The user has questions about a solicitation document. "
"Answer based solely on the document content provided below. "
"If the information is not present, say so clearly. Be concise and direct.\n\n"
f"## {context_label}\n\n" + context_body
)
# Sanitize and cap conversation history at 20 turns
api_messages = []
for m in messages[-20:]:
role = m.get("role") if isinstance(m, dict) else None
content = (m.get("content") or "").strip() if isinstance(m, dict) else ""
if role in ("user", "assistant") and content:
api_messages.append({"role": role, "content": content})
if not api_messages or api_messages[-1]["role"] != "user":
return jsonify({"error": "Last message must be from the user."}), 400
try:
response = http_requests.post(
"https://api.groq.com/openai/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={
"model": model,
"messages": [{"role": "system", "content": system_msg}] + api_messages,
"max_tokens": 1024,
"temperature": 0.2,
},
timeout=60,
)
if not response.ok:
logger.error(f"Groq chat {response.status_code}: {response.text[:200]}")
response.raise_for_status()
reply = response.json()["choices"][0]["message"]["content"] or ""
return jsonify({"reply": reply})
except Exception as e:
logger.error(f"AI chat error: {e}")
return jsonify({"error": f"Chat failed: {e}"}), 500
@ai_summary_bp.route("/history/<int:analysis_id>")
@login_required
def analysis_detail(analysis_id):
+1 -1
View File
@@ -32,7 +32,7 @@
<td class="text-muted">{{ u.created_at.strftime('%Y-%m-%d') if u.created_at else '—' }}</td>
<td class="actions">
<button class="btn btn-ghost btn-sm js-edit-user"
data-user="{{ u|tojson|e }}">✎ Edit</button>
data-user='{{ u|tojson }}'>✎ Edit</button>
{% if u.email %}
<button class="btn btn-ghost btn-sm js-send-reset"
data-id="{{ u.id }}" data-username="{{ u.username }}" data-email="{{ u.email }}"
+277 -40
View File
@@ -96,6 +96,7 @@
<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>
@@ -269,16 +270,33 @@
</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 });
@@ -310,16 +328,6 @@ 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');
@@ -331,15 +339,36 @@ function showResult(data) {
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>';
// 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() {
@@ -351,6 +380,8 @@ function clearOutput() {
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() {
@@ -586,8 +617,9 @@ document.querySelectorAll('.history-row').forEach(function(row) {
} catch(e) { /* ignore malformed snapshot */ }
}
document.getElementById('hist-body').innerHTML =
renderAI(d.summary_text || '');
var histBody = document.getElementById('hist-body');
histBody.innerHTML = renderAI(d.summary_text || '');
colorizeEval(histBody);
if (IS_ADMIN) {
document.getElementById('btn-delete-analysis').style.display = '';
}
@@ -638,6 +670,112 @@ function escHtml(str) {
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>
@@ -663,45 +801,85 @@ function escHtml(str) {
.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;
}
/* ── Meta pill bar ─────────────────────────────────────────── */
.ai-meta-bar{
padding:.6rem 1.5rem;font-size:.78rem;font-family:'DM Mono',monospace;
color:var(--text-muted);background:var(--bg-subtle);
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.1rem 1.4rem 1.5rem;
font-size:.9rem;line-height:1.8;color:var(--text-secondary);
padding:1.4rem 1.75rem 2rem;
font-size:.9rem;line-height:1.85;color:var(--text);
}
.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;
.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,.ai-rendered-output h4{
font-size:.9rem;font-weight:700;color:var(--text);margin:1rem 0 .35rem;
.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 p{margin:.35rem 0}
.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 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 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}
.ai-rendered-output code{padding:.1rem .3rem}
.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}
@@ -749,5 +927,64 @@ function escHtml(str) {
.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 %}