feat: KB Accelerator — convert KB articles into interactive flows
Full-stack implementation of the KB Accelerator feature that converts static MSP knowledge base articles into interactive troubleshooting and procedural flows using AI. Backend: - Migrations 054/055: kb_imports, kb_import_nodes tables + plan_limits KB columns - SQLAlchemy models with relationships and self-referential node hierarchy - Text extraction service (txt, paste, docx with structural metadata) - AI conversion service with MSP-specialist prompts for both flow types - 8 API endpoints: upload, get, list, convert, edit node, commit, delete, quota - Tier-gated access via plan_limits (free: 3 lifetime, pro/team: unlimited) - 8 integration tests covering upload, get/list, quota, commit, delete Frontend: - TypeScript types and API client for all KB Accelerator endpoints - Multi-step wizard page: upload → processing → review → success - Upload screen with paste/file tabs, drag-drop, target type selector - Two-panel review screen with source highlighting and node cards - Per-node actions: approve, edit, regenerate, insert, delete - Confidence color indicators (green/amber/red) - Sidebar navigation with Sparkles icon - Code-split lazy-loaded route at /kb-accelerator Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
334
backend/tests/test_kb_accelerator.py
Normal file
334
backend/tests/test_kb_accelerator.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""Integration tests for KB Accelerator endpoints."""
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch, PropertyMock
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ── Fixtures ──
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def kb_setup(client, auth_headers, test_db):
|
||||
"""Seed KB plan limits and return helpers."""
|
||||
# Update plan_limits with KB columns for 'free' plan
|
||||
await test_db.execute(
|
||||
__import__("sqlalchemy").text("""
|
||||
UPDATE plan_limits SET
|
||||
kb_accelerator_enabled = true,
|
||||
kb_max_lifetime_conversions = 3,
|
||||
kb_allowed_formats = '["txt","paste"]'::jsonb,
|
||||
kb_detailed_analysis = false,
|
||||
kb_conversational_refinement = false,
|
||||
kb_step_library_matching = false,
|
||||
kb_history_limit = 3
|
||||
WHERE plan = 'free'
|
||||
""")
|
||||
)
|
||||
await test_db.execute(
|
||||
__import__("sqlalchemy").text("""
|
||||
UPDATE plan_limits SET
|
||||
kb_accelerator_enabled = true,
|
||||
kb_max_lifetime_conversions = NULL,
|
||||
kb_allowed_formats = '["txt","paste","docx","pdf","html","md"]'::jsonb,
|
||||
kb_detailed_analysis = true,
|
||||
kb_conversational_refinement = true,
|
||||
kb_step_library_matching = true,
|
||||
kb_history_limit = NULL
|
||||
WHERE plan = 'pro'
|
||||
""")
|
||||
)
|
||||
await test_db.commit()
|
||||
return {"client": client, "headers": auth_headers}
|
||||
|
||||
|
||||
def _mock_ai_enabled():
|
||||
"""Context manager to mock AI as enabled."""
|
||||
return patch.object(
|
||||
type(__import__("app.core.config", fromlist=["settings"]).settings),
|
||||
"ai_enabled",
|
||||
new_callable=PropertyMock,
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_KB_TEXT = """
|
||||
Troubleshooting Outlook Connectivity Issues
|
||||
|
||||
Problem: Users report that Outlook keeps disconnecting from Exchange.
|
||||
|
||||
Step 1: Check Network Connectivity
|
||||
Ping the Exchange server to verify network connectivity.
|
||||
If ping fails, check the network configuration.
|
||||
|
||||
Step 2: Verify Outlook Profile
|
||||
If the network is working, check the Outlook profile settings.
|
||||
Go to Control Panel > Mail > Show Profiles.
|
||||
|
||||
Step 3: Check Exchange Server
|
||||
If the profile is correct, verify the Exchange server is running.
|
||||
Open Services.msc and check Microsoft Exchange services.
|
||||
|
||||
Resolution: After following these steps, Outlook should maintain
|
||||
a persistent connection to Exchange.
|
||||
"""
|
||||
|
||||
MOCK_AI_TROUBLESHOOTING_RESPONSE = json.dumps({
|
||||
"title": "Troubleshooting Outlook Connectivity",
|
||||
"description": "Diagnose and fix Outlook disconnection from Exchange",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "root-check",
|
||||
"type": "question",
|
||||
"question": "Is the network connection working?",
|
||||
"options": [
|
||||
{"label": "Yes", "next_node_id": "check-profile"},
|
||||
{"label": "No", "next_node_id": "fix-network"},
|
||||
],
|
||||
"confidence": 0.92,
|
||||
"source_excerpt": "Step 1: Check Network Connectivity",
|
||||
},
|
||||
{
|
||||
"id": "fix-network",
|
||||
"type": "resolution",
|
||||
"question": "Fix the network configuration and retry.",
|
||||
"confidence": 0.85,
|
||||
"source_excerpt": "If ping fails, check the network configuration.",
|
||||
},
|
||||
{
|
||||
"id": "check-profile",
|
||||
"type": "question",
|
||||
"question": "Is the Outlook profile configured correctly?",
|
||||
"options": [
|
||||
{"label": "Yes", "next_node_id": "check-exchange"},
|
||||
{"label": "No", "next_node_id": "fix-profile"},
|
||||
],
|
||||
"confidence": 0.88,
|
||||
"source_excerpt": "Step 2: Verify Outlook Profile",
|
||||
},
|
||||
{
|
||||
"id": "fix-profile",
|
||||
"type": "resolution",
|
||||
"question": "Reconfigure the Outlook profile via Control Panel > Mail.",
|
||||
"confidence": 0.90,
|
||||
"source_excerpt": "Go to Control Panel > Mail > Show Profiles.",
|
||||
},
|
||||
{
|
||||
"id": "check-exchange",
|
||||
"type": "resolution",
|
||||
"question": "Verify Exchange services are running in Services.msc.",
|
||||
"confidence": 0.87,
|
||||
"source_excerpt": "Open Services.msc and check Microsoft Exchange services.",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
MOCK_AI_PROCEDURAL_RESPONSE = json.dumps({
|
||||
"title": "Setup New Domain Controller",
|
||||
"description": "Step-by-step procedure for setting up a new DC",
|
||||
"steps": [
|
||||
{
|
||||
"id": "step-1",
|
||||
"type": "step",
|
||||
"content": "Open Server Manager on [VAR:server_name]",
|
||||
"confidence": 0.95,
|
||||
"source_excerpt": "Step 1: Open Server Manager on DC01",
|
||||
},
|
||||
{
|
||||
"id": "warning-dns",
|
||||
"type": "warning",
|
||||
"content": "WARNING: This will restart DNS and cause brief connectivity loss",
|
||||
"confidence": 0.90,
|
||||
"source_excerpt": "Note: Restarting DNS will cause a brief outage",
|
||||
},
|
||||
{
|
||||
"id": "step-2",
|
||||
"type": "step",
|
||||
"content": "Configure IP address [VAR:ip_address] on the network adapter",
|
||||
"confidence": 0.88,
|
||||
"source_excerpt": "Configure IP 192.168.1.10 on the adapter",
|
||||
},
|
||||
],
|
||||
"intake_form": [
|
||||
{
|
||||
"variable_name": "server_name",
|
||||
"label": "Server Name",
|
||||
"field_type": "text",
|
||||
"required": True,
|
||||
"display_order": 1,
|
||||
},
|
||||
{
|
||||
"variable_name": "ip_address",
|
||||
"label": "IP Address",
|
||||
"field_type": "text",
|
||||
"required": True,
|
||||
"display_order": 2,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
# ── Upload Tests ──
|
||||
|
||||
|
||||
class TestUpload:
|
||||
async def test_upload_text_paste(self, kb_setup):
|
||||
"""Upload via text paste creates a kb_import in processing status."""
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
|
||||
with _mock_ai_enabled():
|
||||
# Mock the background conversion (don't actually call AI)
|
||||
with patch("app.api.endpoints.kb_accelerator._run_conversion"):
|
||||
resp = await c.post(
|
||||
"/api/v1/kb-accelerator/upload",
|
||||
data={"content": SAMPLE_KB_TEXT, "target_type": "troubleshooting"},
|
||||
headers=h,
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["status"] == "processing"
|
||||
assert data["source_format"] == "paste"
|
||||
assert "id" in data
|
||||
|
||||
async def test_upload_empty_content_rejected(self, kb_setup):
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
with _mock_ai_enabled():
|
||||
resp = await c.post(
|
||||
"/api/v1/kb-accelerator/upload",
|
||||
data={"content": "short"},
|
||||
headers=h,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_upload_no_file_no_content_rejected(self, kb_setup):
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
with _mock_ai_enabled():
|
||||
resp = await c.post(
|
||||
"/api/v1/kb-accelerator/upload",
|
||||
data={},
|
||||
headers=h,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── Get/List Tests ──
|
||||
|
||||
|
||||
class TestGetList:
|
||||
async def test_get_import(self, kb_setup):
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
|
||||
with _mock_ai_enabled(), patch("app.api.endpoints.kb_accelerator._run_conversion"):
|
||||
create_resp = await c.post(
|
||||
"/api/v1/kb-accelerator/upload",
|
||||
data={"content": SAMPLE_KB_TEXT, "target_type": "troubleshooting"},
|
||||
headers=h,
|
||||
)
|
||||
import_id = create_resp.json()["id"]
|
||||
|
||||
resp = await c.get(f"/api/v1/kb-accelerator/{import_id}", headers=h)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == import_id
|
||||
assert data["source_format"] == "paste"
|
||||
|
||||
async def test_list_imports(self, kb_setup):
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
|
||||
with _mock_ai_enabled(), patch("app.api.endpoints.kb_accelerator._run_conversion"):
|
||||
await c.post(
|
||||
"/api/v1/kb-accelerator/upload",
|
||||
data={"content": SAMPLE_KB_TEXT, "target_type": "troubleshooting"},
|
||||
headers=h,
|
||||
)
|
||||
|
||||
resp = await c.get("/api/v1/kb-accelerator", headers=h)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
assert len(data["items"]) >= 1
|
||||
|
||||
|
||||
# ── Quota Tests ──
|
||||
|
||||
|
||||
class TestQuota:
|
||||
async def test_get_quota(self, kb_setup):
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
resp = await c.get("/api/v1/kb-accelerator/quota", headers=h)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["kb_accelerator_enabled"] is True
|
||||
assert data["lifetime_conversions_limit"] == 3
|
||||
assert data["can_convert"] is True
|
||||
|
||||
|
||||
# ── Commit Tests ──
|
||||
|
||||
|
||||
class TestCommit:
|
||||
async def test_commit_creates_tree(self, kb_setup, test_db):
|
||||
"""Committing a ready import creates a Tree record."""
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
|
||||
# Create import
|
||||
with _mock_ai_enabled(), patch("app.api.endpoints.kb_accelerator._run_conversion"):
|
||||
create_resp = await c.post(
|
||||
"/api/v1/kb-accelerator/upload",
|
||||
data={"content": SAMPLE_KB_TEXT, "target_type": "troubleshooting"},
|
||||
headers=h,
|
||||
)
|
||||
import_id = create_resp.json()["id"]
|
||||
|
||||
# Simulate conversion complete: update status + add nodes directly
|
||||
from app.models.kb_import import KBImport, KBImportNode
|
||||
from sqlalchemy import select
|
||||
import uuid
|
||||
|
||||
result = await test_db.execute(select(KBImport).where(KBImport.id == uuid.UUID(import_id)))
|
||||
kb_import = result.scalar_one()
|
||||
kb_import.status = "ready"
|
||||
kb_import.source_metadata = {"_conversion": {"title": "Test Flow", "description": "Test"}}
|
||||
|
||||
node = KBImportNode(
|
||||
kb_import_id=kb_import.id,
|
||||
node_order=0,
|
||||
node_type="question",
|
||||
content={"original_id": "root", "question": "Test question?", "options": []},
|
||||
confidence_score=0.9,
|
||||
)
|
||||
test_db.add(node)
|
||||
await test_db.commit()
|
||||
|
||||
# Commit
|
||||
resp = await c.post(f"/api/v1/kb-accelerator/{import_id}/commit", headers=h)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "tree_id" in data
|
||||
assert data["tree_type"] == "troubleshooting"
|
||||
|
||||
|
||||
# ── Delete Tests ──
|
||||
|
||||
|
||||
class TestDelete:
|
||||
async def test_delete_import(self, kb_setup):
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
|
||||
with _mock_ai_enabled(), patch("app.api.endpoints.kb_accelerator._run_conversion"):
|
||||
create_resp = await c.post(
|
||||
"/api/v1/kb-accelerator/upload",
|
||||
data={"content": SAMPLE_KB_TEXT, "target_type": "troubleshooting"},
|
||||
headers=h,
|
||||
)
|
||||
import_id = create_resp.json()["id"]
|
||||
|
||||
resp = await c.delete(f"/api/v1/kb-accelerator/{import_id}", headers=h)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Verify deleted
|
||||
resp = await c.get(f"/api/v1/kb-accelerator/{import_id}", headers=h)
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user