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>
219 lines
7.0 KiB
Python
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()
|