Aug 7 - Update: knowledge base MT19

This commit is contained in:
2026-08-07 17:32:43 -04:00
parent 38ab66d021
commit 3c35835505
6 changed files with 276 additions and 2 deletions
+5
View File
@@ -69,6 +69,11 @@ class SupportKnowledge(db.Model):
title = db.Column(db.String(200), nullable=False)
body = db.Column(db.Text, nullable=False)
active = db.Column(db.Boolean, default=True, nullable=False)
# MT-19 — admin-controlled ordering. Entries are injected into the support
# chat's system prompt in this order, so a low sort_order is how an admin
# promotes the guidance the assistant should reach for first. Ties break on
# id, keeping the order stable.
sort_order = db.Column(db.Integer, nullable=False, default=0)
created_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
updated_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
+28 -2
View File
@@ -91,7 +91,9 @@ FAQS = [
def _system_prompt_with_kb():
"""Return the Groq system prompt, appending active knowledge base entries."""
try:
entries = SupportKnowledge.query.filter_by(active=True).order_by(SupportKnowledge.id).all()
entries = (SupportKnowledge.query.filter_by(active=True)
.order_by(SupportKnowledge.sort_order.asc(),
SupportKnowledge.id.asc()).all())
except Exception:
return _SYSTEM_PROMPT
if not entries:
@@ -516,10 +518,31 @@ def admin_conversation_detail(session_id):
@login_required
@supervisor_required
def admin_knowledge():
entries = SupportKnowledge.query.order_by(SupportKnowledge.created_at.desc()).all()
# Same order the chat prompt uses, so the admin list shows the real
# priority rather than a different one.
entries = (SupportKnowledge.query
.order_by(SupportKnowledge.sort_order.asc(),
SupportKnowledge.id.asc()).all())
return render_template('support/admin_knowledge.html', entries=entries)
def _parse_sort_order(raw, fallback=0):
"""Coerce a submitted sort_order to a sane int.
The column is NOT NULL, so a blank or non-numeric field must not reach the
DB. Clamped to 0..9999 to match the range ST validates, and falls back to
the existing value on edit so a blank field means "leave it alone" rather
than silently resetting the entry to the top.
"""
raw = (raw or '').strip()
if not raw:
return fallback
try:
return max(0, min(9999, int(raw)))
except (TypeError, ValueError):
return fallback
@bp.route('/admin/knowledge/add', methods=['POST'])
@login_required
@supervisor_required
@@ -534,6 +557,7 @@ def admin_knowledge_add():
title = title,
body = body,
active = True,
sort_order = _parse_sort_order(request.form.get('sort_order')),
created_by = current_user.id,
created_at = now_eastern(),
updated_at = now_eastern(),
@@ -561,6 +585,8 @@ def admin_knowledge_edit(entry_id):
return redirect(url_for('support.admin_knowledge_edit', entry_id=entry_id))
entry.title = title
entry.body = body
entry.sort_order = _parse_sort_order(request.form.get('sort_order'),
entry.sort_order)
entry.updated_at = now_eastern()
db.session.commit()
log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, title[:60], 'edited')
@@ -34,6 +34,12 @@
placeholder="Add facts, FAQs, or instructions the AI should know about this customer's account…"></textarea>
<div class="form-text">Keep entries focused and factual. Combined active entries are capped at 6,000 characters.</div>
</div>
<div class="mb-3" style="max-width:200px;">
<label class="form-label fw-semibold">Sort Order</label>
<input type="number" name="sort_order" class="form-control"
min="0" max="9999" step="1" value="0">
<div class="form-text">Lower numbers are sent to the assistant first. Leave at 0 unless an entry should take priority.</div>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-plus me-1"></i>Add Entry
</button>
@@ -54,6 +60,7 @@
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1 me-3">
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge bg-light text-dark border" title="Sort order">#{{ entry.sort_order }}</span>
<span class="fw-semibold">{{ entry.title }}</span>
{% if entry.active %}
<span class="badge bg-success">Active</span>
@@ -23,6 +23,12 @@
<textarea name="body" class="form-control" rows="8" required>{{ entry.body }}</textarea>
<div class="form-text">Combined active entries are capped at 6,000 characters in the AI prompt.</div>
</div>
<div class="mb-3" style="max-width:200px;">
<label class="form-label fw-semibold">Sort Order</label>
<input type="number" name="sort_order" class="form-control"
min="0" max="9999" step="1" value="{{ entry.sort_order }}">
<div class="form-text">Lower numbers are sent to the assistant first. Leave at 0 unless an entry should take priority.</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save Changes
@@ -0,0 +1,69 @@
"""phase53 — admin-controlled ordering for support knowledge entries
Adds to `support_knowledge`:
sort_order INT NOT NULL DEFAULT 0
Knowledge entries are injected into the support chat's Groq system prompt, and
the combined text is capped, so the ORDER entries appear in decides which
guidance the assistant reaches for first and which is truncated away. Before
this the order was `id` — i.e. whenever the entry happened to be created — with
no way for an admin to promote an important entry short of deleting and
re-adding it.
Ordering is `sort_order ASC, id ASC` everywhere it is read, so ties break
deterministically and existing entries (all defaulting to 0) keep their current
relative order on upgrade. Nothing is reshuffled by running this.
Uses an INFORMATION_SCHEMA existence check — safe to re-run.
"""
revision = 'phase53_knowledge_sort_order'
down_revision = 'phase52_user_ui_theme'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def _column_exists(conn, table, column):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
), {"t": table, "c": column}).scalar() > 0
def _table_exists(conn, table):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
), {"t": table}).scalar() > 0
def upgrade():
bind = op.get_bind()
# Guarded on the table too: support_knowledge arrived in phase40, and a
# tenant provisioned from an older baseline could reach this revision
# without it.
if not _table_exists(bind, 'support_knowledge'):
return
if not _column_exists(bind, 'support_knowledge', 'sort_order'):
op.execute(sa.text(
"ALTER TABLE support_knowledge "
"ADD COLUMN sort_order INT NOT NULL DEFAULT 0"
))
# Idempotent repair only — never resets a deliberate ordering.
op.execute(sa.text(
"UPDATE support_knowledge SET sort_order = 0 WHERE sort_order IS NULL"
))
def downgrade():
bind = op.get_bind()
if (_table_exists(bind, 'support_knowledge')
and _column_exists(bind, 'support_knowledge', 'sort_order')):
op.execute(sa.text(
"ALTER TABLE support_knowledge DROP COLUMN sort_order"
))
+161
View File
@@ -0,0 +1,161 @@
"""
tests/test_knowledge_sort_order.py
----------------------------------
Behaviour tests for MT-19 admin-controlled ordering of support knowledge.
Knowledge entries are injected into the support chat's Groq system prompt and
the combined text is capped, so ORDER decides which guidance the assistant
reaches for first and which gets truncated away. These tests pin that the
column actually drives the prompt, not just the admin table.
"""
import pytest
@pytest.fixture
def client(app):
with app.app_context():
from app import db
from app.models import inspector_assignment # noqa: F401
db.drop_all()
db.create_all()
yield app.test_client()
db.session.remove()
def _user(username, role):
from app import db
from app.models.user import User
u = User(username=username, full_name=username.title(), role=role,
email=f'{username}@example.com', active=True)
u.set_password('pw-correct1')
db.session.add(u)
db.session.commit()
return u
def _login(client, user):
resp = client.post('/auth/login',
data={'username': user.username, 'password': 'pw-correct1'},
follow_redirects=True)
assert 'Login - ' not in resp.get_data(as_text=True), 'login failed'
return resp
def _kb(title, body, sort_order=0, active=True):
from app import db
from app.models.support import SupportKnowledge
from app.utils.time_utils import now_eastern
e = SupportKnowledge(title=title, body=body, active=active,
sort_order=sort_order,
created_at=now_eastern(), updated_at=now_eastern())
db.session.add(e)
db.session.commit()
return e
def test_default_sort_order_is_zero(client):
from app import db
from app.models.support import SupportKnowledge
from app.utils.time_utils import now_eastern
e = SupportKnowledge(title='T', body='B', active=True,
created_at=now_eastern(), updated_at=now_eastern())
db.session.add(e)
db.session.commit()
assert e.sort_order == 0
def test_sort_order_drives_the_chat_prompt(client):
"""The behaviour this phase exists for: a low sort_order promotes an entry
in the text handed to the assistant, regardless of creation order."""
from app.routes.support import _system_prompt_with_kb
_kb('Created First', 'AAA-FIRST', sort_order=10)
_kb('Created Second', 'ZZZ-SECOND', sort_order=1)
prompt = _system_prompt_with_kb()
assert prompt.index('ZZZ-SECOND') < prompt.index('AAA-FIRST'), (
'sort_order did not reorder the prompt')
def test_ties_break_on_id_so_order_is_stable(client):
from app.routes.support import _system_prompt_with_kb
_kb('Alpha', 'AAA-ALPHA', sort_order=5)
_kb('Beta', 'BBB-BETA', sort_order=5)
prompt = _system_prompt_with_kb()
assert prompt.index('AAA-ALPHA') < prompt.index('BBB-BETA')
def test_inactive_entries_stay_out_of_the_prompt(client):
"""Ordering must not have widened what reaches the assistant."""
from app.routes.support import _system_prompt_with_kb
_kb('Live', 'AAA-LIVE', sort_order=9)
_kb('Disabled', 'ZZZ-DISABLED', sort_order=0, active=False)
prompt = _system_prompt_with_kb()
assert 'AAA-LIVE' in prompt
assert 'ZZZ-DISABLED' not in prompt
def test_admin_can_set_sort_order_on_add(client):
from app.models.support import SupportKnowledge
admin = _user('ada', 'admin')
_login(client, admin)
client.post('/support/admin/knowledge/add',
data={'title': 'Priority', 'body': 'Body text', 'sort_order': '3'},
follow_redirects=True)
entry = SupportKnowledge.query.filter_by(title='Priority').first()
assert entry is not None
assert entry.sort_order == 3
def test_blank_sort_order_on_edit_keeps_the_existing_value(client):
"""The column is NOT NULL and blank must mean 'leave it alone', not
'silently promote this entry to the top'."""
from app.models.support import SupportKnowledge
admin = _user('ada', 'admin')
entry = _kb('Keep', 'Body', sort_order=7)
_login(client, admin)
client.post(f'/support/admin/knowledge/{entry.id}/edit',
data={'title': 'Keep', 'body': 'Body', 'sort_order': ''},
follow_redirects=True)
from app import db
assert db.session.get(SupportKnowledge, entry.id).sort_order == 7
@pytest.mark.parametrize('raw,expected', [
('abc', 0), # non-numeric
('-5', 0), # clamped low
('99999', 9999), # clamped high
])
def test_bad_sort_order_never_reaches_a_not_null_column(client, raw, expected):
from app.models.support import SupportKnowledge
admin = _user('ada', 'admin')
_login(client, admin)
client.post('/support/admin/knowledge/add',
data={'title': f'E{raw}', 'body': 'B', 'sort_order': raw},
follow_redirects=True)
entry = SupportKnowledge.query.filter_by(title=f'E{raw}').first()
assert entry is not None, 'entry was rejected instead of coerced'
assert entry.sort_order == expected
def test_admin_list_orders_the_same_way_as_the_prompt(client):
"""The admin must see the real priority, not a different order."""
admin = _user('ada', 'admin')
_kb('Zebra Entry', 'B', sort_order=1)
_kb('Apple Entry', 'B', sort_order=9)
_login(client, admin)
body = client.get('/support/admin/knowledge').get_data(as_text=True)
assert body.index('Zebra Entry') < body.index('Apple Entry')