Files
Benjamin Admin 11d4c2fd36 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>
2026-03-09 20:01:27 +01:00

219 lines
7.0 KiB
Python

"""
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()