"""Knowledge Base Management API routes""" from __future__ import annotations import hmac import logging import uuid from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request, Security, UploadFile, File from fastapi.security import APIKeyHeader, APIKeyQuery from pydantic import BaseModel logger = logging.getLogger(__name__) router = APIRouter(tags=["kb-management"]) MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50MB # --------------------------------------------------------------------------- # API Key Authentication # --------------------------------------------------------------------------- _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) _api_key_query = APIKeyQuery(name="api_key", auto_error=False) async def _verify_api_key( request: Request, api_key_header: str | None = Security(_api_key_header), api_key_query: str | None = Security(_api_key_query), ) -> None: """Verify API key for REST endpoints. Raises HTTPException if invalid.""" configured_api_key: str | None = None if hasattr(request.app.state, "server_config") and request.app.state.server_config: configured_api_key = request.app.state.server_config.api_key if configured_api_key is None and hasattr(request.app.state, "api_key"): configured_api_key = request.app.state.api_key # If no API key is configured, allow all requests (backwards compat) if configured_api_key is None: return provided = api_key_header or api_key_query if not hmac.compare_digest((provided or "").encode(), configured_api_key.encode()): raise HTTPException( status_code=401, detail="Invalid or missing API key. Provide via X-API-Key header or api_key query parameter.", ) # --------------------------------------------------------------------------- # In-memory Knowledge Source Store # --------------------------------------------------------------------------- @dataclass class KnowledgeSource: id: str name: str type: str # "local" | "feishu" | "confluence" | "http" config: dict[str, Any] = field(default_factory=dict) status: str = "active" document_count: int = 0 last_synced: str | None = None @dataclass class UploadedDocument: document_id: str filename: str source_id: str chunks: int status: str created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) class KnowledgeSourceStore: def __init__(self, max_sources: int = 500): self._sources: dict[str, KnowledgeSource] = {} self._documents: list[UploadedDocument] = [] self._max = max_sources def add_source(self, name: str, source_type: str, config: dict[str, Any]) -> KnowledgeSource: source_id = str(uuid.uuid4()) source = KnowledgeSource( id=source_id, name=name, type=source_type, config=config, ) self._sources[source_id] = source return source def get_source(self, source_id: str) -> KnowledgeSource | None: return self._sources.get(source_id) def remove_source(self, source_id: str) -> bool: if source_id in self._sources: del self._sources[source_id] return True return False def list_sources(self) -> list[KnowledgeSource]: return list(self._sources.values()) def add_document(self, doc: UploadedDocument) -> None: self._documents.append(doc) # Update source document count source = self._sources.get(doc.source_id) if source: source.document_count += 1 source.last_synced = doc.created_at def list_documents(self, source_id: str | None = None) -> list[UploadedDocument]: if source_id: return [d for d in self._documents if d.source_id == source_id] return list(self._documents) def delete_document(self, document_id: str) -> bool: for i, doc in enumerate(self._documents): if doc.document_id == document_id: self._documents.pop(i) # Update source document count source = self._sources.get(doc.source_id) if source: source.document_count = max(0, source.document_count - 1) return True return False def update_source(self, source_id: str, data: dict[str, Any]) -> KnowledgeSource | None: source = self._sources.get(source_id) if source is None: return None if "name" in data: source.name = data["name"] if "config" in data: source.config = data["config"] if "status" in data: source.status = data["status"] return source # Module-level singleton _source_store = KnowledgeSourceStore() # --------------------------------------------------------------------------- # Request / Response models # --------------------------------------------------------------------------- class AddSourceRequest(BaseModel): name: str type: str # "local" | "feishu" | "confluence" | "http" config: dict[str, Any] = {} class SearchRequest(BaseModel): query: str sources: list[str] | None = None source_ids: list[str] | None = None top_k: int = 5 strategy: str = "vector" # "vector" | "hybrid" class SearchResult(BaseModel): content: str source: str score: float metadata: dict[str, Any] = {} class UpdateSourceRequest(BaseModel): name: str | None = None config: dict[str, Any] | None = None status: str | None = None # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.get("/kb-management/sources") async def list_sources(req: Request, _auth: None = Depends(_verify_api_key)): """List all knowledge sources.""" sources = _source_store.list_sources() return { "sources": [ { "id": s.id, "name": s.name, "type": s.type, "status": s.status, "document_count": s.document_count, "last_synced": s.last_synced, } for s in sources ] } @router.post("/kb-management/sources", status_code=201) async def add_source( request: AddSourceRequest, req: Request, _auth: None = Depends(_verify_api_key) ): """Add a knowledge source.""" valid_types = {"local", "feishu", "confluence", "http"} if request.type not in valid_types: raise HTTPException( status_code=422, detail=f"Invalid source type '{request.type}'. Must be one of: {valid_types}", ) source = _source_store.add_source( name=request.name, source_type=request.type, config=request.config, ) return { "id": source.id, "name": source.name, "type": source.type, "status": source.status, "document_count": source.document_count, "last_synced": source.last_synced, } @router.delete("/kb-management/sources/{source_id}") async def remove_source(source_id: str, req: Request, _auth: None = Depends(_verify_api_key)): """Remove a knowledge source.""" if not _source_store.remove_source(source_id): raise HTTPException(status_code=404, detail=f"Source '{source_id}' not found") return {"status": "removed"} @router.post("/kb-management/sources/{source_id}/sync") async def sync_source(source_id: str, _auth: None = Depends(_verify_api_key)): """Trigger source sync.""" source = _source_store.get_source(source_id) if not source: raise HTTPException(status_code=404, detail="Source not found") # For now, return sync status (actual sync logic depends on source type) return {"status": "syncing", "source_id": source_id} @router.put("/kb-management/sources/{source_id}") async def update_source( source_id: str, data: UpdateSourceRequest, _auth: None = Depends(_verify_api_key) ): """Update source config.""" update_data = {k: v for k, v in data.model_dump().items() if v is not None} source = _source_store.update_source(source_id, update_data) if not source: raise HTTPException(status_code=404, detail="Source not found") return { "id": source.id, "name": source.name, "type": source.type, "status": source.status, "document_count": source.document_count, "last_synced": source.last_synced, } @router.get("/kb-management/documents") async def list_documents(source_id: str | None = None, _auth: None = Depends(_verify_api_key)): """List all documents, optionally filtered by source_id.""" documents = _source_store.list_documents(source_id) return { "documents": [ { "document_id": d.document_id, "filename": d.filename, "source_id": d.source_id, "chunks": d.chunks, "status": d.status, "created_at": d.created_at, } for d in documents ] } @router.delete("/kb-management/documents/{document_id}") async def delete_document(document_id: str, _auth: None = Depends(_verify_api_key)): """Delete a document by ID.""" success = _source_store.delete_document(document_id) if not success: raise HTTPException(status_code=404, detail="Document not found") return {"status": "deleted"} @router.post("/kb-management/documents/upload") async def upload_document( req: Request, file: UploadFile = File(...), source_id: str = "", _auth: None = Depends(_verify_api_key), ): """Upload a document to the knowledge base.""" if not file.filename: raise HTTPException(status_code=422, detail="Filename is required") # Try to use DocumentLoader if available chunks = 1 try: from agentkit.memory.document_loader import DocumentLoader content = await file.read(MAX_UPLOAD_SIZE + 1) if len(content) > MAX_UPLOAD_SIZE: raise HTTPException( status_code=413, detail=f"File too large. Maximum size is {MAX_UPLOAD_SIZE // (1024 * 1024)}MB", ) loader = DocumentLoader() doc = loader.load_bytes(content, file.filename) # Estimate chunks based on content length (rough approximation) chunks = max(1, len(doc.content) // 500) except ImportError: # DocumentLoader not available, use basic estimation content = await file.read(MAX_UPLOAD_SIZE + 1) if len(content) > MAX_UPLOAD_SIZE: raise HTTPException( status_code=413, detail=f"File too large. Maximum size is {MAX_UPLOAD_SIZE // (1024 * 1024)}MB", ) chunks = max(1, len(content) // 500) except Exception as e: logger.warning(f"Document parsing failed: {e}") chunks = 1 # Determine source_id - use "local" default if not provided effective_source_id = source_id or "local" # Ensure a local source exists if effective_source_id == "local": local_sources = [s for s in _source_store.list_sources() if s.type == "local"] if not local_sources: _source_store.add_source("本地文档", "local", {}) uploaded = UploadedDocument( document_id=str(uuid.uuid4()), filename=file.filename, source_id=effective_source_id, chunks=chunks, status="indexed", ) _source_store.add_document(uploaded) return { "document_id": uploaded.document_id, "filename": uploaded.filename, "source_id": uploaded.source_id, "chunks": uploaded.chunks, "status": uploaded.status, "created_at": uploaded.created_at if hasattr(uploaded, "created_at") else "", } @router.post("/kb-management/search") async def search_knowledge( request: SearchRequest, req: Request, _auth: None = Depends(_verify_api_key) ): """Test search/retrieval against the knowledge base.""" # Try to use semantic memory if available memory_retriever = getattr(req.app.state, "memory_retriever", None) if ( memory_retriever and hasattr(memory_retriever, "semantic_memory") and memory_retriever.semantic_memory ): try: results = await memory_retriever.semantic_memory.retrieve( query=request.query, top_k=request.top_k, ) return { "results": [ { "content": r.content if hasattr(r, "content") else str(r), "source": r.source_id if hasattr(r, "source_id") else "", "score": r.score if hasattr(r, "score") else 0.0, "metadata": r.metadata if hasattr(r, "metadata") else {}, } for r in results ] } except Exception as e: logger.warning(f"Semantic search failed: {e}") # Fallback: return empty results with a hint return { "results": [], "message": "未配置语义检索服务,请先配置知识库连接", } @router.get("/kb-management/sources/{source_id}/health") async def check_source_health(source_id: str, req: Request, _auth: None = Depends(_verify_api_key)): """Check the health of a knowledge source.""" source = _source_store.get_source(source_id) if source is None: raise HTTPException(status_code=404, detail=f"Source '{source_id}' not found") # For local sources, always healthy if source.type == "local": return {"source_id": source_id, "status": "healthy", "message": "本地存储正常"} # For external sources, try to check connectivity # This would use the actual KB adapters in production return {"source_id": source_id, "status": "unknown", "message": "健康检查未实现"}