diff --git a/studio/backend/core/rag/authorization.py b/studio/backend/core/rag/authorization.py index 77f59c4435..78df96b5ed 100644 --- a/studio/backend/core/rag/authorization.py +++ b/studio/backend/core/rag/authorization.py @@ -95,24 +95,3 @@ def document_for_subject_or_404( # Docs must belong to a KB or a thread (DB CHECK enforces XOR on insert); # a row satisfying neither is corrupt — treat as 404. raise HTTPException(status_code = 404, detail = _NOT_FOUND_DETAIL) - - -def chunk_belongs_to_document(chunk_id: str, document_id: str) -> bool: - """True iff `chunk_id` exists in `rag_chunks` for `document_id`. - - Used by `/preview-target?chunk_id=...` after the caller has - already established subject authorization for `document_id`. Does - NOT perform authorization itself: callers MUST call - `document_for_subject_or_404(document_id, ...)` first, otherwise a - valid `chunk_id` from another subject's document would leak via a - `True` return. - """ - if not chunk_id or not document_id: - return False - - with get_connection() as conn: - row = conn.execute( - "SELECT 1 FROM rag_chunks WHERE id = ? AND document_id = ?", - (chunk_id, document_id), - ).fetchone() - return row is not None diff --git a/studio/backend/routes/rag.py b/studio/backend/routes/rag.py index 3d017c8f8c..a21f6109be 100644 --- a/studio/backend/routes/rag.py +++ b/studio/backend/routes/rag.py @@ -1458,10 +1458,10 @@ def get_document_preview_target( ) # One connection enforces membership AND fetches the row in a single query. - # A separate `chunk_belongs_to_document` call would open a second SQLite - # connection and open a TOCTOU window — if the chunk is deleted between the - # two calls, the fetch returns None and the route 500s (D1.1). Cross-document - # collapses to the same 404 — never 400 (would leak doc existence). + # A separate membership check would open a second SQLite connection and a + # TOCTOU window — if the chunk is deleted between the two calls, the fetch + # returns None and the route 500s (D1.1). Cross-document collapses to the + # same 404 — never 400 (would leak doc existence). with get_connection() as conn: chunk_row = conn.execute( """ diff --git a/studio/backend/tests/test_rag_authorization.py b/studio/backend/tests/test_rag_authorization.py index a5181f4840..1d1bb1eaf2 100644 --- a/studio/backend/tests/test_rag_authorization.py +++ b/studio/backend/tests/test_rag_authorization.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -"""Tests for document_for_subject_or_404 and chunk_belongs_to_document. +"""Tests for document_for_subject_or_404. Authorization rules under test (contracts.md §1 / §2, Risk #1): @@ -13,7 +13,6 @@ Authorization rules under test (contracts.md §1 / §2, Risk #1): - KB with NULL owner_user_id is NOT accessible (legacy row guard). - Both not-found and not-authorized return HTTP 404 with identical detail to prevent document-existence leaking. -- chunk_belongs_to_document only returns True when chunk.document_id matches. """ from __future__ import annotations @@ -24,10 +23,7 @@ import pytest from fastapi import HTTPException import storage.studio_db as studio_db -from core.rag.authorization import ( - chunk_belongs_to_document, - document_for_subject_or_404, -) +from core.rag.authorization import document_for_subject_or_404 # ── Fixtures ────────────────────────────────────────────────────────── @@ -88,16 +84,6 @@ def _insert_thread_doc( ) -def _insert_chunk(conn, chunk_id: str, doc_id: str, chunk_index: int = 0) -> None: - conn.execute( - """ - INSERT INTO rag_chunks (id, document_id, chunk_index, text, token_count) - VALUES (?, ?, ?, ?, ?) - """, - (chunk_id, doc_id, chunk_index, "some chunk text", 20), - ) - - # ── KB-document authorization ───────────────────────────────────────── @@ -224,49 +210,3 @@ def test_empty_subject_raises_404(tmp_path, monkeypatch): with pytest.raises(HTTPException) as exc_info: document_for_subject_or_404(doc_id, "") assert exc_info.value.status_code == 404 - - -# ── chunk_belongs_to_document ───────────────────────────────────────── - - -def test_chunk_belongs_returns_true_for_matching_doc(tmp_path, monkeypatch): - """chunk_belongs_to_document returns True when chunk.document_id matches.""" - _reset_db(tmp_path, monkeypatch) - doc_id, kb_id, chunk_id = _uid(), _uid(), _uid() - with studio_db.get_connection() as conn: - _insert_kb(conn, kb_id, owner = "alice") - _insert_kb_doc(conn, doc_id, kb_id) - _insert_chunk(conn, chunk_id, doc_id) - assert chunk_belongs_to_document(chunk_id, doc_id) is True - - -def test_chunk_belongs_returns_false_for_wrong_doc(tmp_path, monkeypatch): - """chunk_belongs_to_document returns False when chunk belongs to a different document.""" - _reset_db(tmp_path, monkeypatch) - kb_id = _uid() - doc_a, doc_b, chunk_id = _uid(), _uid(), _uid() - with studio_db.get_connection() as conn: - _insert_kb(conn, kb_id, owner = "alice") - _insert_kb_doc(conn, doc_a, kb_id, "a.pdf") - _insert_kb_doc(conn, doc_b, kb_id, "b.pdf") - _insert_chunk(conn, chunk_id, doc_a) - # chunk is doc_a's; probing doc_b → False. - assert chunk_belongs_to_document(chunk_id, doc_b) is False - - -def test_chunk_belongs_returns_false_for_missing_chunk(tmp_path, monkeypatch): - """chunk_belongs_to_document returns False for a nonexistent chunk_id.""" - _reset_db(tmp_path, monkeypatch) - doc_id, kb_id = _uid(), _uid() - with studio_db.get_connection() as conn: - _insert_kb(conn, kb_id, owner = "alice") - _insert_kb_doc(conn, doc_id, kb_id) - assert chunk_belongs_to_document("ghost-chunk-id", doc_id) is False - - -def test_chunk_belongs_returns_false_for_empty_inputs(tmp_path, monkeypatch): - """chunk_belongs_to_document returns False for empty inputs without DB access.""" - _reset_db(tmp_path, monkeypatch) - assert chunk_belongs_to_document("", "some-doc") is False - assert chunk_belongs_to_document("some-chunk", "") is False - assert chunk_belongs_to_document("", "") is False diff --git a/studio/frontend/src/features/rag/components/ingestion-progress.tsx b/studio/frontend/src/features/rag/components/ingestion-progress.tsx deleted file mode 100644 index 0e85d416aa..0000000000 --- a/studio/frontend/src/features/rag/components/ingestion-progress.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - -import { Progress } from "@/components/ui/progress"; -import { cn } from "@/lib/utils"; -import { useIngestionEvents } from "../hooks/use-ingestion-events"; - -const STAGE_LABELS: Record = { - queued: "Queued", - parse: "Parsing document", - caption_images: "Captioning images", - extract_images: "Extracting images", - load_model: "Loading embedder", - chunk: "Chunking text", - embed: "Embedding chunks", - done: "Indexing complete", -}; - -export function IngestionProgress({ - jobId, - className, -}: { - jobId: string; - className?: string; -}) { - const event = useIngestionEvents(jobId); - if (!event) { - return ( -
- Starting… -
- ); - } - - if (event.type === "error") { - return ( -
- {event.error} -
- ); - } - - if (event.type === "cancelled") { - return ( -
- Cancelled -
- ); - } - - if (event.type === "complete") { - const chunks = event.num_chunks; - return ( -
- 1 document and {chunks} chunk{chunks === 1 ? "" : "s"} indexed -
- ); - } - - const stage = - "stage" in event && event.stage ? (event.stage as string) : "queued"; - const progress = - "progress" in event && typeof event.progress === "number" - ? event.progress - : 0; - const label = STAGE_LABELS[stage] ?? stage; - - return ( -
-
- {label} - {Math.round(progress * 100)}% -
- -
- ); -} diff --git a/studio/frontend/src/features/rag/hooks/use-ingestion-events.ts b/studio/frontend/src/features/rag/hooks/use-ingestion-events.ts deleted file mode 100644 index 357d91230f..0000000000 --- a/studio/frontend/src/features/rag/hooks/use-ingestion-events.ts +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - -import { useEffect } from "react"; -import { useRagStore } from "../stores/rag-store"; - -/** Subscribe to a job's SSE; returns latest event, null skips. */ -export function useIngestionEvents(jobId: string | null) { - const event = useRagStore((s) => - jobId ? (s.jobs[jobId] ?? null) : null, - ); - const subscribeJob = useRagStore((s) => s.subscribeJob); - - useEffect(() => { - if (jobId) subscribeJob(jobId); - }, [jobId, subscribeJob]); - - return event; -} diff --git a/tests/fixtures/rag-preview/make_fixture_pdf.py b/tests/fixtures/rag-preview/make_fixture_pdf.py deleted file mode 100644 index 1d9a251a12..0000000000 --- a/tests/fixtures/rag-preview/make_fixture_pdf.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Generate tests/fixtures/rag-preview/sample.pdf deterministically. - -Run once: python tests/fixtures/rag-preview/make_fixture_pdf.py -Requires no third-party deps — builds a minimal valid single-page PDF -using only stdlib so the fixture can be regenerated in any environment. -The output is committed alongside this script so tests load it directly. -""" - -import os -import struct -import zlib -from pathlib import Path - -OUTPUT = Path(__file__).parent / "sample.pdf" - - -def _compress(data: bytes) -> bytes: - return zlib.compress(data, level = 9) - - -def _pdf() -> bytes: - # Minimal one-page PDF 1.4: header, catalog, pages, page, content - # stream, xref, trailer. - page_text = b"BT /F1 12 Tf 72 720 Td (RAG preview fixture - page 1) Tj ET" - compressed = _compress(page_text) - stream_len = len(compressed) - - objects: list[bytes] = [] - - def obj(n: int, body: bytes) -> bytes: - return f"{n} 0 obj\n".encode() + body + b"\nendobj\n" - - # 1: Catalog - objects.append(obj(1, b"<< /Type /Catalog /Pages 2 0 R >>")) - # 2: Pages - objects.append(obj(2, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>")) - # 3: Page - objects.append( - obj( - 3, - ( - b"<< /Type /Page /Parent 2 0 R " - b"/MediaBox [0 0 612 792] " - b"/Contents 4 0 R " - b"/Resources << /Font << /F1 5 0 R >> >> >>" - ), - ) - ) - # 4: Content stream - objects.append( - obj( - 4, - ( - f"<< /Length {stream_len} /Filter /FlateDecode >>".encode() - + b"\nstream\n" - + compressed - + b"\nendstream" - ), - ) - ) - # 5: Font - objects.append( - obj( - 5, - b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", - ) - ) - - header = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n" - body = b"" - offsets: list[int] = [] - for o in objects: - offsets.append(len(header) + len(body)) - body += o - - xref_offset = len(header) + len(body) - n = len(objects) - xref = f"xref\n0 {n + 1}\n".encode() - xref += b"0000000000 65535 f \n" - for off in offsets: - xref += f"{off:010d} 00000 n \n".encode() - trailer = ( - f"trailer\n<< /Size {n + 1} /Root 1 0 R >>\n" - f"startxref\n{xref_offset}\n%%EOF\n" - ).encode() - - return header + body + xref + trailer - - -if __name__ == "__main__": - pdf_bytes = _pdf() - OUTPUT.write_bytes(pdf_bytes) - print(f"Written {len(pdf_bytes)} bytes to {OUTPUT}") diff --git a/tests/fixtures/rag-preview/sample.pdf b/tests/fixtures/rag-preview/sample.pdf deleted file mode 100644 index 5c9232898a..0000000000 Binary files a/tests/fixtures/rag-preview/sample.pdf and /dev/null differ diff --git a/tests/fixtures/rag-preview/sample.txt b/tests/fixtures/rag-preview/sample.txt deleted file mode 100644 index f240c7bf65..0000000000 --- a/tests/fixtures/rag-preview/sample.txt +++ /dev/null @@ -1,8 +0,0 @@ -This is a test document for RAG preview fixtures. - -Section 1: Introduction -The operating margin rose to 18.2% in Q3, driven by improved efficiency. - -Section 2: Details -Additional supporting evidence and analysis is contained here. -Page 1 of 1.