Aug 19 - Update: customer Inspector AI support chat
This commit is contained in:
+108
-16
@@ -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)
|
||||
|
||||
@@ -199,7 +199,11 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'customer' %}
|
||||
{# Both customer-side roles get the Support menu — a Customer
|
||||
Inspector works at the customer's facilities and has the
|
||||
same questions. The assistant answers them for their own
|
||||
role (see _role addendum in routes/support.py). #}
|
||||
{% if current_user.is_customer_account %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
|
||||
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
{# ── Live support routes (role-aware) ──────────────────────────────────── #}
|
||||
<div class="row g-3 mb-4">
|
||||
{% if current_user.role == 'customer' %}
|
||||
{% if current_user.is_customer_account %}
|
||||
<div class="col-12 col-md-4">
|
||||
<a class="jqc-hub-card" href="{{ url_for('support.chat') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
@@ -115,13 +115,13 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# The AI chat is customer-only (support.chat redirects staff to the ticket
|
||||
{# The AI chat is for customer-side accounts (support.chat redirects staff to the ticket
|
||||
queue, which is itself @supervisor_required). So the destination is chosen
|
||||
per role rather than pointed at support.chat for everyone — an inspector
|
||||
following that chain would land on the dashboard with an access-denied
|
||||
flash, and this page is meant never to dead-end. Roles with no support
|
||||
destination get no card; the how-to guides below are their support. #}
|
||||
{% if current_user.role == 'customer' %}
|
||||
{% if current_user.is_customer_account %}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<a class="jqc-hub-card dark" href="{{ url_for('support.chat') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
|
||||
Reference in New Issue
Block a user