capcut8128 commited on
Commit
64a1c56
·
verified ·
1 Parent(s): d63fff8

Upload 5 files

Browse files
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"