All checks were successful
Build and Deploy DevOpsDash / build-image (push) Successful in 8s
- Add projects router (Redis projects + workcontexts merged)
- Register projects router in main.py
- Extend knowledge router: howtos, agents, skills via MCP proxy
- Extend mcp_client: list/get howtos, agents, skills
- Rewrite dashboard.html: 4 tabs (Taskz/Worklog/Projects/Knowledge)
- Taskz: sidebar board list + Kanban columns with task modal
- Worklog: context/days picker + standup button
- Projects: context filter sidebar + work context display
- Knowledge: 6 sub-tabs (docs/howtos/agents/skills/adrs/memories)
with markdown rendering via marked.js
159 lines
6.5 KiB
Python
159 lines
6.5 KiB
Python
"""Knowledge router — ADRs, memories, knowledge catalog, HOWTOs, agents, skills via MCP proxy."""
|
|
|
|
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
|
|
import app.mcp_client as mcp
|
|
|
|
router = APIRouter(prefix="/api/v1", tags=["knowledge"])
|
|
|
|
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 — MCP proxy (file-based in DevOpsMCP) ──────────────────
|
|
|
|
@router.get("/knowledge")
|
|
async def api_list_knowledge(category: Optional[str] = None, tag: Optional[str] = None):
|
|
try:
|
|
return await mcp.list_knowledge(category=category, tag=tag)
|
|
except Exception as exc:
|
|
# Fallback: try local bind-mount
|
|
if not KNOWLEDGE_CATALOG.exists():
|
|
return {"entries": [], "total": 0, "error": str(exc)}
|
|
try:
|
|
catalog = json.loads(KNOWLEDGE_CATALOG.read_text(encoding="utf-8"))
|
|
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", [])]]
|
|
entries.sort(key=lambda e: e.get("learned_at", ""), reverse=True)
|
|
return {"entries": entries, "total": len(entries)}
|
|
except Exception as e2:
|
|
raise HTTPException(status_code=500, detail=str(e2)) from e2
|
|
|
|
|
|
@router.get("/knowledge/{filename}")
|
|
async def api_get_knowledge_file(filename: str):
|
|
try:
|
|
result = await mcp.get_knowledge(filename)
|
|
content = result.get("content", result.get("raw", ""))
|
|
return PlainTextResponse(str(content), media_type="text/markdown")
|
|
except Exception as exc:
|
|
# Fallback: local bind-mount
|
|
safe_name = Path(filename).name
|
|
file_path = DATA_DIR / "knowledge" / safe_name
|
|
if not file_path.exists():
|
|
raise HTTPException(status_code=404, detail="File not found") from exc
|
|
return PlainTextResponse(file_path.read_text(encoding="utf-8"), media_type="text/markdown")
|
|
|
|
|
|
# ── HOWTOs — MCP proxy ────────────────────────────────────────────────────────
|
|
|
|
@router.get("/howtos")
|
|
async def api_list_howtos():
|
|
try:
|
|
return await mcp.list_howtos()
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=502, detail=f"MCP proxy error: {exc}") from exc
|
|
|
|
|
|
@router.get("/howtos/{filename}")
|
|
async def api_get_howto(filename: str):
|
|
try:
|
|
result = await mcp.get_howto(filename)
|
|
content = result.get("content", result.get("raw", ""))
|
|
return PlainTextResponse(str(content), media_type="text/markdown")
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=502, detail=f"MCP proxy error: {exc}") from exc
|
|
|
|
|
|
# ── Agents — MCP proxy ────────────────────────────────────────────────────────
|
|
|
|
@router.get("/agents")
|
|
async def api_list_agents(domain: Optional[str] = None):
|
|
try:
|
|
return await mcp.list_agents(domain=domain)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=502, detail=f"MCP proxy error: {exc}") from exc
|
|
|
|
|
|
@router.get("/agents/{name}")
|
|
async def api_get_agent(name: str):
|
|
try:
|
|
return await mcp.get_agent(name)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=502, detail=f"MCP proxy error: {exc}") from exc
|
|
|
|
|
|
# ── Skills — MCP proxy ────────────────────────────────────────────────────────
|
|
|
|
@router.get("/skills")
|
|
async def api_list_skills(domain: Optional[str] = None):
|
|
try:
|
|
return await mcp.list_skills(domain=domain)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=502, detail=f"MCP proxy error: {exc}") from exc
|
|
|
|
|
|
@router.get("/skills/{name}")
|
|
async def api_get_skill(name: str):
|
|
try:
|
|
return await mcp.get_skill(name)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=502, detail=f"MCP proxy error: {exc}") from exc
|
|
|
|
|
|
# ── Generic file browser (bind-mount fallback) ────────────────────────────────
|
|
|
|
@router.get("/files")
|
|
def api_list_files(subdir: str = ""):
|
|
target = (DATA_DIR / subdir if subdir else DATA_DIR).resolve()
|
|
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, "available": False}
|
|
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, "available": True}
|