Aug 12 - Fix AI support bugs

This commit is contained in:
2026-08-12 09:27:15 -04:00
parent 59338f678d
commit 3209a0c717
3 changed files with 169 additions and 23 deletions
+79 -16
View File
@@ -101,9 +101,20 @@ to third parties. They download the app, complete the enrollment form, and they
ready to go. ready to go.
Such a person is enrolled with an inspecting role of their own (rather than the \ Such a person is enrolled with an inspecting role of their own (rather than the \
read-only customer portal role), scoped to that customer's own contracts and facilities. \ read-only customer portal role), scoped to that customer's own contracts and facilities. \
Enrollment is the JQC Enrollment Form, reachable from the About Us page ("Enroll More \ See ENROLLING MORE PEOPLE below for the form.
People"), where you list each person, their role, and what they should be able to do. \
Each person then receives an email invitation to set up their own username and password. === ENROLLING MORE PEOPLE (adding users) ===
To add colleagues to JQC, use the JQC Enrollment Form:
https://jqc.ltservicesinc.com/enrollment
It is also linked from the About Us page in the portal ("Enroll More People"). No login \
is needed to fill it in, so it can be forwarded to anyone who needs to be set up.
On the form you list each person (name, job title, email and the role they need), tick \
what each of them should be able to do, and say who should get the mobile app. After \
submitting, the requester receives a confirmation email with a reference number, and \
each person listed then receives their own email invitation to choose a username and \
password.
When a customer asks how to add a user, get the app, or give someone inspecting access, \
give them this URL. It is the correct link — do not alter it or invent another one.
=== GETTING HUMAN HELP === === GETTING HUMAN HELP ===
If the customer needs something this chat can't resolve — an access/login problem, a \ If the customer needs something this chat can't resolve — an access/login problem, a \
@@ -113,11 +124,16 @@ opens a request that the provider's admin team answers by email and in "My Reque
=== STYLE & RULES === === STYLE & RULES ===
- Be concise, warm, and practical. Prefer short paragraphs or numbered steps. - Be concise, warm, and practical. Prefer short paragraphs or numbered steps.
- Ground answers in the features above. If you are not sure or the app may differ, say \ - Ground answers in everything above, INCLUDING the "ADDITIONAL KNOWLEDGE" section when \
so honestly rather than guessing — and suggest "Submit to Support". one is present — that section is curated by the JQC team and is authoritative. If it \
answers the question, use it. If you are not sure or the app may differ, say so \
honestly rather than guessing — and suggest "Submit to Support".
- 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, cleaning schedules, phone numbers, \ - NEVER invent specific staff names, contract prices, cleaning schedules, phone numbers, \
facility data, or scores. You do not have access to the customer's live data — guide \ facility data, or scores that are NOT given above. You do not have access to the \
them to where to find it in the portal instead. customer's live data — guide them to where to find it in the portal instead.
- Do not claim to perform actions yourself; explain where in the portal the customer does it.\ - Do not claim to perform actions yourself; explain where in the portal the customer does it.\
""" """
@@ -166,30 +182,55 @@ def _redact_pii(text):
return redacted return redacted
#: The curated knowledge is spliced in immediately BEFORE this heading, not
#: appended to the end of the prompt. 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 = '=== STYLE & RULES ==='
def _system_prompt_with_kb(): def _system_prompt_with_kb():
"""Return the base system prompt plus all ACTIVE admin knowledge entries """Return the base system prompt with all ACTIVE admin knowledge entries
(phase38), so staff can curate the chatbot's knowledge without code changes. (phase38) spliced in, so staff can curate the chatbot's knowledge without
Best-effort — a KB failure never breaks the chat.""" code changes. Best-effort — a KB failure never breaks the chat."""
prompt = _SYSTEM_PROMPT
try: try:
entries = (SupportKnowledge.query entries = (SupportKnowledge.query
.filter_by(active=True) .filter_by(active=True)
.order_by(SupportKnowledge.sort_order.asc(), SupportKnowledge.id.asc()) .order_by(SupportKnowledge.sort_order.asc(), SupportKnowledge.id.asc())
.all()) .all())
if entries: if not entries:
parts = ["\n\n=== ADDITIONAL KNOWLEDGE (curated by the JQC team; " logger.info('SUPPORT | KB | no active entries — base prompt only')
"treat as authoritative and prefer it over general guesses) ==="] return _SYSTEM_PROMPT
parts = ['=== ADDITIONAL KNOWLEDGE (curated by the JQC team; authoritative — '
'prefer it over general guesses, and quote any link in it exactly) ===']
total = 0 total = 0
used = 0
for e in entries: for e in entries:
block = f"\n\nTopic: {e.title}\n{e.content.strip()}" block = f"\n\nTopic: {e.title}\n{e.content.strip()}"
if total + len(block) > _KB_MAX_CHARS: 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 break
parts.append(block) parts.append(block)
total += len(block) total += len(block)
prompt += ''.join(parts) 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: except Exception as exc:
logger.warning('SUPPORT | knowledge-base load failed: %s', exc) logger.warning('SUPPORT | knowledge-base load failed: %s', exc)
return prompt return _SYSTEM_PROMPT
# ── Customer chat page ──────────────────────────────────────────────────────── # ── Customer chat page ────────────────────────────────────────────────────────
@@ -571,6 +612,28 @@ def admin_knowledge():
entries=entries, groq_ready=groq_ready) entries=entries, groq_ready=groq_ready)
@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)
@bp.route('/admin/knowledge/new', methods=['GET', 'POST']) @bp.route('/admin/knowledge/new', methods=['GET', 'POST'])
@login_required @login_required
@supervisor_required @supervisor_required
@@ -11,6 +11,10 @@
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm"> <a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-life-preserver me-1"></i>Support Tickets <i class="bi bi-life-preserver me-1"></i>Support Tickets
</a> </a>
<a href="{{ url_for('support.admin_knowledge_preview') }}" class="btn btn-outline-primary btn-sm"
title="See the exact prompt the chatbot receives, with your entries in it">
<i class="bi bi-eye me-1"></i>What the AI Sees
</a>
<a href="{{ url_for('support.admin_knowledge_new') }}" class="btn btn-primary btn-sm"> <a href="{{ url_for('support.admin_knowledge_new') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>Add Entry <i class="bi bi-plus-circle me-1"></i>Add Entry
</a> </a>
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}What the AI Sees{% endblock %}
{# Read-only view of the assembled system prompt. Exists so an admin can tell
"my knowledge entry never reached the prompt" apart from "the model saw it
and chose not to use it" — the two have completely different fixes. #}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
<h2 class="mb-0"><i class="bi bi-eye"></i> What the AI Sees</h2>
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Knowledge Base
</a>
</div>
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-4 fw-bold">{{ active_count }}</div>
<div class="text-muted small">Active entries</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-4 fw-bold">{{ total_count - active_count }}</div>
<div class="text-muted small">Inactive (not sent)</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-4 fw-bold">{{ prompt | length }}</div>
<div class="text-muted small">Prompt characters</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
{% if kb_included %}
<div class="fs-4 fw-bold text-success"><i class="bi bi-check-circle"></i></div>
<div class="text-muted small">Knowledge included</div>
{% else %}
<div class="fs-4 fw-bold text-danger"><i class="bi bi-x-circle"></i></div>
<div class="text-muted small">Knowledge NOT included</div>
{% endif %}
</div>
</div>
</div>
</div>
{% if not kb_included and total_count %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-1"></i>
You have {{ total_count }} knowledge entr{{ 'y' if total_count == 1 else 'ies' }}, but
none reached the prompt. Check that at least one is marked <strong>Active</strong>.
</div>
{% endif %}
<div class="alert alert-info">
<i class="bi bi-info-circle me-1"></i>
This is the exact text sent to the AI ahead of every customer question. Entries are
capped at {{ kb_cap }} characters in total — past that, later entries are dropped
(lowest sort order is kept first). If something you wrote appears here but the AI still
will not say it, the wording of the entry is the thing to change, not the setup.
</div>
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">Assembled system prompt</div>
<div class="card-body p-0">
<pre class="mb-0 p-3" style="white-space:pre-wrap; font-size:.8rem; max-height:70vh;
overflow-y:auto; background:#f8fafc;">{{ prompt }}</pre>
</div>
</div>
{% endblock %}