File size: 12,367 Bytes
33962ff | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 | """FastAPI backend for the Next.js site. Run: uv run vrag api (serves on :8000).
The Next.js dev server proxies /api/* and /media/* here (see web/next.config.ts).
"""
import json
import asyncio
import time
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, File, HTTPException, Query, UploadFile
from .mentor_api import user
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from . import remote as rag
from .config import DATA, DIMS, EXPERIMENTS_DIR, INDEX_DIR, VIDEOS_DIR, VIEWS
from .providers import ProviderFailure
from .index import get_index
VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
@asynccontextmanager
async def lifespan(app):
from .mentor_api import session_cleanup
cleanup = asyncio.create_task(session_cleanup())
from .jobs import worker
jobs_task = asyncio.create_task(worker())
from .backup.service import configured, queue
backup_task = asyncio.create_task(queue.run()) if configured() else None
try:
yield
finally:
if backup_task:
backup_task.cancel()
await asyncio.gather(backup_task, return_exceptions=True)
cleanup.cancel()
jobs_task.cancel()
await asyncio.gather(cleanup, jobs_task, return_exceptions=True)
app = FastAPI(title="Visual Mentor", version="1.0", lifespan=lifespan)
# Starlette's StaticFiles honours Range requests, so the browser can seek the mp4s.
app.mount("/media", StaticFiles(directory=VIDEOS_DIR), name="media")
def _video_meta(vdir: Path) -> dict:
meta = json.loads((vdir / "meta.json").read_text())
segs_path = vdir / "segments.json"
meta["num_segments"] = len(json.loads(segs_path.read_text())) if segs_path.exists() else 0
meta["thumb"] = f"/media/{vdir.name}/thumb.jpg"
meta["video"] = f"/media/{vdir.name}/video.mp4"
return meta
def _hit(h: dict) -> dict:
return {**h, "frame": f"/media/{h['video_id']}/segments/{h['idx']:03d}.jpg"}
@app.get("/api/stats")
def stats(owner=Depends(user)) -> dict:
manifest_path=get_index().path/'manifest.json'
manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else {}
idx = get_index()
views_built = manifest.pop("views", {})
return {
**manifest,
"views_built": views_built,
"limitations": "Remote text embeddings support text only. Image/video/audio/fused search needs a verified compatible multimodal adapter and index. Legacy indexes require explicit re-embedding.",
"dims": idx.dims,
"views": idx.available_views,
"view_descriptions": {**VIEWS, "hybrid": "Reciprocal rank fusion of text + video + audio rankings"},
"storage": [{"dim": d, "bytes_per_view": idx.storage_bytes(d)} for d in idx.dims],
"total_duration_s": sum(json.loads((VIDEOS_DIR / vid / "meta.json").read_text())["duration"]
for vid in {s["video_id"] for s in idx.segments}),
}
@app.get("/api/videos")
def videos(owner=Depends(user)) -> list[dict]:
vids = [_video_meta(v) for v in VIDEOS_DIR.iterdir() if (v / "meta.json").exists()]
return sorted(vids, key=lambda m: (m.get("channel_position") is None, m.get("channel_position") or 0, m["title"]))
@app.get("/api/videos/{video_id}")
def video(video_id: str, owner=Depends(user)) -> dict:
if not video_id.replace("-", "").replace("_", "").isalnum():
raise HTTPException(400, "invalid video ID")
vdir = VIDEOS_DIR / video_id
if not (vdir / "meta.json").exists():
raise HTTPException(404, "unknown video")
segs = json.loads((vdir / "segments.json").read_text()) if (vdir / "segments.json").exists() else []
transcript = json.loads((vdir / "transcript.json").read_text()) if (vdir / "transcript.json").exists() else []
return {
**_video_meta(vdir),
"segments": [{**s, "id": f"{video_id}_{s['idx']:03d}", "frame": f"/media/{video_id}/segments/{s['idx']:03d}.jpg"} for s in segs],
"transcript": transcript,
# Every engine's transcript, so the UI can compare them (e.g. whisper vs gemma).
"transcripts": {engine: json.loads(p.read_text()) for engine, p in {p.stem.split(".", 1)[1]: p for p in vdir.glob("transcript.*.json")}.items()},
}
def _check(view: str, dim: int) -> None:
if not get_index().available_views:
raise HTTPException(409, "No index loaded; configure remote embeddings and build a separate versioned index")
if view not in get_index().available_views:
raise HTTPException(400, f"view must be one of {get_index().available_views}")
if dim not in get_index().dims:
raise HTTPException(400, f"dim must be one of {get_index().dims}; this model does not support arbitrary MRL truncation")
@app.get("/api/search")
async def search(q: str = Query(max_length=8000), view: str = "text", dim: int = 768, k: int = Query(10, ge=1, le=50), video_id: str | None = None, owner=Depends(user)) -> dict:
_check(view, dim)
t0 = time.time()
qe = await rag.query(q)
t1 = time.time()
hits = get_index().search(qe, view=view, dim=dim, k=k, video_id=video_id)
return {"query": q, "view": view, "dim": dim, "hits": [_hit(h) for h in hits],
"timing": {"embed_ms": round(1000 * (t1 - t0), 1), "search_ms": round(1000 * (time.time() - t1), 2)}}
@app.post("/api/search/image")
async def search_image(file: UploadFile = File(...), view: str = "frame", dim: int = 768, k: int = Query(10, ge=1, le=50), owner=Depends(user)) -> dict:
"""Query by screenshot: the image is embedded with no prefix and searched against any view."""
raise HTTPException(409, "Remote text embeddings do not support image queries; a verified multimodal adapter is required")
@app.get("/api/compare")
async def compare(q: str = Query(max_length=8000), view: str = "text", k: int = Query(5, ge=1, le=20), owner=Depends(user)) -> dict:
"""Same query at every MRL dim for one view, with overlap against the 768d results."""
_check(view, 768)
qe = await rag.query(q)
idx = get_index()
columns = []
base = None
for dim in idx.dims:
hits = idx.search(qe, view=view, dim=dim, k=k)
ids = [h["id"] for h in hits]
base = base or ids
columns.append({
"dim": dim,
"hits": [_hit(h) for h in hits],
"overlap_with_768": len(set(ids) & set(base)) / k,
"top1_matches_768": ids[:1] == base[:1],
"bytes_per_view": idx.storage_bytes(dim),
})
return {"query": q, "view": view, "k": k, "columns": columns}
@app.get("/api/compare-views")
async def compare_views(q: str = Query(max_length=8000), dim: int = 768, k: int = Query(5, ge=1, le=20), owner=Depends(user)) -> dict:
"""Same query across every embedding view at one dim."""
_check("text", dim)
qe = await rag.query(q)
idx = get_index()
return {"query": q, "dim": dim, "k": k,
"columns": [{"view": v, "hits": [_hit(h) for h in idx.search(qe, view=v, dim=dim, k=k)]} for v in idx.available_views]}
class AskRequest(BaseModel):
question: str = Field(min_length=1, max_length=8000)
view: str = "text"
dim: int = 768
k: int = Field(default=4, ge=1, le=8)
use_video: bool = True
use_audio: bool = False
@app.post("/api/ask")
async def ask(req: AskRequest, owner=Depends(user)) -> dict:
_check(req.view, req.dim)
try:
res = await rag._answer(req.question, view=req.view, dim=req.dim, k=req.k, use_video=req.use_video, use_audio=req.use_audio)
except ProviderFailure:
raise
except Exception as e: # generator offline etc.
raise HTTPException(502, "generation unavailable; check provider configuration") from e
res["clips"] = [_hit(c) for c in res["clips"]]
return res
@app.get("/api/answers")
def answers(limit: int = Query(50, ge=1, le=100), owner=Depends(user)) -> list[dict]:
p = DATA / "answers" / "history.jsonl"
if not p.exists():
return []
rows = [json.loads(line) for line in p.read_text().splitlines() if line.strip()][-limit:][::-1]
for r in rows:
r["clips"] = [_hit(c) for c in r["clips"]]
return rows
@app.get("/api/experiments")
def experiments(transcripts: str | None = Query(None, pattern="^[a-zA-Z0-9_-]+$"), owner=Depends(user)) -> dict:
"""Latest results, or the run over a specific transcript engine (?transcripts=whisper|gemma)."""
p = EXPERIMENTS_DIR / (f"results.{transcripts}.json" if transcripts else "results.json")
if not p.exists():
raise HTTPException(404, "No experiment results for this corpus. Sample mode has labeled UI fixtures.")
res = json.loads(p.read_text())
res["variants"] = sorted(f.name.split(".")[1] for f in EXPERIMENTS_DIR.glob("results.*.json"))
segs = {s["id"]: s for s in get_index().segments}
for q in res["per_query"]:
s = segs.get(q["segment_id"])
if s:
q["segment"] = _hit({k: s[k] for k in ("id", "video_id", "title", "idx", "start", "end")})
return res
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(ProviderFailure)
async def provider_error(request: Request, exc: ProviderFailure):
return JSONResponse(status_code=503, content={"detail": exc.code})
from .mentor_api import router as mentor_router
app.include_router(mentor_router)
from .demo import router as demo_router
app.include_router(demo_router)
request_slots = asyncio.Semaphore(16)
@app.middleware('http')
async def bounded_body(request: Request, call_next):
if request.url.path == '/api/health':
response = await call_next(request)
response.headers['Cache-Control'] = 'no-store'
return response
if request_slots.locked():
return JSONResponse(status_code=429, content={'detail':'server concurrency limit'})
async with request_slots:
if request.url.path.startswith('/media/'):
try:
user(request)
except HTTPException as exc:
return JSONResponse(status_code=exc.status_code,content={'detail':exc.detail})
if request.method in ('POST', 'PUT', 'PATCH') and request.url.path != '/api/ingest':
size = 0
chunks = []
try:
async with asyncio.timeout(10):
async for chunk in request.stream():
size += len(chunk)
if size > 3 * 1024 * 1024:
return JSONResponse(status_code=413, content={'detail':'request exceeds 3 MiB'})
chunks.append(chunk)
except TimeoutError:
return JSONResponse(status_code=408, content={'detail':'request upload timed out'})
request._body = b''.join(chunks)
response = await call_next(request)
response.headers['Cache-Control'] = 'no-store' if request.url.path.startswith('/api/') else 'private, max-age=60'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Referrer-Policy'] = 'same-origin'
return response
@app.get('/api/openapi.json', include_in_schema=False)
def api_schema():
return app.openapi()
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def safe_request_validation(request: Request, exc: RequestValidationError):
# Default Pydantic errors echo input values (including oversized images,
# speech/task text or accidentally pasted credentials). Never return them.
fields = {'body','query','path','header','yaml','expected_version','version',
'consent','camera_id','frame_id','captured_at','image','feature',
'config_version','request_id','audio','duration_ms','goal','step',
'completed','question','view','dim','k','use_video','use_audio',
'q','video_id','file','limit','transcripts','pid','sid'}
errors = [{'loc':[part if isinstance(part,int) or part in fields else '<field>' for part in error.get('loc',[])],
'msg':'Invalid request field', 'type':error.get('type','invalid')}
for error in exc.errors()[:16]]
return JSONResponse(status_code=422, content={'detail':errors})
from .jobs import router as jobs_router
app.include_router(jobs_router)
|