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() conn.commit()
logger.info("Migration: added idx_activity_log_time index to activity_log.") 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) ─ # ── Seed app_settings from environment variables (first-run bootstrap) ─
# Uses INSERT IGNORE so values already saved via the Admin UI are never # Uses INSERT IGNORE so values already saved via the Admin UI are never
# overwritten — .env only fills in keys that are completely absent. # 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, 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 conn = None
try: try:
conn = get_connection() conn = get_connection()
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
"INSERT INTO ai_analysis_log (user_id, file_names, model, verdict, criteria_snapshot, summary_text) " "INSERT INTO ai_analysis_log "
"VALUES (%s,%s,%s,%s,%s,%s)", "(user_id, file_names, model, verdict, criteria_snapshot, document_text, summary_text) "
(user_id, file_names, model, verdict, criteria_snapshot, summary_text), "VALUES (%s,%s,%s,%s,%s,%s,%s)",
(user_id, file_names, model, verdict, criteria_snapshot, document_text, summary_text),
) )
conn.commit() conn.commit()
new_id = cur.lastrowid new_id = cur.lastrowid
+187 -46
View File
@@ -100,7 +100,8 @@ def analyze():
]) ])
analysis_id = save_ai_analysis( analysis_id = save_ai_analysis(
user["id"], ", ".join(file_names), model, 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, log_action(user["id"], "AI_ANALYSIS", "ai_analysis_log", analysis_id,
f"AI analysis on {len(file_names)} file(s). Verdict: {result.get('verdict')}.") 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 used as origin for distance/travel-time estimates.
_OFFICE_ADDRESS = "2815 Hartland Road, Falls Church, VA 22043, USA" _OFFICE_ADDRESS = "2815 Hartland Road, Falls Church, VA 22043, USA"
# Stage 1 — extraction prompt (always sent) # Document text limit per API call.
_EXTRACTION_PROMPT = """You are an expert government procurement analyst. # Stage 1 prompt template is ~1.8 KB overhead; 14 KB of doc text keeps the total
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. # JSON payload well under Groq's request size limit on all plan tiers.
_TEXT_LIMIT = 14_000
IMPORTANT Our office is located at: # Analyst persona — injected as the system message in every API call.
{office} _SYSTEM_PROMPT = (
Use this as the ORIGIN address for all driving distance and travel time calculations in field #9 below. "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 | Field | Value |
B. Contract Period |---|---|
C. Proposal Submission Requirements | Solicitation Number | |
D. Key Deadlines & Action Items | 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:
{documents}""" {documents}"""
# Stage 2 — criteria evaluation suffix (appended only when active criteria exist) # Criteria evaluation appended to the extraction prompt in the same API call.
_CRITERIA_PROMPT_SUFFIX = """ # 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 OPPORTUNITY ALIGNMENT EVALUATION
@@ -255,23 +321,21 @@ in plain business language."""
def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict: def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict:
"""Call the Groq chat completions REST API directly (no SDK required).""" """Single Groq API call: extraction + optional criteria evaluation in one request."""
import re 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( prompt = _EXTRACTION_PROMPT.format(
n=n, office=_OFFICE_ADDRESS, documents=text[:14000] n=n, office=_OFFICE_ADDRESS, documents=doc_text
) )
if criteria: if criteria:
criteria_list = "\n".join( criteria_list = "\n".join(
f" {i+1}. {c['title']}: {c['description']}" f"{i+1}. **{c['title']}**: {c['description']}"
for i, c in enumerate(criteria) 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( response = http_requests.post(
"https://api.groq.com/openai/v1/chat/completions", "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={ json={
"model": model, "model": model,
"messages": [{"role": "user", "content": prompt}], "messages": [
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
"max_tokens": 4096, "max_tokens": 4096,
"temperature": 0.2, "temperature": 0.1,
}, },
timeout=90, timeout=120,
) )
if not response.ok: if not response.ok:
logger.error(f"Groq API {response.status_code}: {response.text[:300]}") 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 "" content = response.json()["choices"][0]["message"]["content"] or ""
# Parse the machine-readable RECOMMENDATION label (only present when criteria used)
verdict = None verdict = None
if criteria: if criteria:
match = re.search( match = re.search(
@@ -358,6 +424,81 @@ def delete_criterion_view(criterion_id):
return redirect(url_for("ai_summary.ai_summary")) 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>") @ai_summary_bp.route("/history/<int:analysis_id>")
@login_required @login_required
def analysis_detail(analysis_id): 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="text-muted">{{ u.created_at.strftime('%Y-%m-%d') if u.created_at else '—' }}</td>
<td class="actions"> <td class="actions">
<button class="btn btn-ghost btn-sm js-edit-user" <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 %} {% if u.email %}
<button class="btn btn-ghost btn-sm js-send-reset" <button class="btn btn-ghost btn-sm js-send-reset"
data-id="{{ u.id }}" data-username="{{ u.username }}" data-email="{{ u.email }}" data-id="{{ u.id }}" data-username="{{ u.username }}" data-email="{{ u.email }}"
+280 -43
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-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-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-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>
</div> </div>
@@ -269,16 +270,33 @@
</div> </div>
<div class="modal-footer"> <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-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> <button class="btn btn-secondary" onclick="closeModal('modal-history')">Close</button>
</div> </div>
</div> </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 --> <!-- marked.js for markdown rendering -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
<script> <script>
var IS_ADMIN = {{ 'true' if is_admin else 'false' }}; var IS_ADMIN = {{ 'true' if is_admin else 'false' }};
var _liveAnalysisId = null; // set after a live analysis completes
/* ─── Markdown renderer config ──────────────────────────────── */ /* ─── Markdown renderer config ──────────────────────────────── */
marked.setOptions({ breaks: true, gfm: true }); marked.setOptions({ breaks: true, gfm: true });
@@ -310,16 +328,6 @@ var _rawText = ''; // kept for copy/save
function showResult(data) { function showResult(data) {
_rawText = data.summary || ''; _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 // Verdict banner
var banner = document.getElementById('verdict-banner'); var banner = document.getElementById('verdict-banner');
@@ -331,15 +339,36 @@ function showResult(data) {
banner.innerHTML = ''; banner.innerHTML = '';
} }
// Render output: header as plain preformatted, then markdown body // Meta pill bar (replaces old <pre> header block)
document.getElementById('ai-output').innerHTML = var files = (data.file_names || '').split(', ').filter(Boolean);
'<pre class="ai-header-block">' + escHtml(header) + '</pre>' var metaHtml = '<div class="ai-meta-bar">'
+ '<div class="ai-rendered-output">' + renderAI(_rawText) + '</div>'; + '<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 // Show action buttons
document.getElementById('btn-copy').style.display = ''; document.getElementById('btn-copy').style.display = '';
document.getElementById('btn-save').style.display = ''; document.getElementById('btn-save').style.display = '';
document.getElementById('btn-clear').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() { function clearOutput() {
@@ -348,9 +377,11 @@ function clearOutput() {
'<div class="ai-placeholder"><p>Upload a document and click <strong>Analyze with AI</strong> to begin.</p></div>'; '<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').style.display = 'none';
document.getElementById('verdict-banner').innerHTML = ''; document.getElementById('verdict-banner').innerHTML = '';
document.getElementById('btn-copy').style.display = 'none'; document.getElementById('btn-copy').style.display = 'none';
document.getElementById('btn-save').style.display = 'none'; document.getElementById('btn-save').style.display = 'none';
document.getElementById('btn-clear').style.display = 'none'; document.getElementById('btn-clear').style.display = 'none';
document.getElementById('btn-chat-live').style.display = 'none';
_liveAnalysisId = null;
} }
function copyOutput() { function copyOutput() {
@@ -586,8 +617,9 @@ document.querySelectorAll('.history-row').forEach(function(row) {
} catch(e) { /* ignore malformed snapshot */ } } catch(e) { /* ignore malformed snapshot */ }
} }
document.getElementById('hist-body').innerHTML = var histBody = document.getElementById('hist-body');
renderAI(d.summary_text || ''); histBody.innerHTML = renderAI(d.summary_text || '');
colorizeEval(histBody);
if (IS_ADMIN) { if (IS_ADMIN) {
document.getElementById('btn-delete-analysis').style.display = ''; document.getElementById('btn-delete-analysis').style.display = '';
} }
@@ -638,6 +670,112 @@ function escHtml(str) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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> </script>
<style> <style>
@@ -663,45 +801,85 @@ function escHtml(str) {
.ai-placeholder{ .ai-placeholder{
padding:2rem 1.5rem;color:var(--text-muted); padding:2rem 1.5rem;color:var(--text-muted);
} }
.ai-header-block{
background:var(--bg-subtle);border-bottom:1px solid var(--border); /* ── Meta pill bar ─────────────────────────────────────────── */
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{ .ai-meta-bar{
padding:.6rem 1.5rem;font-size:.78rem;font-family:'DM Mono',monospace; display:flex;flex-wrap:wrap;gap:.4rem;align-items:center;
color:var(--text-muted);background:var(--bg-subtle); padding:.7rem 1.4rem;background:var(--bg-subtle);
border-bottom:1px solid var(--border); 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 ──────────────────────────────── */ /* ── Rendered markdown output ──────────────────────────────── */
.ai-rendered-output{ .ai-rendered-output{
padding:1.1rem 1.4rem 1.5rem; padding:1.4rem 1.75rem 2rem;
font-size:.9rem;line-height:1.8;color:var(--text-secondary); font-size:.9rem;line-height:1.85;color:var(--text);
} }
.ai-rendered-output h1,.ai-rendered-output h2{ .ai-rendered-output h1{
font-size:1rem;font-weight:700;color:var(--text); font-size:1.05rem;font-weight:800;color:var(--text);
margin:1.4rem 0 .5rem;padding-bottom:.3rem; 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); border-bottom:1px solid var(--border);
} }
.ai-rendered-output h3,.ai-rendered-output h4{ .ai-rendered-output h3{
font-size:.9rem;font-weight:700;color:var(--text);margin:1rem 0 .35rem; 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 strong{color:var(--text);font-weight:700}
.ai-rendered-output ul,.ai-rendered-output ol{ .ai-rendered-output em{font-style:italic;color:var(--text-secondary)}
padding-left:1.4rem;margin:.35rem 0; .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 li{margin:.2rem 0} .ai-rendered-output hr{border:none;border-top:2px solid var(--border);margin:1.75rem 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{ .ai-rendered-output pre,.ai-rendered-output code{
font-family:'DM Mono',monospace;font-size:.82rem; font-family:'DM Mono',monospace;font-size:.82rem;
background:var(--bg-subtle);border-radius:var(--r-sm); background:var(--bg-subtle);border-radius:var(--r-sm);
} }
.ai-rendered-output pre{padding:.75rem 1rem;overflow-x:auto} .ai-rendered-output pre{padding:.75rem 1rem;overflow-x:auto;border:1px solid var(--border)}
.ai-rendered-output code{padding:.1rem .3rem} .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 ─────────────────────────────────────────── */
.criteria-list{display:flex;flex-direction:column} .criteria-list{display:flex;flex-direction:column}
@@ -749,5 +927,64 @@ function escHtml(str) {
.ai-layout{grid-template-columns:1fr} .ai-layout{grid-template-columns:1fr}
.ai-output-body{max-height:60vh} .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> </style>
{% endblock %} {% endblock %}