"""Knowledge router — ADRs, memories, and file browser for mounted data directory.""" from __future__ import annotations import json import os from pathlib import Path from typing import Optional from fastapi import APIRouter, HTTPException from fastapi.responses import PlainTextResponse from app.redis_client import get_redis, list_adrs, get_adr, list_memories router = APIRouter(prefix="/api/v1", tags=["knowledge"]) # Data dir is mounted from DevOpsMCP at /data (read-only bind mount in production) DATA_DIR = Path(os.environ.get("DATA_DIR", "/data")) KNOWLEDGE_CATALOG = DATA_DIR / "knowledge" / "catalog.json" # ── ADRs ────────────────────────────────────────────────────────────────────── @router.get("/adrs") def api_list_adrs(status: Optional[str] = None): r = get_redis() adrs = list_adrs(r, status_filter=status) return {"adrs": adrs, "total": len(adrs)} @router.get("/adrs/{adr_id}") def api_get_adr(adr_id: str): r = get_redis() adr = get_adr(r, adr_id) if not adr: raise HTTPException(status_code=404, detail="ADR not found") return adr # ── Memories ────────────────────────────────────────────────────────────────── @router.get("/memories") def api_list_memories(entity_type: Optional[str] = None, limit: int = 50): r = get_redis() memories = list_memories(r, entity_type=entity_type, limit=limit) return {"memories": memories, "total": len(memories)} # ── Knowledge catalog (file-based, from bind-mounted data dir) ──────────────── @router.get("/knowledge") def api_list_knowledge(category: Optional[str] = None, tag: Optional[str] = None): if not KNOWLEDGE_CATALOG.exists(): return {"entries": [], "total": 0, "data_dir_available": False} try: catalog: dict = json.loads(KNOWLEDGE_CATALOG.read_text(encoding="utf-8")) except Exception as exc: raise HTTPException(status_code=500, detail=f"Catalog read error: {exc}") from exc entries = list(catalog.values()) if category: entries = [e for e in entries if e.get("category", "").lower() == category.lower()] if tag: entries = [e for e in entries if tag.lower() in [t.lower() for t in e.get("tags", [])]] summaries = [ { "storage_filename": e["storage_filename"], "original_filename": e["original_filename"], "title": e.get("title", e["original_filename"]), "file_type": e.get("file_type", "markdown"), "category": e.get("category", "general"), "tags": e.get("tags", []), "summary": e.get("summary", "")[:200], "char_count": e.get("char_count", 0), "learned_at": e.get("learned_at", ""), } for e in entries ] summaries.sort(key=lambda e: e.get("learned_at", ""), reverse=True) return {"entries": summaries, "total": len(summaries), "data_dir_available": True} @router.get("/knowledge/{filename}") def api_get_knowledge_file(filename: str): """Return raw markdown content of a knowledge file.""" safe_name = Path(filename).name # prevent path traversal file_path = DATA_DIR / "knowledge" / safe_name if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail="File not found") content = file_path.read_text(encoding="utf-8") return PlainTextResponse(content, media_type="text/markdown") # ── Generic file browser for data directory ─────────────────────────────────── @router.get("/files") def api_list_files(subdir: str = ""): """List files in the bound data directory.""" target = DATA_DIR / subdir if subdir else DATA_DIR target = target.resolve() # Safety: must stay within DATA_DIR if not str(target).startswith(str(DATA_DIR.resolve())): raise HTTPException(status_code=400, detail="Path traversal not allowed") if not target.exists(): return {"files": [], "dirs": [], "path": subdir} files = [] dirs = [] for item in sorted(target.iterdir()): if item.is_file() and item.suffix in {".md", ".yml", ".yaml", ".txt", ".rst", ".json"}: files.append({ "name": item.name, "size": item.stat().st_size, "path": str(item.relative_to(DATA_DIR)), }) elif item.is_dir(): dirs.append(item.name) return {"files": files, "dirs": dirs, "path": subdir} @router.get("/files/{file_path:path}") def api_get_file(file_path: str): """Return raw content of a file from the data directory.""" target = (DATA_DIR / file_path).resolve() if not str(target).startswith(str(DATA_DIR.resolve())): raise HTTPException(status_code=400, detail="Path traversal not allowed") if not target.exists() or not target.is_file(): raise HTTPException(status_code=404, detail="File not found") allowed = {".md", ".yml", ".yaml", ".txt", ".rst"} if target.suffix not in allowed: raise HTTPException(status_code=400, detail="File type not allowed") content = target.read_text(encoding="utf-8") media_type = "text/markdown" if target.suffix == ".md" else "text/plain" return PlainTextResponse(content, media_type=media_type)