Aug 19 - Update: customer Inspector AI support chat
This commit is contained in:
@@ -206,7 +206,7 @@ Both roles below belong to the **customer**, not to us. They are the two seats t
|
||||
| Stored ENUM value | Display label | Scoped by | Capabilities |
|
||||
|---|---|---|---|
|
||||
| `customer` | **Customer Director** | `CustomerAssignment` (contract **or** single facility) | The portal, unchanged — read-mostly, own-facility issues/comments/follow-up requests |
|
||||
| `external_inspector` | **Customer Inspector** | `InspectorAssignment` (whole contracts only) | Identical to the internal `inspector`, limited to their contracts |
|
||||
| `external_inspector` | **Customer Inspector** | `InspectorAssignment` (whole contracts only) | Identical to the internal `inspector`, limited to their contracts, **plus the customer support surface** (AI chat + tickets) — see §7 `support` |
|
||||
|
||||
**This is a LABEL-only rename** — the same posture as rule 19 ("Project" → "Contract"). The ENUM values are unchanged, so phase51 needed **no user migration** and moved none of the ~63 `external_inspector` call sites or the many `role == 'customer'` checks. `User.ROLE_LABELS` is the one place the names live.
|
||||
|
||||
@@ -582,7 +582,7 @@ Management of the underlying routes is otherwise unchanged; **Start** is the **a
|
||||
| Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Issue comments | ✅ | ✅ | ✅ | ✅ | followed/reported issues only |
|
||||
| Support Chat (AI) | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| Support Chat (AI) | ❌ | ❌ | ❌ | ❌ | ✅ (both customer roles) |
|
||||
| Support Tickets (manage) | ✅ | ✅ | ❌ | ❌ | own only |
|
||||
| Reports | ✅ | ✅ | ✅ | ✅ | scoped |
|
||||
| Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
@@ -623,7 +623,7 @@ Management of the underlying routes is otherwise unchanged; **Start** is the **a
|
||||
| `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_inspections` | `/scheduled-inspections` | list (`?tab=pending\|completed` — Phase 47), new/edit/delete (`@schedule_manager_required` — admin/director/PM/auditor **plus Customer Directors, scoped to their own contracts**), `GET /<id>/start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST /<id>/acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `GET /confirm/<token>` (**login-free** one-click email confirm; signed `itsdangerous` token binding schedule+inspector — Phase 47), `POST /run` (cron reminders, `token=DIGEST_SECRET`) |
|
||||
| `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), `GET /admin/knowledge` + `/new`, `/<id>/edit`, `/<id>/delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
|
||||
| `support` | `/support` | **Customer-facing routes serve BOTH customer roles** (`_is_customer_side()`, Aug 2026) — a Customer Inspector gets the same chat, conversations and tickets, with facilities resolved through `InspectorAssignment` and a role-specific addendum on the AI system prompt. `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), `GET /admin/knowledge` + `/new`, `/<id>/edit`, `/<id>/delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
|
||||
| `enrollment` | `/enrollment` | **Self-contained onboarding intake — see §24.** `GET/POST /` (**login-free** public form), `GET /admin` (admin inbox), `GET/POST /admin/<id>` (detail + office-use fields), `GET /admin/<id>.json`, `GET /admin/export.csv`. Lives in `app/enrollment/` with its own templates; touches **no** DB table. |
|
||||
| `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) |
|
||||
@@ -1705,7 +1705,7 @@ timeout = 30
|
||||
| 94 | **A failed flag-issue POST must return a non-2xx** | The offcanvas JS branches on `res.ok`, so a 200 re-render of the invalid form reads as success: the panel closes, the page reloads, and no issue exists — with nothing in the logs and no message to the user. `flag_issue()` returns 400 on a failed POST for exactly this reason. Any future AJAX-submitted form needs the same treatment (rule 60 is the same failure seen from the other end). |
|
||||
| 91 | **A bulk-action form must live OUTSIDE the table; row checkboxes join it via the HTML5 `form=` attribute** | Wrapping the table in the bulk form nests the per-row delete/unfollow forms inside it, and browsers **silently discard** nested forms (rule 9) — the row buttons would post nothing, with no console error and no server log. `<form id="issuesBulkForm">` sits above the table and each checkbox carries `form="issuesBulkForm"`. Same for `inspectionsBulkForm`. Applies to all four list templates (classic + modern). |
|
||||
| 92 | **Bulk deletes: DB rows first, storage files second** | Collect the keys, `db.session.delete()` every row, `commit()`, and only then `storage.delete()`. Deleting files first means a failed/rolled-back commit leaves surviving rows pointing at missing photos. `_collect_inspection_photos()` is shared by the single and bulk inspection delete paths precisely so the two cannot drift — a key missed there is an invisible permanent storage leak. |
|
||||
| 89 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | The inverse of rule 87, and it fails in both directions. Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fail-OPEN, nothing errors). Narrowing an account-management check back to `'customer'` strands every Customer Inspector in a page that no longer lists or edits them (fail-closed, but invisible until someone looks for a missing account). `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query, its route guards, the `auth.list_users` exclusion, and **narrowing** uses that WITHHOLD something from an external account (`_assignable_staff_for()` uses it to hide our internal staff — safe direction, and commented as such). Everything else — portal gates, `@customer_required`, `get_customer_scope()`, support chat, `notify_customers_for_facility()`, the customer branch of every `app/api/*` scope check — keeps the equality test, because a Customer Inspector is an **inspector** there (rule 87 already routes it correctly). |
|
||||
| 89 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | The inverse of rule 87, and it fails in both directions. Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fail-OPEN, nothing errors). Narrowing an account-management check back to `'customer'` strands every Customer Inspector in a page that no longer lists or edits them (fail-closed, but invisible until someone looks for a missing account). `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query, its route guards, the `auth.list_users` exclusion, **the customer-facing support surface** (`_is_customer_side()` — both roles get the same door, then branch per role for scope and for the AI's system prompt), and **narrowing** uses that WITHHOLD something from an external account (`_assignable_staff_for()` uses it to hide our internal staff — safe direction, and commented as such). Everything else — portal gates, `@customer_required`, `get_customer_scope()`, `notify_customers_for_facility()`, the customer branch of every `app/api/*` scope check — keeps the equality test, because a Customer Inspector is an **inspector** there (rule 87 already routes it correctly). |
|
||||
| 90 | **A per-account notification opt-IN must survive a globally-OFF column** | `notify_by_matrix()` skips a role column early when the matrix says off. For the two customer columns that early `continue` has to also ask whether anyone opted in (`any(overrides.values())`), or the override saves, displays as on, and never sends — a silent failure with no error anywhere. Equally, `notify_customers_for_facility()` re-queries recipients from assignment rows, so `notify_by_matrix()` must hand it `allowed_user_ids` or the facility-scoped path bypasses every override. Both halves are needed; either one alone leaves a hole. See §11. |
|
||||
| 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. |
|
||||
|
||||
|
||||
+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