Download vrag/api.py from Leon4gr45/openweb: direct link, hf CLI and curl.
- Browser
- Download file 12.4 kB
-
https://huggingface.co/spaces/Leon4gr45/openweb/resolve/main/vrag/api.py
- Command line
-
hf download hf://spaces/Leon4gr45/openweb/vrag/api.py
-
curl -L -o api.py https://huggingface.co/spaces/Leon4gr45/openweb/resolve/main/vrag/api.py
12.4 kB
| """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) | |
| 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"} | |
| 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}), | |
| } | |
| 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"])) | |
| 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") | |
| 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)}} | |
| 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") | |
| 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} | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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) | |
| 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 | |
| def api_schema(): | |
| return app.openapi() | |
| from fastapi.exceptions import 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) | |