from __future__ import annotations import json from fastapi import Depends, FastAPI, HTTPException from sqlalchemy import func, select from services.shared.core import Role, new_id, utc_now_iso from services.shared.db import get_session from services.shared.kb_localization import normalize_kb_language, resolve_article_group_id from services.shared.kb_search import search_kb_rows from services.shared.models import ( HealthResponse, KBArticleCreate, KBArticleOut, KBArticleUpdate, KBCategoryCreate, KBCategoryOut, ) from services.shared.security import require_roles from services.shared.sql_init import init_sql_schema from services.shared.sql_models import KBArticleRow, KBCategoryRow app = FastAPI(title="kb-service", version="1.0.0") init_sql_schema() def _category_out(row: KBCategoryRow) -> KBCategoryOut: return KBCategoryOut( category_id=row.category_id, name=row.name, description=row.description, created_at=row.created_at, ) def _article_out(row: KBArticleRow) -> KBArticleOut: return KBArticleOut( article_id=row.article_id, category_id=row.category_id, article_group_id=resolve_article_group_id(row.article_id, row.article_group_id), intent_code=row.intent_code, language=normalize_kb_language(row.language), title=row.title, body=row.body, tags=json.loads(row.tags_json or "[]"), created_at=row.created_at, updated_at=row.updated_at, ) def _normalize_intent_code(value: str | None) -> str | None: return str(value or "").strip().upper() or None def _article_group_expr(): return func.coalesce(KBArticleRow.article_group_id, KBArticleRow.article_id) def _find_group_language_conflict( session, *, article_group_id: str, language: str, exclude_article_id: str | None = None, ) -> KBArticleRow | None: stmt = select(KBArticleRow).where(_article_group_expr() == article_group_id).where(KBArticleRow.language == language) if exclude_article_id: stmt = stmt.where(KBArticleRow.article_id != exclude_article_id) return session.execute(stmt).scalar_one_or_none() @app.get("/health", response_model=HealthResponse) def health() -> HealthResponse: return HealthResponse(status="ok", service="kb-service") @app.post("/knowledge/categories", response_model=KBCategoryOut) def create_category( payload: KBCategoryCreate, _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), ) -> KBCategoryOut: session = get_session() try: row = KBCategoryRow( category_id=new_id("kbc"), name=payload.name, description=payload.description, created_at=utc_now_iso(), ) session.add(row) session.commit() session.refresh(row) return _category_out(row) finally: session.close() @app.get("/knowledge/categories", response_model=list[KBCategoryOut]) def list_categories() -> list[KBCategoryOut]: session = get_session() try: rows = session.execute(select(KBCategoryRow).order_by(KBCategoryRow.id.asc())).scalars().all() return [_category_out(r) for r in rows] finally: session.close() @app.post("/knowledge/articles", response_model=KBArticleOut) def create_article( payload: KBArticleCreate, _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), ) -> KBArticleOut: session = get_session() try: category = session.execute( select(KBCategoryRow).where(KBCategoryRow.category_id == payload.category_id) ).scalar_one_or_none() if not category: raise HTTPException(status_code=400, detail="Unknown category_id") now = utc_now_iso() article_id = new_id("kba") language = normalize_kb_language(payload.language) article_group_id = resolve_article_group_id(article_id, payload.article_group_id) conflict = _find_group_language_conflict( session, article_group_id=article_group_id, language=language, ) if conflict: raise HTTPException( status_code=409, detail="Localized article already exists for this article_group_id and language", ) row = KBArticleRow( article_id=article_id, category_id=payload.category_id, article_group_id=article_group_id, intent_code=_normalize_intent_code(payload.intent_code), language=language, title=payload.title, body=payload.body, tags_json=json.dumps(payload.tags, ensure_ascii=False), created_at=now, updated_at=now, ) session.add(row) session.commit() session.refresh(row) return _article_out(row) finally: session.close() @app.get("/knowledge/articles/{article_id}", response_model=KBArticleOut) def get_article(article_id: str) -> KBArticleOut: session = get_session() try: row = session.execute( select(KBArticleRow).where(KBArticleRow.article_id == article_id) ).scalar_one_or_none() if not row: raise HTTPException(status_code=404, detail="Article not found") return _article_out(row) finally: session.close() @app.patch("/knowledge/articles/{article_id}", response_model=KBArticleOut) def update_article( article_id: str, payload: KBArticleUpdate, _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), ) -> KBArticleOut: session = get_session() try: row = session.execute( select(KBArticleRow).where(KBArticleRow.article_id == article_id) ).scalar_one_or_none() if not row: raise HTTPException(status_code=404, detail="Article not found") data = payload.model_dump(exclude_none=True) target_group_id = resolve_article_group_id( row.article_id, data.get("article_group_id", row.article_group_id), ) target_language = normalize_kb_language(data.get("language", row.language)) conflict = _find_group_language_conflict( session, article_group_id=target_group_id, language=target_language, exclude_article_id=row.article_id, ) if conflict: raise HTTPException( status_code=409, detail="Localized article already exists for this article_group_id and language", ) if "article_group_id" in data: row.article_group_id = target_group_id if "intent_code" in data: row.intent_code = _normalize_intent_code(data["intent_code"]) if "language" in data: row.language = target_language if "title" in data: row.title = data["title"] if "body" in data: row.body = data["body"] if "tags" in data: row.tags_json = json.dumps(data["tags"], ensure_ascii=False) row.updated_at = utc_now_iso() session.commit() return _article_out(row) finally: session.close() @app.get("/knowledge/search", response_model=list[KBArticleOut]) def search_articles( q: str = "", limit: int = 50, language: str | None = None, article_group_id: str | None = None, ) -> list[KBArticleOut]: session = get_session() try: stmt = select(KBArticleRow).order_by(KBArticleRow.id.desc()) if language is not None: stmt = stmt.where(KBArticleRow.language == normalize_kb_language(language)) if article_group_id: stmt = stmt.where(_article_group_expr() == article_group_id.strip()) rows = session.execute(stmt).scalars().all() matches = search_kb_rows(rows, q, limit=limit, empty_query_returns_all=True) return [_article_out(row) for row in matches] finally: session.close()