feat(sofiia-console): add docs index and runbook search API (FTS5)
adds SQLite docs index (files/chunks + FTS5) and CLI rebuild exposes authenticated runbook search/preview/raw endpoints Made-with: Cursor
This commit is contained in:
64
services/sofiia-console/app/runbooks_router.py
Normal file
64
services/sofiia-console/app/runbooks_router.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Runbooks / docs index API — read-only search and preview (PR1.1).
|
||||
GET /api/runbooks/search, /api/runbooks/preview, /api/runbooks/raw.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from .auth import require_auth
|
||||
from . import docs_store as store
|
||||
|
||||
runbooks_router = APIRouter(prefix="/api/runbooks", tags=["runbooks-docs"])
|
||||
|
||||
|
||||
def _safe_path(path: str) -> bool:
|
||||
"""Reject path traversal and non-docs paths."""
|
||||
if not path or ".." in path or path.startswith("/"):
|
||||
return False
|
||||
norm = path.replace("\\", "/").strip()
|
||||
return bool(re.match(r"^(docs/|runbook/|release/)?[\w/\-.]+\.md$", norm, re.I))
|
||||
|
||||
|
||||
@runbooks_router.get("/search")
|
||||
async def runbooks_search(
|
||||
q: str = Query(..., min_length=1, max_length=200),
|
||||
doc_type: Optional[str] = Query(None, description="runbook | release | spec"),
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
_auth: str = Depends(require_auth),
|
||||
):
|
||||
"""Search runbooks/docs by full text. Returns path, title, snippet."""
|
||||
items = await store.search_docs(q=q, doc_type=doc_type, limit=limit)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@runbooks_router.get("/preview")
|
||||
async def runbooks_preview(
|
||||
path: str = Query(..., description="Relative path, e.g. runbook/rehearsal-v1-30min-checklist.md"),
|
||||
_auth: str = Depends(require_auth),
|
||||
):
|
||||
"""Get path, title, and sections (heading + excerpt) for a doc."""
|
||||
if not _safe_path(path):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
out = await store.get_preview(path)
|
||||
if not out:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return out
|
||||
|
||||
|
||||
@runbooks_router.get("/raw")
|
||||
async def runbooks_raw(
|
||||
path: str = Query(..., description="Relative path to markdown file"),
|
||||
_auth: str = Depends(require_auth),
|
||||
):
|
||||
"""Get raw markdown content (read-only)."""
|
||||
if not _safe_path(path):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
content = await store.get_raw(path)
|
||||
if content is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
from fastapi.responses import PlainTextResponse
|
||||
return PlainTextResponse(content, media_type="text/markdown; charset=utf-8")
|
||||
Reference in New Issue
Block a user