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:
parent
b5ce0a24b2
commit
d8c3eba0f8
1 changed files with 124 additions and 1 deletions
125
app/rest.py
125
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("/")
|
||||
|
|
|
|||
Loading…
Reference in a new issue