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)
|
title = db.Column(db.String(200), nullable=False)
|
||||||
body = db.Column(db.Text, nullable=False)
|
body = db.Column(db.Text, nullable=False)
|
||||||
active = db.Column(db.Boolean, default=True, 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_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)
|
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||||
updated_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():
|
def _system_prompt_with_kb():
|
||||||
"""Return the Groq system prompt, appending active knowledge base entries."""
|
"""Return the Groq system prompt, appending active knowledge base entries."""
|
||||||
try:
|
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:
|
except Exception:
|
||||||
return _SYSTEM_PROMPT
|
return _SYSTEM_PROMPT
|
||||||
if not entries:
|
if not entries:
|
||||||
@@ -516,10 +518,31 @@ def admin_conversation_detail(session_id):
|
|||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@supervisor_required
|
||||||
def admin_knowledge():
|
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)
|
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'])
|
@bp.route('/admin/knowledge/add', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@supervisor_required
|
||||||
@@ -534,6 +557,7 @@ def admin_knowledge_add():
|
|||||||
title = title,
|
title = title,
|
||||||
body = body,
|
body = body,
|
||||||
active = True,
|
active = True,
|
||||||
|
sort_order = _parse_sort_order(request.form.get('sort_order')),
|
||||||
created_by = current_user.id,
|
created_by = current_user.id,
|
||||||
created_at = now_eastern(),
|
created_at = now_eastern(),
|
||||||
updated_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))
|
return redirect(url_for('support.admin_knowledge_edit', entry_id=entry_id))
|
||||||
entry.title = title
|
entry.title = title
|
||||||
entry.body = body
|
entry.body = body
|
||||||
|
entry.sort_order = _parse_sort_order(request.form.get('sort_order'),
|
||||||
|
entry.sort_order)
|
||||||
entry.updated_at = now_eastern()
|
entry.updated_at = now_eastern()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, title[:60], 'edited')
|
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>
|
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 class="form-text">Keep entries focused and factual. Combined active entries are capped at 6,000 characters.</div>
|
||||||
</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">
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
<i class="bi bi-plus me-1"></i>Add Entry
|
<i class="bi bi-plus me-1"></i>Add Entry
|
||||||
</button>
|
</button>
|
||||||
@@ -54,6 +60,7 @@
|
|||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div class="flex-grow-1 me-3">
|
<div class="flex-grow-1 me-3">
|
||||||
<div class="d-flex align-items-center gap-2 mb-1">
|
<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>
|
<span class="fw-semibold">{{ entry.title }}</span>
|
||||||
{% if entry.active %}
|
{% if entry.active %}
|
||||||
<span class="badge bg-success">Active</span>
|
<span class="badge bg-success">Active</span>
|
||||||
|
|||||||
@@ -23,6 +23,12 @@
|
|||||||
<textarea name="body" class="form-control" rows="8" required>{{ entry.body }}</textarea>
|
<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 class="form-text">Combined active entries are capped at 6,000 characters in the AI prompt.</div>
|
||||||
</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">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-check-lg me-1"></i>Save Changes
|
<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