Jun 30 - Add chat to AI analysis result
This commit is contained in:
@@ -450,6 +450,72 @@ 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])
|
||||
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>")
|
||||
@login_required
|
||||
def analysis_detail(analysis_id):
|
||||
|
||||
@@ -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 });
|
||||
@@ -340,6 +358,12 @@ function showResult(data) {
|
||||
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 +375,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() {
|
||||
@@ -638,6 +664,93 @@ function escHtml(str) {
|
||||
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
|
||||
<style>
|
||||
@@ -749,5 +862,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 %}
|
||||
|
||||
Reference in New Issue
Block a user