Aug 7 - Update: knowledge base MT19
This commit is contained in:
@@ -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
@@ -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"
|
||||
))
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user