dirsql-plugin-embeddings 0.1.6__tar.gz → 0.1.8__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 (22) hide show
  1. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/PKG-INFO +24 -5
  2. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/README.md +23 -4
  3. dirsql_plugin_embeddings-0.1.6/e2e-attestations/claude-701-cache-pdf.json → dirsql_plugin_embeddings-0.1.8/e2e-attestations/claude-706-widen-glob.json +3 -3
  4. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/pyproject.toml +2 -2
  5. dirsql_plugin_embeddings-0.1.8/src/dirsql_plugin_embeddings/dirsql.toml +27 -0
  6. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/src/dirsql_plugin_embeddings/on_file/read_content.py +9 -0
  7. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/tests/conftest.py +40 -0
  8. dirsql_plugin_embeddings-0.1.6/src/dirsql_plugin_embeddings/dirsql.toml +0 -18
  9. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/.gitignore +0 -0
  10. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/src/dirsql_plugin_embeddings/__init__.py +0 -0
  11. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/src/dirsql_plugin_embeddings/cache.py +0 -0
  12. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/src/dirsql_plugin_embeddings/config.py +0 -0
  13. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/src/dirsql_plugin_embeddings/embedder.py +0 -0
  14. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/src/dirsql_plugin_embeddings/on_file/__init__.py +0 -0
  15. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/src/dirsql_plugin_embeddings/on_file/build_rows.py +0 -0
  16. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/src/dirsql_plugin_embeddings/on_file/on_file.py +0 -0
  17. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/src/dirsql_plugin_embeddings/on_file/read_pdf.py +0 -0
  18. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/src/dirsql_plugin_embeddings/on_file/read_text.py +0 -0
  19. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/src/dirsql_plugin_embeddings/pre_query.py +0 -0
  20. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/testing-conventions.toml +0 -0
  21. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/tests/e2e/__init__.py +0 -0
  22. {dirsql_plugin_embeddings-0.1.6 → dirsql_plugin_embeddings-0.1.8}/tests/integration/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql-plugin-embeddings
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: First-party dirsql plugin: semantic search via an OpenAI-compatible embeddings endpoint.
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: cachetta>=0.6.15
@@ -10,7 +10,8 @@ Description-Content-Type: text/markdown
10
10
  # dirsql-plugin-embeddings
11
11
 
12
12
  A first-party [`dirsql`](https://github.com/thekevinscott/dirsql) plugin that
13
- adds **semantic search** over a directory of Markdown files. It is the worked
13
+ adds **semantic search** over a directory of documents -- Markdown, plain
14
+ text, reStructuredText and PDFs. It is the worked
14
15
  implementation behind the [Search documents by
15
16
  meaning](https://thekevinscott.github.io/dirsql/howto/search-by-meaning) how-to,
16
17
  swapping that guide's local `model2vec` model for any OpenAI-compatible
@@ -30,13 +31,31 @@ is installed alongside it. The fragment declares:
30
31
 
31
32
  - the [`sqlite-vec`](https://github.com/asg017/sqlite-vec) extension, for
32
33
  `vec_distance_cosine()`;
33
- - a `documents` table whose `on-file` hook embeds each `**/*.md` file into a
34
- TEXT `embedding` column;
34
+ - a `documents` table whose `on-file` hook embeds each
35
+ `**/*.{md,markdown,mdx,rst,txt,pdf}` file into a TEXT `embedding` column;
35
36
  - a `pre-query` hook that embeds the incoming question and emits the
36
37
  nearest-neighbor SQL.
37
38
 
38
39
  Both hooks are console scripts that call the same embedder.
39
40
 
41
+ Every matched extension except `.pdf` is read as UTF-8 text; a `.pdf` is read with
42
+ [pypdf](https://pypdf.readthedocs.io), whose per-page extracted text is joined
43
+ and embedded like any other document. The extension check is case-insensitive
44
+ (`.PDF` is a PDF), though the glob above is not — globset matches case-sensitively,
45
+ so an uppercase-suffixed file needs its own `glob` entry to be picked up at all.
46
+
47
+ The glob is an allowlist rather than `**/*` for a reason worth knowing: a hook
48
+ that exits non-zero aborts the entire scan
49
+ ([dirsql#697](https://github.com/thekevinscott/dirsql/issues/697)), and reading a
50
+ PNG as UTF-8 does exactly that. Matching everything would mean one image anywhere
51
+ under the root produces no index at all, so the list stays limited to what the
52
+ plugin can actually read.
53
+
54
+ A PDF that cannot be parsed aborts the scan rather than being skipped: the hook
55
+ exits non-zero and dirsql reports the path and the pypdf reason. A *scanned*,
56
+ image-only PDF is not a failure — pypdf yields no text, and the file is indexed
57
+ with an empty `text`, exactly like an empty `.md`.
58
+
40
59
  ## Configuration
41
60
 
42
61
  The embedder reads three environment variables (point them at any hosted or
@@ -52,7 +71,7 @@ self-managed OpenAI-compatible inference server):
52
71
 
53
72
  | Script | Hook | Input | Output |
54
73
  |---|---|---|---|
55
- | `dirsql-embeddings-on-file` | `on-file` | a file's absolute path (`argv[1]`) | one-line JSON row array with `path`, `text`, `embedding` |
74
+ | `dirsql-embeddings-on-file` | `on-file` | a file's absolute path (`argv[1]`), text or PDF | one-line JSON row array with `path`, `text`, `embedding` |
56
75
  | `dirsql-embeddings-pre-query` | `pre-query` | a raw request body (`argv[1]`) | nearest-neighbor SQL over `documents` |
57
76
 
58
77
  `pre-query` accepts both a verbatim server body (`{"q": ...}`) and the CLI
@@ -1,7 +1,8 @@
1
1
  # dirsql-plugin-embeddings
2
2
 
3
3
  A first-party [`dirsql`](https://github.com/thekevinscott/dirsql) plugin that
4
- adds **semantic search** over a directory of Markdown files. It is the worked
4
+ adds **semantic search** over a directory of documents -- Markdown, plain
5
+ text, reStructuredText and PDFs. It is the worked
5
6
  implementation behind the [Search documents by
6
7
  meaning](https://thekevinscott.github.io/dirsql/howto/search-by-meaning) how-to,
7
8
  swapping that guide's local `model2vec` model for any OpenAI-compatible
@@ -21,13 +22,31 @@ is installed alongside it. The fragment declares:
21
22
 
22
23
  - the [`sqlite-vec`](https://github.com/asg017/sqlite-vec) extension, for
23
24
  `vec_distance_cosine()`;
24
- - a `documents` table whose `on-file` hook embeds each `**/*.md` file into a
25
- TEXT `embedding` column;
25
+ - a `documents` table whose `on-file` hook embeds each
26
+ `**/*.{md,markdown,mdx,rst,txt,pdf}` file into a TEXT `embedding` column;
26
27
  - a `pre-query` hook that embeds the incoming question and emits the
27
28
  nearest-neighbor SQL.
28
29
 
29
30
  Both hooks are console scripts that call the same embedder.
30
31
 
32
+ Every matched extension except `.pdf` is read as UTF-8 text; a `.pdf` is read with
33
+ [pypdf](https://pypdf.readthedocs.io), whose per-page extracted text is joined
34
+ and embedded like any other document. The extension check is case-insensitive
35
+ (`.PDF` is a PDF), though the glob above is not — globset matches case-sensitively,
36
+ so an uppercase-suffixed file needs its own `glob` entry to be picked up at all.
37
+
38
+ The glob is an allowlist rather than `**/*` for a reason worth knowing: a hook
39
+ that exits non-zero aborts the entire scan
40
+ ([dirsql#697](https://github.com/thekevinscott/dirsql/issues/697)), and reading a
41
+ PNG as UTF-8 does exactly that. Matching everything would mean one image anywhere
42
+ under the root produces no index at all, so the list stays limited to what the
43
+ plugin can actually read.
44
+
45
+ A PDF that cannot be parsed aborts the scan rather than being skipped: the hook
46
+ exits non-zero and dirsql reports the path and the pypdf reason. A *scanned*,
47
+ image-only PDF is not a failure — pypdf yields no text, and the file is indexed
48
+ with an empty `text`, exactly like an empty `.md`.
49
+
31
50
  ## Configuration
32
51
 
33
52
  The embedder reads three environment variables (point them at any hosted or
@@ -43,7 +62,7 @@ self-managed OpenAI-compatible inference server):
43
62
 
44
63
  | Script | Hook | Input | Output |
45
64
  |---|---|---|---|
46
- | `dirsql-embeddings-on-file` | `on-file` | a file's absolute path (`argv[1]`) | one-line JSON row array with `path`, `text`, `embedding` |
65
+ | `dirsql-embeddings-on-file` | `on-file` | a file's absolute path (`argv[1]`), text or PDF | one-line JSON row array with `path`, `text`, `embedding` |
47
66
  | `dirsql-embeddings-pre-query` | `pre-query` | a raw request body (`argv[1]`) | nearest-neighbor SQL over `documents` |
48
67
 
49
68
  `pre-query` accepts both a verbatim server body (`{"q": ...}`) and the CLI
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "command": "uv run --with sqlite-vec --with-editable . --with-editable ../../packages/python python -m pytest tests/e2e -x -q",
3
- "ran_at": 1785337806,
3
+ "ran_at": 1785339217,
4
4
  "exit_code": 0,
5
- "commit": "43a5ab7f9e72cde7f55e943649f4e24b85d7fe69",
6
- "branch": "claude/701-cache-pdf"
5
+ "commit": "f96d82085395d4e38bd74992a6ae3c7b029d5f60",
6
+ "branch": "claude/706-widen-glob"
7
7
  }
@@ -16,8 +16,8 @@ requires-python = ">=3.10"
16
16
  # MIT-licensed package propagates to everyone who installs it.
17
17
  #
18
18
  # cachetta >=0.6.15 is load-bearing, not a nicety: every earlier release
19
- # declares `requires-python >=3.12`, which would drag this package's `>=3.11`
20
- # floor up with it. 0.6.15 lowered it to `>=3.10`.
19
+ # declares `requires-python >=3.12`, which would drag this package's floor up
20
+ # with it. 0.6.15 lowered cachetta's own floor to `>=3.10`, matching ours.
21
21
  dependencies = ["pypdf>=6", "cachetta>=0.6.15"]
22
22
 
23
23
  [tool.hatch.version]
@@ -0,0 +1,27 @@
1
+ # Fragment shipped by the dirsql-plugin-embeddings plugin.
2
+ # Discovered via the package's [project.entry-points.dirsql] declaration and
3
+ # injected by the launcher as an ordinary -c flag ("installed = active", #529).
4
+ #
5
+ # The table name here MUST match pre_query.TABLE_NAME (the SQL the pre-query
6
+ # hook prints queries this table).
7
+ [dirsql]
8
+ pre-query = "dirsql-embeddings-pre-query {args}"
9
+ hook-timeout = 300
10
+
11
+ [[dirsql.extension]]
12
+ path = "sqlite_vec"
13
+ entrypoint = "sqlite3_vec_init"
14
+
15
+ # The brace is globset alternation, not a dirsql `{name}` placeholder: the core
16
+ # rewrites those to `*`, but only when the braces hold a bare identifier, so a
17
+ # comma-separated list reaches globset intact.
18
+ #
19
+ # An allowlist rather than `**/*`, and not for tidiness: a hook that fails takes
20
+ # the whole scan down (dirsql#697), and reading a PNG as UTF-8 fails. Matching
21
+ # everything would mean one image anywhere under the root yields no index at
22
+ # all. The list is therefore what `read_content` can actually read -- prose, not
23
+ # source -- and widening it is safe only as far as that stays true.
24
+ [[table]]
25
+ ddl = "CREATE TABLE documents (path TEXT, text TEXT, embedding TEXT)"
26
+ glob = "**/*.{md,markdown,mdx,rst,txt,pdf}"
27
+ on-file = "dirsql-embeddings-on-file {path}"
@@ -1,13 +1,22 @@
1
1
  """The entry point's single read seam: everything the hook reads comes through
2
2
  here, so per-format handling has one place to land without touching `on_file`.
3
3
 
4
+ The extension is matched case-insensitively, and more permissively than the
5
+ fragment's glob (which globset matches case-sensitively): the hook is a console
6
+ script runnable against any path, not only the ones a glob selected.
7
+
4
8
  Annotations are evaluated at runtime (no ``from __future__ import
5
9
  annotations``) so a mutated ``X | None`` union in a signature fails at import
6
10
  rather than surviving as an inert string.
7
11
  """
8
12
 
13
+ import os
14
+
15
+ from .read_pdf import read_pdf
9
16
  from .read_text import read_text
10
17
 
11
18
 
12
19
  def read_content(path: str) -> str:
20
+ if os.path.splitext(path)[1].lower() == ".pdf":
21
+ return read_pdf(path)
13
22
  return read_text(path)
@@ -5,6 +5,10 @@
5
5
  vector, so nearest-neighbor search over the fixtures is reproducible without a
6
6
  real model or network.
7
7
 
8
+ `make_pdf` builds a real PDF by hand rather than pulling in a writer library:
9
+ these tiers mock nothing, and a one-page catalog with a `BT ... Tj ET` content
10
+ stream is enough for pypdf to extract the text back out.
11
+
8
12
  Cache reads are switched off for both tiers. These tiers run the hooks as
9
13
  subprocesses, which no in-process mock can reach, so a cached extraction from an
10
14
  earlier run would otherwise decide this one's result. Writes are left alone --
@@ -57,6 +61,42 @@ class _Handler(BaseHTTPRequestHandler):
57
61
  pass
58
62
 
59
63
 
64
+ def _make_pdf(text):
65
+ stream = f"BT /F1 24 Tf 72 700 Td ({text}) Tj ET".encode()
66
+ objs = [
67
+ b"<< /Type /Catalog /Pages 2 0 R >>",
68
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
69
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
70
+ b"/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
71
+ b"<< /Length "
72
+ + str(len(stream)).encode()
73
+ + b" >>\nstream\n"
74
+ + stream
75
+ + b"\nendstream",
76
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
77
+ ]
78
+ out = bytearray(b"%PDF-1.4\n")
79
+ offsets = []
80
+ for index, body in enumerate(objs, start=1):
81
+ offsets.append(len(out))
82
+ out += f"{index} 0 obj\n".encode() + body + b"\nendobj\n"
83
+ xref = len(out)
84
+ out += f"xref\n0 {len(objs) + 1}\n".encode()
85
+ out += b"0000000000 65535 f \n"
86
+ for offset in offsets:
87
+ out += f"{offset:010d} 00000 n \n".encode()
88
+ out += (
89
+ f"trailer\n<< /Size {len(objs) + 1} /Root 1 0 R >>\n"
90
+ f"startxref\n{xref}\n%%EOF\n"
91
+ ).encode()
92
+ return bytes(out)
93
+
94
+
95
+ @pytest.fixture
96
+ def make_pdf():
97
+ return _make_pdf
98
+
99
+
60
100
  @pytest.fixture
61
101
  def stub_server():
62
102
  server = HTTPServer(("127.0.0.1", 0), _Handler)
@@ -1,18 +0,0 @@
1
- # Fragment shipped by the dirsql-plugin-embeddings plugin.
2
- # Discovered via the package's [project.entry-points.dirsql] declaration and
3
- # injected by the launcher as an ordinary -c flag ("installed = active", #529).
4
- #
5
- # The table name here MUST match pre_query.TABLE_NAME (the SQL the pre-query
6
- # hook prints queries this table).
7
- [dirsql]
8
- pre-query = "dirsql-embeddings-pre-query {args}"
9
- hook-timeout = 300
10
-
11
- [[dirsql.extension]]
12
- path = "sqlite_vec"
13
- entrypoint = "sqlite3_vec_init"
14
-
15
- [[table]]
16
- ddl = "CREATE TABLE documents (path TEXT, text TEXT, embedding TEXT)"
17
- glob = "**/*.md"
18
- on-file = "dirsql-embeddings-on-file {path}"