feat: context REST API — docs, facts, wizard, and debug endpoints

Wires the context/RAG layer into FastAPI via a dedicated _ctx router
(/turnstone/api/context/*): document upload (POST/GET/DELETE /docs),
fact CRUD (POST/GET/DELETE /facts), wizard state machine
(/wizard/schema, /wizard/step, /wizard/apply), and a debug search
endpoint (/debug/search). All blocking DB calls are dispatched via
asyncio.to_thread to keep the event loop free.
This commit is contained in:
pyr0ball 2026-05-13 16:31:07 -07:00
parent f19f896300
commit 074240c061

View file

@ -6,6 +6,7 @@ Caddy (menagerie.circuitforge.tech/turnstone) without prefix stripping.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import dataclasses import dataclasses
import json import json
import os import os
@ -15,7 +16,7 @@ from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, FastAPI, HTTPException, Query from fastapi import APIRouter, FastAPI, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@ -42,6 +43,17 @@ from app.services.search import (
) )
from app.services.diagnose import diagnose as _diagnose, diagnose_stream as _diagnose_stream from app.services.diagnose import diagnose as _diagnose, diagnose_stream as _diagnose_stream
from app.watch.watcher import Watcher, load_watch_config from app.watch.watcher import Watcher, load_watch_config
from app.context.store import (
add_fact as _add_fact,
list_facts as _list_facts,
delete_fact as _delete_fact,
list_documents as _list_documents,
delete_document as _delete_document,
)
from app.context.retriever import retrieve_context as _retrieve_context, format_context_block
from app.ingest.doc_upload import ingest_upload as _ingest_upload
from app.context.wizard import get_schema as _wizard_schema, advance_step, is_complete, apply_session
from app.context.chunker import UnsupportedDocType, FileTooLarge
DB_PATH = Path(os.environ.get("TURNSTONE_DB", Path(__file__).parent.parent / "data" / "turnstone.db")) DB_PATH = Path(os.environ.get("TURNSTONE_DB", Path(__file__).parent.parent / "data" / "turnstone.db"))
PREFS_PATH = DB_PATH.parent / "preferences.json" PREFS_PATH = DB_PATH.parent / "preferences.json"
@ -135,6 +147,23 @@ class IncidentCreate(BaseModel):
notes: str = "" notes: str = ""
severity: str = "medium" severity: str = "medium"
class FactBody(BaseModel):
category: str
key: str
value: str
source: str | None = None
class WizardStepBody(BaseModel):
session: dict
step_id: str
answer: str | list[str] | None = None
class WizardApplyBody(BaseModel):
session: dict
# Serve built Vue assets at the path Vite embeds in index.html. # Serve built Vue assets at the path Vite embeds in index.html.
if (DIST_DIR / "assets").exists(): if (DIST_DIR / "assets").exists():
app.mount("/turnstone/assets", StaticFiles(directory=str(DIST_DIR / "assets")), name="assets") app.mount("/turnstone/assets", StaticFiles(directory=str(DIST_DIR / "assets")), name="assets")
@ -432,6 +461,100 @@ def get_bundle_endpoint(bundle_id: str) -> dict:
app.include_router(router) app.include_router(router)
_ctx = APIRouter(prefix="/turnstone/api/context")
@_ctx.post("/docs")
async def upload_doc(file: UploadFile):
content = await file.read()
try:
result = await asyncio.to_thread(
lambda: _ingest_upload(DB_PATH, file.filename or "upload", content)
)
except UnsupportedDocType as e:
raise HTTPException(status_code=415, detail=str(e))
except FileTooLarge as e:
raise HTTPException(status_code=413, detail=str(e))
return result
@_ctx.get("/docs")
async def list_docs():
docs = await asyncio.to_thread(lambda: _list_documents(DB_PATH))
return [
{
"id": d.id,
"filename": d.filename,
"doc_type": d.doc_type,
"file_size": d.file_size,
"uploaded_at": d.uploaded_at,
}
for d in docs
]
@_ctx.delete("/docs/{doc_id}")
async def delete_doc(doc_id: str):
deleted = await asyncio.to_thread(lambda: _delete_document(DB_PATH, doc_id))
if not deleted:
raise HTTPException(status_code=404, detail="Document not found")
return {"deleted": doc_id}
@_ctx.post("/facts")
async def create_fact(body: FactBody):
fact = await asyncio.to_thread(
lambda: _add_fact(DB_PATH, body.category, body.key, body.value, body.source)
)
return {"id": fact.id, "category": fact.category, "key": fact.key,
"value": fact.value, "source": fact.source, "created_at": fact.created_at}
@_ctx.get("/facts")
async def list_facts_endpoint(category: str | None = None):
facts = await asyncio.to_thread(lambda: _list_facts(DB_PATH, category))
return [
{"id": f.id, "category": f.category, "key": f.key,
"value": f.value, "source": f.source, "created_at": f.created_at}
for f in facts
]
@_ctx.delete("/facts/{fact_id}")
async def delete_fact_endpoint(fact_id: str):
deleted = await asyncio.to_thread(lambda: _delete_fact(DB_PATH, fact_id))
if not deleted:
raise HTTPException(status_code=404, detail="Fact not found")
return {"deleted": fact_id}
@_ctx.get("/wizard/schema")
async def wizard_schema():
return _wizard_schema()
@_ctx.post("/wizard/step")
async def wizard_step(body: WizardStepBody):
updated = advance_step(body.session, body.step_id, body.answer)
return {"session": updated, "complete": is_complete(updated)}
@_ctx.post("/wizard/apply")
async def wizard_apply(body: WizardApplyBody):
if not is_complete(body.session):
raise HTTPException(status_code=400, detail="Wizard session is not complete")
result = await asyncio.to_thread(lambda: apply_session(DB_PATH, body.session))
return result
@_ctx.get("/debug/search")
async def debug_search(q: str):
ctx = await asyncio.to_thread(lambda: _retrieve_context(DB_PATH, q))
return {"facts": ctx.facts, "chunks": ctx.chunks, "block": format_context_block(ctx)}
app.include_router(_ctx)
# Root redirect → /turnstone/ # Root redirect → /turnstone/
@app.get("/") @app.get("/")