Files
DevOpsDash/app/routers/knowledge.py

135 lines
5.5 KiB
Python
Raw Normal View History

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