Jul 9 - Chat - save chat sessions

This commit is contained in:
2026-07-09 13:40:56 -04:00
parent 71aef4fe8e
commit 5cf85c564b
12 changed files with 471 additions and 50 deletions
+51
View File
@@ -39,3 +39,54 @@ class SupportTicketReply(db.Model):
def __repr__(self):
return f'<SupportTicketReply {self.id} ticket={self.ticket_id}>'
# ── AI support-chat persistence (phase37) ─────────────────────────────────────
class SupportChatSession(db.Model):
"""One saved AI-chat conversation for a customer. Persisted so both the
customer and staff can reference past conversations and maintain continuity
(prior messages are reloaded into the chat window on return)."""
__tablename__ = 'support_chat_sessions'
id = db.Column(db.Integer, primary_key=True)
customer_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False, index=True)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
updated_at = db.Column(db.DateTime, default=now_eastern, nullable=False, index=True)
customer = db.relationship('User', foreign_keys=[customer_id])
messages = db.relationship(
'SupportChatMessage', backref='session',
cascade='all, delete-orphan',
order_by='SupportChatMessage.created_at',
lazy='dynamic',
)
@property
def message_count(self):
return self.messages.count()
@property
def preview(self):
"""First user message, for list views."""
first = self.messages.filter_by(role='user').first()
return first.content if first else '(no messages)'
def __repr__(self):
return f'<SupportChatSession {self.id} customer={self.customer_id}>'
class SupportChatMessage(db.Model):
"""A single turn in a SupportChatSession. role = 'user' | 'assistant'."""
__tablename__ = 'support_chat_messages'
id = db.Column(db.Integer, primary_key=True)
session_id = db.Column(db.Integer, db.ForeignKey('support_chat_sessions.id', ondelete='CASCADE'),
nullable=False, index=True)
role = db.Column(db.String(16), nullable=False) # 'user' | 'assistant'
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
def __repr__(self):
return f'<SupportChatMessage {self.id} session={self.session_id} role={self.role}>'
+122 -33
View File
@@ -6,7 +6,8 @@ from flask import (Blueprint, render_template, redirect, url_for,
from flask_login import login_required, current_user
from app import db
from app.models.support import SupportTicket, SupportTicketReply
from app.models.support import (SupportTicket, SupportTicketReply,
SupportChatSession, SupportChatMessage)
from app.models.user import User
from app.models.facility import Facility
from app.utils.decorators import supervisor_required
@@ -65,11 +66,29 @@ def chat():
.filter(Facility.id.in_(cids), Facility.active == True)
.order_by(Facility.name).all()) if cids else []
# Load the customer's most recent conversation so it continues on return.
# A ?new=1 param (New conversation button) starts a fresh, empty window.
start_new = request.args.get('new')
session = None
if not start_new:
session = (SupportChatSession.query
.filter_by(customer_id=current_user.id)
.order_by(SupportChatSession.updated_at.desc())
.first())
chat_history = []
if session:
chat_history = [
{'role': m.role, 'content': m.content}
for m in session.messages.order_by(SupportChatMessage.created_at.asc()).all()
]
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
return render_template('support/chat.html',
faqs=FAQS,
facilities=facilities,
groq_ready=groq_ready)
groq_ready=groq_ready,
chat_session_id=(session.id if session else None),
chat_history=chat_history)
# ── Groq chat AJAX endpoint ───────────────────────────────────────────────────
@@ -80,47 +99,92 @@ def chat_message():
if current_user.role != 'customer':
return jsonify({'error': 'Forbidden'}), 403
api_key = os.environ.get('GROQ_API_KEY')
if not api_key:
return jsonify({'reply': (
"I'm sorry, the AI assistant isn't configured right now. "
"Please use the **Submit to Support** form to reach our team directly."
)})
data = request.get_json(silent=True) or {}
history = data.get('messages', []) # list of {role, content} dicts
user_message = data.get('message', '').strip()
session_id = data.get('session_id')
if not user_message:
return jsonify({'error': 'Empty message'}), 400
try:
from groq import Groq
client = Groq(api_key=api_key)
# ── Generate the reply ────────────────────────────────────────────────
api_key = os.environ.get('GROQ_API_KEY')
if not api_key:
reply = ("I'm sorry, the AI assistant isn't configured right now. "
"Please use the **Submit to Support** form to reach our team directly.")
else:
try:
from groq import Groq
client = Groq(api_key=api_key)
messages = [{'role': 'system', 'content': _SYSTEM_PROMPT}]
# Append prior conversation (cap at last 20 turns to control token usage)
for m in history[-20:]:
if m.get('role') in ('user', 'assistant') and m.get('content'):
messages.append({'role': m['role'], 'content': m['content']})
messages.append({'role': 'user', 'content': user_message})
messages = [{'role': 'system', 'content': _SYSTEM_PROMPT}]
# Append prior conversation (cap at last 20 turns to control token usage)
for m in history[-20:]:
if m.get('role') in ('user', 'assistant') and m.get('content'):
messages.append({'role': m['role'], 'content': m['content']})
messages.append({'role': 'user', 'content': user_message})
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
completion = client.chat.completions.create(
model=model,
messages=messages,
max_tokens=512,
temperature=0.5,
)
reply = completion.choices[0].message.content.strip()
return jsonify({'reply': reply})
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
completion = client.chat.completions.create(
model=model,
messages=messages,
max_tokens=512,
temperature=0.5,
)
reply = completion.choices[0].message.content.strip()
except Exception as exc:
logger.error('SUPPORT | Groq error: %s', exc)
reply = ("I ran into a problem reaching the AI assistant. "
"Please try again, or use **Submit to Support** to contact our team.")
except Exception as exc:
logger.error('SUPPORT | Groq error: %s', exc)
return jsonify({'reply': (
"I ran into a problem reaching the AI assistant. "
"Please try again, or use **Submit to Support** to contact our team."
)})
# ── Persist the turn (user message + assistant reply) ─────────────────
now = now_eastern()
session = None
if session_id:
session = db.session.get(SupportChatSession, session_id)
if session is not None and session.customer_id != current_user.id:
session = None # never write into someone else's session
if session is None:
session = SupportChatSession(customer_id=current_user.id,
created_at=now, updated_at=now)
db.session.add(session)
db.session.flush() # assign session.id
db.session.add(SupportChatMessage(session_id=session.id, role='user',
content=user_message, created_at=now))
db.session.add(SupportChatMessage(session_id=session.id, role='assistant',
content=reply, created_at=now))
session.updated_at = now
db.session.commit()
return jsonify({'reply': reply, 'session_id': session.id})
# ── Customer: saved chat conversations ────────────────────────────────────────
@bp.route('/my-conversations')
@login_required
def my_conversations():
if current_user.role != 'customer':
abort(403)
sessions = (SupportChatSession.query
.filter_by(customer_id=current_user.id)
.order_by(SupportChatSession.updated_at.desc())
.all())
return render_template('support/my_conversations.html', sessions=sessions)
@bp.route('/my-conversations/<int:session_id>')
@login_required
def conversation_detail(session_id):
if current_user.role != 'customer':
abort(403)
session = db.session.get(SupportChatSession, session_id)
if session is None or session.customer_id != current_user.id:
abort(404)
messages = session.messages.order_by(SupportChatMessage.created_at.asc()).all()
return render_template('support/conversation_detail.html',
session=session, messages=messages)
# ── Submit support ticket ─────────────────────────────────────────────────────
@@ -327,6 +391,31 @@ def admin_ticket_detail(ticket_id):
replies=replies)
# ── Admin: view saved chat conversations (read-only) ──────────────────────────
@bp.route('/admin/conversations')
@login_required
@supervisor_required
def admin_conversations():
page = request.args.get('page', 1, type=int)
sessions = (SupportChatSession.query
.order_by(SupportChatSession.updated_at.desc())
.paginate(page=page, per_page=25, error_out=False))
return render_template('support/admin_conversations.html', sessions=sessions)
@bp.route('/admin/conversations/<int:session_id>')
@login_required
@supervisor_required
def admin_conversation_detail(session_id):
session = db.session.get(SupportChatSession, session_id)
if session is None:
abort(404)
messages = session.messages.order_by(SupportChatMessage.created_at.asc()).all()
return render_template('support/admin_conversation_detail.html',
session=session, messages=messages)
def _notify_customer_reply(ticket, reply):
"""Create an in-app notification and send an email to the customer."""
if not ticket.customer:
+5
View File
@@ -180,6 +180,11 @@
<i class="bi bi-chat-dots me-2"></i>Ask a Question
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('support.my_conversations') }}">
<i class="bi bi-clock-history me-2"></i>My Conversations
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
<i class="bi bi-inbox me-2"></i>My Requests
+21
View File
@@ -0,0 +1,21 @@
{# Read-only chat transcript. Expects `messages` (list of SupportChatMessage). #}
<style>
.cbubble { max-width:80%; padding:.6rem .9rem; border-radius:1rem; font-size:.9rem;
line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.cbubble-user { background:#0d6efd; color:#fff; border-bottom-right-radius:.25rem; }
.cbubble-ai { background:#fff; border:1px solid #dee2e6; border-bottom-left-radius:.25rem; }
</style>
<div class="p-3" style="background:#f8f9fa;">
{% for m in messages %}
<div class="d-flex mb-2 {{ 'justify-content-end' if m.role == 'user' else 'justify-content-start' }}">
<div class="cbubble {{ 'cbubble-user' if m.role == 'user' else 'cbubble-ai' }}">
{{ m.content }}
<div class="mt-1" style="font-size:.7rem;opacity:.6;">
{{ 'You' if m.role == 'user' else 'JQC Assistant' }} · {{ m.created_at.strftime('%b %d, %I:%M %p') }}
</div>
</div>
</div>
{% else %}
<div class="text-muted text-center small">This conversation has no messages.</div>
{% endfor %}
</div>
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Conversation #{{ session.id }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-chat-square-text me-2 text-primary"></i>Conversation #{{ session.id }}</h4>
<small class="text-muted">
{{ session.customer.display_name if session.customer else 'Unknown customer' }}
· started {{ session.created_at.strftime('%b %d, %Y %I:%M %p') }}
</small>
</div>
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>All Conversations
</a>
</div>
<div class="card shadow-sm">
{% include 'support/_transcript.html' %}
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Support Conversations{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0"><i class="bi bi-chat-left-dots me-2 text-primary"></i>Customer Chat Conversations</h4>
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-life-preserver me-1"></i>Support Tickets
</a>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
{% if sessions.items %}
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Customer</th>
<th>First message</th>
<th class="text-center">Messages</th>
<th>Last activity</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in sessions.items %}
<tr>
<td class="fw-semibold">{{ s.customer.display_name if s.customer else '—' }}</td>
<td class="text-muted small text-truncate" style="max-width:340px;">
{{ s.preview[:120] }}{% if s.preview|length > 120 %}…{% endif %}
</td>
<td class="text-center">{{ s.message_count }}</td>
<td class="small text-muted text-nowrap">{{ s.updated_at.strftime('%b %d, %Y %I:%M %p') }}</td>
<td class="text-end">
<a href="{{ url_for('support.admin_conversation_detail', session_id=s.id) }}"
class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i> View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-muted text-center">No chat conversations yet.</div>
{% endif %}
</div>
</div>
{% if sessions.pages > 1 %}
<nav class="mt-3"><ul class="pagination justify-content-center">
{% for p in range(1, sessions.pages + 1) %}
<li class="page-item {{ 'active' if p == sessions.page }}">
<a class="page-link" href="{{ url_for('support.admin_conversations', page=p) }}">{{ p }}</a>
</li>
{% endfor %}
</ul></nav>
{% endif %}
{% endblock %}
+3
View File
@@ -7,6 +7,9 @@
<h4 class="mb-0"><i class="bi bi-inbox me-2 text-primary"></i>Customer Support Tickets</h4>
<small class="text-muted">{{ tickets.total }} ticket{{ 's' if tickets.total != 1 }}</small>
</div>
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-chat-left-dots me-1"></i>Chat Conversations
</a>
</div>
{# Status filter tabs #}
+33 -13
View File
@@ -39,14 +39,24 @@
<div class="col-lg-8">
{# ── Header ── #}
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div>
<h4 class="mb-0"><i class="bi bi-chat-dots me-2 text-primary"></i>JQC Support Chat</h4>
<small class="text-muted">Ask a question or browse common topics below</small>
</div>
<button class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#submitModal">
<i class="bi bi-envelope me-1"></i>Submit to Support
</button>
<div class="d-flex gap-2">
<a href="{{ url_for('support.my_conversations') }}" class="btn btn-outline-secondary btn-sm"
title="View your saved conversations">
<i class="bi bi-clock-history me-1"></i>History
</a>
<a href="{{ url_for('support.chat', new=1) }}" class="btn btn-outline-secondary btn-sm"
title="Start a fresh conversation">
<i class="bi bi-plus-circle me-1"></i>New
</a>
<button class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#submitModal">
<i class="bi bi-envelope me-1"></i>Submit to Support
</button>
</div>
</div>
{# ── Chat card ── #}
@@ -153,17 +163,27 @@
const chatWindow = document.getElementById('chat-window');
const chatInput = document.getElementById('chat-input');
const faqSection = document.getElementById('faq-section');
const CSRF_TOKEN = '{{ csrf_token() }}';
// In-memory conversation history sent to the server with each message
let history = [];
// Server-side saved session this chat is being persisted to (null until first send)
let sessionId = {{ chat_session_id | tojson }};
const PRIOR = {{ chat_history | tojson }};
// ── Greeting on page load ──────────────────────────────────────────────
// ── Restore prior conversation, or greet on a fresh session ────────────
var userName = {{ current_user.display_name | tojson }};
appendMessage('ai', "👋 Hi " + userName + "! I'm your JQC support assistant. " +
"I can help you with inspections, issues, reports, and more. " +
"Click a question below or type your own.");
if (PRIOR && PRIOR.length) {
PRIOR.forEach(function (m) {
appendMessage(m.role === 'user' ? 'user' : 'ai', m.content);
history.push({ role: m.role, content: m.content });
});
appendSystem('Continuing your previous conversation. Click "New" above to start a fresh one.');
} else {
appendMessage('ai', "👋 Hi " + userName + "! I'm your JQC support assistant. " +
"I can help you with inspections, issues, reports, and more. " +
"Click a question below or type your own.");
}
// ── Append a message bubble ────────────────────────────────────────────
function appendMessage(role, text) {
@@ -202,8 +222,7 @@
function sendMessage(text) {
if (!text.trim()) return;
// Hide FAQ chips after first interaction
if (faqSection) faqSection.style.display = 'none';
// FAQ chips stay visible for the whole session (kept intentionally).
appendMessage('user', text);
history.push({ role: 'user', content: text });
@@ -219,11 +238,12 @@
'Content-Type': 'application/json',
'X-CSRFToken': CSRF_TOKEN,
},
body: JSON.stringify({ message: text, messages: history }),
body: JSON.stringify({ message: text, messages: history, session_id: sessionId }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
hideTyping();
if (data.session_id) { sessionId = data.session_id; }
const reply = data.reply || 'Sorry, I could not process your request.';
appendMessage('ai', reply);
history.push({ role: 'assistant', content: reply });
@@ -252,7 +272,7 @@
};
window.sendFaq = function (btn) {
btn.disabled = true;
// Chips remain enabled/visible so they can be used throughout the session.
sendMessage(btn.dataset.faq);
};
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Conversation{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-chat-square-text me-2 text-primary"></i>Conversation</h4>
<small class="text-muted">{{ session.created_at.strftime('%b %d, %Y %I:%M %p') }}</small>
</div>
<a href="{{ url_for('support.my_conversations') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>All Conversations
</a>
</div>
<div class="card shadow-sm">
{% include 'support/_transcript.html' %}
</div>
<p class="text-muted small mt-2 text-center">
Need more help? <a href="{{ url_for('support.chat') }}">Return to chat</a>.
</p>
</div>
</div>
{% endblock %}
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}My Conversations{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0"><i class="bi bi-clock-history me-2 text-primary"></i>My Support Conversations</h4>
<a href="{{ url_for('support.chat') }}" class="btn btn-primary btn-sm">
<i class="bi bi-chat-dots me-1"></i>Back to Chat
</a>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
{% if sessions %}
<div class="list-group list-group-flush">
{% for s in sessions %}
<a href="{{ url_for('support.conversation_detail', session_id=s.id) }}"
class="list-group-item list-group-item-action">
<div class="d-flex justify-content-between align-items-start">
<div class="me-3 text-truncate">
<div class="fw-semibold text-truncate">{{ s.preview[:100] }}{% if s.preview|length > 100 %}…{% endif %}</div>
<small class="text-muted">{{ s.message_count }} message{{ 's' if s.message_count != 1 }}</small>
</div>
<small class="text-muted text-nowrap">{{ s.updated_at.strftime('%b %d, %Y %I:%M %p') }}</small>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="p-4 text-muted text-center">
You have no saved conversations yet.
<a href="{{ url_for('support.chat') }}">Start a chat</a>.
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}