Spaces:
Runtime error
Runtime error
Upload 5 files
Browse files- tests/__init__.py +0 -0
- tests/conftest.py +47 -0
- tests/test_api.py +38 -0
- tests/test_pipeline.py +146 -0
- tests/test_units.py +55 -0
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests run with a fake embedder and a temp data dir: no model download, no Groq key needed."""
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import tempfile
|
| 5 |
+
import zlib
|
| 6 |
+
|
| 7 |
+
_TMP = tempfile.mkdtemp(prefix="rag-test-")
|
| 8 |
+
os.environ["DATA_DIR"] = _TMP
|
| 9 |
+
os.environ["USE_RERANKER"] = "false"
|
| 10 |
+
os.environ["USE_QUERY_REWRITE"] = "false"
|
| 11 |
+
os.environ["GROQ_API_KEY"] = "test-key"
|
| 12 |
+
|
| 13 |
+
import numpy as np # noqa: E402
|
| 14 |
+
import pytest # noqa: E402
|
| 15 |
+
|
| 16 |
+
from app import store # noqa: E402
|
| 17 |
+
from app.bm25 import bm25 # noqa: E402
|
| 18 |
+
from app.registry import get_registry # noqa: E402
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class FakeEmbedder:
|
| 22 |
+
"""Hashed bag-of-words vectors: deterministic, similar words -> similar vectors."""
|
| 23 |
+
|
| 24 |
+
dim = 256
|
| 25 |
+
|
| 26 |
+
def get_sentence_embedding_dimension(self):
|
| 27 |
+
return self.dim
|
| 28 |
+
|
| 29 |
+
def encode(self, texts, normalize_embeddings=True, **kw):
|
| 30 |
+
out = np.zeros((len(texts), self.dim), dtype="float32")
|
| 31 |
+
for i, t in enumerate(texts):
|
| 32 |
+
for tok in re.findall(r"\w+", t.lower()):
|
| 33 |
+
out[i, zlib.crc32(tok.encode()) % self.dim] += 1
|
| 34 |
+
norms = np.linalg.norm(out, axis=1, keepdims=True)
|
| 35 |
+
norms[norms == 0] = 1
|
| 36 |
+
return out / norms
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
store.set_embedder(FakeEmbedder())
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@pytest.fixture(autouse=True)
|
| 43 |
+
def clean_state():
|
| 44 |
+
store.clear_all()
|
| 45 |
+
get_registry().clear()
|
| 46 |
+
bm25.mark_dirty()
|
| 47 |
+
yield
|
tests/test_api.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi.testclient import TestClient
|
| 2 |
+
|
| 3 |
+
from app import rag
|
| 4 |
+
from app.main import app
|
| 5 |
+
|
| 6 |
+
client = TestClient(app) # no `with`: skips model warm-up
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def upload(name, text):
|
| 10 |
+
return client.post("/documents", files={"file": (name, text.encode(), "text/plain")})
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_upload_list_ask_delete(monkeypatch):
|
| 14 |
+
r = upload("notes.txt", "First page about apples.\fSecond page about oranges and juice.")
|
| 15 |
+
assert r.status_code == 200 and r.json()["duplicate"] is False
|
| 16 |
+
|
| 17 |
+
docs = client.get("/documents").json()
|
| 18 |
+
assert docs[0]["status"] == "ready" and docs[0]["pages"] == 2
|
| 19 |
+
|
| 20 |
+
assert upload("notes.txt", "First page about apples.\fSecond page about oranges and juice.").json()["duplicate"]
|
| 21 |
+
|
| 22 |
+
monkeypatch.setattr(rag.llm, "chat", lambda *a, **k: "Oranges and juice [1]")
|
| 23 |
+
r = client.post("/ask", json={"question": "what is on page 2?", "history": []})
|
| 24 |
+
body = r.json()
|
| 25 |
+
assert r.status_code == 200 and body["mode"] == "page_lookup" and body["sources"][0]["page"] == 2
|
| 26 |
+
|
| 27 |
+
assert client.delete(f"/documents/{docs[0]['doc_id']}").status_code == 200
|
| 28 |
+
assert client.get("/documents").json() == []
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_ask_without_documents_gives_409():
|
| 32 |
+
r = client.post("/ask", json={"question": "hello?"})
|
| 33 |
+
assert r.status_code == 409
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_bad_file_type():
|
| 37 |
+
r = client.post("/documents", files={"file": ("x.exe", b"abc", "application/octet-stream")})
|
| 38 |
+
assert r.status_code == 400
|
tests/test_pipeline.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from app import ingest, rag, retrieval, store
|
| 4 |
+
from app.registry import get_registry
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def make_doc(name: str, pages: list[str]) -> str:
|
| 8 |
+
data = "\f".join(pages).encode()
|
| 9 |
+
entry, dup = ingest.submit_document(name, data)
|
| 10 |
+
assert not dup
|
| 11 |
+
ingest.process_document(entry["doc_id"], entry["filename"], data)
|
| 12 |
+
return entry["doc_id"]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def big_doc_pages(n=30):
|
| 16 |
+
return [
|
| 17 |
+
f"Page {i} of the handbook. The company policy covers general procedures, "
|
| 18 |
+
f"office conduct and reporting lines. Section {i} describes routine item {i}. "
|
| 19 |
+
"Employees should follow the standard procedure at all times."
|
| 20 |
+
for i in range(1, n + 1)
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_ingest_registers_pages_and_chunks():
|
| 25 |
+
doc = make_doc("handbook.txt", big_doc_pages(30))
|
| 26 |
+
entry = get_registry().get(doc)
|
| 27 |
+
assert entry["status"] == "ready" and entry["pages"] == 30 and entry["chunks"] >= 30
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_duplicate_upload_is_detected():
|
| 31 |
+
data = b"hello world, this is a document with enough text to count."
|
| 32 |
+
e1, d1 = ingest.submit_document("a.txt", data)
|
| 33 |
+
e2, d2 = ingest.submit_document("copy-of-a.txt", data)
|
| 34 |
+
assert not d1 and d2 and e1["doc_id"] == e2["doc_id"]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_page_lookup_returns_exact_page():
|
| 38 |
+
doc = make_doc("handbook.txt", big_doc_pages(30))
|
| 39 |
+
chunks = retrieval.fetch_pages([doc], [20])
|
| 40 |
+
assert chunks and all(c["page"] == 20 for c in chunks)
|
| 41 |
+
assert "Page 20 of the handbook" in chunks[0]["text"]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_small_document_not_crowded_out_by_big_one():
|
| 45 |
+
"""The bug: a large PDF used to hog every result slot."""
|
| 46 |
+
big = make_doc("handbook.txt", big_doc_pages(60))
|
| 47 |
+
small = make_doc("zoology.txt", ["Zebra migration happens every year across the savanna plains."])
|
| 48 |
+
|
| 49 |
+
hits = retrieval.hybrid_search("zebra migration", [big, small], top_k=6)
|
| 50 |
+
assert hits[0]["doc_id"] == small
|
| 51 |
+
|
| 52 |
+
# generic query that matches both: results must come from BOTH documents
|
| 53 |
+
hits = retrieval.hybrid_search("policy procedure migration", [big, small], top_k=6)
|
| 54 |
+
assert {h["doc_id"] for h in hits} == {big, small}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_scope_to_one_document():
|
| 58 |
+
big = make_doc("handbook.txt", big_doc_pages(10))
|
| 59 |
+
small = make_doc("zoology.txt", ["Zebra migration happens every year across the plains."])
|
| 60 |
+
hits = retrieval.hybrid_search("zebra", [big], top_k=6)
|
| 61 |
+
assert all(h["doc_id"] == big for h in hits)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_search_with_page_filter():
|
| 65 |
+
doc = make_doc("handbook.txt", big_doc_pages(30))
|
| 66 |
+
hits = retrieval.hybrid_search("routine item", [doc], pages=[5, 6], top_k=6)
|
| 67 |
+
assert hits and {h["page"] for h in hits} <= {5, 6}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_delete_removes_everything():
|
| 71 |
+
doc = make_doc("handbook.txt", big_doc_pages(5))
|
| 72 |
+
assert ingest.delete_document(doc)
|
| 73 |
+
assert retrieval.fetch_pages([doc], [1]) == []
|
| 74 |
+
assert get_registry().get(doc) is None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_empty_pages_are_reported(monkeypatch):
|
| 78 |
+
pages = big_doc_pages(5)
|
| 79 |
+
pages[2] = "" # page 3 has no text
|
| 80 |
+
doc = make_doc("scan.txt", pages)
|
| 81 |
+
assert get_registry().get(doc)["empty_pages"] == [3]
|
| 82 |
+
|
| 83 |
+
monkeypatch.setattr(rag.llm, "chat", lambda *a, **k: "unused")
|
| 84 |
+
out = rag.answer_question("what is on page 3?", [doc])
|
| 85 |
+
assert out["mode"] == "page_lookup"
|
| 86 |
+
assert "no extractable text" in out["answer"]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_page_beyond_document_length(monkeypatch):
|
| 90 |
+
doc = make_doc("short.txt", big_doc_pages(5))
|
| 91 |
+
monkeypatch.setattr(rag.llm, "chat", lambda *a, **k: "unused")
|
| 92 |
+
out = rag.answer_question("what is written on page 99", [doc])
|
| 93 |
+
assert "only 5 pages" in out["answer"]
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_answer_page_lookup_builds_context_with_page_text(monkeypatch):
|
| 97 |
+
doc = make_doc("handbook.txt", big_doc_pages(30))
|
| 98 |
+
seen = {}
|
| 99 |
+
|
| 100 |
+
def fake_chat(messages, **kw):
|
| 101 |
+
seen["prompt"] = messages[-1]["content"]
|
| 102 |
+
return "It says routine item 20 [1]."
|
| 103 |
+
|
| 104 |
+
monkeypatch.setattr(rag.llm, "chat", fake_chat)
|
| 105 |
+
out = rag.answer_question("20 no ke page pe kya likha hai", [doc])
|
| 106 |
+
assert out["mode"] == "page_lookup" and out["pages"] == [20]
|
| 107 |
+
assert "Page 20 of the handbook" in seen["prompt"]
|
| 108 |
+
assert out["sources"][0]["page"] == 20
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_question_naming_a_document_narrows_scope(monkeypatch):
|
| 112 |
+
a = make_doc("alpha_guide.txt", ["Alpha guide explains the red button procedure."])
|
| 113 |
+
b = make_doc("beta_guide.txt", ["Beta guide explains the blue button procedure."])
|
| 114 |
+
monkeypatch.setattr(rag.llm, "chat", lambda *a_, **k: "ok")
|
| 115 |
+
out = rag.answer_question("what does the beta guide say about the button", None)
|
| 116 |
+
assert out["documents"] == ["beta_guide.txt"]
|
| 117 |
+
assert {s["doc_id"] for s in out["sources"]} == {b}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_failed_ingest_marks_document_failed():
|
| 121 |
+
entry, _ = ingest.submit_document("empty.txt", b"\f\f\f ")
|
| 122 |
+
ingest.process_document(entry["doc_id"], "empty.txt", b"\f\f\f ")
|
| 123 |
+
e = get_registry().get(entry["doc_id"])
|
| 124 |
+
assert e["status"] == "failed" and "No extractable text" in e["error"]
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_pdf_end_to_end(tmp_path):
|
| 128 |
+
reportlab = pytest.importorskip("reportlab")
|
| 129 |
+
from reportlab.pdfgen import canvas
|
| 130 |
+
|
| 131 |
+
path = tmp_path / "book.pdf"
|
| 132 |
+
c = canvas.Canvas(str(path))
|
| 133 |
+
for i in range(1, 26):
|
| 134 |
+
if i != 21: # page 21 is blank
|
| 135 |
+
c.drawString(72, 750, f"Chapter text on page {i}. Unique marker kiwi{i}.")
|
| 136 |
+
c.drawString(72, 730, "The book explains many interesting subjects in detail.")
|
| 137 |
+
c.showPage()
|
| 138 |
+
c.save()
|
| 139 |
+
|
| 140 |
+
data = path.read_bytes()
|
| 141 |
+
entry, _ = ingest.submit_document("book.pdf", data)
|
| 142 |
+
ingest.process_document(entry["doc_id"], "book.pdf", data)
|
| 143 |
+
e = get_registry().get(entry["doc_id"])
|
| 144 |
+
assert e["status"] == "ready" and e["pages"] == 25 and e["empty_pages"] == [21]
|
| 145 |
+
chunks = retrieval.fetch_pages([entry["doc_id"]], [20])
|
| 146 |
+
assert "kiwi20" in chunks[0]["text"]
|
tests/test_units.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.bm25 import BM25Index
|
| 2 |
+
from app.chunking import chunk_text
|
| 3 |
+
from app.query import extract_pages, match_docs
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_chunks_respect_size_and_overlap():
|
| 7 |
+
text = " ".join(f"This is sentence number {i} about topic {i % 5}." for i in range(80))
|
| 8 |
+
chunks = chunk_text(text, size=300, overlap=80)
|
| 9 |
+
assert len(chunks) > 5
|
| 10 |
+
assert all(len(c) <= 300 + 80 for c in chunks)
|
| 11 |
+
# overlap: the end of one chunk reappears at the start of the next
|
| 12 |
+
assert chunks[0].split(". ")[-1] in chunks[1]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_chunk_handles_giant_unbroken_text():
|
| 16 |
+
chunks = chunk_text("word " * 2000, size=500, overlap=50)
|
| 17 |
+
assert chunks and all(len(c) <= 560 for c in chunks)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_extract_pages_variants():
|
| 21 |
+
assert extract_pages("what is written on page 20?") == [20]
|
| 22 |
+
assert extract_pages("page no. 7 pe kya hai") == [7]
|
| 23 |
+
assert extract_pages("20 no ke page pe kya likha hai") == [20]
|
| 24 |
+
assert extract_pages("20th page summary") == [20]
|
| 25 |
+
assert extract_pages("explain pages 20-23") == [20, 21, 22, 23]
|
| 26 |
+
assert extract_pages("page 5 to 7") == [5, 6, 7]
|
| 27 |
+
assert extract_pages("20 se 22 pages") == [20, 21, 22]
|
| 28 |
+
assert extract_pages("page 3, 9 and 12") == [3, 9, 12]
|
| 29 |
+
assert extract_pages("what is the refund policy?") == []
|
| 30 |
+
assert extract_pages("who was born in 1990?") == []
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_match_docs_by_filename():
|
| 34 |
+
docs = [
|
| 35 |
+
{"doc_id": "a", "filename": "HR_Policy.pdf"},
|
| 36 |
+
{"doc_id": "b", "filename": "annual-report-2024.pdf"},
|
| 37 |
+
]
|
| 38 |
+
assert match_docs("what does the hr policy say about leave", docs) == ["a"]
|
| 39 |
+
assert match_docs("summary of annual report 2024", docs) == ["b"]
|
| 40 |
+
assert match_docs("what about leave", docs) == []
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_bm25_scopes_by_doc_and_page():
|
| 44 |
+
idx = BM25Index()
|
| 45 |
+
idx.build(
|
| 46 |
+
[
|
| 47 |
+
{"id": "1", "doc_id": "A", "page": 1, "text": "zebra migration across the plains"},
|
| 48 |
+
{"id": "2", "doc_id": "B", "page": 2, "text": "zebra stripes and lions"},
|
| 49 |
+
{"id": "3", "doc_id": "B", "page": 3, "text": "completely unrelated tax filing"},
|
| 50 |
+
]
|
| 51 |
+
)
|
| 52 |
+
idx._dirty = False
|
| 53 |
+
assert {h["id"] for h in idx.search("zebra", limit=5)} == {"1", "2"}
|
| 54 |
+
assert [h["id"] for h in idx.search("zebra", doc_ids=["B"])] == ["2"]
|
| 55 |
+
assert idx.search("zebra", pages=[1])[0]["id"] == "1"
|