test_ingest.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. """
  2. Tests for citation-aware ingestion (M2 Phase E).
  3. lambdagent.ingest — page-preserving chunker + Obsidian import + the
  4. rag.RAGTool `[Source N | file p.12]` citation format (FR-005).
  5. PDF extraction itself (extract_pdf_pages) needs pdfplumber/PyPDF2 + a real
  6. PDF, so we test the chunker against synthetic page tuples (the part that
  7. actually carries provenance) and exercise Obsidian import with real temp
  8. markdown files.
  9. """
  10. from __future__ import annotations
  11. import os
  12. import pytest
  13. from lambdagent.ingest import (
  14. CitedChunk,
  15. chunk_text,
  16. chunk_pages,
  17. import_obsidian_vault,
  18. )
  19. from lambdagent.rag import RAGTool, SimpleVectorStore
  20. # ── chunk_text ──────────────────────────────────────────────────────────────
  21. def test_chunk_text_short_returns_single():
  22. assert chunk_text("short text", chunk_size=800) == ["short text"]
  23. def test_chunk_text_empty_returns_empty():
  24. assert chunk_text("") == []
  25. assert chunk_text(" \n ") == []
  26. def test_chunk_text_splits_long_with_overlap():
  27. text = "para one.\n\n" + ("word " * 400) # well over 800 chars
  28. chunks = chunk_text(text, chunk_size=300, overlap=50)
  29. assert len(chunks) > 1
  30. # No chunk wildly exceeds chunk_size (allow some boundary slack).
  31. assert all(len(c) <= 300 + 50 for c in chunks)
  32. # ── chunk_pages: provenance preserved ───────────────────────────────────────
  33. def test_chunk_pages_preserves_page_numbers():
  34. pages = [
  35. (1, "Introduction. " + "alpha " * 200),
  36. (2, "Methods. " + "beta " * 200),
  37. (3, ""), # blank page — produces no chunks but doesn't break numbering
  38. (4, "Results. " + "gamma " * 200),
  39. ]
  40. chunks = chunk_pages(pages, source="paper.pdf", chunk_size=300, overlap=40)
  41. assert chunks, "expected chunks"
  42. # Every chunk knows its source + a real page number.
  43. assert all(c.source == "paper.pdf" for c in chunks)
  44. assert all(c.page in (1, 2, 4) for c in chunks)
  45. # A chunk never spans pages: page-1 chunks only contain alpha, etc.
  46. for c in chunks:
  47. if c.page == 1:
  48. assert "alpha" in c.text and "beta" not in c.text
  49. if c.page == 2:
  50. assert "beta" in c.text and "gamma" not in c.text
  51. # chunk_index is globally increasing.
  52. idxs = [c.chunk_index for c in chunks]
  53. assert idxs == sorted(idxs)
  54. assert len(set(idxs)) == len(idxs)
  55. def test_cited_chunk_to_metadata():
  56. c = CitedChunk(text="x", source="paper.pdf", page=12, chunk_index=3)
  57. md = c.to_metadata()
  58. assert md["source"] == "paper.pdf"
  59. assert md["page"] == 12
  60. assert md["chunk_index"] == 3
  61. # Non-paged source (markdown) omits page.
  62. c2 = CitedChunk(text="y", source="note.md", page=None)
  63. assert "page" not in c2.to_metadata()
  64. # ── Obsidian vault import ────────────────────────────────────────────────────
  65. def test_import_obsidian_vault(tmp_path):
  66. vault = tmp_path / "vault"
  67. (vault / "notes").mkdir(parents=True)
  68. (vault / "index.md").write_text("# Index\nSee [[notes/qgt|the QGT note]] for details.")
  69. (vault / "notes" / "qgt.md").write_text("# QGT\nQuantum geometric tensor basics. " * 5)
  70. # Obsidian config dir + a non-md file must be ignored.
  71. (vault / ".obsidian").mkdir()
  72. (vault / ".obsidian" / "app.json").write_text("{}")
  73. (vault / "image.png").write_bytes(b"\x89PNG")
  74. chunks = import_obsidian_vault(str(vault))
  75. sources = {c.source for c in chunks}
  76. # Only the two .md files, with vault-relative paths.
  77. assert "index.md" in sources
  78. assert os.path.join("notes", "qgt.md") in sources
  79. assert not any(".obsidian" in s for s in sources)
  80. assert not any(s.endswith(".png") for s in sources)
  81. # Markdown chunks have no page.
  82. assert all(c.page is None for c in chunks)
  83. # Wikilink was flattened to its alias text + recorded in extra.links.
  84. index_chunks = [c for c in chunks if c.source == "index.md"]
  85. assert index_chunks
  86. assert "the QGT note" in index_chunks[0].text
  87. assert "[[" not in index_chunks[0].text
  88. assert "notes/qgt" in index_chunks[0].extra.get("links", [])
  89. def test_import_obsidian_missing_dir_raises(tmp_path):
  90. with pytest.raises(NotADirectoryError):
  91. import_obsidian_vault(str(tmp_path / "nope"))
  92. # ── RAGTool citation format (FR-005) ─────────────────────────────────────────
  93. def test_ragtool_renders_pdf_citation():
  94. """A chunk ingested from a PDF (metadata source+page) must render as
  95. `[Source N | paper.pdf p.12]` — the headline FR-005 deliverable."""
  96. store = SimpleVectorStore()
  97. store.add(
  98. "The quantum geometric tensor encodes the metric and Berry curvature.",
  99. metadata=CitedChunk(text="", source="qgt.pdf", page=12).to_metadata(),
  100. )
  101. store.add(
  102. "Unrelated content about cats and dogs.",
  103. metadata=CitedChunk(text="", source="pets.pdf", page=3).to_metadata(),
  104. )
  105. rag = RAGTool(store, top_k=1, format="numbered")
  106. out = rag.apply("quantum geometric tensor metric Berry curvature")
  107. assert "| qgt.pdf p.12" in out, out
  108. def test_ragtool_renders_source_without_page():
  109. """Markdown chunk (no page) renders `| note.md`, no `p.None`."""
  110. store = SimpleVectorStore()
  111. store.add(
  112. "Obsidian note about lambda calculus and Y combinators.",
  113. metadata=CitedChunk(text="", source="lambda.md", page=None).to_metadata(),
  114. )
  115. rag = RAGTool(store, top_k=1, format="numbered")
  116. out = rag.apply("lambda calculus Y combinator")
  117. assert "| lambda.md" in out
  118. assert "p.None" not in out