* Studio: make project sources work with RAG and polish project UI
Projects had a disabled Sources tab with an Add sources placeholder.
This wires it up end to end on top of the RAG engine:
- Add a project scope to the RAG store, ingestion and retrieval
- New endpoints: POST/GET /api/rag/projects/{id}/documents
- search_knowledge_base resolves kb, project and thread scopes; an
explicit KB stays exclusive, project and thread scopes combine
- Multi-scope search: FTS uses scope IN (...), vec0 KNN runs per
scope and merges by cosine score
- Lazy ALTER TABLE adds documents.project_id on existing databases
- Deleting a project also removes its indexed sources
- Sources tab now uploads with progress chips and drag and drop
- Chats inside a project auto-enable retrieval over project sources
when the project has indexed documents (cached probe, no Docs pill
needed); external providers still never receive rag_scope
UI polish:
- Rounder project cards with folder icon chip and softer shadow
- Project header icon in a rounded chip
- Chats/Sources pills and Add sources button without borders
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: match Add sources button shadow to the chat composer in light mode
* Studio: round project switcher hover pill and pad the folder icon
* Studio: remove border from project sources box
* Studio: grey hover on project cards and menu, move search into header, widen page spacing
* Studio: shorten sources copy, white header pills with composer shadow, fixed-width search, hub-size page headings
* Studio: align project landing blocks to the composer width
* Studio: restore muted background and flat look on projects header controls
* Studio: darker grey hover on project cards in light mode
* Studio: soften project card hover grey
* Studio: keep project card menu button visible while its menu is open
* Studio: drop focus outlines and rings on buttons and clickable icons, keep input focus styles
* Studio: address review feedback on project sources
- Remove uploaded files from disk when a project is deleted, confined
to the uploads root
- 404 project uploads when the project does not exist, matching the KB
endpoint
- Guard lexical search against an empty scope list
- Re-invalidate the project sources probe after uploads and removals
settle so a chat sent mid-upload cannot cache a stale negative
- Keep keyboard focus rings: only mouse focus drops the Tailwind ring,
the browser default outline stays removed
* Studio: add a green New badge to the project Sources tab
* Studio: unify New pills, fully round with soft emerald fill and no border
* Studio: a touch more vertical padding on New pills
* Fix project RAG source edge cases for PR #6205
* Fix duplicate RAG upload cleanup for PR #6205
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
266 lines
8.3 KiB
Python
266 lines
8.3 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
|
|
|
|
"""Ingestion lifecycle tests: pending -> completed, SSE events, dedupe, delete."""
|
|
|
|
import os
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from core.rag import ingestion, store
|
|
from storage import rag_db
|
|
|
|
|
|
def _write(tmp_path, name, text):
|
|
path = tmp_path / name
|
|
path.write_text(text, encoding = "utf-8")
|
|
return str(path)
|
|
|
|
|
|
def _drain(job_id):
|
|
return list(ingestion.job_events(job_id))
|
|
|
|
|
|
def _wait_completed(job_id, timeout = 30.0):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
status = ingestion.get_job_status(job_id)
|
|
if status and status["status"] in ("completed", "failed"):
|
|
return status
|
|
time.sleep(0.05)
|
|
raise AssertionError("ingestion did not finish in time")
|
|
|
|
|
|
def test_ingestion_lifecycle_pending_to_completed(rag_home, stub_embeddings, tmp_path):
|
|
path = _write(tmp_path, "doc.txt", "alpha bravo charlie " * 50)
|
|
scope = store.kb_scope("K1")
|
|
doc_id, job_id = ingestion.start_ingestion(scope, "K1", None, "doc.txt", path)
|
|
|
|
conn = rag_db.get_connection()
|
|
try:
|
|
assert store.get_document(conn, doc_id)["status"] in {"pending", "running", "completed"}
|
|
finally:
|
|
conn.close()
|
|
|
|
events = _drain(job_id)
|
|
assert any(e["type"] == "progress" for e in events)
|
|
assert events[-1]["type"] == "complete"
|
|
assert events[-1]["num_chunks"] > 0
|
|
|
|
status = _wait_completed(job_id)
|
|
assert status["status"] == "completed"
|
|
assert status["progress"] == 1.0
|
|
|
|
conn = rag_db.get_connection()
|
|
try:
|
|
doc = store.get_document(conn, doc_id)
|
|
assert doc["status"] == "completed"
|
|
assert doc["num_chunks"] > 0
|
|
assert store.search_lexical(conn, scope, "alpha", 10)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_ingestion_dedupe_by_hash(rag_home, stub_embeddings, tmp_path):
|
|
path = _write(tmp_path, "doc.txt", "alpha bravo charlie")
|
|
scope = store.kb_scope("K1")
|
|
doc_id, job_id = ingestion.start_ingestion(scope, "K1", None, "doc.txt", path)
|
|
_drain(job_id)
|
|
_wait_completed(job_id)
|
|
|
|
# Identical content -> same doc id, no re-ingest.
|
|
path2 = _write(tmp_path, "copy.txt", "alpha bravo charlie")
|
|
doc_id2, job_id2 = ingestion.start_ingestion(scope, "K1", None, "copy.txt", path2)
|
|
events = _drain(job_id2)
|
|
assert doc_id2 == doc_id
|
|
assert any(e.get("deduped") for e in events)
|
|
|
|
conn = rag_db.get_connection()
|
|
try:
|
|
assert len(store.list_documents(conn, scope)) == 1
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_ingestion_dedupe_removes_duplicate_upload(rag_home, stub_embeddings):
|
|
from utils.paths import ensure_dir, rag_uploads_root
|
|
|
|
uploads = ensure_dir(rag_uploads_root())
|
|
first_path = uploads / "doc.txt"
|
|
duplicate_path = uploads / "copy.txt"
|
|
first_path.write_text("alpha bravo charlie", encoding = "utf-8")
|
|
duplicate_path.write_text("alpha bravo charlie", encoding = "utf-8")
|
|
scope = store.project_scope("P1")
|
|
|
|
doc_id, job_id = ingestion.start_ingestion(
|
|
scope,
|
|
None,
|
|
None,
|
|
"doc.txt",
|
|
str(first_path),
|
|
project_id = "P1",
|
|
)
|
|
_drain(job_id)
|
|
_wait_completed(job_id)
|
|
|
|
doc_id2, job_id2 = ingestion.start_ingestion(
|
|
scope,
|
|
None,
|
|
None,
|
|
"copy.txt",
|
|
str(duplicate_path),
|
|
project_id = "P1",
|
|
)
|
|
events = _drain(job_id2)
|
|
assert doc_id2 == doc_id
|
|
assert any(e.get("deduped") for e in events)
|
|
assert first_path.exists()
|
|
assert not duplicate_path.exists()
|
|
|
|
|
|
def test_ingestion_retry_replaces_failed_hash(rag_home, stub_embeddings):
|
|
from utils.paths import ensure_dir, rag_uploads_root
|
|
|
|
uploads = ensure_dir(rag_uploads_root())
|
|
old_path = uploads / "failed.txt"
|
|
retry_path = uploads / "retry.txt"
|
|
old_path.write_text("alpha bravo charlie", encoding = "utf-8")
|
|
retry_path.write_text("alpha bravo charlie", encoding = "utf-8")
|
|
scope = store.project_scope("P1")
|
|
sha = ingestion._sha256_file(str(old_path))
|
|
|
|
conn = rag_db.get_connection()
|
|
try:
|
|
failed_id = store.create_document(
|
|
conn,
|
|
scope = scope,
|
|
filename = "failed.txt",
|
|
sha256 = sha,
|
|
project_id = "P1",
|
|
status = "failed",
|
|
stored_path = str(old_path),
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
doc_id, job_id = ingestion.start_ingestion(
|
|
scope,
|
|
None,
|
|
None,
|
|
"retry.txt",
|
|
str(retry_path),
|
|
project_id = "P1",
|
|
)
|
|
events = _drain(job_id)
|
|
assert doc_id != failed_id
|
|
assert not any(e.get("deduped") for e in events)
|
|
assert not old_path.exists()
|
|
assert retry_path.exists()
|
|
|
|
status = _wait_completed(job_id)
|
|
assert status["status"] == "completed"
|
|
conn = rag_db.get_connection()
|
|
try:
|
|
assert store.get_document(conn, failed_id) is None
|
|
assert store.get_document(conn, doc_id)["status"] == "completed"
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_delete_document_route_removes_stored_upload(rag_home):
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from auth.authentication import get_current_subject
|
|
from routes.rag import router
|
|
from utils.paths import ensure_dir, rag_uploads_root
|
|
|
|
upload = ensure_dir(rag_uploads_root()) / "delete-me.txt"
|
|
upload.write_text("alpha bravo", encoding = "utf-8")
|
|
scope = store.project_scope("P1")
|
|
|
|
conn = rag_db.get_connection()
|
|
try:
|
|
doc_id = store.create_document(
|
|
conn,
|
|
scope = scope,
|
|
filename = "delete-me.txt",
|
|
sha256 = "delete-route-sha",
|
|
project_id = "P1",
|
|
status = "completed",
|
|
stored_path = str(upload),
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
app = FastAPI()
|
|
app.include_router(router, prefix = "/api/rag")
|
|
app.dependency_overrides[get_current_subject] = lambda: "tester"
|
|
client = TestClient(app)
|
|
|
|
res = client.delete(f"/api/rag/documents/{doc_id}")
|
|
assert res.status_code == 200
|
|
assert not upload.exists()
|
|
|
|
conn = rag_db.get_connection()
|
|
try:
|
|
assert store.get_document(conn, doc_id) is None
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_ingestion_delete_removes_all_rows(rag_home, stub_embeddings, tmp_path):
|
|
path = _write(tmp_path, "doc.txt", "alpha bravo charlie delta")
|
|
scope = store.kb_scope("K1")
|
|
doc_id, job_id = ingestion.start_ingestion(scope, "K1", None, "doc.txt", path)
|
|
_drain(job_id)
|
|
_wait_completed(job_id)
|
|
|
|
conn = rag_db.get_connection()
|
|
try:
|
|
store.delete_document(conn, doc_id)
|
|
assert store.get_document(conn, doc_id) is None
|
|
assert store.search_lexical(conn, scope, "alpha", 10) == []
|
|
assert store.list_documents(conn, scope) == []
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_ingestion_rejects_unsupported_ext(rag_home, stub_embeddings, tmp_path):
|
|
path = _write(tmp_path, "doc.xyz", "alpha")
|
|
with pytest.raises(ValueError):
|
|
ingestion.start_ingestion(store.kb_scope("K1"), "K1", None, "doc.xyz", path)
|
|
|
|
|
|
def test_ingestion_empty_doc_completes_with_zero_chunks(rag_home, stub_embeddings, tmp_path):
|
|
path = _write(tmp_path, "empty.txt", " \n ")
|
|
scope = store.kb_scope("K1")
|
|
doc_id, job_id = ingestion.start_ingestion(scope, "K1", None, "empty.txt", path)
|
|
events = _drain(job_id)
|
|
assert events[-1]["type"] == "complete"
|
|
assert events[-1]["num_chunks"] == 0
|
|
status = _wait_completed(job_id)
|
|
assert status["status"] == "completed"
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
os.environ.get("RAG_REAL_EMBEDDER") != "1",
|
|
reason = "set RAG_REAL_EMBEDDER=1 to run the real sentence-transformers test",
|
|
)
|
|
def test_ingestion_with_real_embedder(rag_home, tmp_path):
|
|
path = _write(tmp_path, "doc.txt", "The Kestrel-9 turbine is rated at 9.5 megawatts.")
|
|
scope = store.kb_scope("K1")
|
|
doc_id, job_id = ingestion.start_ingestion(scope, "K1", None, "doc.txt", path)
|
|
_drain(job_id)
|
|
status = _wait_completed(job_id, timeout = 120.0)
|
|
assert status["status"] == "completed"
|
|
|
|
from core.rag import retrieval
|
|
|
|
conn = rag_db.get_connection()
|
|
try:
|
|
hits = retrieval.retrieve_hybrid(conn, scope, "how much power does the turbine make?", k = 5)
|
|
assert hits and hits[0].chunk_id == f"{doc_id}:0"
|
|
finally:
|
|
conn.close()
|