getbased-rag 0.6.1__py3-none-any.whl
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.
- getbased_rag-0.6.1.dist-info/METADATA +243 -0
- getbased_rag-0.6.1.dist-info/RECORD +15 -0
- getbased_rag-0.6.1.dist-info/WHEEL +5 -0
- getbased_rag-0.6.1.dist-info/entry_points.txt +2 -0
- getbased_rag-0.6.1.dist-info/licenses/LICENSE +22 -0
- getbased_rag-0.6.1.dist-info/top_level.txt +1 -0
- lens/__init__.py +3 -0
- lens/api_key.py +55 -0
- lens/cli.py +225 -0
- lens/config.py +147 -0
- lens/embedder.py +524 -0
- lens/ingest.py +365 -0
- lens/registry.py +256 -0
- lens/server.py +826 -0
- lens/store.py +283 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: getbased-rag
|
|
3
|
+
Version: 0.6.1
|
|
4
|
+
Summary: getbased-rag — standalone RAG knowledge server (formerly the Electron-bundled Lens)
|
|
5
|
+
License-Expression: GPL-3.0-only
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: fastapi>=0.110
|
|
10
|
+
Requires-Dist: uvicorn[standard]>=0.29
|
|
11
|
+
Requires-Dist: qdrant-client>=1.9
|
|
12
|
+
Requires-Dist: sentence-transformers>=2.6
|
|
13
|
+
Requires-Dist: typer>=0.12
|
|
14
|
+
Requires-Dist: pydantic>=2.0
|
|
15
|
+
Requires-Dist: pyyaml>=6.0
|
|
16
|
+
Requires-Dist: rich>=13.0
|
|
17
|
+
Requires-Dist: python-multipart>=0.0.6
|
|
18
|
+
Provides-Extra: pdf
|
|
19
|
+
Requires-Dist: pypdf>=3.9; extra == "pdf"
|
|
20
|
+
Provides-Extra: docx
|
|
21
|
+
Requires-Dist: python-docx>=1.0; extra == "docx"
|
|
22
|
+
Provides-Extra: onnx
|
|
23
|
+
Requires-Dist: onnxruntime>=1.17; extra == "onnx"
|
|
24
|
+
Requires-Dist: optimum[onnxruntime]>=1.17; extra == "onnx"
|
|
25
|
+
Requires-Dist: transformers>=5.0; extra == "onnx"
|
|
26
|
+
Provides-Extra: full
|
|
27
|
+
Requires-Dist: pypdf>=3.9; extra == "full"
|
|
28
|
+
Requires-Dist: python-docx>=1.0; extra == "full"
|
|
29
|
+
Requires-Dist: onnxruntime>=1.17; extra == "full"
|
|
30
|
+
Requires-Dist: optimum[onnxruntime]>=1.17; extra == "full"
|
|
31
|
+
Requires-Dist: transformers>=5.0; extra == "full"
|
|
32
|
+
Provides-Extra: test
|
|
33
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
34
|
+
Requires-Dist: httpx>=0.27; extra == "test"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# getbased-rag
|
|
38
|
+
|
|
39
|
+
> **Installing for the first time?** The [getbased-agent-stack](https://github.com/elkimek/getbased-agents/tree/main/packages/stack) meta-package bundles this server with the MCP that Claude Code / Hermes / OpenClaw talk to, plus [getbased-dashboard](https://github.com/elkimek/getbased-agents/tree/main/packages/dashboard) for a browser UI. One command and you're up.
|
|
40
|
+
|
|
41
|
+
A standalone RAG knowledge server — the backend that used to ship inside the getbased Electron desktop app, now just Python. Point any client (the getbased PWA's *External server* lens backend, the dashboard, or your own) at it.
|
|
42
|
+
|
|
43
|
+
- **Stack**: FastAPI + Uvicorn · Qdrant (embedded local mode) · sentence-transformers / ONNX Runtime
|
|
44
|
+
- **Default port**: 8322, loopback only
|
|
45
|
+
- **Auth**: Bearer token, auto-generated on first start
|
|
46
|
+
- **Stores**: every library is its own Qdrant collection, pinned to its own embedding model at creation
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
Requires Python ≥ 3.10.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pipx install "getbased-rag[full]"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or from source:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone https://github.com/elkimek/getbased-agents.git
|
|
62
|
+
cd getbased-agents
|
|
63
|
+
uv sync --all-packages --all-extras
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Run
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
lens serve
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
First start auto-generates an API key at the data dir (see below), prints the bind address, and lazy-loads the embedding model on the first query (~90 MB download for MiniLM).
|
|
75
|
+
|
|
76
|
+
Copy the API key out when you need to configure a client:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
lens key
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Smoke test:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
curl -s http://127.0.0.1:8322/health
|
|
86
|
+
curl -s -H "Authorization: Bearer $(lens key)" http://127.0.0.1:8322/info | jq
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Ingest a file or directory from the CLI:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
lens ingest ~/Documents/research
|
|
93
|
+
lens stats
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Or over HTTP (what the dashboard + PWA use):
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
curl -H "Authorization: Bearer $(lens key)" \
|
|
100
|
+
-F "files=@paper.pdf" -F "files=@notes.md" \
|
|
101
|
+
http://127.0.0.1:8322/ingest
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Per-library embedding models
|
|
107
|
+
|
|
108
|
+
Every library is pinned to one embedding model at creation time — Qdrant collections are dimension-locked, so you can't swap models on an existing library without re-ingesting. Call `GET /models` for the curated list (MiniLM-L6-v2 · BGE-small/base/large-en · BGE-M3) with dims and download sizes, then pass `embedding_model` on create:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
curl -H "Authorization: Bearer $(lens key)" \
|
|
112
|
+
-H "Content-Type: application/json" \
|
|
113
|
+
-d '{"name":"Research","embedding_model":"BAAI/bge-m3"}' \
|
|
114
|
+
http://127.0.0.1:8322/libraries
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Libraries on the same model share one embedder instance in memory. Two libraries both on BGE-M3 use ~2 GB total, not 4.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Streaming ingest progress
|
|
122
|
+
|
|
123
|
+
The HTTP `POST /ingest` endpoint speaks two content types:
|
|
124
|
+
|
|
125
|
+
- Default (no `Accept`): single-shot JSON summary after the run completes
|
|
126
|
+
- `Accept: application/x-ndjson`: newline-delimited JSON progress stream — one `start` event (with total chunks), per-batch `embed` events every ~5 chunks (with current source + index), terminal `result` or `error` event
|
|
127
|
+
|
|
128
|
+
The dashboard uses the streaming path for its bottom-right pill (chunks/sec rate, cancel button, per-filename status). Cancellation works by client disconnect: aborting the fetch causes the server's worker thread to exit at the next batch boundary with `cancelled: true` in the result. Partial-commit — whatever was embedded before the cancel stays.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Wiring into the getbased PWA
|
|
133
|
+
|
|
134
|
+
In the PWA: **Settings → AI → Knowledge Base → External server**
|
|
135
|
+
|
|
136
|
+
| Field | Value |
|
|
137
|
+
|---|---|
|
|
138
|
+
| URL | `http://127.0.0.1:8322` |
|
|
139
|
+
| API key | output of `lens key` |
|
|
140
|
+
|
|
141
|
+
Click **Save**, then **Test connection**. `rag_ready: false` is expected before you ingest anything.
|
|
142
|
+
|
|
143
|
+
### Agent access (Claude Code, Hermes, OpenClaw, etc.)
|
|
144
|
+
|
|
145
|
+
Pair this server with [getbased-mcp](https://github.com/elkimek/getbased-agents/tree/main/packages/mcp) to expose `knowledge_search`, `knowledge_list_libraries`, `knowledge_activate_library`, and `knowledge_stats` as MCP tools. Typical setup: run both the lens server and getbased-mcp on the same VM, point MCP's `LENS_URL` at `http://localhost:8322`.
|
|
146
|
+
|
|
147
|
+
### Browser UI
|
|
148
|
+
|
|
149
|
+
Install [getbased-dashboard](https://github.com/elkimek/getbased-agents/tree/main/packages/dashboard) for a web UI on top of this server — library management, drag-drop ingest with live progress pill, search preview, MCP config generator.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Configuration
|
|
154
|
+
|
|
155
|
+
Every setting is an environment variable. Defaults in parentheses.
|
|
156
|
+
|
|
157
|
+
| Variable | Purpose |
|
|
158
|
+
|---|---|
|
|
159
|
+
| `LENS_HOST` (`127.0.0.1`) | Bind interface. Change to `0.0.0.0` only if you really want LAN access |
|
|
160
|
+
| `LENS_PORT` (`8322`) | TCP port |
|
|
161
|
+
| `LENS_DATA_DIR` (platform default) | Where Qdrant DB, API key, and model cache live |
|
|
162
|
+
| `LENS_EMBEDDING_MODEL` (`sentence-transformers/all-MiniLM-L6-v2`) | Default model for new libraries (overridable per library) |
|
|
163
|
+
| `LENS_SIMILARITY_FLOOR` (`0.55`) | Minimum cosine score for a returned chunk |
|
|
164
|
+
| `LENS_ONNX_PROVIDER` (auto) | `cuda` \| `rocm` \| `openvino` \| `coreml` \| `cpu` |
|
|
165
|
+
| `LENS_RERANKER` (`false`) | Enable reranking of top candidates |
|
|
166
|
+
| `LENS_MAX_INGEST_BYTES` (`268435456` — 256 MB) | Cap on a single ingest upload's total size |
|
|
167
|
+
| `LENS_CHUNK_MAX_SIZE` (`800`) | Max chunk size in characters |
|
|
168
|
+
| `LENS_CORS_ORIGINS` (empty) | Comma-separated extra CORS origins to allow, in addition to the PWA + loopback defaults |
|
|
169
|
+
|
|
170
|
+
Default data dir:
|
|
171
|
+
|
|
172
|
+
- Linux: `$XDG_DATA_HOME/getbased/lens` or `~/.local/share/getbased/lens`
|
|
173
|
+
- macOS: `~/Library/Application Support/getbased/lens`
|
|
174
|
+
- Windows: `%APPDATA%\getbased\lens`
|
|
175
|
+
|
|
176
|
+
A legacy `~/.getbased/lens` is honored if it already exists, so pre-v1.21 installs don't lose their data.
|
|
177
|
+
|
|
178
|
+
### GPU acceleration
|
|
179
|
+
|
|
180
|
+
Install the matching `onnxruntime-*` wheel (e.g. `onnxruntime-gpu` for CUDA), then:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
LENS_ONNX_PROVIDER=cuda lens serve
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## HTTP API
|
|
189
|
+
|
|
190
|
+
All endpoints except `/`, `/health` require `Authorization: Bearer <key>`.
|
|
191
|
+
|
|
192
|
+
| Method | Path | Purpose |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| `GET` | `/health` | Liveness + `rag_ready` + chunk count. Public |
|
|
195
|
+
| `GET` | `/info` | Embedder engine/model/dim, active library, similarity floor. For UI engine badges |
|
|
196
|
+
| `GET` | `/models` | Curated model picker list (id, label, dim, size_mb) plus the server's default |
|
|
197
|
+
| `POST` | `/query` | `{ query, top_k }` → top-k chunks from the active library, encoded with that library's model |
|
|
198
|
+
| `POST` | `/ingest` | Multipart upload; accepts streaming NDJSON progress when `Accept: application/x-ndjson` |
|
|
199
|
+
| `GET` | `/stats` | Per-source chunk counts for the active library |
|
|
200
|
+
| `DELETE` | `/sources/{source}` | Drop one source from the active library |
|
|
201
|
+
| `DELETE` | `/sources` | Clear the active library's chunks (library stays) |
|
|
202
|
+
| `GET` | `/libraries` | List libraries + active id. Each row includes `chunks`, `lastIngestAt`, `embedding_model` |
|
|
203
|
+
| `POST` | `/libraries` | `{ name, embedding_model? }` → create. 409 on duplicate name (case-insensitive) |
|
|
204
|
+
| `POST` | `/libraries/{id}/activate` | Set active |
|
|
205
|
+
| `PATCH` | `/libraries/{id}` | Rename |
|
|
206
|
+
| `DELETE` | `/libraries/{id}` | Delete (drops Qdrant collection) |
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Security notes
|
|
211
|
+
|
|
212
|
+
- Default bind is `127.0.0.1` — queries never leak to the LAN unless you explicitly set `LENS_HOST=0.0.0.0`.
|
|
213
|
+
- The API key file is mode `0600` and never exposed over HTTP. Use `lens key` locally to read it.
|
|
214
|
+
- Bearer comparison uses `secrets.compare_digest` — constant-time, no timing-leak class of bug.
|
|
215
|
+
- Upload paths are basename-sanitised server-side (so `../../etc/passwd` can't escape the ingest temp dir).
|
|
216
|
+
- Zip uploads are zip-slip-guarded — each archive entry must resolve inside its own per-zip subdirectory AND inside the overall ingest root.
|
|
217
|
+
- If you expose the server to a LAN or the internet, front it with a reverse proxy that terminates TLS and rate-limits.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## CLI
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
lens serve Start the HTTP server (default)
|
|
225
|
+
lens ingest <path> Index files into the active library
|
|
226
|
+
lens stats List indexed sources + chunk counts
|
|
227
|
+
lens delete <source> Drop chunks belonging to one source
|
|
228
|
+
lens clear Wipe the active library
|
|
229
|
+
lens info Show config + API key
|
|
230
|
+
lens key Print the API key (creates one if missing)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## License
|
|
236
|
+
|
|
237
|
+
GPL-3.0-only.
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## Lineage
|
|
242
|
+
|
|
243
|
+
This repo is the Python portion lifted out of [getbased](https://github.com/elkimek/getbased) after the Electron desktop app was retired. The PWA's `external-server` lens backend speaks this same HTTP contract unchanged.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
getbased_rag-0.6.1.dist-info/licenses/LICENSE,sha256=K-IjLWkez1gJQMrlqA5zgyw8vh19mDzk4hKM9Dslmts,1024
|
|
2
|
+
lens/__init__.py,sha256=E4lyDbXhAwhHbsIONEtqzrBs02OXlA0Z8ds-VihY5pg,75
|
|
3
|
+
lens/api_key.py,sha256=6qyZSGoWzCrVViBQm-X2mA_wz3BK2bUCgmBNxZpSSjA,1693
|
|
4
|
+
lens/cli.py,sha256=-tA5a7VtSiWt--N6lcVSnehX5GY76cIIbqW_CyjTIuc,7072
|
|
5
|
+
lens/config.py,sha256=jhlRf6gzMpWNjDsyonQaHV-rQN-lFQk24Sq6_O5XpKE,6009
|
|
6
|
+
lens/embedder.py,sha256=VVKOE29209FvIa7gJ6J5pwkfACPXFoi8uQRVysYqDrU,19848
|
|
7
|
+
lens/ingest.py,sha256=bRt2RYaM_v03lxnBNuMSqlDn-wGD24eftKjaWwGlKjE,14374
|
|
8
|
+
lens/registry.py,sha256=jgcle03t7tMoODLBUzMuy78-i_hNINLtmlbwJ07i8Qw,10304
|
|
9
|
+
lens/server.py,sha256=hv2wgAwrTJX3Gw8rVOawz61y9I9jV1v--dI_bK51zu0,33648
|
|
10
|
+
lens/store.py,sha256=kiQo6AcQQ3ptHJ2oG-bYkJe2-AE_D6fwv6h97OBTm5M,10308
|
|
11
|
+
getbased_rag-0.6.1.dist-info/METADATA,sha256=S9rCw9FsorD44LA7cWwUyA9zliTqIHMkFraMqHfd2Fk,9655
|
|
12
|
+
getbased_rag-0.6.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
getbased_rag-0.6.1.dist-info/entry_points.txt,sha256=4ExZzbsCpB1cRlUc4ph9CGGtCsQMABcMs7gvr-yOIkk,38
|
|
14
|
+
getbased_rag-0.6.1.dist-info/top_level.txt,sha256=weQcpg8Ic-kDl-iV7HcZ-TOJbXaihpy6vVm771yjAyw,5
|
|
15
|
+
getbased_rag-0.6.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
GNU GENERAL PUBLIC LICENSE
|
|
2
|
+
Version 3, 29 June 2007
|
|
3
|
+
|
|
4
|
+
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
5
|
+
Everyone is permitted to copy and distribute verbatim copies
|
|
6
|
+
of this license document, but changing it is not allowed.
|
|
7
|
+
|
|
8
|
+
Preamble
|
|
9
|
+
|
|
10
|
+
The GNU General Public License is a free, copyleft license for
|
|
11
|
+
software and other kinds of works.
|
|
12
|
+
|
|
13
|
+
The licenses for most software and other practical works are designed
|
|
14
|
+
to take away your freedom to share and change the works. By contrast,
|
|
15
|
+
the GNU General Public License is intended to guarantee your freedom to
|
|
16
|
+
share and change all versions of a program--to make sure it remains free
|
|
17
|
+
software for all its users. We, the Free Software Foundation, use the
|
|
18
|
+
GNU General Public License for most of our software; it applies also to
|
|
19
|
+
any other work released this way by its authors. You can apply it to
|
|
20
|
+
your programs, too.
|
|
21
|
+
|
|
22
|
+
For the full license text, see <https://www.gnu.org/licenses/gpl-3.0.txt>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lens
|
lens/__init__.py
ADDED
lens/api_key.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""API key generation + persistence for the Lens HTTP server.
|
|
2
|
+
|
|
3
|
+
Auto-generates a key on first start if one doesn't exist. The desktop wrapper
|
|
4
|
+
(Tauri) reads this file via `getbased_lens_config` MCP tool to display the
|
|
5
|
+
key for the user to paste into the getbased web app's Custom Knowledge Source.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_or_create_api_key(key_file: Path) -> str:
|
|
16
|
+
"""Read the API key from disk; generate + write one if missing.
|
|
17
|
+
|
|
18
|
+
Creates the file with O_EXCL + mode 0o600 in one syscall, so the key
|
|
19
|
+
is never briefly present with loose permissions (the race the old
|
|
20
|
+
write_text → chmod sequence had).
|
|
21
|
+
"""
|
|
22
|
+
if key_file.exists():
|
|
23
|
+
try:
|
|
24
|
+
key = key_file.read_text().strip()
|
|
25
|
+
if key:
|
|
26
|
+
return key
|
|
27
|
+
except OSError:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
key_file.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
key = secrets.token_urlsafe(32)
|
|
32
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
33
|
+
try:
|
|
34
|
+
fd = os.open(str(key_file), flags, 0o600)
|
|
35
|
+
except FileExistsError:
|
|
36
|
+
# Another process beat us to it — trust whatever they wrote rather
|
|
37
|
+
# than clobbering it with a fresh key.
|
|
38
|
+
existing = key_file.read_text().strip()
|
|
39
|
+
if existing:
|
|
40
|
+
return existing
|
|
41
|
+
raise
|
|
42
|
+
with os.fdopen(fd, "w") as f:
|
|
43
|
+
f.write(key + "\n")
|
|
44
|
+
return key
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_api_key(key_file: Path) -> str | None:
|
|
48
|
+
"""Read existing API key without generating one."""
|
|
49
|
+
try:
|
|
50
|
+
if key_file.exists():
|
|
51
|
+
key = key_file.read_text().strip()
|
|
52
|
+
return key if key else None
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
return None
|
lens/cli.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Lens CLI — typer-based commands.
|
|
2
|
+
|
|
3
|
+
lens serve Start the HTTP server (default if no command)
|
|
4
|
+
lens ingest <path> Index files into the local store
|
|
5
|
+
lens info Show config + key + status
|
|
6
|
+
lens key Print the API key (creates one if missing)
|
|
7
|
+
|
|
8
|
+
Configuration comes from environment variables — see config.py for the full list.
|
|
9
|
+
The Tauri desktop wrapper sets LENS_HOST, LENS_PORT, LENS_DATA_DIR,
|
|
10
|
+
LENS_EMBEDDING_MODEL, and LENS_ONNX_PROVIDER for you.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from rich.table import Table
|
|
22
|
+
|
|
23
|
+
from .api_key import get_or_create_api_key
|
|
24
|
+
from .config import LensConfig
|
|
25
|
+
from .server import run_server
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
app = typer.Typer(
|
|
29
|
+
name="lens",
|
|
30
|
+
help="getbased-lens — local RAG knowledge server.",
|
|
31
|
+
no_args_is_help=False,
|
|
32
|
+
add_completion=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _setup_logging(verbose: bool) -> None:
|
|
37
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
38
|
+
logging.basicConfig(
|
|
39
|
+
level=level,
|
|
40
|
+
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
41
|
+
datefmt="%H:%M:%S",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.callback(invoke_without_command=True)
|
|
46
|
+
def _default(
|
|
47
|
+
ctx: typer.Context,
|
|
48
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose logging"),
|
|
49
|
+
):
|
|
50
|
+
"""When invoked with no subcommand, run `serve`."""
|
|
51
|
+
_setup_logging(verbose)
|
|
52
|
+
if ctx.invoked_subcommand is None:
|
|
53
|
+
ctx.invoke(serve)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command()
|
|
57
|
+
def serve():
|
|
58
|
+
"""Start the HTTP server (uvicorn). Blocking."""
|
|
59
|
+
config = LensConfig.from_env()
|
|
60
|
+
console.print(f"[bold cyan]getbased-lens[/] starting on http://{config.host}:{config.port}")
|
|
61
|
+
console.print(f" Data dir: {config.data_dir}")
|
|
62
|
+
console.print(f" Model: {config.embedding_model}")
|
|
63
|
+
console.print(f" Collection: {config.collection}")
|
|
64
|
+
if config.onnx_provider:
|
|
65
|
+
console.print(f" ONNX: {config.onnx_provider}")
|
|
66
|
+
try:
|
|
67
|
+
run_server(config)
|
|
68
|
+
except KeyboardInterrupt:
|
|
69
|
+
console.print("\n[yellow]Stopped.[/]")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command()
|
|
73
|
+
def ingest(
|
|
74
|
+
path: Path = typer.Argument(..., help="File or directory to ingest"),
|
|
75
|
+
json_out: bool = typer.Option(False, "--json", help="Emit machine-parseable JSON"),
|
|
76
|
+
):
|
|
77
|
+
"""Index documents from a path into the local store."""
|
|
78
|
+
from .ingest import ingest_path # lazy import (heavy deps)
|
|
79
|
+
import json as _json
|
|
80
|
+
|
|
81
|
+
config = LensConfig.from_env()
|
|
82
|
+
if not json_out:
|
|
83
|
+
console.print(f"[bold cyan]Ingesting[/] {path}…")
|
|
84
|
+
try:
|
|
85
|
+
# JSONL progress is only useful for a parent process — emit it
|
|
86
|
+
# exactly when --json was requested. Human runs stay clean.
|
|
87
|
+
result = ingest_path(config, path, emit_progress=json_out)
|
|
88
|
+
except FileNotFoundError as e:
|
|
89
|
+
if json_out:
|
|
90
|
+
print(_json.dumps({"error": str(e)}))
|
|
91
|
+
else:
|
|
92
|
+
console.print(f"[red]Error:[/] {e}")
|
|
93
|
+
raise typer.Exit(1)
|
|
94
|
+
|
|
95
|
+
if json_out:
|
|
96
|
+
print(_json.dumps(result))
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
table = Table(title="Ingest result", show_header=False, box=None)
|
|
100
|
+
table.add_row("Files scanned", str(result["files_seen"]))
|
|
101
|
+
table.add_row("Chunks indexed", str(result["chunks_indexed"]))
|
|
102
|
+
if result["skipped"]:
|
|
103
|
+
table.add_row("Skipped", str(len(result["skipped"])))
|
|
104
|
+
console.print(table)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _active_store(config: LensConfig):
|
|
108
|
+
"""CLI helper — resolve a Store bound to the active library.
|
|
109
|
+
|
|
110
|
+
Matches how the server's active_store() works: bootstrap a "Default"
|
|
111
|
+
library on first use so a fresh shell command doesn't 404 on the
|
|
112
|
+
registry being empty."""
|
|
113
|
+
from .registry import Registry
|
|
114
|
+
from .store import QdrantBackend, Store
|
|
115
|
+
|
|
116
|
+
registry = Registry(config)
|
|
117
|
+
registry.ensure_default()
|
|
118
|
+
return Store(
|
|
119
|
+
config,
|
|
120
|
+
collection=registry.active_collection(),
|
|
121
|
+
backend=QdrantBackend(config),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command()
|
|
126
|
+
def stats(json_out: bool = typer.Option(False, "--json", help="Emit JSON")):
|
|
127
|
+
"""List knowledge base contents: per-source chunk counts."""
|
|
128
|
+
import json as _json
|
|
129
|
+
|
|
130
|
+
config = LensConfig.from_env()
|
|
131
|
+
store = _active_store(config)
|
|
132
|
+
try:
|
|
133
|
+
docs = store.list_sources()
|
|
134
|
+
except Exception as e:
|
|
135
|
+
if json_out:
|
|
136
|
+
print(_json.dumps({"error": str(e), "total_chunks": 0, "documents": []}))
|
|
137
|
+
else:
|
|
138
|
+
console.print(f"[red]Error:[/] {e}")
|
|
139
|
+
raise typer.Exit(1)
|
|
140
|
+
|
|
141
|
+
total_chunks = sum(d["chunks"] for d in docs)
|
|
142
|
+
if json_out:
|
|
143
|
+
print(_json.dumps({"total_chunks": total_chunks, "documents": docs}))
|
|
144
|
+
return
|
|
145
|
+
if not docs:
|
|
146
|
+
console.print("No documents indexed yet. Use [bold]lens ingest <path>[/] to add some.")
|
|
147
|
+
return
|
|
148
|
+
table = Table(title=f"Indexed: {len(docs)} sources, {total_chunks} chunks")
|
|
149
|
+
table.add_column("Source")
|
|
150
|
+
table.add_column("Chunks", justify="right")
|
|
151
|
+
for d in docs:
|
|
152
|
+
table.add_row(d["source"], str(d["chunks"]))
|
|
153
|
+
console.print(table)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command()
|
|
157
|
+
def delete(
|
|
158
|
+
source: str = typer.Argument(..., help="Source filename to delete (exact match)"),
|
|
159
|
+
json_out: bool = typer.Option(False, "--json", help="Emit JSON"),
|
|
160
|
+
):
|
|
161
|
+
"""Delete all chunks belonging to a source from the knowledge base."""
|
|
162
|
+
import json as _json
|
|
163
|
+
|
|
164
|
+
config = LensConfig.from_env()
|
|
165
|
+
store = _active_store(config)
|
|
166
|
+
deleted = store.delete_by_source(source)
|
|
167
|
+
if json_out:
|
|
168
|
+
print(_json.dumps({"source": source, "deleted_chunks": deleted}))
|
|
169
|
+
return
|
|
170
|
+
console.print(f"Deleted {deleted} chunks matching source '{source}'")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.command()
|
|
174
|
+
def clear(
|
|
175
|
+
json_out: bool = typer.Option(False, "--json", help="Emit JSON"),
|
|
176
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip interactive confirmation"),
|
|
177
|
+
):
|
|
178
|
+
"""Delete ALL chunks from the knowledge base (drops the collection)."""
|
|
179
|
+
import json as _json
|
|
180
|
+
|
|
181
|
+
config = LensConfig.from_env()
|
|
182
|
+
store = _active_store(config)
|
|
183
|
+
|
|
184
|
+
if not yes and not json_out:
|
|
185
|
+
console.print(f"[yellow]This will delete all chunks from[/] {config.qdrant_path}")
|
|
186
|
+
confirm = typer.confirm("Proceed?")
|
|
187
|
+
if not confirm:
|
|
188
|
+
console.print("Aborted.")
|
|
189
|
+
raise typer.Exit(0)
|
|
190
|
+
|
|
191
|
+
deleted = store.clear()
|
|
192
|
+
if json_out:
|
|
193
|
+
print(_json.dumps({"deleted_chunks": deleted}))
|
|
194
|
+
return
|
|
195
|
+
console.print(f"Cleared {deleted} chunks.")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.command()
|
|
199
|
+
def info():
|
|
200
|
+
"""Show current configuration + status."""
|
|
201
|
+
config = LensConfig.from_env()
|
|
202
|
+
console.print(config.display())
|
|
203
|
+
console.print()
|
|
204
|
+
key = get_or_create_api_key(config.api_key_file)
|
|
205
|
+
console.print(f" api_key: {key[:8]}…{key[-4:]} (file: {config.api_key_file})")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.command()
|
|
209
|
+
def key():
|
|
210
|
+
"""Print the API key (generates one on first invocation)."""
|
|
211
|
+
config = LensConfig.from_env()
|
|
212
|
+
print(get_or_create_api_key(config.api_key_file))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main():
|
|
216
|
+
"""Entry point for `python -m lens`."""
|
|
217
|
+
try:
|
|
218
|
+
app()
|
|
219
|
+
except Exception as e: # noqa: BLE001
|
|
220
|
+
console.print(f"[red]Error:[/] {e}")
|
|
221
|
+
sys.exit(1)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
main()
|