Files
unslothai--unsloth/studio/backend/routes/rag.py
T
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

539 lines
18 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""HTTP API for the RAG engine: KB CRUD, uploads, SSE ingestion, search.
Single-tenant: the subject gates access, not data. Without sqlite-vec the router
mounts but every endpoint returns 503.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import re
import secrets
import time
import uuid
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel, Field
from auth.authentication import get_current_subject
from core.rag import config, ingestion, retrieval, store
from storage import rag_db
from utils.paths import ensure_dir, rag_uploads_root
logger = logging.getLogger(__name__)
router = APIRouter()
def _require_rag() -> None:
if not rag_db.RAG_AVAILABLE:
raise HTTPException(
status_code = 503,
detail = "RAG is unavailable: the sqlite-vec extension could not be loaded.",
)
_SAFE = re.compile(r"[^A-Za-z0-9._-]+")
def _sanitize_filename(name: str) -> str:
base = os.path.basename(name or "").strip() or "document"
base = _SAFE.sub("_", base)
return base[:200]
def _save_upload(file: UploadFile) -> tuple[str, str]:
"""Persist an upload; returns (stored_path, filename)."""
filename = _sanitize_filename(file.filename or "document")
ext = os.path.splitext(filename)[1].lower()
if ext not in config.UPLOAD_EXTS:
raise HTTPException(
status_code = 400,
detail = f"Unsupported file type '{ext}'. Allowed: {sorted(config.UPLOAD_EXTS)}",
)
uploads = ensure_dir(rag_uploads_root())
stored_path = str(uploads / f"{uuid.uuid4().hex}{ext}")
size = 0
cap = config.MAX_UPLOAD_BYTES
too_big = False
with open(stored_path, "wb") as out:
while True:
block = file.file.read(1 << 20)
if not block:
break
size += len(block)
if cap and size > cap:
too_big = True
break
out.write(block)
if too_big:
os.remove(stored_path)
raise HTTPException(
status_code = 413,
detail = f"File exceeds the {cap // (1024 * 1024)} MB upload limit.",
)
if size == 0:
os.remove(stored_path)
raise HTTPException(status_code = 400, detail = "Uploaded file is empty.")
return stored_path, filename
def _remove_stored_upload(stored_path: str | None) -> None:
"""Best-effort cleanup for files saved by _save_upload."""
if not stored_path:
return
try:
uploads = os.path.realpath(str(rag_uploads_root()))
target = os.path.realpath(stored_path)
if os.path.isfile(target) and os.path.commonpath([uploads, target]) == uploads:
os.remove(target)
except Exception: # noqa: BLE001 - DB/index deletion has already succeeded.
logger.warning("failed to remove RAG upload %s", stored_path, exc_info = True)
def _doc_view(row: dict) -> dict:
return {
"id": row["id"],
"filename": row["filename"],
"status": row["status"],
"error": row.get("error"),
"numChunks": row.get("num_chunks") or 0,
"kbId": row.get("kb_id"),
"threadId": row.get("thread_id"),
"projectId": row.get("project_id"),
"createdAt": row.get("created_at"),
}
class CreateKbRequest(BaseModel):
name: str = Field(min_length = 1, max_length = 200)
description: str | None = None
class UpdateKbRequest(BaseModel):
name: str | None = Field(default = None, max_length = 200)
description: str | None = None
class SearchRequest(BaseModel):
query: str
kb_id: str | None = None
thread_id: str | None = None
project_id: str | None = None
top_k: int = Field(default = config.TOP_K_HYBRID, ge = 1, le = 50)
min_score: float = 0.0
mode: str = "hybrid" # hybrid | lexical | dense
@router.get("/knowledge-bases")
def list_knowledge_bases(subject: str = Depends(get_current_subject)) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
kbs = store.list_kbs(conn)
out = []
for kb in kbs:
docs = store.list_documents(conn, store.kb_scope(kb["id"]))
out.append(
{
"id": kb["id"],
"name": kb["name"],
"description": kb.get("description"),
"createdAt": kb.get("created_at"),
"documentCount": len(docs),
}
)
return {"knowledgeBases": out}
finally:
conn.close()
@router.post("/knowledge-bases")
def create_knowledge_base(
payload: CreateKbRequest, subject: str = Depends(get_current_subject)
) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
kb_id = store.create_kb(
conn,
name = payload.name.strip(),
description = (payload.description or None),
embedding_model = config.effective_embedding_model(),
)
return {"id": kb_id, "name": payload.name.strip()}
finally:
conn.close()
@router.patch("/knowledge-bases/{kb_id}")
def update_knowledge_base(
kb_id: str,
payload: UpdateKbRequest,
subject: str = Depends(get_current_subject),
) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
if store.get_kb(conn, kb_id) is None:
raise HTTPException(status_code = 404, detail = "Knowledge base not found")
sets, params = [], []
if payload.name is not None:
sets.append("name=?")
params.append(payload.name.strip())
if payload.description is not None:
sets.append("description=?")
params.append(payload.description or None)
if sets:
params.append(kb_id)
conn.execute(f"UPDATE knowledge_bases SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
return {"ok": True}
finally:
conn.close()
@router.delete("/knowledge-bases/{kb_id}")
def delete_knowledge_base(kb_id: str, subject: str = Depends(get_current_subject)) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
if store.get_kb(conn, kb_id) is None:
raise HTTPException(status_code = 404, detail = "Knowledge base not found")
store.delete_kb(conn, kb_id)
return {"ok": True}
finally:
conn.close()
@router.post("/knowledge-bases/{kb_id}/documents")
async def upload_kb_document(
kb_id: str,
file: UploadFile = File(...),
ocr: bool | None = Form(None),
caption: bool | None = Form(None),
subject: str = Depends(get_current_subject),
) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
if store.get_kb(conn, kb_id) is None:
raise HTTPException(status_code = 404, detail = "Knowledge base not found")
finally:
conn.close()
stored_path, filename = _save_upload(file)
document_id, job_id = ingestion.start_ingestion(
store.kb_scope(kb_id), kb_id, None, filename, stored_path, ocr = ocr, caption = caption
)
return {"documentId": document_id, "jobId": job_id, "filename": filename}
@router.get("/knowledge-bases/{kb_id}/documents")
def list_kb_documents(kb_id: str, subject: str = Depends(get_current_subject)) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
docs = store.list_documents(conn, store.kb_scope(kb_id))
return {"documents": [_doc_view(d) for d in docs]}
finally:
conn.close()
@router.post("/threads/{thread_id}/documents")
async def upload_thread_document(
thread_id: str,
file: UploadFile = File(...),
ocr: bool | None = Form(None),
caption: bool | None = Form(None),
subject: str = Depends(get_current_subject),
) -> dict:
_require_rag()
stored_path, filename = _save_upload(file)
document_id, job_id = ingestion.start_ingestion(
store.thread_scope(thread_id),
None,
thread_id,
filename,
stored_path,
ocr = ocr,
caption = caption,
)
return {"documentId": document_id, "jobId": job_id, "filename": filename}
@router.get("/threads/{thread_id}/documents")
def list_thread_documents(thread_id: str, subject: str = Depends(get_current_subject)) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
docs = store.list_documents(conn, store.thread_scope(thread_id))
return {"documents": [_doc_view(d) for d in docs]}
finally:
conn.close()
@router.post("/projects/{project_id}/documents")
async def upload_project_document(
project_id: str,
file: UploadFile = File(...),
ocr: bool | None = Form(None),
caption: bool | None = Form(None),
subject: str = Depends(get_current_subject),
) -> dict:
_require_rag()
from storage.studio_db import get_chat_project
if get_chat_project(project_id) is None:
raise HTTPException(status_code = 404, detail = "Project not found")
stored_path, filename = _save_upload(file)
document_id, job_id = ingestion.start_ingestion(
store.project_scope(project_id),
None,
None,
filename,
stored_path,
project_id = project_id,
ocr = ocr,
caption = caption,
)
return {"documentId": document_id, "jobId": job_id, "filename": filename}
@router.get("/projects/{project_id}/documents")
def list_project_documents(project_id: str, subject: str = Depends(get_current_subject)) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
docs = store.list_documents(conn, store.project_scope(project_id))
return {"documents": [_doc_view(d) for d in docs]}
finally:
conn.close()
@router.delete("/documents/{document_id}")
def delete_document(document_id: str, subject: str = Depends(get_current_subject)) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
doc = store.get_document(conn, document_id)
if doc is None:
raise HTTPException(status_code = 404, detail = "Document not found")
store.delete_document(conn, document_id)
_remove_stored_upload(doc.get("stored_path"))
return {"ok": True}
finally:
conn.close()
@router.get("/jobs/{job_id}")
def job_status(job_id: str, subject: str = Depends(get_current_subject)) -> dict:
_require_rag()
row = ingestion.get_job_status(job_id)
if row is None:
raise HTTPException(status_code = 404, detail = "Job not found")
return {
"id": row["id"],
"documentId": row["document_id"],
"status": row["status"],
"stage": row.get("stage"),
"progress": row.get("progress") or 0.0,
"error": row.get("error"),
"numChunks": row.get("num_chunks") or 0,
}
@router.get("/jobs/{job_id}/events")
def job_events(job_id: str, subject: str = Depends(get_current_subject)) -> StreamingResponse:
_require_rag()
def gen():
try:
for event in ingestion.job_events(job_id):
yield f"data: {json.dumps(event)}\n\n"
except Exception as exc: # noqa: BLE001
yield f"data: {json.dumps({'type': 'error', 'error': str(exc)})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
gen(),
media_type = "text/event-stream",
headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/search")
def search(payload: SearchRequest, subject: str = Depends(get_current_subject)) -> dict:
_require_rag()
if payload.kb_id:
scope = store.kb_scope(payload.kb_id)
else:
scopes = []
if payload.project_id:
scopes.append(store.project_scope(payload.project_id))
if payload.thread_id:
scopes.append(store.thread_scope(payload.thread_id))
if not scopes:
raise HTTPException(status_code = 400, detail = "Provide kb_id, project_id, or thread_id")
scope = scopes[0] if len(scopes) == 1 else scopes
conn = rag_db.get_connection()
try:
if payload.mode == "lexical":
hits = retrieval.retrieve_lexical(conn, scope, payload.query, payload.top_k)
elif payload.mode == "dense":
hits = retrieval.retrieve_dense(conn, scope, payload.query, payload.top_k)
else:
hits = retrieval.retrieve_hybrid(conn, scope, payload.query, k = payload.top_k)
hits = retrieval.filter_min_score(hits, payload.min_score)
rows = store.chunks_by_id(conn, [h.chunk_id for h in hits])
results = []
for h in hits:
r = rows.get(h.chunk_id)
if r is None:
continue
results.append(
{
"chunkId": h.chunk_id,
"documentId": r["document_id"],
"filename": r["filename"],
"page": r["page_number"],
"score": h.score,
"text": r["text"],
}
)
return {"results": results}
finally:
conn.close()
# Per-process secret so pdf.js range requests fetch the file without a bearer
# header; tokens only work on this server instance.
_PREVIEW_SECRET = secrets.token_bytes(32)
_PREVIEW_TTL = 600 # seconds
_CONTENT_TYPES = {
".pdf": "application/pdf",
".txt": "text/plain; charset=utf-8",
".md": "text/markdown; charset=utf-8",
".markdown": "text/markdown; charset=utf-8",
".html": "text/html; charset=utf-8",
".htm": "text/html; charset=utf-8",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
def _sign_document(document_id: str) -> str:
exp = int(time.time()) + _PREVIEW_TTL
payload = f"{document_id}.{exp}"
sig = hmac.new(_PREVIEW_SECRET, payload.encode(), hashlib.sha256).hexdigest()
return f"{payload}.{sig}"
def _verify_document_token(token: str) -> str | None:
try:
document_id, exp_s, sig = token.rsplit(".", 2)
except ValueError:
return None
expected = hmac.new(
_PREVIEW_SECRET, f"{document_id}.{exp_s}".encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
try:
if int(exp_s) < int(time.time()):
return None
except ValueError:
return None
return document_id
@router.get("/documents/{document_id}/preview-target")
def preview_target(
document_id: str,
chunk_id: str | None = Query(default = None),
subject: str = Depends(get_current_subject),
) -> dict:
"""Resolve a citation to filename, page, and highlight regions."""
_require_rag()
conn = rag_db.get_connection()
try:
doc = store.get_document(conn, document_id)
if doc is None:
raise HTTPException(status_code = 404, detail = "Document not found")
ext = os.path.splitext(doc["filename"])[1].lower()
out = {
"documentId": document_id,
"filename": doc["filename"],
"mediaKind": "pdf" if ext == ".pdf" else "text",
"targetPage": None,
"pdfRegions": [],
"text": None,
}
if chunk_id:
row = conn.execute(
"SELECT text, page_number, pdf_regions_json FROM chunks WHERE id=?",
(chunk_id,),
).fetchone()
if row is not None:
out["text"] = row["text"]
out["targetPage"] = row["page_number"]
if row["pdf_regions_json"]:
try:
out["pdfRegions"] = json.loads(row["pdf_regions_json"])
except Exception:
out["pdfRegions"] = []
return out
finally:
conn.close()
@router.get("/documents/{document_id}/file-url")
def document_file_url(document_id: str, subject: str = Depends(get_current_subject)) -> dict:
"""Mint a short-lived signed URL for the source file."""
_require_rag()
conn = rag_db.get_connection()
try:
doc = store.get_document(conn, document_id)
if doc is None or not doc.get("stored_path"):
raise HTTPException(status_code = 404, detail = "Document file not available")
finally:
conn.close()
token = _sign_document(document_id)
return {"url": f"/api/rag/documents/{document_id}/file-signed?token={token}"}
@router.get("/documents/{document_id}/file-signed", response_model = None)
def document_file_signed(document_id: str, token: str = Query(...)) -> FileResponse:
"""Serve the source file gated by the HMAC token (no bearer) so pdf.js range
requests work."""
_require_rag()
signed_id = _verify_document_token(token)
if signed_id != document_id:
raise HTTPException(status_code = 401, detail = "Invalid or expired token")
conn = rag_db.get_connection()
try:
doc = store.get_document(conn, document_id)
finally:
conn.close()
stored_path = (doc or {}).get("stored_path")
if not doc or not stored_path or not os.path.isfile(stored_path):
raise HTTPException(status_code = 404, detail = "Document file not found")
# Confine to the uploads root (defense in depth).
uploads = os.path.realpath(str(rag_uploads_root()))
if not os.path.realpath(stored_path).startswith(uploads):
raise HTTPException(status_code = 403, detail = "Forbidden")
ext = os.path.splitext(doc["filename"])[1].lower()
return FileResponse(
stored_path,
media_type = _CONTENT_TYPES.get(ext, "application/octet-stream"),
filename = doc["filename"],
)