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.
- xyberos_documents-0.1.0/PKG-INFO +106 -0
- xyberos_documents-0.1.0/README.md +90 -0
- xyberos_documents-0.1.0/pyproject.toml +33 -0
- xyberos_documents-0.1.0/setup.cfg +4 -0
- xyberos_documents-0.1.0/tests/test_csv_loader.py +20 -0
- xyberos_documents-0.1.0/tests/test_docx_loader.py +16 -0
- xyberos_documents-0.1.0/tests/test_file_loader.py +40 -0
- xyberos_documents-0.1.0/tests/test_html_loader.py +40 -0
- xyberos_documents-0.1.0/tests/test_pdf_loader.py +20 -0
- xyberos_documents-0.1.0/tests/test_plugin.py +79 -0
- xyberos_documents-0.1.0/tests/test_registry.py +54 -0
- xyberos_documents-0.1.0/tests/test_xlsx_loader.py +17 -0
- xyberos_documents-0.1.0/xyberos_documents/__init__.py +44 -0
- xyberos_documents-0.1.0/xyberos_documents/base.py +59 -0
- xyberos_documents-0.1.0/xyberos_documents/csv_loader.py +39 -0
- xyberos_documents-0.1.0/xyberos_documents/docx_loader.py +57 -0
- xyberos_documents-0.1.0/xyberos_documents/file_loader.py +80 -0
- xyberos_documents-0.1.0/xyberos_documents/html_loader.py +94 -0
- xyberos_documents-0.1.0/xyberos_documents/pdf_loader.py +69 -0
- xyberos_documents-0.1.0/xyberos_documents/plugin.py +122 -0
- xyberos_documents-0.1.0/xyberos_documents/registry.py +96 -0
- xyberos_documents-0.1.0/xyberos_documents/xlsx_loader.py +59 -0
- xyberos_documents-0.1.0/xyberos_documents.egg-info/PKG-INFO +106 -0
- xyberos_documents-0.1.0/xyberos_documents.egg-info/SOURCES.txt +26 -0
- xyberos_documents-0.1.0/xyberos_documents.egg-info/dependency_links.txt +1 -0
- xyberos_documents-0.1.0/xyberos_documents.egg-info/entry_points.txt +2 -0
- xyberos_documents-0.1.0/xyberos_documents.egg-info/requires.txt +9 -0
- 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,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)
|