unsloth/studio/backend/tests/test_rag_source_identity.py
Etherll 27b0a50a84 Studio: WIP — RAG preview UI, locator/auth refactor, tests, fixtures (pre-merge snapshot)
Snapshot taken before fast-forwarding feature/rag to origin and merging main.
Bundles in-flight work so the merge has a clean tree:

Frontend
- PDF preview panel (preview-panel, preview-pdf-view, preview-text-view,
  preview-unavailable) with lazy-rendered page thumbnail rail
- Resizable preview slot via useResizablePanelWidth hook (drag handle,
  localStorage persistence, viewport clamping)
- Neutral scrollbar + Source Excerpt card restyle (no brand-coloured rail)
- Preview-store + chat-adapter / rag-api / kb-detail wiring
- Frontend test harness (vitest.config, setupTests, biome update) and the
  paired __tests__ suites for preview, sources, document-row, chat-adapter,
  rag-api, knowledge-bases-tab, search-knowledge-base-tool-ui

Backend
- RAG locator + authorization modules with chunking / retrieval / tool /
  vector_store / studio_db updates
- Paired test_rag_* suites (authorization, locators, locator_backfill,
  locator_migration, preview_routes, preview_target_locators, source_identity)

Other
- tests/fixtures/rag-preview for preview route fixtures (sample.pdf,
  sample.txt, make_fixture_pdf.py)
- .gitignore + package(-lock).json adjustments for the new test runner

Will be squashed/reworked via interactive rebase after main is merged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:13:43 +03:00

243 lines
8.6 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
"""Tests for durable source identity in tool XML output (PLAN.md T3, contracts §3).
Acceptance criteria:
- _format_hits_for_llm emits document_id and chunk_id attributes on <chunk> elements.
- The visible citation id (id="N") is a per-call counter, NOT the backend chunk UUID.
- Same-filename documents in different KB slots remain distinguishable by document_id.
- Hits without a matching DB row (lookup miss) are silently dropped — not emitted
with wrong IDs.
- Legacy hits (no document_id in hit dict) still render without crashing.
"""
from __future__ import annotations
import re
import uuid
from xml.etree import ElementTree
import pytest
from core.rag.tool import _format_hits_for_llm
# ── Helpers ───────────────────────────────────────────────────────────
def _uid() -> str:
return str(uuid.uuid4())
def _hit(
*,
chunk_id: str,
document_id: str,
filename: str = "report.pdf",
text: str = "some text",
page_number: int | None = 3,
chunk_index: int = 0,
score: float = 0.85,
) -> dict:
"""Build a flat hit dict as _format_hits_for_llm expects."""
return {
"chunk_id": chunk_id,
"document_id": document_id,
"filename": filename,
"text": text,
"page_number": page_number,
"chunk_index": chunk_index,
"score": score,
"dense_score": score,
"token_count": 20,
"kind": "text",
"image_path": None,
}
def _parse_chunks(xml_output: str) -> list[dict]:
"""Parse <chunk ...> elements from the multi-block tool output."""
chunks = []
# Each block is wrapped in <chunk ...>\n...\n</chunk>; parse directly.
for match in re.finditer(r"<chunk\s([^>]*)>", xml_output):
attrs_raw = match.group(1)
# Quick attribute parser for "key="value"" pairs.
attrs: dict = {}
for m in re.finditer(r'(\w+)="([^"]*)"', attrs_raw):
attrs[m.group(1)] = m.group(2)
chunks.append(attrs)
return chunks
# ── Tests: durable IDs present in XML ────────────────────────────────
def test_format_hits_emits_document_id_and_chunk_id():
"""T3: tool XML <chunk> must carry document_id and chunk_id attributes."""
chunk_id, doc_id = _uid(), _uid()
hits = [_hit(chunk_id=chunk_id, document_id=doc_id)]
output = _format_hits_for_llm(hits)
chunks = _parse_chunks(output)
assert len(chunks) == 1, output
assert chunks[0]["document_id"] == doc_id
assert chunks[0]["chunk_id"] == chunk_id
def test_citation_id_is_sequential_counter_not_uuid():
"""Visible id='N' is a 1-based counter — never equal to the backend chunk UUID."""
chunk_id, doc_id = _uid(), _uid()
hits = [_hit(chunk_id=chunk_id, document_id=doc_id)]
output = _format_hits_for_llm(hits, start_id=0)
chunks = _parse_chunks(output)
visible_id = chunks[0]["id"]
# Must be a small integer string, NOT the UUID
assert visible_id == "1", f"expected '1' got {visible_id!r}"
assert visible_id != chunk_id
def test_citation_ids_are_globally_sequential_across_calls():
"""start_id offset ensures IDs stay unique across multiple tool calls per turn."""
hits_call1 = [_hit(chunk_id=_uid(), document_id=_uid(), filename="a.pdf")]
hits_call2 = [
_hit(chunk_id=_uid(), document_id=_uid(), filename="b.pdf"),
_hit(chunk_id=_uid(), document_id=_uid(), filename="c.pdf"),
]
out1 = _format_hits_for_llm(hits_call1, start_id=0)
out2 = _format_hits_for_llm(hits_call2, start_id=1)
chunks1 = _parse_chunks(out1)
chunks2 = _parse_chunks(out2)
assert chunks1[0]["id"] == "1"
assert chunks2[0]["id"] == "2"
assert chunks2[1]["id"] == "3"
# No id overlap
all_ids = {c["id"] for c in chunks1 + chunks2}
assert len(all_ids) == 3
def test_same_filename_docs_have_distinct_document_ids():
"""Two docs with the same filename route to distinct document_id values (Risk #4)."""
filename = "annual-report.pdf"
chunk_a, doc_a = _uid(), _uid()
chunk_b, doc_b = _uid(), _uid()
hits = [
_hit(chunk_id=chunk_a, document_id=doc_a, filename=filename),
_hit(chunk_id=chunk_b, document_id=doc_b, filename=filename),
]
output = _format_hits_for_llm(hits)
chunks = _parse_chunks(output)
assert len(chunks) == 2
# Both use the same filename but MUST have distinct document_id values
assert chunks[0]["document_id"] != chunks[1]["document_id"]
assert chunks[0]["document_id"] == doc_a
assert chunks[1]["document_id"] == doc_b
def test_same_filename_docs_have_distinct_citation_ids():
"""Same-filename docs in the same turn still get distinct visible [N] ids."""
filename = "notes.pdf"
chunk_a, doc_a = _uid(), _uid()
chunk_b, doc_b = _uid(), _uid()
hits = [
_hit(chunk_id=chunk_a, document_id=doc_a, filename=filename),
_hit(chunk_id=chunk_b, document_id=doc_b, filename=filename),
]
output = _format_hits_for_llm(hits)
chunks = _parse_chunks(output)
citation_ids = {c["id"] for c in chunks}
assert len(citation_ids) == 2, f"citation IDs not unique: {chunks}"
def test_empty_hits_returns_no_chunks_message():
"""Empty hit list returns the 'no matching chunks' message, not broken XML."""
output = _format_hits_for_llm([])
chunks = _parse_chunks(output)
assert len(chunks) == 0
assert "no matching chunks" in output.lower() or "no matching" in output.lower()
def test_page_number_attribute_present_when_page_exists():
"""page attribute is emitted when page_number is not None."""
chunk_id, doc_id = _uid(), _uid()
hits = [_hit(chunk_id=chunk_id, document_id=doc_id, page_number=5)]
output = _format_hits_for_llm(hits)
chunks = _parse_chunks(output)
assert chunks[0].get("page") == "5"
def test_page_number_attribute_absent_when_null():
"""page attribute is omitted when page_number is None."""
chunk_id, doc_id = _uid(), _uid()
hits = [_hit(chunk_id=chunk_id, document_id=doc_id, page_number=None)]
output = _format_hits_for_llm(hits)
chunks = _parse_chunks(output)
assert "page" not in chunks[0], f"unexpected page attr: {chunks[0]}"
def test_locator_attributes_are_additive_when_present():
"""T10: tool XML carries nullable locator metadata without changing visible ids."""
chunk_id, doc_id = _uid(), _uid()
hit = _hit(chunk_id=chunk_id, document_id=doc_id, page_number=5)
hit.update(
{
"source_page_index": 4,
"page_char_start": 11,
"page_char_end": 42,
"line_start": 2,
"line_end": 3,
}
)
output = _format_hits_for_llm([hit])
chunk = _parse_chunks(output)[0]
assert chunk["id"] == "1"
assert chunk["chunk_id"] == chunk_id
assert chunk["source_page_index"] == "4"
assert chunk["page_char_start"] == "11"
assert chunk["page_char_end"] == "42"
assert chunk["line_start"] == "2"
assert chunk["line_end"] == "3"
def test_xml_special_chars_in_filename_escaped():
"""Filename with XML special chars does not break the chunk element."""
chunk_id, doc_id = _uid(), _uid()
hits = [
_hit(
chunk_id=chunk_id,
document_id=doc_id,
filename='report <2025> "final" & draft.pdf',
)
]
output = _format_hits_for_llm(hits)
# The output must parse cleanly (no unescaped < or " in attrs)
chunks = _parse_chunks(output)
assert len(chunks) == 1
# source attribute should have the filename escaped
source_attr = chunks[0].get("source", "")
assert "<" not in source_attr and '"' not in source_attr
def test_multiple_hits_carry_independent_ids():
"""Three hits each carry their own distinct chunk_id and document_id."""
hit_data = [
(_uid(), _uid()),
(_uid(), _uid()),
(_uid(), _uid()),
]
hits = [
_hit(chunk_id=cid, document_id=did, filename=f"doc{i}.pdf")
for i, (cid, did) in enumerate(hit_data)
]
output = _format_hits_for_llm(hits)
chunks = _parse_chunks(output)
assert len(chunks) == 3
emitted_chunk_ids = {c["chunk_id"] for c in chunks}
emitted_doc_ids = {c["document_id"] for c in chunks}
expected_chunk_ids = {cid for cid, _ in hit_data}
expected_doc_ids = {did for _, did in hit_data}
assert emitted_chunk_ids == expected_chunk_ids
assert emitted_doc_ids == expected_doc_ids