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
+27 -4
View File
@@ -319,6 +319,17 @@ support_ticket_replies: id, ticket_id (FK→support_tickets CASCADE), user_id (F
body TEXT, created_at DATETIME body TEXT, created_at DATETIME
``` ```
### SupportChatSession / SupportChatMessage (Phase 37)
```
support_chat_sessions: id, customer_id (FK→users CASCADE, indexed),
created_at, updated_at (indexed)
support_chat_messages: id, session_id (FK→support_chat_sessions CASCADE, indexed),
role ('user'|'assistant'), content TEXT, created_at
```
Persists the customer AI support chat. `chat_message()` writes both the user turn and the assistant reply into the session (creating one lazily on the first message; `session.updated_at` bumped each turn). `chat()` reloads the customer's **most recent** session into the chat window for continuity (unless `?new=1`). Read-only history views exist for the customer (`/support/my-conversations`) and staff (`/support/admin/conversations`). `SupportChatSession.preview` = first user message; `.message_count` for list views. See §18 "Support Chat".
**Flow:** **Flow:**
- Customer submits ticket via chat page modal → status `open` → admins notified (in-app + email) - Customer submits ticket via chat page modal → status `open` → admins notified (in-app + email)
- Admin replies → status auto-advances to `answered` → customer notified (in-app + email, link to `/support/my-tickets/<id>`) - Admin replies → status auto-advances to `answered` → customer notified (in-app + email, link to `/support/my-tickets/<id>`)
@@ -454,7 +465,7 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF | | `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) | | `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) |
| `scheduled_inspections` | `/scheduled-inspections` | list, new/edit/delete (PM+), `GET /<id>/start` (assigned inspector or manager → creates linked inspection), `POST /run` (cron reminders, `token=DIGEST_SECRET`) | | `scheduled_inspections` | `/scheduled-inspections` | list, new/edit/delete (PM+), `GET /<id>/start` (assigned inspector or manager → creates linked inspection), `POST /run` (cron reminders, `token=DIGEST_SECRET`) |
| `support` | `/support` | `GET /chat`, `POST /chat/message` (AJAX→Groq), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` | | `support` | `/support` | `GET /chat` (loads latest saved session; `?new=1` to start fresh), `POST /chat/message` (AJAX→Groq; **persists** user+assistant turns, returns `session_id`), `GET /my-conversations`, `GET /my-conversations/<id>` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/<id>` (staff, read-only), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
| `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) | | `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) |
| `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) | | `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) |
| `api` | `/api/v1` | parent blueprint | | `api` | `/api/v1` | parent blueprint |
@@ -761,7 +772,8 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
→ phase33_contract_recipients → phase33_contract_recipients
→ phase34_facility_qr → phase34_facility_qr
→ phase35_issue_handler → phase35_issue_handler
→ phase36_scheduled_insp ← HEAD → phase36_scheduled_insp
→ phase37_support_chat ← HEAD
``` ```
### phase21_performance_indexes ### phase21_performance_indexes
@@ -880,6 +892,16 @@ sudo systemctl restart gunicorn
# -d "token=YOUR_DIGEST_SECRET" # -d "token=YOUR_DIGEST_SECRET"
``` ```
### phase37_support_chat
Revision id `phase37_support_chat`. Creates `support_chat_sessions` + `support_chat_messages` so the customer AI support-chat is persisted (see §5 and §18 "Support Chat"). Table existence checks — safe to re-run.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
```
**Deploy order for phases 2432:** **Deploy order for phases 2432:**
```bash ```bash
flask db upgrade flask db upgrade
@@ -1097,9 +1119,10 @@ A **PDF Summary** button was added to `reports/scorecard.html` alongside the exi
`GET /support/chat` — customer only. Renders: `GET /support/chat` — customer only. Renders:
- Greeting message with `current_user.display_name` (injected via `var userName = {{ current_user.display_name | tojson }}` — use `tojson` not inline interpolation to prevent XSS/quote breaks). - Greeting message with `current_user.display_name` (injected via `var userName = {{ current_user.display_name | tojson }}` — use `tojson` not inline interpolation to prevent XSS/quote breaks).
- FAQ quick-reply chips: text stored in `data-faq="..."` HTML attribute (HTML-escaped with `| e`), read in JS via `btn.dataset.faq`. **Never use `| tojson` in an `onclick=""` attribute** — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the `<script>` tag. - FAQ quick-reply chips: text stored in `data-faq="..."` HTML attribute (HTML-escaped with `| e`), read in JS via `btn.dataset.faq`. **Never use `| tojson` in an `onclick=""` attribute** — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the `<script>` tag. **The FAQ section stays visible for the whole session** (phase37) — it is NOT hidden after the first message, and chips are not disabled after clicking (reusable throughout).
- Chat history kept client-side in `let history = []`, sent with each AJAX `POST /support/chat/message`. Server caps at last 20 turns. - Chat history kept client-side in `let history = []`, sent with each AJAX `POST /support/chat/message`. Server caps at last 20 turns.
- If `GROQ_API_KEY` is absent, input is disabled and a fallback "Submit to Support" link is shown. - **Persistence (phase37):** conversations are saved to `support_chat_sessions` / `support_chat_messages`. The page reloads the customer's most recent session into the window (continuity) unless `?new=1`. `let sessionId` (seeded from `chat_session_id`) is sent with each message and updated from the response; the server persists both turns. Header buttons: **History** (`/support/my-conversations`) and **New** (`/support/chat?new=1`). Staff can review any customer's chats read-only at `/support/admin/conversations`. Both detail views `{% include 'support/_transcript.html' %}`.
- If `GROQ_API_KEY` is absent, input is disabled and a fallback "Submit to Support" link is shown (the reply is still persisted).
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from last user message in history. - "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from last user message in history.
### Inspection Execute Page — UX Patterns ### Inspection Execute Page — UX Patterns
+51
View File
@@ -39,3 +39,54 @@ class SupportTicketReply(db.Model):
def __repr__(self): def __repr__(self):
return f'<SupportTicketReply {self.id} ticket={self.ticket_id}>' 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 flask_login import login_required, current_user
from app import db 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.user import User
from app.models.facility import Facility from app.models.facility import Facility
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
@@ -65,11 +66,29 @@ def chat():
.filter(Facility.id.in_(cids), Facility.active == True) .filter(Facility.id.in_(cids), Facility.active == True)
.order_by(Facility.name).all()) if cids else [] .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')) groq_ready = bool(os.environ.get('GROQ_API_KEY'))
return render_template('support/chat.html', return render_template('support/chat.html',
faqs=FAQS, faqs=FAQS,
facilities=facilities, 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 ─────────────────────────────────────────────────── # ── Groq chat AJAX endpoint ───────────────────────────────────────────────────
@@ -80,47 +99,92 @@ def chat_message():
if current_user.role != 'customer': if current_user.role != 'customer':
return jsonify({'error': 'Forbidden'}), 403 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 {} data = request.get_json(silent=True) or {}
history = data.get('messages', []) # list of {role, content} dicts history = data.get('messages', []) # list of {role, content} dicts
user_message = data.get('message', '').strip() user_message = data.get('message', '').strip()
session_id = data.get('session_id')
if not user_message: if not user_message:
return jsonify({'error': 'Empty message'}), 400 return jsonify({'error': 'Empty message'}), 400
try: # ── Generate the reply ────────────────────────────────────────────────
from groq import Groq api_key = os.environ.get('GROQ_API_KEY')
client = Groq(api_key=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}] messages = [{'role': 'system', 'content': _SYSTEM_PROMPT}]
# Append prior conversation (cap at last 20 turns to control token usage) # Append prior conversation (cap at last 20 turns to control token usage)
for m in history[-20:]: for m in history[-20:]:
if m.get('role') in ('user', 'assistant') and m.get('content'): if m.get('role') in ('user', 'assistant') and m.get('content'):
messages.append({'role': m['role'], 'content': m['content']}) messages.append({'role': m['role'], 'content': m['content']})
messages.append({'role': 'user', 'content': user_message}) messages.append({'role': 'user', 'content': user_message})
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile') model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
completion = client.chat.completions.create( completion = client.chat.completions.create(
model=model, model=model,
messages=messages, messages=messages,
max_tokens=512, max_tokens=512,
temperature=0.5, temperature=0.5,
) )
reply = completion.choices[0].message.content.strip() reply = completion.choices[0].message.content.strip()
return jsonify({'reply': reply}) 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: # ── Persist the turn (user message + assistant reply) ─────────────────
logger.error('SUPPORT | Groq error: %s', exc) now = now_eastern()
return jsonify({'reply': ( session = None
"I ran into a problem reaching the AI assistant. " if session_id:
"Please try again, or use **Submit to Support** to contact our team." 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 ───────────────────────────────────────────────────── # ── Submit support ticket ─────────────────────────────────────────────────────
@@ -327,6 +391,31 @@ def admin_ticket_detail(ticket_id):
replies=replies) 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): def _notify_customer_reply(ticket, reply):
"""Create an in-app notification and send an email to the customer.""" """Create an in-app notification and send an email to the customer."""
if not ticket.customer: if not ticket.customer:
+5
View File
@@ -180,6 +180,11 @@
<i class="bi bi-chat-dots me-2"></i>Ask a Question <i class="bi bi-chat-dots me-2"></i>Ask a Question
</a> </a>
</li> </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> <li>
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}"> <a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
<i class="bi bi-inbox me-2"></i>My Requests <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> <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> <small class="text-muted">{{ tickets.total }} ticket{{ 's' if tickets.total != 1 }}</small>
</div> </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> </div>
{# Status filter tabs #} {# Status filter tabs #}
+33 -13
View File
@@ -39,14 +39,24 @@
<div class="col-lg-8"> <div class="col-lg-8">
{# ── Header ── #} {# ── 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> <div>
<h4 class="mb-0"><i class="bi bi-chat-dots me-2 text-primary"></i>JQC Support Chat</h4> <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> <small class="text-muted">Ask a question or browse common topics below</small>
</div> </div>
<button class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#submitModal"> <div class="d-flex gap-2">
<i class="bi bi-envelope me-1"></i>Submit to Support <a href="{{ url_for('support.my_conversations') }}" class="btn btn-outline-secondary btn-sm"
</button> 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> </div>
{# ── Chat card ── #} {# ── Chat card ── #}
@@ -153,17 +163,27 @@
const chatWindow = document.getElementById('chat-window'); const chatWindow = document.getElementById('chat-window');
const chatInput = document.getElementById('chat-input'); const chatInput = document.getElementById('chat-input');
const faqSection = document.getElementById('faq-section');
const CSRF_TOKEN = '{{ csrf_token() }}'; const CSRF_TOKEN = '{{ csrf_token() }}';
// In-memory conversation history sent to the server with each message // In-memory conversation history sent to the server with each message
let history = []; 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 }}; var userName = {{ current_user.display_name | tojson }};
appendMessage('ai', "👋 Hi " + userName + "! I'm your JQC support assistant. " + if (PRIOR && PRIOR.length) {
"I can help you with inspections, issues, reports, and more. " + PRIOR.forEach(function (m) {
"Click a question below or type your own."); 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 ──────────────────────────────────────────── // ── Append a message bubble ────────────────────────────────────────────
function appendMessage(role, text) { function appendMessage(role, text) {
@@ -202,8 +222,7 @@
function sendMessage(text) { function sendMessage(text) {
if (!text.trim()) return; if (!text.trim()) return;
// Hide FAQ chips after first interaction // FAQ chips stay visible for the whole session (kept intentionally).
if (faqSection) faqSection.style.display = 'none';
appendMessage('user', text); appendMessage('user', text);
history.push({ role: 'user', content: text }); history.push({ role: 'user', content: text });
@@ -219,11 +238,12 @@
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRFToken': CSRF_TOKEN, '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 (r) { return r.json(); })
.then(function (data) { .then(function (data) {
hideTyping(); hideTyping();
if (data.session_id) { sessionId = data.session_id; }
const reply = data.reply || 'Sorry, I could not process your request.'; const reply = data.reply || 'Sorry, I could not process your request.';
appendMessage('ai', reply); appendMessage('ai', reply);
history.push({ role: 'assistant', content: reply }); history.push({ role: 'assistant', content: reply });
@@ -252,7 +272,7 @@
}; };
window.sendFaq = function (btn) { window.sendFaq = function (btn) {
btn.disabled = true; // Chips remain enabled/visible so they can be used throughout the session.
sendMessage(btn.dataset.faq); 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 %}
@@ -0,0 +1,58 @@
"""phase37 — persist AI support-chat conversations
Creates support_chat_sessions + support_chat_messages so customer AI-chat
conversations are saved for later reference and continuity.
Uses table existence checks safe to re-run.
"""
revision = 'phase37_support_chat'
down_revision = 'phase36_scheduled_insp'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def _table_exists(conn, table):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
), {"t": table}).scalar() > 0
def upgrade():
bind = op.get_bind()
if not _table_exists(bind, 'support_chat_sessions'):
op.create_table(
'support_chat_sessions',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('customer_id', sa.Integer,
sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('created_at', sa.DateTime, nullable=False),
sa.Column('updated_at', sa.DateTime, nullable=False),
)
op.create_index('ix_scs_customer', 'support_chat_sessions', ['customer_id'])
op.create_index('ix_scs_updated', 'support_chat_sessions', ['updated_at'])
if not _table_exists(bind, 'support_chat_messages'):
op.create_table(
'support_chat_messages',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('session_id', sa.Integer,
sa.ForeignKey('support_chat_sessions.id', ondelete='CASCADE'), nullable=False),
sa.Column('role', sa.String(16), nullable=False),
sa.Column('content', sa.Text, nullable=False),
sa.Column('created_at', sa.DateTime, nullable=False),
)
op.create_index('ix_scm_session', 'support_chat_messages', ['session_id'])
def downgrade():
bind = op.get_bind()
if _table_exists(bind, 'support_chat_messages'):
op.drop_table('support_chat_messages')
if _table_exists(bind, 'support_chat_sessions'):
op.drop_table('support_chat_sessions')