xyberos-documents 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. xyberos_documents-0.1.0/PKG-INFO +106 -0
  2. xyberos_documents-0.1.0/README.md +90 -0
  3. xyberos_documents-0.1.0/pyproject.toml +33 -0
  4. xyberos_documents-0.1.0/setup.cfg +4 -0
  5. xyberos_documents-0.1.0/tests/test_csv_loader.py +20 -0
  6. xyberos_documents-0.1.0/tests/test_docx_loader.py +16 -0
  7. xyberos_documents-0.1.0/tests/test_file_loader.py +40 -0
  8. xyberos_documents-0.1.0/tests/test_html_loader.py +40 -0
  9. xyberos_documents-0.1.0/tests/test_pdf_loader.py +20 -0
  10. xyberos_documents-0.1.0/tests/test_plugin.py +79 -0
  11. xyberos_documents-0.1.0/tests/test_registry.py +54 -0
  12. xyberos_documents-0.1.0/tests/test_xlsx_loader.py +17 -0
  13. xyberos_documents-0.1.0/xyberos_documents/__init__.py +44 -0
  14. xyberos_documents-0.1.0/xyberos_documents/base.py +59 -0
  15. xyberos_documents-0.1.0/xyberos_documents/csv_loader.py +39 -0
  16. xyberos_documents-0.1.0/xyberos_documents/docx_loader.py +57 -0
  17. xyberos_documents-0.1.0/xyberos_documents/file_loader.py +80 -0
  18. xyberos_documents-0.1.0/xyberos_documents/html_loader.py +94 -0
  19. xyberos_documents-0.1.0/xyberos_documents/pdf_loader.py +69 -0
  20. xyberos_documents-0.1.0/xyberos_documents/plugin.py +122 -0
  21. xyberos_documents-0.1.0/xyberos_documents/registry.py +96 -0
  22. xyberos_documents-0.1.0/xyberos_documents/xlsx_loader.py +59 -0
  23. xyberos_documents-0.1.0/xyberos_documents.egg-info/PKG-INFO +106 -0
  24. xyberos_documents-0.1.0/xyberos_documents.egg-info/SOURCES.txt +26 -0
  25. xyberos_documents-0.1.0/xyberos_documents.egg-info/dependency_links.txt +1 -0
  26. xyberos_documents-0.1.0/xyberos_documents.egg-info/entry_points.txt +2 -0
  27. xyberos_documents-0.1.0/xyberos_documents.egg-info/requires.txt +9 -0
  28. xyberos_documents-0.1.0/xyberos_documents.egg-info/top_level.txt +1 -0
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: xyberos-documents
3
+ Version: 0.1.0
4
+ Summary: Filesystem + document loaders plugin (M1): PDF, DOCX, HTML, CSV, XLSX -> IngestingKnowledge
5
+ License: Apache-2.0
6
+ Keywords: xyberos,plugin,documents,loader,knowledge,ingestion
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: xyberos>=1.0
10
+ Provides-Extra: documents
11
+ Requires-Dist: pypdf; extra == "documents"
12
+ Requires-Dist: python-docx; extra == "documents"
13
+ Requires-Dist: openpyxl; extra == "documents"
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest; extra == "test"
16
+
17
+ # xyberos-documents
18
+
19
+ **Filesystem + document loaders plugin — RFC-0019, M1.** Turns plain-text-only
20
+ ingestion into real document ingestion.
21
+
22
+ Loaders return text chunks consumed by
23
+ [`IngestingKnowledge.ingest`](https://docs.xyberos.com):
24
+
25
+ | Loader | Formats | Dependency |
26
+ | ------ | ------- | ---------- |
27
+ | `FileLoader` | `.md .txt .json .py .rst …` + directory walks | stdlib only |
28
+ | `HtmlLoader` | `.html .htm` (tag stripping) | stdlib only |
29
+ | `CsvLoader` | `.csv` | stdlib only |
30
+ | `PdfLoader` | `.pdf` | lazy `pypdf` / `PyPDF2` / `pymupdf` |
31
+ | `DocxLoader` | `.docx` | lazy `python-docx` |
32
+ | `XlsxLoader` | `.xlsx .xlsm` | lazy `openpyxl` |
33
+
34
+ The stdlib loaders keep the zero-dependency core sacred; the binary loaders
35
+ import their backend lazily and raise a clear `ProviderError` when missing.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install -e ./documents
41
+ # PDF/DOCX/XLSX backends (also available as the core extra):
42
+ pip install xyberos[documents] # pypdf, python-docx, openpyxl
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ Load a document as text chunks, then feed `IngestingKnowledge`:
48
+
49
+ ```python
50
+ from xyberos import create_app
51
+ from xyberos.knowledge import IngestingKnowledge
52
+ from xyberos.llm import HashEmbedder
53
+ from xyberos.vector import SqliteVectorStore
54
+ from xyberos_documents import DocumentsPlugin, load_document
55
+
56
+ app = create_app(
57
+ knowledge=IngestingKnowledge(SqliteVectorStore("learning.db"), embedder=HashEmbedder())
58
+ )
59
+ app.load_plugin(DocumentsPlugin())
60
+
61
+ app.tools.execute("ingest_document", None, path="report.pdf", chunk_size=512)
62
+ app.tools.execute("ingest_directory", None, path="docs/", extensions=[".pdf", ".docx"])
63
+ ```
64
+
65
+ Each loader can also be used standalone (no app required):
66
+
67
+ ```python
68
+ from xyberos_documents import PdfLoader, DocxLoader, HtmlLoader, load_document
69
+
70
+ for doc in PdfLoader().load("report.pdf"):
71
+ print(doc.text)
72
+ ```
73
+
74
+ ## Tools registered
75
+
76
+ - `ingest_document(path, chunk_size=512, loader=None)` — auto-detect by
77
+ extension (`loader` can force `text`/`html`/`pdf`/`docx`/`csv`/`xlsx`).
78
+ - `ingest_directory(path, extensions=None, chunk_size=512, recursive=True)` —
79
+ walk a folder and route each file to the right loader.
80
+
81
+ Both require the registered `knowledge` provider to support `ingest()` (an
82
+ `IngestingKnowledge`); otherwise a clear `ProviderError` is raised.
83
+
84
+ ## Examples
85
+
86
+ - `examples/ingest_documents.py` — generates a sample PDF + DOCX and ingests
87
+ both, then queries the knowledge base.
88
+
89
+ ## Tests
90
+
91
+ ```bash
92
+ pip install pytest
93
+ pytest tests/
94
+ ```
95
+
96
+ Optional-dep tests (`pdf`, `docx`, `xlsx`) skip cleanly when their library is
97
+ not installed — the same pattern as the core's `test_sentence_embedder.py`.
98
+
99
+ ## Contract & ship location
100
+
101
+ - **Contract:** `Knowledge` (via `IngestingKnowledge`), plus `Tool` for the
102
+ two ingest tools.
103
+ - **Ship:** `FileLoader` / `HtmlLoader` / `CsvLoader` are stdlib (would be
104
+ Core); `PdfLoader` / `DocxLoader` / `XlsxLoader` map to the `[documents]`
105
+ extra.
106
+ - **Dependencies:** `xyberos>=1.0`; optional `[documents]` for binary formats.
@@ -0,0 +1,90 @@
1
+ # xyberos-documents
2
+
3
+ **Filesystem + document loaders plugin — RFC-0019, M1.** Turns plain-text-only
4
+ ingestion into real document ingestion.
5
+
6
+ Loaders return text chunks consumed by
7
+ [`IngestingKnowledge.ingest`](https://docs.xyberos.com):
8
+
9
+ | Loader | Formats | Dependency |
10
+ | ------ | ------- | ---------- |
11
+ | `FileLoader` | `.md .txt .json .py .rst …` + directory walks | stdlib only |
12
+ | `HtmlLoader` | `.html .htm` (tag stripping) | stdlib only |
13
+ | `CsvLoader` | `.csv` | stdlib only |
14
+ | `PdfLoader` | `.pdf` | lazy `pypdf` / `PyPDF2` / `pymupdf` |
15
+ | `DocxLoader` | `.docx` | lazy `python-docx` |
16
+ | `XlsxLoader` | `.xlsx .xlsm` | lazy `openpyxl` |
17
+
18
+ The stdlib loaders keep the zero-dependency core sacred; the binary loaders
19
+ import their backend lazily and raise a clear `ProviderError` when missing.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install -e ./documents
25
+ # PDF/DOCX/XLSX backends (also available as the core extra):
26
+ pip install xyberos[documents] # pypdf, python-docx, openpyxl
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ Load a document as text chunks, then feed `IngestingKnowledge`:
32
+
33
+ ```python
34
+ from xyberos import create_app
35
+ from xyberos.knowledge import IngestingKnowledge
36
+ from xyberos.llm import HashEmbedder
37
+ from xyberos.vector import SqliteVectorStore
38
+ from xyberos_documents import DocumentsPlugin, load_document
39
+
40
+ app = create_app(
41
+ knowledge=IngestingKnowledge(SqliteVectorStore("learning.db"), embedder=HashEmbedder())
42
+ )
43
+ app.load_plugin(DocumentsPlugin())
44
+
45
+ app.tools.execute("ingest_document", None, path="report.pdf", chunk_size=512)
46
+ app.tools.execute("ingest_directory", None, path="docs/", extensions=[".pdf", ".docx"])
47
+ ```
48
+
49
+ Each loader can also be used standalone (no app required):
50
+
51
+ ```python
52
+ from xyberos_documents import PdfLoader, DocxLoader, HtmlLoader, load_document
53
+
54
+ for doc in PdfLoader().load("report.pdf"):
55
+ print(doc.text)
56
+ ```
57
+
58
+ ## Tools registered
59
+
60
+ - `ingest_document(path, chunk_size=512, loader=None)` — auto-detect by
61
+ extension (`loader` can force `text`/`html`/`pdf`/`docx`/`csv`/`xlsx`).
62
+ - `ingest_directory(path, extensions=None, chunk_size=512, recursive=True)` —
63
+ walk a folder and route each file to the right loader.
64
+
65
+ Both require the registered `knowledge` provider to support `ingest()` (an
66
+ `IngestingKnowledge`); otherwise a clear `ProviderError` is raised.
67
+
68
+ ## Examples
69
+
70
+ - `examples/ingest_documents.py` — generates a sample PDF + DOCX and ingests
71
+ both, then queries the knowledge base.
72
+
73
+ ## Tests
74
+
75
+ ```bash
76
+ pip install pytest
77
+ pytest tests/
78
+ ```
79
+
80
+ Optional-dep tests (`pdf`, `docx`, `xlsx`) skip cleanly when their library is
81
+ not installed — the same pattern as the core's `test_sentence_embedder.py`.
82
+
83
+ ## Contract & ship location
84
+
85
+ - **Contract:** `Knowledge` (via `IngestingKnowledge`), plus `Tool` for the
86
+ two ingest tools.
87
+ - **Ship:** `FileLoader` / `HtmlLoader` / `CsvLoader` are stdlib (would be
88
+ Core); `PdfLoader` / `DocxLoader` / `XlsxLoader` map to the `[documents]`
89
+ extra.
90
+ - **Dependencies:** `xyberos>=1.0`; optional `[documents]` for binary formats.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "xyberos-documents"
7
+ version = "0.1.0"
8
+ description = "Filesystem + document loaders plugin (M1): PDF, DOCX, HTML, CSV, XLSX -> IngestingKnowledge"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "Apache-2.0"}
12
+ dependencies = ["xyberos>=1.0"]
13
+ keywords = ["xyberos", "plugin", "documents", "loader", "knowledge", "ingestion"]
14
+
15
+ [project.optional-dependencies]
16
+ documents = [
17
+ "pypdf",
18
+ "python-docx",
19
+ "openpyxl",
20
+ ]
21
+ test = [
22
+ "pytest",
23
+ ]
24
+
25
+ [project.entry-points."xyberos.plugins"]
26
+ documents = "xyberos_documents.plugin:plugin"
27
+
28
+ [tool.setuptools]
29
+ packages = ["xyberos_documents"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ pythonpath = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,20 @@
1
+ """Tests for the stdlib CsvLoader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from xyberos_documents import CsvLoader
6
+
7
+ _CSV = "name,value\nalpha,1\nbeta,2\n"
8
+
9
+
10
+ def test_parses_rows(tmp_path):
11
+ path = tmp_path / "data.csv"
12
+ path.write_text(_CSV, encoding="utf-8")
13
+ docs = CsvLoader().load(str(path))
14
+ assert len(docs) == 1
15
+ text = docs[0].text
16
+ assert "name | value" in text
17
+ assert "alpha | 1" in text
18
+ assert "beta | 2" in text
19
+ assert docs[0].metadata["columns"] == ["name", "value"]
20
+ assert docs[0].metadata["row_count"] == 3
@@ -0,0 +1,16 @@
1
+ """Tests for the lazy DocxLoader (skips when python-docx is missing)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from xyberos_documents import DocxLoader
6
+
7
+
8
+ def test_extracts_paragraphs_and_tables(sample_docx):
9
+ docs = DocxLoader().load(str(sample_docx))
10
+ assert len(docs) == 1
11
+ text = docs[0].text
12
+ assert "First paragraph." in text
13
+ assert "Second paragraph." in text
14
+ assert "Alpha" in text
15
+ assert "Name | Value" in text
16
+ assert docs[0].metadata["tables"] == 1
@@ -0,0 +1,40 @@
1
+ """Tests for the stdlib FileLoader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from xyberos_documents import FileLoader
8
+
9
+
10
+ def test_load_single_file(sample_txt):
11
+ docs = FileLoader().load(str(sample_txt))
12
+ assert len(docs) == 1
13
+ assert "quick brown fox" in docs[0].text
14
+ assert docs[0].metadata["extension"] == ".md"
15
+
16
+
17
+ def test_load_directory_recursive_with_extension_filter(sample_dir):
18
+ docs = FileLoader(extensions=[".md", ".txt"]).load(str(sample_dir))
19
+ sources = {d.source for d in docs}
20
+ assert len(sources) == 3 # a.md, b.txt, nested/c.md
21
+ assert not any("skip.md" in s for s in sources) # hidden dir skipped
22
+
23
+
24
+ def test_load_directory_non_recursive(sample_dir):
25
+ docs = FileLoader(extensions=[".md"], recursive=False).load(str(sample_dir))
26
+ assert {d.source for d in docs} == {str(sample_dir / "a.md")}
27
+
28
+
29
+ def test_chunking(sample_txt):
30
+ long_text = "\n\n".join(f"Paragraph number {i} with enough words to split." for i in range(20))
31
+ sample_txt.write_text(long_text, encoding="utf-8")
32
+ docs = FileLoader(chunk_size=50).load(str(sample_txt))
33
+ assert len(docs) > 1
34
+ assert all(len(d.text) <= 50 for d in docs)
35
+ assert docs[0].metadata["chunk"] == 0
36
+
37
+
38
+ def test_missing_path_raises(tmp_path):
39
+ with pytest.raises(FileNotFoundError):
40
+ FileLoader().load(str(tmp_path / "nope.txt"))
@@ -0,0 +1,40 @@
1
+ """Tests for the stdlib HtmlLoader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from xyberos_documents import HtmlLoader
6
+
7
+ _HTML = """
8
+ <html>
9
+ <head><title>Test Page</title></head>
10
+ <body>
11
+ <h1>Heading</h1>
12
+ <p>Some <b>bold</b> text.</p>
13
+ <script>var secret = "no";</script>
14
+ <style>.hidden { color: red; }</style>
15
+ <p>After script.</p>
16
+ </body>
17
+ </html>
18
+ """
19
+
20
+
21
+ def test_strips_tags_and_script(tmp_path):
22
+ path = tmp_path / "page.html"
23
+ path.write_text(_HTML, encoding="utf-8")
24
+ docs = HtmlLoader().load(str(path))
25
+ assert len(docs) == 1
26
+ text = docs[0].text
27
+ assert "Some bold text." in text
28
+ assert "After script." in text
29
+ assert "Heading" in text
30
+ assert "var secret" not in text
31
+ assert ".hidden" not in text
32
+ assert docs[0].metadata["title"] == "Test Page"
33
+
34
+
35
+ def test_chunking(tmp_path):
36
+ body = "".join(f"<p>Sentence {i} here.</p>" for i in range(30))
37
+ path = tmp_path / "big.html"
38
+ path.write_text(f"<html><body>{body}</body></html>", encoding="utf-8")
39
+ docs = HtmlLoader(chunk_size=60).load(str(path))
40
+ assert len(docs) > 1
@@ -0,0 +1,20 @@
1
+ """Tests for the lazy PdfLoader (skips when no PDF library is installed)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+
7
+ import pytest
8
+
9
+ from xyberos_documents import PdfLoader
10
+
11
+ PDF_LIBS = ("pypdf", "PyPDF2", "fitz")
12
+
13
+
14
+ def test_extracts_text(sample_pdf):
15
+ if not any(importlib.util.find_spec(lib) for lib in PDF_LIBS):
16
+ pytest.skip("no pypdf / PyPDF2 / PyMuPDF available")
17
+ docs = PdfLoader().load(str(sample_pdf))
18
+ assert len(docs) == 1
19
+ assert "Hello Xyberos PDF" in docs[0].text
20
+ assert docs[0].metadata["pages"] == 1
@@ -0,0 +1,79 @@
1
+ """Tests for loading the documents plugin into a Xyberos app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+ from xyberos import create_app
9
+ from xyberos.exceptions.provider import ProviderError
10
+ from xyberos.knowledge import IngestingKnowledge
11
+ from xyberos.llm import HashEmbedder
12
+ from xyberos.vector import SqliteVectorStore
13
+
14
+ from xyberos_documents import DocumentsPlugin
15
+
16
+
17
+ def _app_with_ingesting_knowledge():
18
+ knowledge = IngestingKnowledge(SqliteVectorStore(":memory:"), embedder=HashEmbedder())
19
+ return create_app(knowledge=knowledge)
20
+
21
+
22
+ def test_plugin_conforms_to_contract():
23
+ plugin = DocumentsPlugin()
24
+ assert plugin.name == "documents"
25
+ assert callable(plugin.register) and callable(plugin.unregister)
26
+ assert {tool.name for tool in plugin.tools()} == {"ingest_document", "ingest_directory"}
27
+
28
+
29
+ def test_ingest_document(sample_txt):
30
+ app = _app_with_ingesting_knowledge()
31
+ app.load_plugin(DocumentsPlugin())
32
+ result = app.tools.execute("ingest_document", None, path=str(sample_txt))
33
+ assert result["documents"] == 1
34
+ assert result["chunks"] == 1
35
+ # Exact-match query against the ingested chunk returns the fact.
36
+ knowledge = app.knowledge
37
+ rendered = knowledge.query(SimpleNamespace(prompt="The quick brown fox jumps over the lazy dog."))
38
+ assert "The quick brown fox jumps over the lazy dog." in rendered
39
+ app.unload_plugin("documents")
40
+
41
+
42
+ def test_ingest_directory(sample_dir):
43
+ app = _app_with_ingesting_knowledge()
44
+ app.load_plugin(DocumentsPlugin())
45
+ result = app.tools.execute(
46
+ "ingest_directory", None, path=str(sample_dir), extensions=[".md"]
47
+ )
48
+ assert result["documents"] == 2 # a.md + nested/c.md
49
+ assert result["chunks"] == 2
50
+ app.unload_plugin("documents")
51
+
52
+
53
+ def test_ingest_directory_mixed_formats(sample_dir, sample_pdf):
54
+ import importlib.util
55
+
56
+ if not any(importlib.util.find_spec(lib) for lib in ("pypdf", "PyPDF2", "fitz")):
57
+ pytest.skip("no PDF library available")
58
+ # The sample_dir fixture is tmp_path, and sample_pdf writes into the same
59
+ # tmp_path — so the walk covers every format automatically.
60
+ app = _app_with_ingesting_knowledge()
61
+ app.load_plugin(DocumentsPlugin())
62
+ # a.md, b.txt, page.html, nested/c.md, sample.pdf
63
+ result = app.tools.execute(
64
+ "ingest_directory",
65
+ None,
66
+ path=str(sample_dir),
67
+ extensions=[".md", ".txt", ".html", ".pdf"],
68
+ )
69
+ assert result["documents"] == 5
70
+ assert result["chunks"] >= 5
71
+ app.unload_plugin("documents")
72
+
73
+
74
+ def test_ingest_requires_ingesting_knowledge():
75
+ app = create_app() # default InMemoryKnowledge — no ingest()
76
+ app.load_plugin(DocumentsPlugin())
77
+ with pytest.raises(ProviderError, match="ingest"):
78
+ app.tools.execute("ingest_document", None, path="anything.md")
79
+ app.unload_plugin("documents")
@@ -0,0 +1,54 @@
1
+ """Tests for extension -> loader routing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+
7
+ import pytest
8
+
9
+ from xyberos_documents import (
10
+ CsvLoader,
11
+ DocxLoader,
12
+ FileLoader,
13
+ HtmlLoader,
14
+ PdfLoader,
15
+ XlsxLoader,
16
+ get_loader,
17
+ load_document,
18
+ loader_for,
19
+ )
20
+
21
+
22
+ def test_loader_for_known_extensions():
23
+ assert isinstance(loader_for(".html"), HtmlLoader)
24
+ assert isinstance(loader_for(".htm"), HtmlLoader)
25
+ assert isinstance(loader_for(".pdf"), PdfLoader)
26
+ assert isinstance(loader_for(".docx"), DocxLoader)
27
+ assert isinstance(loader_for(".csv"), CsvLoader)
28
+ assert isinstance(loader_for(".xlsx"), XlsxLoader)
29
+
30
+
31
+ def test_loader_for_defaults_to_text():
32
+ loader = loader_for(".md")
33
+ assert isinstance(loader, FileLoader)
34
+ assert "md" in loader.extensions
35
+
36
+
37
+ def test_get_loader_by_name():
38
+ assert isinstance(get_loader("html"), HtmlLoader)
39
+ assert isinstance(get_loader("pdf"), PdfLoader)
40
+ assert isinstance(get_loader("text"), FileLoader)
41
+
42
+
43
+ def test_get_loader_unknown_raises():
44
+ with pytest.raises(ValueError, match="unknown loader"):
45
+ get_loader("bogus")
46
+
47
+
48
+ def test_load_document_auto_detect(tmp_path, sample_pdf, sample_docx):
49
+ md = tmp_path / "note.md"
50
+ md.write_text("Markdown text.", encoding="utf-8")
51
+ assert load_document(str(md))[0].text == "Markdown text."
52
+ if any(importlib.util.find_spec(lib) for lib in ("pypdf", "PyPDF2", "fitz")):
53
+ assert "Hello Xyberos PDF" in load_document(str(sample_pdf))[0].text
54
+ assert load_document(str(sample_docx))[0].metadata["extension"] == ".docx"
@@ -0,0 +1,17 @@
1
+ """Tests for the lazy XlsxLoader (skips when openpyxl is missing)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from xyberos_documents import XlsxLoader
6
+
7
+
8
+ def test_parses_cells(sample_xlsx):
9
+ docs = XlsxLoader().load(str(sample_xlsx))
10
+ assert len(docs) == 1
11
+ text = docs[0].text
12
+ assert "# Sheet1" in text
13
+ assert "name | value" in text
14
+ assert "alpha | 1" in text
15
+ assert "beta | 2" in text
16
+ assert docs[0].metadata["sheets"] == ["Sheet1"]
17
+ assert docs[0].metadata["row_count"] == 3
@@ -0,0 +1,44 @@
1
+ """Filesystem + document loaders plugin (RFC-0019, M1).
2
+
3
+ Turns plain-text-only ingestion into real document ingestion. Each loader
4
+ returns text chunks that ``IngestingKnowledge.ingest`` consumes directly:
5
+
6
+ * :class:`FileLoader` and :class:`HtmlLoader` are **stdlib-only** (the
7
+ zero-dependency core stays sacred).
8
+ * :class:`PdfLoader`, :class:`DocxLoader` and :class:`XlsxLoader` import their
9
+ backend lazily and raise a clear ``ProviderError`` when it is missing
10
+ (``pip install xyberos[documents]`` provides ``pypdf`` / ``python-docx`` /
11
+ ``openpyxl``).
12
+ * :class:`CsvLoader` is stdlib-only (``csv``).
13
+
14
+ The :class:`~xyberos_documents.plugin.DocumentsPlugin` registers two tools —
15
+ ``ingest_document`` and ``ingest_directory`` — that feed the app's
16
+ ``IngestingKnowledge``.
17
+ """
18
+
19
+ from .base import Document, Loader, chunk_text, chunk_documents
20
+ from .csv_loader import CsvLoader
21
+ from .docx_loader import DocxLoader
22
+ from .file_loader import FileLoader
23
+ from .html_loader import HtmlLoader
24
+ from .pdf_loader import PdfLoader
25
+ from .plugin import DocumentsPlugin
26
+ from .registry import get_loader, load_document, loader_for
27
+ from .xlsx_loader import XlsxLoader
28
+
29
+ __all__ = [
30
+ "CsvLoader",
31
+ "Document",
32
+ "DocumentsPlugin",
33
+ "DocxLoader",
34
+ "FileLoader",
35
+ "HtmlLoader",
36
+ "Loader",
37
+ "PdfLoader",
38
+ "XlsxLoader",
39
+ "chunk_documents",
40
+ "chunk_text",
41
+ "get_loader",
42
+ "load_document",
43
+ "loader_for",
44
+ ]
@@ -0,0 +1,59 @@
1
+ """Shared types and text chunking for the documents plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Mapping, Protocol, runtime_checkable
7
+
8
+
9
+ @dataclass
10
+ class Document:
11
+ """One loaded document: its source, extracted text, and metadata."""
12
+
13
+ source: str
14
+ text: str
15
+ metadata: Mapping[str, Any] | None = None
16
+
17
+
18
+ @runtime_checkable
19
+ class Loader(Protocol):
20
+ """Any object that turns a path into a list of :class:`Document`."""
21
+
22
+ def load(self, path: str, **kwargs: Any) -> list[Document]: ...
23
+
24
+
25
+ def chunk_text(text: str, chunk_size: int = 512) -> list[str]:
26
+ """Split ``text`` into paragraph-aware chunks of at most ``chunk_size`` chars.
27
+
28
+ Mirrors ``xyberos.knowledge.ingesting._chunk_text`` so the loaders and the
29
+ core agree on chunk boundaries.
30
+ """
31
+ if chunk_size <= 0:
32
+ raise ValueError("chunk_size must be a positive integer")
33
+ text = (text or "").strip()
34
+ if not text:
35
+ return []
36
+ paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
37
+ chunks: list[str] = []
38
+ for paragraph in paragraphs:
39
+ if len(paragraph) <= chunk_size:
40
+ chunks.append(paragraph)
41
+ else:
42
+ chunks.extend(
43
+ paragraph[index : index + chunk_size]
44
+ for index in range(0, len(paragraph), chunk_size)
45
+ )
46
+ return chunks
47
+
48
+
49
+ def chunk_documents(documents: list[Document], chunk_size: int | None) -> list[Document]:
50
+ """Expand each document into one :class:`Document` per chunk (if sized)."""
51
+ if chunk_size is None:
52
+ return documents
53
+ expanded: list[Document] = []
54
+ for doc in documents:
55
+ for index, chunk in enumerate(chunk_text(doc.text, chunk_size)):
56
+ metadata = dict(doc.metadata or {})
57
+ metadata["chunk"] = index
58
+ expanded.append(Document(source=doc.source, text=chunk, metadata=metadata))
59
+ return expanded
@@ -0,0 +1,39 @@
1
+ """CSV loader — stdlib-only (``csv``).
2
+
3
+ Each row becomes one line with columns joined by ``" | "``; the first row is
4
+ kept as the header line and also recorded in metadata.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import csv
10
+ from pathlib import Path
11
+
12
+ from .base import Document, chunk_documents
13
+
14
+
15
+ class CsvLoader:
16
+ """Loads a CSV file as text (one line per row, columns joined)."""
17
+
18
+ def __init__(self, chunk_size: int | None = None, *, delimiter: str = ",") -> None:
19
+ self._chunk_size = chunk_size
20
+ self._delimiter = delimiter
21
+
22
+ def load(self, path: str) -> list[Document]:
23
+ path_obj = Path(path)
24
+ if not path_obj.is_file():
25
+ raise FileNotFoundError(path)
26
+ with path_obj.open(newline="", encoding="utf-8-sig") as handle:
27
+ rows = list(csv.reader(handle, delimiter=self._delimiter))
28
+ columns = rows[0] if rows else []
29
+ lines = [" | ".join(cell.strip() for cell in row) for row in rows]
30
+ document = Document(
31
+ source=str(path_obj),
32
+ text="\n".join(lines),
33
+ metadata={
34
+ "columns": columns,
35
+ "row_count": len(rows),
36
+ "extension": ".csv",
37
+ },
38
+ )
39
+ return chunk_documents([document], self._chunk_size)