From d8c3eba0f83be5c250f7cc9d01e839593039cb3e Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 13 May 2026 16:31:07 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20context=20REST=20API=20=E2=80=94=20docs?= =?UTF-8?q?,=20facts,=20wizard,=20and=20debug=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/rest.py | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/app/rest.py b/app/rest.py index f642e81..1355cf6 100644 --- a/app/rest.py +++ b/app/rest.py @@ -6,6 +6,7 @@ Caddy (menagerie.circuitforge.tech/turnstone) without prefix stripping. """ from __future__ import annotations +import asyncio import dataclasses import json import os @@ -15,7 +16,7 @@ from contextlib import asynccontextmanager from pathlib import Path 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.responses import FileResponse, RedirectResponse, StreamingResponse 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.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")) PREFS_PATH = DB_PATH.parent / "preferences.json" @@ -135,6 +147,23 @@ class IncidentCreate(BaseModel): notes: str = "" 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. if (DIST_DIR / "assets").exists(): 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) +_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/ @app.get("/")