feat: Add Compliance Wiki as internal admin knowledge base
Migration 040 with wiki_categories + wiki_articles tables, 10 seed articles across 8 categories (DSGVO, Art. 9, AVV, HinSchG etc.). Read-only FastAPI API, Next.js proxy, and two-column frontend with full-text search. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
87
admin-compliance/app/api/sdk/v1/wiki/route.ts
Normal file
87
admin-compliance/app/api/sdk/v1/wiki/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/wiki?endpoint=...
|
||||
*
|
||||
* Routes to backend wiki endpoints:
|
||||
* endpoint=categories → GET /api/compliance/v1/wiki/categories
|
||||
* endpoint=articles → GET /api/compliance/v1/wiki/articles(?category_id=...)
|
||||
* endpoint=search → GET /api/compliance/v1/wiki/search?q=...
|
||||
* endpoint=article&id= → GET /api/compliance/v1/wiki/articles/{id}
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint') || 'categories'
|
||||
|
||||
let backendPath: string
|
||||
|
||||
switch (endpoint) {
|
||||
case 'categories':
|
||||
backendPath = '/api/compliance/v1/wiki/categories'
|
||||
break
|
||||
|
||||
case 'articles': {
|
||||
const categoryId = searchParams.get('category_id')
|
||||
backendPath = '/api/compliance/v1/wiki/articles'
|
||||
if (categoryId) {
|
||||
backendPath += `?category_id=${encodeURIComponent(categoryId)}`
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'article': {
|
||||
const articleId = searchParams.get('id')
|
||||
if (!articleId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing article id' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
backendPath = `/api/compliance/v1/wiki/articles/${encodeURIComponent(articleId)}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'search': {
|
||||
const query = searchParams.get('q')
|
||||
if (!query) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing search query' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
backendPath = `/api/compliance/v1/wiki/search?q=${encodeURIComponent(query)}`
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown endpoint: ${endpoint}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}${backendPath}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return NextResponse.json(null, { status: 404 })
|
||||
}
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Wiki proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
451
admin-compliance/app/sdk/wiki/page.tsx
Normal file
451
admin-compliance/app/sdk/wiki/page.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import {
|
||||
Search, BookOpen, AlertTriangle, Shield, Scale, Handshake,
|
||||
Briefcase, MessageCircle, Building2, Database, ChevronRight,
|
||||
ArrowLeft, ExternalLink, Tag,
|
||||
} from 'lucide-react'
|
||||
import type { WikiCategory, WikiArticle, WikiSearchResult } from '@/lib/sdk/types'
|
||||
|
||||
// =============================================================================
|
||||
// SIMPLE MARKDOWN RENDERER
|
||||
// =============================================================================
|
||||
|
||||
function renderMarkdown(md: string): string {
|
||||
let html = md
|
||||
// Escape HTML
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// Tables (must be before other block elements)
|
||||
html = html.replace(
|
||||
/^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)*)/gm,
|
||||
(_match, header: string, _sep: string, body: string) => {
|
||||
const ths = header.split('|').filter((c: string) => c.trim()).map((c: string) => `<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase border-b">${c.trim()}</th>`).join('')
|
||||
const rows = body.trim().split('\n').map((row: string) => {
|
||||
const tds = row.split('|').filter((c: string) => c.trim()).map((c: string) => `<td class="px-3 py-2 text-sm text-gray-700 border-b border-gray-100">${c.trim()}</td>`).join('')
|
||||
return `<tr>${tds}</tr>`
|
||||
}).join('')
|
||||
return `<table class="w-full border-collapse my-3 text-sm"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`
|
||||
}
|
||||
)
|
||||
|
||||
// Headers
|
||||
html = html.replace(/^### (.+)$/gm, '<h4 class="text-sm font-semibold text-gray-800 mt-4 mb-2">$1</h4>')
|
||||
html = html.replace(/^## (.+)$/gm, '<h3 class="text-base font-semibold text-gray-900 mt-5 mb-2">$1</h3>')
|
||||
|
||||
// Bold
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
|
||||
// Unordered lists
|
||||
html = html.replace(/^- (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-disc">$1</li>')
|
||||
html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul class="my-2 space-y-1">$1</ul>')
|
||||
|
||||
// Paragraphs (lines that aren't already HTML)
|
||||
html = html.replace(/^(?!<[hultd]|$)(.+)$/gm, '<p class="text-sm text-gray-700 my-2">$1</p>')
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ICON MAP
|
||||
// =============================================================================
|
||||
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Database,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
Scale,
|
||||
Handshake,
|
||||
Briefcase,
|
||||
MessageCircle,
|
||||
Building2,
|
||||
}
|
||||
|
||||
function CategoryIcon({ icon, className }: { icon: string; className?: string }) {
|
||||
const Icon = ICON_MAP[icon] || BookOpen
|
||||
return <Icon className={className} />
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RELEVANCE BADGE
|
||||
// =============================================================================
|
||||
|
||||
function RelevanceBadge({ relevance }: { relevance: string }) {
|
||||
const config = {
|
||||
critical: { bg: 'bg-red-100 text-red-800', label: 'Kritisch' },
|
||||
important: { bg: 'bg-amber-100 text-amber-800', label: 'Wichtig' },
|
||||
info: { bg: 'bg-blue-100 text-blue-800', label: 'Info' },
|
||||
}[relevance] || { bg: 'bg-gray-100 text-gray-600', label: relevance }
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// WIKI PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function WikiPage() {
|
||||
const [categories, setCategories] = useState<WikiCategory[]>([])
|
||||
const [articles, setArticles] = useState<WikiArticle[]>([])
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||
const [selectedArticle, setSelectedArticle] = useState<WikiArticle | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<WikiSearchResult[] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Load categories on mount
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/wiki?endpoint=categories')
|
||||
if (!res.ok) throw new Error('Failed to load categories')
|
||||
const data = await res.json()
|
||||
const cats: WikiCategory[] = (data.categories || []).map((c: Record<string, unknown>) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
description: c.description || '',
|
||||
icon: c.icon || '',
|
||||
sortOrder: c.sort_order ?? 0,
|
||||
articleCount: c.article_count ?? 0,
|
||||
}))
|
||||
setCategories(cats)
|
||||
|
||||
// Load all articles
|
||||
const artRes = await fetch('/api/sdk/v1/wiki?endpoint=articles')
|
||||
if (artRes.ok) {
|
||||
const artData = await artRes.json()
|
||||
setArticles((artData.articles || []).map((a: Record<string, unknown>) => ({
|
||||
id: a.id,
|
||||
categoryId: a.category_id,
|
||||
categoryName: a.category_name,
|
||||
title: a.title,
|
||||
summary: a.summary,
|
||||
content: a.content,
|
||||
legalRefs: a.legal_refs || [],
|
||||
tags: a.tags || [],
|
||||
relevance: a.relevance || 'info',
|
||||
sourceUrls: a.source_urls || [],
|
||||
version: a.version || 1,
|
||||
updatedAt: a.updated_at || '',
|
||||
})))
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
// Search handler
|
||||
const handleSearch = useCallback(async (query: string) => {
|
||||
setSearchQuery(query)
|
||||
if (query.length < 2) {
|
||||
setSearchResults(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/wiki?endpoint=search&q=${encodeURIComponent(query)}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSearchResults((data.results || []).map((r: Record<string, unknown>) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
summary: r.summary,
|
||||
categoryName: r.category_name,
|
||||
relevance: r.relevance || 'info',
|
||||
highlight: r.highlight || '',
|
||||
})))
|
||||
}
|
||||
} catch {
|
||||
// silently fail search
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (searchQuery.length >= 2) handleSearch(searchQuery)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchQuery, handleSearch])
|
||||
|
||||
// Filtered articles for selected category
|
||||
const filteredArticles = useMemo(() => {
|
||||
if (!selectedCategory) return articles
|
||||
return articles.filter(a => a.categoryId === selectedCategory)
|
||||
}, [articles, selectedCategory])
|
||||
|
||||
// Select article from search result
|
||||
const selectFromSearch = (id: string) => {
|
||||
const article = articles.find(a => a.id === id)
|
||||
if (article) {
|
||||
setSelectedArticle(article)
|
||||
setSelectedCategory(article.categoryId)
|
||||
setSearchResults(null)
|
||||
setSearchQuery('')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-purple-600 border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-800 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<BookOpen className="w-6 h-6 text-purple-600" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Compliance Wiki</h1>
|
||||
<p className="text-xs text-gray-500">Interne Wissensbasis — {articles.length} Artikel in {categories.length} Kategorien</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Search */}
|
||||
<div className="relative w-80">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Wiki durchsuchen..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
{/* Search results dropdown */}
|
||||
{searchResults && searchResults.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-80 overflow-y-auto">
|
||||
{searchResults.map(r => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => selectFromSearch(r.id)}
|
||||
className="w-full text-left px-4 py-3 hover:bg-gray-50 border-b border-gray-100 last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-gray-900">{r.title}</span>
|
||||
<RelevanceBadge relevance={r.relevance} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{r.categoryName}</p>
|
||||
{r.highlight && (
|
||||
<p className="text-xs text-gray-600 mt-1 line-clamp-2">{r.highlight.replace(/\*\*/g, '')}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchResults && searchResults.length === 0 && searchQuery.length >= 2 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white rounded-lg shadow-lg border border-gray-200 z-50 px-4 py-3 text-sm text-gray-500">
|
||||
Keine Ergebnisse fuer "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content: Two columns */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left: Categories */}
|
||||
<div className="w-72 border-r border-gray-200 bg-gray-50 overflow-y-auto flex-shrink-0">
|
||||
<div className="p-3">
|
||||
<button
|
||||
onClick={() => { setSelectedCategory(null); setSelectedArticle(null) }}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
|
||||
!selectedCategory ? 'bg-purple-100 text-purple-900 font-medium' : 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>Alle Artikel</span>
|
||||
<span className="ml-auto text-xs text-gray-400">{articles.length}</span>
|
||||
</button>
|
||||
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => { setSelectedCategory(cat.id); setSelectedArticle(null) }}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
selectedCategory === cat.id ? 'bg-purple-100 text-purple-900 font-medium' : 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<CategoryIcon icon={cat.icon} className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate text-left">{cat.name}</span>
|
||||
<span className="ml-auto text-xs text-gray-400 flex-shrink-0">{cat.articleCount}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Article list or detail */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selectedArticle ? (
|
||||
/* Article detail view */
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<button
|
||||
onClick={() => setSelectedArticle(null)}
|
||||
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurueck zur Uebersicht
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<RelevanceBadge relevance={selectedArticle.relevance} />
|
||||
<span className="text-xs text-gray-400">
|
||||
{selectedArticle.categoryName} · v{selectedArticle.version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">
|
||||
{selectedArticle.title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mb-6">{selectedArticle.summary}</p>
|
||||
|
||||
{/* Content (rendered markdown) */}
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(selectedArticle.content) }}
|
||||
/>
|
||||
|
||||
{/* Legal References */}
|
||||
{selectedArticle.legalRefs.length > 0 && (
|
||||
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Rechtsreferenzen</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedArticle.legalRefs.map(ref => (
|
||||
<span key={ref} className="inline-flex items-center gap-1 px-2 py-1 bg-purple-50 text-purple-700 rounded text-xs">
|
||||
<Scale className="w-3 h-3" />
|
||||
{ref}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{selectedArticle.tags.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Tags</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedArticle.tags.map(tag => (
|
||||
<span key={tag} className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">
|
||||
<Tag className="w-3 h-3" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source URLs */}
|
||||
{selectedArticle.sourceUrls.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Quellen</h4>
|
||||
<div className="space-y-1">
|
||||
{selectedArticle.sourceUrls.map(url => (
|
||||
<div key={url} className="flex items-center gap-1 text-xs text-blue-600">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{url.startsWith('http') ? (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="hover:underline">{url}</a>
|
||||
) : (
|
||||
<span>{url}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Article list view */
|
||||
<div className="p-6">
|
||||
<h2 className="text-base font-semibold text-gray-900 mb-4">
|
||||
{selectedCategory
|
||||
? categories.find(c => c.id === selectedCategory)?.name || 'Artikel'
|
||||
: 'Alle Artikel'
|
||||
}
|
||||
<span className="ml-2 text-sm font-normal text-gray-400">
|
||||
({filteredArticles.length})
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{selectedCategory && (
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
{categories.find(c => c.id === selectedCategory)?.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredArticles.map(article => (
|
||||
<button
|
||||
key={article.id}
|
||||
onClick={() => setSelectedArticle(article)}
|
||||
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:shadow-sm transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<RelevanceBadge relevance={article.relevance} />
|
||||
{!selectedCategory && (
|
||||
<span className="text-xs text-gray-400">{article.categoryName}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-purple-700">
|
||||
{article.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{article.summary}</p>
|
||||
{article.legalRefs.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{article.legalRefs.slice(0, 3).map(ref => (
|
||||
<span key={ref} className="text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">
|
||||
{ref}
|
||||
</span>
|
||||
))}
|
||||
{article.legalRefs.length > 3 && (
|
||||
<span className="text-xs text-gray-400">+{article.legalRefs.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-purple-500 flex-shrink-0 mt-1" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{filteredArticles.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
Keine Artikel in dieser Kategorie.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -760,6 +760,19 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/wiki"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
}
|
||||
label="Compliance Wiki"
|
||||
isActive={pathname?.startsWith('/sdk/wiki')}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/api-docs"
|
||||
icon={
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* retry logic, and optimistic locking support.
|
||||
*/
|
||||
|
||||
import { SDKState, CheckpointStatus, ProjectInfo } from './types'
|
||||
import { SDKState, CheckpointStatus, ProjectInfo, WikiCategory, WikiArticle, WikiSearchResult } from './types'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -730,6 +730,110 @@ export class SDKApiClient {
|
||||
)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// WIKI (read-only knowledge base)
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* List all wiki categories with article counts
|
||||
*/
|
||||
async listWikiCategories(): Promise<WikiCategory[]> {
|
||||
const data = await this.fetchWithRetry<{ categories: Array<{
|
||||
id: string; name: string; description: string; icon: string;
|
||||
sort_order: number; article_count: number
|
||||
}> }>(
|
||||
`${this.baseUrl}/wiki?endpoint=categories`,
|
||||
{ method: 'GET' }
|
||||
)
|
||||
return (data.categories || []).map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
icon: c.icon,
|
||||
sortOrder: c.sort_order,
|
||||
articleCount: c.article_count,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* List wiki articles, optionally filtered by category
|
||||
*/
|
||||
async listWikiArticles(categoryId?: string): Promise<WikiArticle[]> {
|
||||
const params = new URLSearchParams({ endpoint: 'articles' })
|
||||
if (categoryId) params.set('category_id', categoryId)
|
||||
const data = await this.fetchWithRetry<{ articles: Array<{
|
||||
id: string; category_id: string; category_name: string; title: string;
|
||||
summary: string; content: string; legal_refs: string[]; tags: string[];
|
||||
relevance: string; source_urls: string[]; version: number; updated_at: string
|
||||
}> }>(
|
||||
`${this.baseUrl}/wiki?${params.toString()}`,
|
||||
{ method: 'GET' }
|
||||
)
|
||||
return (data.articles || []).map(a => ({
|
||||
id: a.id,
|
||||
categoryId: a.category_id,
|
||||
categoryName: a.category_name,
|
||||
title: a.title,
|
||||
summary: a.summary,
|
||||
content: a.content,
|
||||
legalRefs: a.legal_refs || [],
|
||||
tags: a.tags || [],
|
||||
relevance: a.relevance as WikiArticle['relevance'],
|
||||
sourceUrls: a.source_urls || [],
|
||||
version: a.version,
|
||||
updatedAt: a.updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single wiki article by ID
|
||||
*/
|
||||
async getWikiArticle(id: string): Promise<WikiArticle> {
|
||||
const data = await this.fetchWithRetry<{
|
||||
id: string; category_id: string; category_name: string; title: string;
|
||||
summary: string; content: string; legal_refs: string[]; tags: string[];
|
||||
relevance: string; source_urls: string[]; version: number; updated_at: string
|
||||
}>(
|
||||
`${this.baseUrl}/wiki?endpoint=article&id=${encodeURIComponent(id)}`,
|
||||
{ method: 'GET' }
|
||||
)
|
||||
return {
|
||||
id: data.id,
|
||||
categoryId: data.category_id,
|
||||
categoryName: data.category_name,
|
||||
title: data.title,
|
||||
summary: data.summary,
|
||||
content: data.content,
|
||||
legalRefs: data.legal_refs || [],
|
||||
tags: data.tags || [],
|
||||
relevance: data.relevance as WikiArticle['relevance'],
|
||||
sourceUrls: data.source_urls || [],
|
||||
version: data.version,
|
||||
updatedAt: data.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-text search across wiki articles
|
||||
*/
|
||||
async searchWiki(query: string): Promise<WikiSearchResult[]> {
|
||||
const data = await this.fetchWithRetry<{ results: Array<{
|
||||
id: string; title: string; summary: string; category_name: string;
|
||||
relevance: string; highlight: string
|
||||
}> }>(
|
||||
`${this.baseUrl}/wiki?endpoint=search&q=${encodeURIComponent(query)}`,
|
||||
{ method: 'GET' }
|
||||
)
|
||||
return (data.results || []).map(r => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
summary: r.summary,
|
||||
categoryName: r.category_name,
|
||||
relevance: r.relevance,
|
||||
highlight: r.highlight,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
|
||||
@@ -2398,3 +2398,40 @@ export const DSFA_CATEGORY_LABELS: Record<DSFACategory, string> = {
|
||||
process: 'Prozessschritte',
|
||||
criteria: 'Kriterien',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPLIANCE WIKI
|
||||
// =============================================================================
|
||||
|
||||
export interface WikiCategory {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
sortOrder: number
|
||||
articleCount: number
|
||||
}
|
||||
|
||||
export interface WikiArticle {
|
||||
id: string
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
title: string
|
||||
summary: string
|
||||
content: string
|
||||
legalRefs: string[]
|
||||
tags: string[]
|
||||
relevance: 'critical' | 'important' | 'info'
|
||||
sourceUrls: string[]
|
||||
version: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface WikiSearchResult {
|
||||
id: string
|
||||
title: string
|
||||
summary: string
|
||||
categoryName: string
|
||||
relevance: string
|
||||
highlight: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user