Aug 19 - Update: customer Inspector AI support chat

This commit is contained in:
2026-08-19 11:16:47 -04:00
parent 5df105a9d5
commit 070e9be993
4 changed files with 120 additions and 24 deletions
+108 -16
View File
@@ -12,7 +12,7 @@ from app.models.support import (SupportTicket, SupportTicketReply,
from app.models.user import User
from app.models.facility import Facility
from app.utils.decorators import supervisor_required
from app.utils.scope import get_customer_scope
from app.utils.scope import get_customer_scope, get_inspector_scope
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.time_utils import now_eastern
from app.utils.notifications import notify
@@ -150,6 +150,16 @@ FAQS = [
{'icon': 'bi-phone', 'text': 'Can our own staff use the JQC app to conduct inspections?'},
]
#: Extra chips shown to a Customer Inspector, whose questions are about doing
#: the work rather than reading the results. Appended to FAQS, not replacing
#: them — they still care about scores and issues.
INSPECTOR_FAQS = [
{'icon': 'bi-clipboard-plus', 'text': 'How do I start an inspection on the iPad?'},
{'icon': 'bi-wifi-off', 'text': 'What happens if I lose signal during an inspection?'},
{'icon': 'bi-flag', 'text': 'How do I flag an issue while inspecting?'},
{'icon': 'bi-search', 'text': "Why can't I see a form for this facility?"},
]
# Soft cap on injected knowledge to keep prompt size (and token cost) reasonable.
_KB_MAX_CHARS = 6000
@@ -172,6 +182,77 @@ _PII_PATTERNS = [
]
def _is_customer_side(user):
"""True for both customer-side roles — Director and Customer Inspector.
The AI assistant and the ticket flow are for the CUSTOMER organisation, and
a Customer Inspector is part of it: they work at the customer's facilities
and have the same questions about scores, issues and the app. This is one
of the few places where User.CUSTOMER_ROLES is the right test; every
capability/scoping decision below still branches per role (see
_support_facilities and _role_addendum), which is what keeps rule 89
intact — the two roles get the same DOOR, not the same answers.
"""
return getattr(user, 'is_customer_account', False)
def _support_facilities(user):
"""The facilities this user may pick on a support ticket.
Directors are scoped by CustomerAssignment, Customer Inspectors by
InspectorAssignment — reusing the customer helper for both would silently
return nothing for an inspector (it returns None for any non-'customer'
role) and the facility dropdown would come up empty.
"""
if getattr(user, 'is_inspector', False):
fids = get_inspector_scope(user) or []
else:
fids = get_customer_scope(user) or []
if not fids:
return []
return (Facility.query
.filter(Facility.id.in_(fids), Facility.active == True)
.order_by(Facility.name).all())
#: Appended to the system prompt for a Customer Inspector. The base prompt is
#: written for the read-mostly portal customer and explicitly tells the model
#: NOT to describe staff actions; without this the assistant would deny a
#: Customer Inspector the very things they are employed to do.
_INSPECTOR_ADDENDUM = """\
=== ABOUT THE PERSON YOU ARE TALKING TO: CUSTOMER INSPECTOR ===
This user works FOR the customer but holds an inspecting role in JQC, limited to \
the contracts they have been assigned. This section OVERRIDES the "only describe \
what a customer can do" restriction above, for this user only.
Everything above about the portal still applies to their assigned facilities. IN \
ADDITION, they can:
- Conduct inspections themselves — start one on the web (Inspections → New \
Inspection) or in the JQC iPad app, fill in the checklist form, add photos, and \
submit it.
- Use the iPad app OFFLINE: inspections and photos are stored on the device and \
sync automatically when back online.
- Flag an issue during an inspection, and log new issues at their facilities.
- Assign an issue to an inspector working on the SAME contract (their own \
colleagues, or the provider's inspectors) — never to anyone outside it.
- Update an issue's status, add comments, and set "Handled By" \
(Janitorial Staff / Facility Staff / External Vendor) from the iPad.
- Work from Scheduled Inspections assigned to them: confirm receipt of the \
request, then Start it when they are on site.
They CANNOT: verify or close out issues (the provider's admin/director does that), \
manage users, create or edit inspection forms, change the notification matrix, or \
see anything outside their assigned contracts. If they ask for one of those, say \
who to ask instead — their own Customer Director, or the provider's team via \
"Submit to Support".
Note on forms: the inspection forms they can choose from are the shared standard \
forms plus any built specifically for their contract. A form built for a different \
customer will never appear.
"""
def _redact_pii(text):
"""Best-effort scrub of email/phone/SSN/card-like sequences from outbound text."""
if not text:
@@ -190,6 +271,18 @@ def _redact_pii(text):
_STYLE_MARKER = '=== STYLE & RULES ==='
def _system_prompt_for(user):
"""Base prompt plus the addendum for this user's role, then the KB.
Kept separate from _system_prompt_with_kb() so the curated knowledge base
still lands at the same marker regardless of role.
"""
prompt = _system_prompt_with_kb()
if getattr(user, 'is_external_inspector', False):
prompt += _INSPECTOR_ADDENDUM
return prompt
def _system_prompt_with_kb():
"""Return the base system prompt with all ACTIVE admin knowledge entries
(phase38) spliced in, so staff can curate the chatbot's knowledge without
@@ -238,13 +331,10 @@ def _system_prompt_with_kb():
@bp.route('/chat')
@login_required
def chat():
if current_user.role != 'customer':
if not _is_customer_side(current_user):
return redirect(url_for('support.admin_tickets'))
cids = get_customer_scope(current_user) or []
facilities = (Facility.query
.filter(Facility.id.in_(cids), Facility.active == True)
.order_by(Facility.name).all()) if cids else []
facilities = _support_facilities(current_user)
# Load the customer's most recent conversation so it continues on return.
# A ?new=1 param (New conversation button) starts a fresh, empty window.
@@ -263,8 +353,9 @@ def chat():
]
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
faqs = (FAQS + INSPECTOR_FAQS) if current_user.is_external_inspector else FAQS
return render_template('support/chat.html',
faqs=FAQS,
faqs=faqs,
facilities=facilities,
groq_ready=groq_ready,
chat_session_id=(session.id if session else None),
@@ -276,7 +367,7 @@ def chat():
@bp.route('/chat/message', methods=['POST'])
@login_required
def chat_message():
if current_user.role != 'customer':
if not _is_customer_side(current_user):
return jsonify({'error': 'Forbidden'}), 403
data = request.get_json(silent=True) or {}
@@ -297,7 +388,7 @@ def chat_message():
from groq import Groq
client = Groq(api_key=api_key)
messages = [{'role': 'system', 'content': _system_prompt_with_kb()}]
messages = [{'role': 'system', 'content': _system_prompt_for(current_user)}]
# Append prior conversation (cap at last 20 turns to control token usage).
# Redact PII-shaped text before it leaves the app for the Groq API —
# the unredacted originals stay in support_chat_messages below.
@@ -347,7 +438,7 @@ def chat_message():
@bp.route('/my-conversations')
@login_required
def my_conversations():
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
sessions = (SupportChatSession.query
.filter_by(customer_id=current_user.id)
@@ -359,7 +450,7 @@ def my_conversations():
@bp.route('/my-conversations/<int:session_id>')
@login_required
def conversation_detail(session_id):
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
session = db.session.get(SupportChatSession, session_id)
if session is None or session.customer_id != current_user.id:
@@ -374,7 +465,7 @@ def conversation_detail(session_id):
@bp.route('/tickets', methods=['POST'])
@login_required
def submit_ticket():
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
subject = request.form.get('subject', '').strip()
@@ -385,8 +476,9 @@ def submit_ticket():
flash('Please fill in both subject and description.', 'warning')
return redirect(url_for('support.chat'))
# Validate facility belongs to this customer
cids = get_customer_scope(current_user) or []
# Validate facility belongs to this user — by whichever assignment table
# their role is scoped through.
cids = [f.id for f in _support_facilities(current_user)]
if facility_id and facility_id not in cids:
facility_id = None
@@ -416,7 +508,7 @@ def submit_ticket():
@bp.route('/my-tickets')
@login_required
def my_tickets():
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
tickets = (SupportTicket.query
@@ -431,7 +523,7 @@ def my_tickets():
@bp.route('/my-tickets/<int:ticket_id>', methods=['GET', 'POST'])
@login_required
def my_ticket_detail(ticket_id):
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
ticket = db.session.get(SupportTicket, ticket_id)