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:
@@ -32,6 +32,7 @@ from .incident_routes import router as incident_router
|
||||
from .change_request_routes import router as change_request_router
|
||||
from .generation_routes import router as generation_router
|
||||
from .project_routes import router as project_router
|
||||
from .wiki_routes import router as wiki_router
|
||||
|
||||
# Include sub-routers
|
||||
router.include_router(audit_router)
|
||||
@@ -65,6 +66,7 @@ router.include_router(incident_router)
|
||||
router.include_router(change_request_router)
|
||||
router.include_router(generation_router)
|
||||
router.include_router(project_router)
|
||||
router.include_router(wiki_router)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
@@ -98,4 +100,5 @@ __all__ = [
|
||||
"change_request_router",
|
||||
"generation_router",
|
||||
"project_router",
|
||||
"wiki_router",
|
||||
]
|
||||
|
||||
218
backend-compliance/compliance/api/wiki_routes.py
Normal file
218
backend-compliance/compliance/api/wiki_routes.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
FastAPI routes for Compliance Wiki (read-only knowledge base).
|
||||
|
||||
Endpoints:
|
||||
- GET /v1/wiki/categories → All categories with article counts
|
||||
- GET /v1/wiki/articles → All articles (optional category filter)
|
||||
- GET /v1/wiki/articles/{id} → Single article
|
||||
- GET /v1/wiki/search → Full-text search (PostgreSQL tsvector)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
from database import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1/wiki", tags=["wiki"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RESPONSE MODELS
|
||||
# =============================================================================
|
||||
|
||||
class WikiCategoryResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
icon: str
|
||||
sort_order: int
|
||||
article_count: int
|
||||
|
||||
|
||||
class WikiArticleResponse(BaseModel):
|
||||
id: str
|
||||
category_id: str
|
||||
category_name: str
|
||||
title: str
|
||||
summary: str
|
||||
content: str
|
||||
legal_refs: list[str]
|
||||
tags: list[str]
|
||||
relevance: str
|
||||
source_urls: list[str]
|
||||
version: int
|
||||
updated_at: str
|
||||
|
||||
|
||||
class WikiSearchResultResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
summary: str
|
||||
category_name: str
|
||||
relevance: str
|
||||
highlight: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPERS
|
||||
# =============================================================================
|
||||
|
||||
def _article_row_to_response(row) -> dict:
|
||||
"""Convert a DB row to WikiArticleResponse dict."""
|
||||
return {
|
||||
"id": row.id,
|
||||
"category_id": row.category_id,
|
||||
"category_name": getattr(row, "category_name", ""),
|
||||
"title": row.title,
|
||||
"summary": row.summary,
|
||||
"content": row.content,
|
||||
"legal_refs": list(row.legal_refs) if row.legal_refs else [],
|
||||
"tags": list(row.tags) if row.tags else [],
|
||||
"relevance": row.relevance or "info",
|
||||
"source_urls": list(row.source_urls) if row.source_urls else [],
|
||||
"version": row.version or 1,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else "",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/categories")
|
||||
async def list_categories():
|
||||
"""List all wiki categories with article counts."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(text("""
|
||||
SELECT c.id, c.name, c.description, c.icon, c.sort_order,
|
||||
COUNT(a.id) AS article_count
|
||||
FROM compliance_wiki_categories c
|
||||
LEFT JOIN compliance_wiki_articles a ON a.category_id = c.id
|
||||
GROUP BY c.id, c.name, c.description, c.icon, c.sort_order
|
||||
ORDER BY c.sort_order
|
||||
"""))
|
||||
rows = result.fetchall()
|
||||
return {
|
||||
"categories": [
|
||||
{
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"description": row.description or "",
|
||||
"icon": row.icon or "",
|
||||
"sort_order": row.sort_order or 0,
|
||||
"article_count": row.article_count or 0,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/articles")
|
||||
async def list_articles(
|
||||
category_id: Optional[str] = Query(None, description="Filter by category"),
|
||||
):
|
||||
"""List all wiki articles, optionally filtered by category."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if category_id:
|
||||
result = db.execute(text("""
|
||||
SELECT a.*, c.name AS category_name
|
||||
FROM compliance_wiki_articles a
|
||||
JOIN compliance_wiki_categories c ON c.id = a.category_id
|
||||
WHERE a.category_id = :category_id
|
||||
ORDER BY
|
||||
CASE a.relevance
|
||||
WHEN 'critical' THEN 0
|
||||
WHEN 'important' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
a.title
|
||||
"""), {"category_id": category_id})
|
||||
else:
|
||||
result = db.execute(text("""
|
||||
SELECT a.*, c.name AS category_name
|
||||
FROM compliance_wiki_articles a
|
||||
JOIN compliance_wiki_categories c ON c.id = a.category_id
|
||||
ORDER BY c.sort_order,
|
||||
CASE a.relevance
|
||||
WHEN 'critical' THEN 0
|
||||
WHEN 'important' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
a.title
|
||||
"""))
|
||||
rows = result.fetchall()
|
||||
return {
|
||||
"articles": [_article_row_to_response(row) for row in rows],
|
||||
"total": len(rows),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/articles/{article_id}")
|
||||
async def get_article(article_id: str):
|
||||
"""Get a single wiki article by ID."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(text("""
|
||||
SELECT a.*, c.name AS category_name
|
||||
FROM compliance_wiki_articles a
|
||||
JOIN compliance_wiki_categories c ON c.id = a.category_id
|
||||
WHERE a.id = :article_id
|
||||
"""), {"article_id": article_id})
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
return _article_row_to_response(row)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_wiki(
|
||||
q: str = Query(..., min_length=2, description="Search query"),
|
||||
):
|
||||
"""Full-text search across wiki articles using PostgreSQL tsvector."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(text("""
|
||||
SELECT a.id, a.title, a.summary, a.relevance,
|
||||
c.name AS category_name,
|
||||
ts_headline('german', a.content, plainto_tsquery('german', :query),
|
||||
'MaxWords=40, MinWords=20, StartSel=**, StopSel=**') AS highlight
|
||||
FROM compliance_wiki_articles a
|
||||
JOIN compliance_wiki_categories c ON c.id = a.category_id
|
||||
WHERE to_tsvector('german', a.title || ' ' || a.summary || ' ' || a.content)
|
||||
@@ plainto_tsquery('german', :query)
|
||||
ORDER BY
|
||||
ts_rank(to_tsvector('german', a.title || ' ' || a.summary || ' ' || a.content),
|
||||
plainto_tsquery('german', :query)) DESC
|
||||
LIMIT 20
|
||||
"""), {"query": q})
|
||||
rows = result.fetchall()
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"id": row.id,
|
||||
"title": row.title,
|
||||
"summary": row.summary,
|
||||
"category_name": row.category_name,
|
||||
"relevance": row.relevance or "info",
|
||||
"highlight": row.highlight or "",
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
"total": len(rows),
|
||||
"query": q,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user