Aug 19 - Update code to catch up with ST

This commit is contained in:
2026-08-19 14:05:18 -04:00
parent c9984e7ae6
commit 12141c2f75
52 changed files with 3321 additions and 342 deletions
+202 -26
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
@@ -69,6 +69,12 @@ Help customers with:
Rules:
- Keep answers concise (3-5 sentences max) and friendly.
- Ground answers in everything above, INCLUDING the "ADDITIONAL KNOWLEDGE" section when \
one is present — that section is curated by the provider's team and is authoritative. \
If it answers the question, use it.
- When the knowledge above contains a link (URL), email address or exact wording, quote \
it EXACTLY as written. Repeating something given to you here is not inventing — do it \
freely. Never alter a URL, shorten it, or replace it with a description.
- Never invent specific staff names, contract prices, schedules, or contact numbers.
- If the customer has an access problem, billing question, or a concern you genuinely \
cannot resolve through guidance, say so clearly and suggest they click \
@@ -85,23 +91,161 @@ FAQS = [
{'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'},
{'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'},
{'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'},
{'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?"},
]
#: Groq model used when GROQ_MODEL is unset. Verified available Aug 2026.
#: Groq RETIRES models without notice, and when the configured one disappears
#: every question fails with the generic "problem reaching the AI assistant"
#: reply — invisible until a customer complains. That is exactly how
#: llama-3.3-70b-versatile took the chat down. See the error handler in
#: chat_message(): it names the model and says to set GROQ_MODEL, which fixes
#: it with an env change and a restart — no deploy.
_DEFAULT_GROQ_MODEL = 'openai/gpt-oss-120b'
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 _system_prompt_for) — 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.
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.
"""
#: The curated knowledge is spliced in immediately BEFORE this heading, not
#: appended after it. The rules under it say "ground answers in everything
#: above", so knowledge appended after them was, by the prompt's own
#: instruction, out of scope — which is exactly why admin KB entries appeared
#: to be ignored. Keep this marker in sync with the heading in _SYSTEM_PROMPT.
_STYLE_MARKER = 'Rules:'
def _system_prompt_for(user):
"""Base prompt + curated knowledge, plus the addendum for this user's role.
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 Groq system prompt, appending active knowledge base entries."""
"""Return the Groq system prompt with active knowledge entries spliced in.
Best-effort — a knowledge-base failure never breaks the chat.
"""
try:
entries = (SupportKnowledge.query.filter_by(active=True)
.order_by(SupportKnowledge.sort_order.asc(),
SupportKnowledge.id.asc()).all())
except Exception:
if not entries:
logger.info('SUPPORT | KB | no active entries — base prompt only')
return _SYSTEM_PROMPT
parts = ['=== ADDITIONAL KNOWLEDGE (curated by the provider team; authoritative '
'— prefer it over general guesses, and quote any link in it exactly) ===']
total = 0
used = 0
for e in entries:
block = f'\n\nTopic: {e.title}\n{(e.body or "").strip()}'
if total + len(block) > _KB_MAX_CHARS:
logger.warning('SUPPORT | KB | %d of %d entries dropped — %d char cap '
'reached', len(entries) - used, len(entries), _KB_MAX_CHARS)
break
parts.append(block)
total += len(block)
used += 1
kb_block = ''.join(parts)
idx = _SYSTEM_PROMPT.find(_STYLE_MARKER)
if idx == -1: # marker renamed — fall back to append
logger.warning('SUPPORT | KB | style marker not found; appending at end')
prompt = f'{_SYSTEM_PROMPT}\n\n{kb_block}'
else:
prompt = f'{_SYSTEM_PROMPT[:idx]}{kb_block}\n\n{_SYSTEM_PROMPT[idx:]}'
logger.info('SUPPORT | KB | %d/%d entries injected (%d chars), prompt=%d chars',
used, len(entries), total, len(prompt))
return prompt
except Exception as exc:
logger.warning('SUPPORT | knowledge-base load failed: %s', exc)
return _SYSTEM_PROMPT
if not entries:
return _SYSTEM_PROMPT
kb_text = '\n\n'.join(f'[{e.title}]\n{e.body}' for e in entries)
if len(kb_text) > _KB_MAX_CHARS:
kb_text = kb_text[:_KB_MAX_CHARS] + '\n…(truncated)'
return _SYSTEM_PROMPT + '\n\n# Additional Context\n' + kb_text
# ── Customer chat page ────────────────────────────────────────────────────────
@@ -109,13 +253,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)
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
session_id = request.args.get('session_id', type=int)
@@ -130,8 +271,9 @@ def chat():
if chat_session:
db_history = list(chat_session.messages)
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=chat_session,
@@ -143,7 +285,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
api_key = os.environ.get('GROQ_API_KEY')
@@ -192,14 +334,14 @@ 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)}]
# Redact before the text leaves the app for Groq. The unredacted
# originals are persisted below, so nothing is lost in-app.
for m in prior[-20:]:
messages.append({'role': m.role, 'content': _redact_pii(m.content)})
messages.append({'role': 'user', 'content': _redact_pii(user_message)})
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
model = os.environ.get('GROQ_MODEL', _DEFAULT_GROQ_MODEL)
completion = client.chat.completions.create(
model=model,
messages=messages,
@@ -220,7 +362,18 @@ def chat_message():
return jsonify({'reply': reply, 'session_id': chat_session.id})
except Exception as exc:
logger.error('SUPPORT | Groq error: %s', exc)
# Always name the model — a bare "Groq error" gives whoever reads the
# log nothing to act on, and a retired model is the most likely cause
# of a total outage here.
_model = locals().get('model') or os.environ.get('GROQ_MODEL', _DEFAULT_GROQ_MODEL)
if 'model_not_found' in str(exc) or 'does not exist' in str(exc):
logger.error(
'SUPPORT | Groq model %r is not available on this account — '
'the assistant is DOWN for every user. Set GROQ_MODEL to a '
'current model (see https://console.groq.com/docs/models). '
'Underlying error: %s', _model, exc)
else:
logger.error('SUPPORT | Groq error (model=%r): %s', _model, exc)
db.session.rollback()
return jsonify({'reply': (
"I ran into a problem reaching the AI assistant. "
@@ -233,7 +386,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)
@@ -245,7 +398,7 @@ def my_conversations():
@bp.route('/my-conversations/<int:session_id>')
@login_required
def my_conversation_detail(session_id):
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
chat_session = db.session.get(SupportChatSession, session_id)
if chat_session is None or chat_session.customer_id != current_user.id:
@@ -261,7 +414,7 @@ def my_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()
@@ -272,8 +425,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 the 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
@@ -303,7 +457,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
@@ -318,7 +472,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)
@@ -526,6 +680,28 @@ def admin_knowledge():
return render_template('support/admin_knowledge.html', entries=entries)
@bp.route('/admin/knowledge/preview')
@login_required
@supervisor_required
def admin_knowledge_preview():
"""Show the exact system prompt the chatbot receives, knowledge included.
Added after admin entries appeared to be ignored: without this there is no
way to tell "my entry never reached the prompt" from "the model saw it and
chose not to use it". Read-only, builds nothing of its own — it calls the
same _system_prompt_with_kb() the chat endpoint calls.
"""
prompt = _system_prompt_with_kb()
active_count = SupportKnowledge.query.filter_by(active=True).count()
total_count = SupportKnowledge.query.count()
return render_template('support/admin_knowledge_preview.html',
prompt=prompt,
active_count=active_count,
total_count=total_count,
kb_included='=== ADDITIONAL KNOWLEDGE' in prompt,
kb_cap=_KB_MAX_CHARS)
def _parse_sort_order(raw, fallback=0):
"""Coerce a submitted sort_order to a sane int.