Jun 30 - Add chat to AI analysis result

This commit is contained in:
2026-06-30 11:12:52 -04:00
parent 57b11b2aa9
commit 5719bcf06e
2 changed files with 241 additions and 3 deletions
+66
View File
@@ -450,6 +450,72 @@ 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])
summary = (record.get("summary_text") or "")[:8000]
system_msg = (
"You are a government procurement analyst assistant. "
"The user has questions about a solicitation document that has already been analyzed. "
"Answer questions based solely on the analysis summary below. "
"If the information is not in the summary, say so clearly. Be concise and direct.\n\n"
"## ANALYZED DOCUMENT SUMMARY\n\n" + summary
)
# 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):
+175 -3
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 });
@@ -340,6 +358,12 @@ function showResult(data) {
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 +372,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() {
@@ -638,6 +664,93 @@ function escHtml(str) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
}); });
} }
/* ─── 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>
@@ -749,5 +862,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 %}