Aug 19 - Update code to catch up with ST
This commit is contained in:
+202
-26
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user