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