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:
Michael Chihlas
2026-03-10 20:56:28 -04:00
parent c65aa4f0b7
commit 71ff4a8c35
27 changed files with 4426 additions and 2 deletions

View File

@@ -0,0 +1,205 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Sparkles, Loader2 } from 'lucide-react'
import { toast } from '@/lib/toast'
import { kbAcceleratorApi } from '@/api'
import { UploadScreen } from '@/components/kb-accelerator/UploadScreen'
import { ReviewScreen } from '@/components/kb-accelerator/ReviewScreen'
import { SuccessScreen } from '@/components/kb-accelerator/SuccessScreen'
import { getTreeEditorPath } from '@/lib/routing'
import type { KBImport, KBQuotaResponse, KBCommitResponse, KBNodeEditRequest } from '@/types/kbAccelerator'
type Phase = 'upload' | 'processing' | 'review' | 'success'
type TargetType = 'troubleshooting' | 'procedural' | 'auto'
export default function KBAcceleratorPage() {
const navigate = useNavigate()
const [phase, setPhase] = useState<Phase>('upload')
const [quota, setQuota] = useState<KBQuotaResponse | null>(null)
const [importId, setImportId] = useState<string | null>(null)
const [kbImport, setKbImport] = useState<KBImport | null>(null)
const [commitResult, setCommitResult] = useState<KBCommitResponse | null>(null)
const [loading, setLoading] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Load quota on mount
useEffect(() => {
kbAcceleratorApi.getQuota().then(setQuota).catch(() => {})
}, [])
// Poll for processing status
const startPolling = useCallback((id: string) => {
if (pollRef.current) clearInterval(pollRef.current)
pollRef.current = setInterval(async () => {
try {
const data = await kbAcceleratorApi.get(id)
if (data.status === 'ready') {
if (pollRef.current) clearInterval(pollRef.current)
setKbImport(data)
setPhase('review')
} else if (data.status === 'failed') {
if (pollRef.current) clearInterval(pollRef.current)
toast.error(data.error_message || 'Conversion failed')
setPhase('upload')
}
} catch {
// Keep polling on transient errors
}
}, 2000)
}, [])
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [])
const handleSubmitText = async (content: string, title: string, targetType: TargetType) => {
setLoading(true)
try {
const resp = await kbAcceleratorApi.uploadText({
content,
title: title || undefined,
target_type: targetType === 'auto' ? undefined : targetType,
})
setImportId(resp.id)
setPhase('processing')
startPolling(resp.id)
} catch (err: unknown) {
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Upload failed'
toast.error(message)
} finally {
setLoading(false)
}
}
const handleSubmitFile = async (file: File, targetType: TargetType) => {
setLoading(true)
try {
const resp = await kbAcceleratorApi.uploadFile(file, targetType === 'auto' ? undefined : targetType)
setImportId(resp.id)
setPhase('processing')
startPolling(resp.id)
} catch (err: unknown) {
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Upload failed'
toast.error(message)
} finally {
setLoading(false)
}
}
const handleEditNode = async (nodeId: string, data: KBNodeEditRequest) => {
if (!importId) return
const updatedNode = await kbAcceleratorApi.editNode(importId, nodeId, data)
setKbImport(prev => {
if (!prev) return prev
if (data.operation === 'delete') {
return { ...prev, nodes: prev.nodes.filter(n => n.id !== nodeId) }
}
if (data.operation === 'insert_after') {
const idx = prev.nodes.findIndex(n => n.id === nodeId)
const newNodes = [...prev.nodes]
newNodes.splice(idx + 1, 0, updatedNode)
return { ...prev, nodes: newNodes }
}
return {
...prev,
nodes: prev.nodes.map(n => n.id === updatedNode.id ? updatedNode : n),
}
})
}
const handleCommit = async () => {
if (!importId) return
setLoading(true)
try {
const result = await kbAcceleratorApi.commit(importId)
setCommitResult(result)
setPhase('success')
// Refresh quota
kbAcceleratorApi.getQuota().then(setQuota).catch(() => {})
} catch (err: unknown) {
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Commit failed'
toast.error(message)
} finally {
setLoading(false)
}
}
const handleDiscard = async () => {
if (!importId) return
setLoading(true)
try {
await kbAcceleratorApi.delete(importId)
resetWizard()
} catch {
toast.error('Failed to discard')
} finally {
setLoading(false)
}
}
const resetWizard = () => {
setPhase('upload')
setImportId(null)
setKbImport(null)
setCommitResult(null)
setLoading(false)
}
return (
<div className="flex flex-col h-full p-6">
{/* Page title */}
<div className="flex items-center gap-3 mb-6">
<Sparkles size={24} className="text-primary" />
<h1 className="text-2xl font-heading font-bold text-foreground">KB Accelerator</h1>
</div>
{/* Phases */}
{phase === 'upload' && (
<UploadScreen
quota={quota}
onSubmitText={handleSubmitText}
onSubmitFile={handleSubmitFile}
loading={loading}
/>
)}
{phase === 'processing' && (
<div className="flex-1 flex flex-col items-center justify-center gap-4">
<Loader2 size={40} className="text-primary animate-spin" />
<div className="text-center">
<p className="text-lg font-heading font-semibold text-foreground">
Converting your KB article...
</p>
<p className="text-sm text-muted-foreground mt-1">
AI is analyzing your content and generating an interactive flow.
</p>
</div>
</div>
)}
{phase === 'review' && kbImport && (
<ReviewScreen
kbImport={kbImport}
onEditNode={handleEditNode}
onCommit={handleCommit}
onDiscard={handleDiscard}
loading={loading}
/>
)}
{phase === 'success' && commitResult && (
<SuccessScreen
result={commitResult}
onViewFlow={() => {
const path = getTreeEditorPath(commitResult.tree_id, commitResult.tree_type as 'troubleshooting' | 'procedural')
navigate(path)
}}
onConvertAnother={resetWizard}
/>
)}
</div>
)
}