dirsql-plugin-embeddings 0.1.5__tar.gz → 0.1.7__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.
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/PKG-INFO +17 -5
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/README.md +15 -4
- dirsql_plugin_embeddings-0.1.5/e2e-attestations/claude-699-read-pdf.json → dirsql_plugin_embeddings-0.1.7/e2e-attestations/claude-700-route-pdf.json +3 -3
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/pyproject.toml +5 -1
- dirsql_plugin_embeddings-0.1.7/src/dirsql_plugin_embeddings/cache.py +20 -0
- dirsql_plugin_embeddings-0.1.7/src/dirsql_plugin_embeddings/config.py +31 -0
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/src/dirsql_plugin_embeddings/dirsql.toml +4 -1
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/src/dirsql_plugin_embeddings/on_file/read_content.py +9 -0
- dirsql_plugin_embeddings-0.1.7/src/dirsql_plugin_embeddings/on_file/read_pdf.py +31 -0
- dirsql_plugin_embeddings-0.1.7/tests/conftest.py +110 -0
- dirsql_plugin_embeddings-0.1.5/src/dirsql_plugin_embeddings/on_file/read_pdf.py +0 -16
- dirsql_plugin_embeddings-0.1.5/tests/conftest.py +0 -62
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/.gitignore +0 -0
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/src/dirsql_plugin_embeddings/__init__.py +0 -0
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/src/dirsql_plugin_embeddings/embedder.py +0 -0
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/src/dirsql_plugin_embeddings/on_file/__init__.py +0 -0
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/src/dirsql_plugin_embeddings/on_file/build_rows.py +0 -0
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/src/dirsql_plugin_embeddings/on_file/on_file.py +0 -0
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/src/dirsql_plugin_embeddings/on_file/read_text.py +0 -0
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/src/dirsql_plugin_embeddings/pre_query.py +0 -0
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/testing-conventions.toml +0 -0
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/tests/e2e/__init__.py +0 -0
- {dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/tests/integration/__init__.py +0 -0
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dirsql-plugin-embeddings
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: First-party dirsql plugin: semantic search via an OpenAI-compatible embeddings endpoint.
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: cachetta>=0.6.15
|
|
6
7
|
Requires-Dist: pypdf>=6
|
|
7
8
|
Description-Content-Type: text/markdown
|
|
8
9
|
|
|
9
10
|
# dirsql-plugin-embeddings
|
|
10
11
|
|
|
11
12
|
A first-party [`dirsql`](https://github.com/thekevinscott/dirsql) plugin that
|
|
12
|
-
adds **semantic search** over a directory of Markdown files. It is the worked
|
|
13
|
+
adds **semantic search** over a directory of Markdown files and PDFs. It is the worked
|
|
13
14
|
implementation behind the [Search documents by
|
|
14
15
|
meaning](https://thekevinscott.github.io/dirsql/howto/search-by-meaning) how-to,
|
|
15
16
|
swapping that guide's local `model2vec` model for any OpenAI-compatible
|
|
@@ -29,13 +30,24 @@ is installed alongside it. The fragment declares:
|
|
|
29
30
|
|
|
30
31
|
- the [`sqlite-vec`](https://github.com/asg017/sqlite-vec) extension, for
|
|
31
32
|
`vec_distance_cosine()`;
|
|
32
|
-
- a `documents` table whose `on-file` hook embeds each `**/*.md` file into
|
|
33
|
-
TEXT `embedding` column;
|
|
33
|
+
- a `documents` table whose `on-file` hook embeds each `**/*.{md,pdf}` file into
|
|
34
|
+
a TEXT `embedding` column;
|
|
34
35
|
- a `pre-query` hook that embeds the incoming question and emits the
|
|
35
36
|
nearest-neighbor SQL.
|
|
36
37
|
|
|
37
38
|
Both hooks are console scripts that call the same embedder.
|
|
38
39
|
|
|
40
|
+
Markdown is read as UTF-8 text; a `.pdf` is read with
|
|
41
|
+
[pypdf](https://pypdf.readthedocs.io), whose per-page extracted text is joined
|
|
42
|
+
and embedded like any other document. The extension check is case-insensitive
|
|
43
|
+
(`.PDF` is a PDF), though the glob above is not — globset matches case-sensitively,
|
|
44
|
+
so an uppercase-suffixed file needs its own `glob` entry to be picked up at all.
|
|
45
|
+
|
|
46
|
+
A PDF that cannot be parsed aborts the scan rather than being skipped: the hook
|
|
47
|
+
exits non-zero and dirsql reports the path and the pypdf reason. A *scanned*,
|
|
48
|
+
image-only PDF is not a failure — pypdf yields no text, and the file is indexed
|
|
49
|
+
with an empty `text`, exactly like an empty `.md`.
|
|
50
|
+
|
|
39
51
|
## Configuration
|
|
40
52
|
|
|
41
53
|
The embedder reads three environment variables (point them at any hosted or
|
|
@@ -51,7 +63,7 @@ self-managed OpenAI-compatible inference server):
|
|
|
51
63
|
|
|
52
64
|
| Script | Hook | Input | Output |
|
|
53
65
|
|---|---|---|---|
|
|
54
|
-
| `dirsql-embeddings-on-file` | `on-file` | a file's absolute path (`argv[1]`) | one-line JSON row array with `path`, `text`, `embedding` |
|
|
66
|
+
| `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` |
|
|
55
67
|
| `dirsql-embeddings-pre-query` | `pre-query` | a raw request body (`argv[1]`) | nearest-neighbor SQL over `documents` |
|
|
56
68
|
|
|
57
69
|
`pre-query` accepts both a verbatim server body (`{"q": ...}`) and the CLI
|
|
@@ -1,7 +1,7 @@
|
|
|
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 Markdown files and PDFs. It is the worked
|
|
5
5
|
implementation behind the [Search documents by
|
|
6
6
|
meaning](https://thekevinscott.github.io/dirsql/howto/search-by-meaning) how-to,
|
|
7
7
|
swapping that guide's local `model2vec` model for any OpenAI-compatible
|
|
@@ -21,13 +21,24 @@ is installed alongside it. The fragment declares:
|
|
|
21
21
|
|
|
22
22
|
- the [`sqlite-vec`](https://github.com/asg017/sqlite-vec) extension, for
|
|
23
23
|
`vec_distance_cosine()`;
|
|
24
|
-
- a `documents` table whose `on-file` hook embeds each `**/*.md` file into
|
|
25
|
-
TEXT `embedding` column;
|
|
24
|
+
- a `documents` table whose `on-file` hook embeds each `**/*.{md,pdf}` file into
|
|
25
|
+
a TEXT `embedding` column;
|
|
26
26
|
- a `pre-query` hook that embeds the incoming question and emits the
|
|
27
27
|
nearest-neighbor SQL.
|
|
28
28
|
|
|
29
29
|
Both hooks are console scripts that call the same embedder.
|
|
30
30
|
|
|
31
|
+
Markdown is read as UTF-8 text; a `.pdf` is read with
|
|
32
|
+
[pypdf](https://pypdf.readthedocs.io), whose per-page extracted text is joined
|
|
33
|
+
and embedded like any other document. The extension check is case-insensitive
|
|
34
|
+
(`.PDF` is a PDF), though the glob above is not — globset matches case-sensitively,
|
|
35
|
+
so an uppercase-suffixed file needs its own `glob` entry to be picked up at all.
|
|
36
|
+
|
|
37
|
+
A PDF that cannot be parsed aborts the scan rather than being skipped: the hook
|
|
38
|
+
exits non-zero and dirsql reports the path and the pypdf reason. A *scanned*,
|
|
39
|
+
image-only PDF is not a failure — pypdf yields no text, and the file is indexed
|
|
40
|
+
with an empty `text`, exactly like an empty `.md`.
|
|
41
|
+
|
|
31
42
|
## Configuration
|
|
32
43
|
|
|
33
44
|
The embedder reads three environment variables (point them at any hosted or
|
|
@@ -43,7 +54,7 @@ self-managed OpenAI-compatible inference server):
|
|
|
43
54
|
|
|
44
55
|
| Script | Hook | Input | Output |
|
|
45
56
|
|---|---|---|---|
|
|
46
|
-
| `dirsql-embeddings-on-file` | `on-file` | a file's absolute path (`argv[1]`) | one-line JSON row array with `path`, `text`, `embedding` |
|
|
57
|
+
| `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
58
|
| `dirsql-embeddings-pre-query` | `pre-query` | a raw request body (`argv[1]`) | nearest-neighbor SQL over `documents` |
|
|
48
59
|
|
|
49
60
|
`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":
|
|
3
|
+
"ran_at": 1785338799,
|
|
4
4
|
"exit_code": 0,
|
|
5
|
-
"commit": "
|
|
6
|
-
"branch": "claude/
|
|
5
|
+
"commit": "fcca643ac74c98dd9a6b525681711c720c303250",
|
|
6
|
+
"branch": "claude/700-route-pdf"
|
|
7
7
|
}
|
|
@@ -14,7 +14,11 @@ readme = "README.md"
|
|
|
14
14
|
requires-python = ">=3.10"
|
|
15
15
|
# pypdf, not pymupdf: pymupdf is AGPL-3.0, and a runtime dependency of this
|
|
16
16
|
# MIT-licensed package propagates to everyone who installs it.
|
|
17
|
-
|
|
17
|
+
#
|
|
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 floor up
|
|
20
|
+
# with it. 0.6.15 lowered cachetta's own floor to `>=3.10`, matching ours.
|
|
21
|
+
dependencies = ["pypdf>=6", "cachetta>=0.6.15"]
|
|
18
22
|
|
|
19
23
|
[tool.hatch.version]
|
|
20
24
|
source = "vcs"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""The plugin's one configured cache.
|
|
2
|
+
|
|
3
|
+
Every cached function derives its own sub-cache from this singleton with
|
|
4
|
+
cachetta's ``cache / 'name'``, which keeps the defaults and appends a
|
|
5
|
+
subdirectory. Naming that subdirectory after the function is what keeps caches
|
|
6
|
+
apart: cachetta hashes the *arguments* of a call, not the identity of the
|
|
7
|
+
function, so two cached functions sharing a directory would collide as soon as
|
|
8
|
+
their argument shapes matched.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from cachetta import Cachetta
|
|
12
|
+
|
|
13
|
+
from .config import CACHE_DIR, CACHE_DURATION, CACHE_READ
|
|
14
|
+
|
|
15
|
+
cache = Cachetta(
|
|
16
|
+
path=CACHE_DIR,
|
|
17
|
+
hashed=True,
|
|
18
|
+
duration=CACHE_DURATION,
|
|
19
|
+
read=CACHE_READ,
|
|
20
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Cache configuration: what the plugin caches, where, and for how long.
|
|
2
|
+
|
|
3
|
+
Annotations are evaluated at runtime (no ``from __future__ import
|
|
4
|
+
annotations``) so a mutated ``X | None`` union in a signature fails at import
|
|
5
|
+
rather than surviving as an inert string.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
PLUGIN_NAME = "dirsql-plugin-embeddings"
|
|
13
|
+
|
|
14
|
+
CACHE_DIR = Path.home() / ".cache" / PLUGIN_NAME
|
|
15
|
+
|
|
16
|
+
# Every cache here keys on the source file's mtime, so a hit can never be
|
|
17
|
+
# stale and expiry would only re-do work for a file nothing has touched.
|
|
18
|
+
# cachetta's own default is 7 days.
|
|
19
|
+
CACHE_DURATION = timedelta(days=365)
|
|
20
|
+
|
|
21
|
+
# Set to "0" to make every call recompute. Writes still happen -- it is reads
|
|
22
|
+
# that would otherwise let one test run's leftovers decide the next one's
|
|
23
|
+
# result, and the hooks are subprocesses, so an inherited environment variable
|
|
24
|
+
# is what reaches them.
|
|
25
|
+
ENV_CACHE_READ = "DIRSQL_EMBEDDINGS_CACHE_READ"
|
|
26
|
+
CACHE_READ_OFF = frozenset({"0"})
|
|
27
|
+
|
|
28
|
+
# Membership rather than `!= "0"`: CPython interns single-character strings, so
|
|
29
|
+
# for any value the environment can hold, `x == "0"` and `x is "0"` agree --
|
|
30
|
+
# which makes the equality form's identity variant untestable in one direction.
|
|
31
|
+
CACHE_READ = os.environ.get(ENV_CACHE_READ, "1") not in CACHE_READ_OFF
|
|
@@ -12,7 +12,10 @@ hook-timeout = 300
|
|
|
12
12
|
path = "sqlite_vec"
|
|
13
13
|
entrypoint = "sqlite3_vec_init"
|
|
14
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.
|
|
15
18
|
[[table]]
|
|
16
19
|
ddl = "CREATE TABLE documents (path TEXT, text TEXT, embedding TEXT)"
|
|
17
|
-
glob = "**/*.md"
|
|
20
|
+
glob = "**/*.{md,pdf}"
|
|
18
21
|
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)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Extract a PDF's text, once per version of the file.
|
|
2
|
+
|
|
3
|
+
pypdf rather than pymupdf: pymupdf is AGPL-3.0, and a runtime dependency of
|
|
4
|
+
this MIT-licensed package propagates to everyone who installs it.
|
|
5
|
+
|
|
6
|
+
Extraction is the expensive step of a scan and a scan re-reads every matched
|
|
7
|
+
file, so the result is persisted to disk. The cache key is the pair
|
|
8
|
+
``(path, mtime)``: an edited PDF gets a different key rather than a stale hit.
|
|
9
|
+
|
|
10
|
+
Annotations are evaluated at runtime (no ``from __future__ import
|
|
11
|
+
annotations``) so a mutated ``X | None`` union in a signature fails at import
|
|
12
|
+
rather than surviving as an inert string.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
from pypdf import PdfReader
|
|
18
|
+
|
|
19
|
+
from ..cache import cache
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@cache / "extract"
|
|
23
|
+
def extract(path: str, mtime: float) -> str:
|
|
24
|
+
# `mtime` is unread on purpose: it is what makes an edited PDF a cache
|
|
25
|
+
# miss, since cachetta derives the key from the arguments it is called with.
|
|
26
|
+
reader = PdfReader(path)
|
|
27
|
+
return "\n".join(page.extract_text() for page in reader.pages)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_pdf(path: str) -> str:
|
|
31
|
+
return extract(path, os.path.getmtime(path))
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Shared fixtures for the integration and e2e tiers.
|
|
2
|
+
|
|
3
|
+
`stub_server` brings up a real, local, threaded OpenAI-compatible
|
|
4
|
+
`/v1/embeddings` endpoint. Its embedding is a deterministic keyword-count
|
|
5
|
+
vector, so nearest-neighbor search over the fixtures is reproducible without a
|
|
6
|
+
real model or network.
|
|
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
|
+
|
|
12
|
+
Cache reads are switched off for both tiers. These tiers run the hooks as
|
|
13
|
+
subprocesses, which no in-process mock can reach, so a cached extraction from an
|
|
14
|
+
earlier run would otherwise decide this one's result. Writes are left alone --
|
|
15
|
+
they are harmless, and it is only the read that leaks state between runs.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import threading
|
|
21
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
22
|
+
|
|
23
|
+
os.environ["DIRSQL_EMBEDDINGS_CACHE_READ"] = "0"
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
# One dimension per keyword; the embedding of a text is the per-keyword count.
|
|
28
|
+
KEYWORDS = [
|
|
29
|
+
"pasta",
|
|
30
|
+
"cook",
|
|
31
|
+
"garlic",
|
|
32
|
+
"git",
|
|
33
|
+
"code",
|
|
34
|
+
"review",
|
|
35
|
+
"tomato",
|
|
36
|
+
"plant",
|
|
37
|
+
"seed",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def keyword_vector(text):
|
|
42
|
+
lowered = text.lower()
|
|
43
|
+
return [float(lowered.count(keyword)) for keyword in KEYWORDS]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
47
|
+
def do_POST(self):
|
|
48
|
+
length = int(self.headers["Content-Length"])
|
|
49
|
+
payload = json.loads(self.rfile.read(length))
|
|
50
|
+
text = payload["input"][0]
|
|
51
|
+
body = json.dumps(
|
|
52
|
+
{"data": [{"index": 0, "embedding": keyword_vector(text)}]}
|
|
53
|
+
).encode("utf-8")
|
|
54
|
+
self.send_response(200)
|
|
55
|
+
self.send_header("Content-Type", "application/json")
|
|
56
|
+
self.send_header("Content-Length", str(len(body)))
|
|
57
|
+
self.end_headers()
|
|
58
|
+
self.wfile.write(body)
|
|
59
|
+
|
|
60
|
+
def log_message(self, *args): # silence the default stderr access log
|
|
61
|
+
pass
|
|
62
|
+
|
|
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
|
+
|
|
100
|
+
@pytest.fixture
|
|
101
|
+
def stub_server():
|
|
102
|
+
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
|
103
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
104
|
+
thread.start()
|
|
105
|
+
host, port = server.server_address
|
|
106
|
+
try:
|
|
107
|
+
yield f"http://{host}:{port}"
|
|
108
|
+
finally:
|
|
109
|
+
server.shutdown()
|
|
110
|
+
thread.join()
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
"""Extract a PDF's text.
|
|
2
|
-
|
|
3
|
-
pypdf rather than pymupdf: pymupdf is AGPL-3.0, and a runtime dependency of
|
|
4
|
-
this MIT-licensed package propagates to everyone who installs it.
|
|
5
|
-
|
|
6
|
-
Annotations are evaluated at runtime (no ``from __future__ import
|
|
7
|
-
annotations``) so a mutated ``X | None`` union in a signature fails at import
|
|
8
|
-
rather than surviving as an inert string.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
from pypdf import PdfReader
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def read_pdf(path: str) -> str:
|
|
15
|
-
reader = PdfReader(path)
|
|
16
|
-
return "\n".join(page.extract_text() for page in reader.pages)
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
"""Shared fixtures for the integration and e2e tiers.
|
|
2
|
-
|
|
3
|
-
`stub_server` brings up a real, local, threaded OpenAI-compatible
|
|
4
|
-
`/v1/embeddings` endpoint. Its embedding is a deterministic keyword-count
|
|
5
|
-
vector, so nearest-neighbor search over the fixtures is reproducible without a
|
|
6
|
-
real model or network.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import json
|
|
10
|
-
import threading
|
|
11
|
-
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
12
|
-
|
|
13
|
-
import pytest
|
|
14
|
-
|
|
15
|
-
# One dimension per keyword; the embedding of a text is the per-keyword count.
|
|
16
|
-
KEYWORDS = [
|
|
17
|
-
"pasta",
|
|
18
|
-
"cook",
|
|
19
|
-
"garlic",
|
|
20
|
-
"git",
|
|
21
|
-
"code",
|
|
22
|
-
"review",
|
|
23
|
-
"tomato",
|
|
24
|
-
"plant",
|
|
25
|
-
"seed",
|
|
26
|
-
]
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def keyword_vector(text):
|
|
30
|
-
lowered = text.lower()
|
|
31
|
-
return [float(lowered.count(keyword)) for keyword in KEYWORDS]
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class _Handler(BaseHTTPRequestHandler):
|
|
35
|
-
def do_POST(self):
|
|
36
|
-
length = int(self.headers["Content-Length"])
|
|
37
|
-
payload = json.loads(self.rfile.read(length))
|
|
38
|
-
text = payload["input"][0]
|
|
39
|
-
body = json.dumps(
|
|
40
|
-
{"data": [{"index": 0, "embedding": keyword_vector(text)}]}
|
|
41
|
-
).encode("utf-8")
|
|
42
|
-
self.send_response(200)
|
|
43
|
-
self.send_header("Content-Type", "application/json")
|
|
44
|
-
self.send_header("Content-Length", str(len(body)))
|
|
45
|
-
self.end_headers()
|
|
46
|
-
self.wfile.write(body)
|
|
47
|
-
|
|
48
|
-
def log_message(self, *args): # silence the default stderr access log
|
|
49
|
-
pass
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@pytest.fixture
|
|
53
|
-
def stub_server():
|
|
54
|
-
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
|
55
|
-
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
56
|
-
thread.start()
|
|
57
|
-
host, port = server.server_address
|
|
58
|
-
try:
|
|
59
|
-
yield f"http://{host}:{port}"
|
|
60
|
-
finally:
|
|
61
|
-
server.shutdown()
|
|
62
|
-
thread.join()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dirsql_plugin_embeddings-0.1.5 → dirsql_plugin_embeddings-0.1.7}/tests/integration/__init__.py
RENAMED
|
File without changes
|