@melaya/runner 1.0.37 → 1.0.39
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.
- package/dist/connection.js +67 -0
- package/dist/localRagIngest.py +252 -0
- package/localRagIngest.py +252 -0
- package/package.json +4 -2
package/dist/connection.js
CHANGED
|
@@ -390,6 +390,73 @@ export async function connect(opts) {
|
|
|
390
390
|
// Hard refusal: if embedderProvider is "openai", we refuse with a clear
|
|
391
391
|
// error message. Cloud embedders would defeat the data-residency
|
|
392
392
|
// guarantee. Mode B = local-only embedder, period.
|
|
393
|
+
// ── Native folder picker ──────────────────────────────────────────────
|
|
394
|
+
// FE asks the runner to pop the OS-native folder dialog so the user can
|
|
395
|
+
// pick a path WITHOUT having to type it. Browsers can't reveal absolute
|
|
396
|
+
// paths for security, so the runner — which lives on the user's box —
|
|
397
|
+
// does it. Mac: osascript. Windows: PowerShell WinForms. Linux: zenity.
|
|
398
|
+
socket.on("runner:pick-folder", async (payload) => {
|
|
399
|
+
const sid = payload?.sessionId;
|
|
400
|
+
const reply = (ok, path, error) => {
|
|
401
|
+
socket.emit("runner:pick-folder-result", { session_id: sid, ok, path, error });
|
|
402
|
+
};
|
|
403
|
+
try {
|
|
404
|
+
const { execFile } = await import("child_process");
|
|
405
|
+
const platform = process.platform;
|
|
406
|
+
const run = (cmd, args) => new Promise((resolve, reject) => {
|
|
407
|
+
execFile(cmd, args, { maxBuffer: 1024 * 64 }, (err, stdout, stderr) => {
|
|
408
|
+
if (err) {
|
|
409
|
+
const out = (stdout || "").trim();
|
|
410
|
+
const errMsg = (stderr || err.message || "").trim();
|
|
411
|
+
// osascript returns code 1 when user cancels — empty stdout
|
|
412
|
+
if (!out && /User canceled|cancelled/i.test(errMsg))
|
|
413
|
+
return resolve("");
|
|
414
|
+
return reject(new Error(errMsg || "pick failed"));
|
|
415
|
+
}
|
|
416
|
+
resolve((stdout || "").trim());
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
let picked = "";
|
|
420
|
+
if (platform === "darwin") {
|
|
421
|
+
// osascript returns "Macintosh HD:Users:me:Documents" — convert to POSIX
|
|
422
|
+
const raw = await run("osascript", [
|
|
423
|
+
"-e", 'tell application "System Events"',
|
|
424
|
+
"-e", "activate",
|
|
425
|
+
"-e", "set p to choose folder with prompt \"Select your reference docs folder\"",
|
|
426
|
+
"-e", "end tell",
|
|
427
|
+
"-e", "return POSIX path of p",
|
|
428
|
+
]);
|
|
429
|
+
picked = raw.replace(/\/$/, "");
|
|
430
|
+
}
|
|
431
|
+
else if (platform === "win32") {
|
|
432
|
+
const ps = [
|
|
433
|
+
"Add-Type -AssemblyName System.Windows.Forms;",
|
|
434
|
+
"$f = New-Object System.Windows.Forms.FolderBrowserDialog;",
|
|
435
|
+
"$f.Description = 'Select your reference docs folder';",
|
|
436
|
+
"$f.ShowNewFolderButton = $true;",
|
|
437
|
+
"if ($f.ShowDialog() -eq 'OK') { [Console]::Out.Write($f.SelectedPath) }",
|
|
438
|
+
].join(" ");
|
|
439
|
+
picked = await run("powershell", ["-NoProfile", "-STA", "-Command", ps]);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
// Linux — try zenity, fall back to error if missing
|
|
443
|
+
picked = await run("zenity", [
|
|
444
|
+
"--file-selection", "--directory",
|
|
445
|
+
"--title=Select your reference docs folder",
|
|
446
|
+
]);
|
|
447
|
+
}
|
|
448
|
+
if (!picked)
|
|
449
|
+
return reply(false, null, "cancelled");
|
|
450
|
+
reply(true, picked);
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
const msg = String(e?.message || "pick failed");
|
|
454
|
+
const hint = /ENOENT|not found|spawn/i.test(msg) && process.platform === "linux"
|
|
455
|
+
? "Install 'zenity' (apt install zenity) or type the path manually."
|
|
456
|
+
: "";
|
|
457
|
+
reply(false, null, hint ? `${msg} — ${hint}` : msg);
|
|
458
|
+
}
|
|
459
|
+
});
|
|
393
460
|
socket.on("rag:ingest", async (payload) => {
|
|
394
461
|
const sid = payload?.sessionId;
|
|
395
462
|
if (!sid)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Local-folder RAG ingest (Mode B) — runs on the USER'S machine via the
|
|
2
|
+
@melaya/runner subprocess. Reads docs from a local folder, embeds via the
|
|
3
|
+
user's local Ollama (or LM Studio), persists the Qdrant vector store at
|
|
4
|
+
`~/.melaya-runner/rag/<pipeline_name>/store/`.
|
|
5
|
+
|
|
6
|
+
Data residency guarantee:
|
|
7
|
+
* Document text is NEVER uploaded to the prod box
|
|
8
|
+
* Embedding API calls go to localhost (user's Ollama at :11434)
|
|
9
|
+
* Vector store sits at ~/.melaya-runner/... on the user's filesystem
|
|
10
|
+
* The only network egress is the Socket.IO progress events
|
|
11
|
+
(file names + chunk counts only, NO chunk text)
|
|
12
|
+
|
|
13
|
+
Invoked by packages/runner/src/connection.ts on `rag:ingest` Socket.IO event.
|
|
14
|
+
Emits `progress: <json>` lines to stdout that the parent process forwards to
|
|
15
|
+
the FE so the user sees per-file ingest progress in real time.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import asyncio
|
|
21
|
+
import datetime as _dt
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
# Path hack — the runner's spawned venv has agentscope vendored at the
|
|
30
|
+
# PYTHONPATH it sets (the parent connection.ts sets env.PYTHONPATH to the
|
|
31
|
+
# shared bundle dir). Confirm imports work, fail loud otherwise.
|
|
32
|
+
try:
|
|
33
|
+
from agentscope.rag import ( # noqa: F401
|
|
34
|
+
TextReader, PDFReader, WordReader, PowerPointReader, ExcelReader,
|
|
35
|
+
)
|
|
36
|
+
except ImportError as exc:
|
|
37
|
+
print(f"ImportError: agentscope.rag not reachable on PYTHONPATH={os.environ.get('PYTHONPATH')!r}: {exc}", file=sys.stderr)
|
|
38
|
+
sys.exit(2)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _emit(kind: str, **fields):
|
|
42
|
+
"""Stream a progress event to the runner subprocess parent which
|
|
43
|
+
forwards it to the FE over Socket.IO."""
|
|
44
|
+
payload = {"kind": kind, **fields, "ts": _dt.datetime.utcnow().isoformat() + "Z"}
|
|
45
|
+
print("progress: " + json.dumps(payload), flush=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
SUPPORTED_EXTS = {".txt", ".md", ".csv", ".json", ".pdf",
|
|
49
|
+
".docx", ".pptx", ".xlsx"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _reader_for(ext: str):
|
|
53
|
+
"""Map extension to agentscope reader. Returns None when no extractor
|
|
54
|
+
exists (legacy .doc, image-only PDFs that need OCR, etc.)."""
|
|
55
|
+
return {
|
|
56
|
+
".txt": TextReader, ".md": TextReader,
|
|
57
|
+
".csv": TextReader, ".json": TextReader,
|
|
58
|
+
".pdf": PDFReader,
|
|
59
|
+
".docx": WordReader,
|
|
60
|
+
".pptx": PowerPointReader,
|
|
61
|
+
".xlsx": ExcelReader,
|
|
62
|
+
}.get(ext)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_store_path(pipeline_name: str) -> Path:
|
|
66
|
+
"""Mode B store path — under ~/.melaya-runner/rag/<pipeline>/store/.
|
|
67
|
+
Matches the server-side facade's resolve_store_path(mode="local_folder")
|
|
68
|
+
so cross-runtime debugging is consistent."""
|
|
69
|
+
return Path.home() / ".melaya-runner" / "rag" / pipeline_name / "store"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _resolve_manifest_path(pipeline_name: str) -> Path:
|
|
73
|
+
return _resolve_store_path(pipeline_name).parent / ".ingest.json"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _ingest(args) -> int:
|
|
77
|
+
folder = Path(args.folder).expanduser()
|
|
78
|
+
if not folder.exists() or not folder.is_dir():
|
|
79
|
+
_emit("error", reason=f"folder not found or not a directory: {folder}")
|
|
80
|
+
return 3
|
|
81
|
+
|
|
82
|
+
# Build the facade-equivalent: pick the embedder, instantiate Qdrant.
|
|
83
|
+
# We import lazily so a missing dep produces a clear error message.
|
|
84
|
+
try:
|
|
85
|
+
if args.embedder_provider == "ollama":
|
|
86
|
+
from agentscope.embedding import OllamaTextEmbedding
|
|
87
|
+
# Centralized dimension lookup — same as the server-side facade
|
|
88
|
+
DIMS = {
|
|
89
|
+
"nomic-embed-text": 768, "nomic-embed-text:v1.5": 768,
|
|
90
|
+
"mxbai-embed-large": 1024, "all-minilm": 384,
|
|
91
|
+
"snowflake-arctic-embed": 1024,
|
|
92
|
+
}
|
|
93
|
+
dim = DIMS.get(args.embedder_model.lower())
|
|
94
|
+
if dim is None:
|
|
95
|
+
_emit("error", reason=(
|
|
96
|
+
f"unknown ollama embedding model '{args.embedder_model}' — "
|
|
97
|
+
f"supported: {sorted(DIMS)}"
|
|
98
|
+
))
|
|
99
|
+
return 4
|
|
100
|
+
embedder = OllamaTextEmbedding(
|
|
101
|
+
model_name=args.embedder_model,
|
|
102
|
+
dimensions=dim,
|
|
103
|
+
host=None, # → uses default localhost:11434
|
|
104
|
+
)
|
|
105
|
+
elif args.embedder_provider == "lmstudio":
|
|
106
|
+
from agentscope.embedding import OpenAITextEmbedding
|
|
107
|
+
# LM Studio exposes an OpenAI-compatible /v1; reuse the OpenAI
|
|
108
|
+
# client pointed at its local endpoint. Dim default = 768 (the
|
|
109
|
+
# FE picker should report the real dim per loaded model).
|
|
110
|
+
embedder = OpenAITextEmbedding(
|
|
111
|
+
api_key="lmstudio",
|
|
112
|
+
model_name=args.embedder_model,
|
|
113
|
+
dimensions=768,
|
|
114
|
+
)
|
|
115
|
+
embedder.client.base_url = "http://localhost:1234/v1"
|
|
116
|
+
# LM Studio's /v1/embeddings rejects the OpenAI-specific
|
|
117
|
+
# `dimensions` kwarg (only OpenAI text-embedding-3 supports
|
|
118
|
+
# output-dim reduction). Strip it before each call so the
|
|
119
|
+
# request is shaped exactly as LMS expects.
|
|
120
|
+
_orig_create = embedder.client.embeddings.create
|
|
121
|
+
async def _lms_create(**kw):
|
|
122
|
+
kw.pop("dimensions", None)
|
|
123
|
+
return await _orig_create(**kw)
|
|
124
|
+
embedder.client.embeddings.create = _lms_create # type: ignore[assignment]
|
|
125
|
+
else:
|
|
126
|
+
_emit("error", reason=(
|
|
127
|
+
f"refused embedder provider '{args.embedder_provider}' for Mode B "
|
|
128
|
+
"— only ollama and lmstudio keep documents private"
|
|
129
|
+
))
|
|
130
|
+
return 5
|
|
131
|
+
|
|
132
|
+
from agentscope.rag import QdrantStore, SimpleKnowledge
|
|
133
|
+
store_path = _resolve_store_path(args.pipeline_name)
|
|
134
|
+
store_path.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
store = QdrantStore(
|
|
136
|
+
location=str(store_path),
|
|
137
|
+
collection_name=args.pipeline_name,
|
|
138
|
+
dimensions=embedder.dimensions,
|
|
139
|
+
distance="Cosine",
|
|
140
|
+
)
|
|
141
|
+
kb = SimpleKnowledge(embedding_store=store, embedding_model=embedder)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
_emit("error", reason=f"failed to build KB: {type(exc).__name__}: {exc}")
|
|
144
|
+
return 6
|
|
145
|
+
|
|
146
|
+
# Read the manifest to skip unchanged files
|
|
147
|
+
manifest_path = _resolve_manifest_path(args.pipeline_name)
|
|
148
|
+
if manifest_path.exists():
|
|
149
|
+
try:
|
|
150
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
151
|
+
except Exception:
|
|
152
|
+
manifest = {"files": {}, "embedder": None}
|
|
153
|
+
else:
|
|
154
|
+
manifest = {"files": {}, "embedder": None}
|
|
155
|
+
stored = manifest.get("embedder") or {}
|
|
156
|
+
embedder_changed = (
|
|
157
|
+
stored.get("provider") != args.embedder_provider
|
|
158
|
+
or stored.get("model") != args.embedder_model
|
|
159
|
+
)
|
|
160
|
+
if embedder_changed:
|
|
161
|
+
_emit("embedder_changed", was=stored, now={
|
|
162
|
+
"provider": args.embedder_provider, "model": args.embedder_model,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
started = time.time()
|
|
166
|
+
chunks_added = 0
|
|
167
|
+
files_processed = 0
|
|
168
|
+
skipped = 0
|
|
169
|
+
files_failed = []
|
|
170
|
+
|
|
171
|
+
# Walk the folder — only top-level + one level deep. Going arbitrarily
|
|
172
|
+
# deep would silently include things like venv/.git which would be
|
|
173
|
+
# surprising. If someone needs recursive, they can manually flatten.
|
|
174
|
+
candidates = [p for p in sorted(folder.iterdir()) if p.is_file()]
|
|
175
|
+
candidates += [p for sub in folder.iterdir() if sub.is_dir() and not sub.name.startswith(".")
|
|
176
|
+
for p in sorted(sub.iterdir()) if p.is_file()]
|
|
177
|
+
|
|
178
|
+
total = sum(1 for p in candidates if p.suffix.lower() in SUPPORTED_EXTS)
|
|
179
|
+
_emit("scanned", folder=str(folder), total_supported=total)
|
|
180
|
+
|
|
181
|
+
for f in candidates:
|
|
182
|
+
ext = f.suffix.lower()
|
|
183
|
+
if ext not in SUPPORTED_EXTS:
|
|
184
|
+
continue
|
|
185
|
+
# macOS AppleDouble resource forks (._foo.docx, ._.DS_Store, …) —
|
|
186
|
+
# they're not real documents and every extractor will choke on them.
|
|
187
|
+
if f.name.startswith("._") or f.name == ".DS_Store":
|
|
188
|
+
continue
|
|
189
|
+
try:
|
|
190
|
+
raw = f.read_bytes()
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
files_failed.append({"file": str(f), "error": f"read: {type(exc).__name__}: {exc}"})
|
|
193
|
+
continue
|
|
194
|
+
sha = hashlib.sha256(raw).hexdigest()
|
|
195
|
+
prev = manifest["files"].get(f.name, {})
|
|
196
|
+
if (not embedder_changed) and prev.get("sha256") == sha:
|
|
197
|
+
skipped += 1
|
|
198
|
+
_emit("skipped_sha_clean", file=f.name)
|
|
199
|
+
continue
|
|
200
|
+
reader_cls = _reader_for(ext)
|
|
201
|
+
if reader_cls is None:
|
|
202
|
+
files_failed.append({"file": str(f), "error": "no extractor"})
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
reader = reader_cls()
|
|
206
|
+
docs = await reader(str(f))
|
|
207
|
+
for doc in docs:
|
|
208
|
+
if hasattr(doc.metadata, "doc_id"):
|
|
209
|
+
doc.metadata.doc_id = f.name
|
|
210
|
+
await kb.add_documents(docs)
|
|
211
|
+
chunks_added += len(docs)
|
|
212
|
+
manifest["files"][f.name] = {
|
|
213
|
+
"sha256": sha, "chunks": len(docs),
|
|
214
|
+
"ingested_at": _dt.datetime.utcnow().isoformat() + "Z",
|
|
215
|
+
}
|
|
216
|
+
files_processed += 1
|
|
217
|
+
_emit("file_done", file=f.name, chunks=len(docs))
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
files_failed.append({
|
|
220
|
+
"file": str(f), "error": f"{type(exc).__name__}: {exc}",
|
|
221
|
+
})
|
|
222
|
+
_emit("file_failed", file=f.name, error=str(exc))
|
|
223
|
+
|
|
224
|
+
manifest["embedder"] = {
|
|
225
|
+
"provider": args.embedder_provider, "model": args.embedder_model,
|
|
226
|
+
}
|
|
227
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
229
|
+
|
|
230
|
+
elapsed_ms = int((time.time() - started) * 1000)
|
|
231
|
+
_emit("done",
|
|
232
|
+
files_processed=files_processed,
|
|
233
|
+
chunks_added=chunks_added,
|
|
234
|
+
skipped=skipped,
|
|
235
|
+
embedder_changed=embedder_changed,
|
|
236
|
+
elapsed_ms=elapsed_ms,
|
|
237
|
+
errors=files_failed)
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def main() -> None:
|
|
242
|
+
ap = argparse.ArgumentParser()
|
|
243
|
+
ap.add_argument("--pipeline-name", required=True)
|
|
244
|
+
ap.add_argument("--folder", required=True)
|
|
245
|
+
ap.add_argument("--embedder-provider", required=True, choices=["ollama", "lmstudio"])
|
|
246
|
+
ap.add_argument("--embedder-model", required=True)
|
|
247
|
+
args = ap.parse_args()
|
|
248
|
+
sys.exit(asyncio.run(_ingest(args)))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
main()
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Local-folder RAG ingest (Mode B) — runs on the USER'S machine via the
|
|
2
|
+
@melaya/runner subprocess. Reads docs from a local folder, embeds via the
|
|
3
|
+
user's local Ollama (or LM Studio), persists the Qdrant vector store at
|
|
4
|
+
`~/.melaya-runner/rag/<pipeline_name>/store/`.
|
|
5
|
+
|
|
6
|
+
Data residency guarantee:
|
|
7
|
+
* Document text is NEVER uploaded to the prod box
|
|
8
|
+
* Embedding API calls go to localhost (user's Ollama at :11434)
|
|
9
|
+
* Vector store sits at ~/.melaya-runner/... on the user's filesystem
|
|
10
|
+
* The only network egress is the Socket.IO progress events
|
|
11
|
+
(file names + chunk counts only, NO chunk text)
|
|
12
|
+
|
|
13
|
+
Invoked by packages/runner/src/connection.ts on `rag:ingest` Socket.IO event.
|
|
14
|
+
Emits `progress: <json>` lines to stdout that the parent process forwards to
|
|
15
|
+
the FE so the user sees per-file ingest progress in real time.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import asyncio
|
|
21
|
+
import datetime as _dt
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
# Path hack — the runner's spawned venv has agentscope vendored at the
|
|
30
|
+
# PYTHONPATH it sets (the parent connection.ts sets env.PYTHONPATH to the
|
|
31
|
+
# shared bundle dir). Confirm imports work, fail loud otherwise.
|
|
32
|
+
try:
|
|
33
|
+
from agentscope.rag import ( # noqa: F401
|
|
34
|
+
TextReader, PDFReader, WordReader, PowerPointReader, ExcelReader,
|
|
35
|
+
)
|
|
36
|
+
except ImportError as exc:
|
|
37
|
+
print(f"ImportError: agentscope.rag not reachable on PYTHONPATH={os.environ.get('PYTHONPATH')!r}: {exc}", file=sys.stderr)
|
|
38
|
+
sys.exit(2)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _emit(kind: str, **fields):
|
|
42
|
+
"""Stream a progress event to the runner subprocess parent which
|
|
43
|
+
forwards it to the FE over Socket.IO."""
|
|
44
|
+
payload = {"kind": kind, **fields, "ts": _dt.datetime.utcnow().isoformat() + "Z"}
|
|
45
|
+
print("progress: " + json.dumps(payload), flush=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
SUPPORTED_EXTS = {".txt", ".md", ".csv", ".json", ".pdf",
|
|
49
|
+
".docx", ".pptx", ".xlsx"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _reader_for(ext: str):
|
|
53
|
+
"""Map extension to agentscope reader. Returns None when no extractor
|
|
54
|
+
exists (legacy .doc, image-only PDFs that need OCR, etc.)."""
|
|
55
|
+
return {
|
|
56
|
+
".txt": TextReader, ".md": TextReader,
|
|
57
|
+
".csv": TextReader, ".json": TextReader,
|
|
58
|
+
".pdf": PDFReader,
|
|
59
|
+
".docx": WordReader,
|
|
60
|
+
".pptx": PowerPointReader,
|
|
61
|
+
".xlsx": ExcelReader,
|
|
62
|
+
}.get(ext)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_store_path(pipeline_name: str) -> Path:
|
|
66
|
+
"""Mode B store path — under ~/.melaya-runner/rag/<pipeline>/store/.
|
|
67
|
+
Matches the server-side facade's resolve_store_path(mode="local_folder")
|
|
68
|
+
so cross-runtime debugging is consistent."""
|
|
69
|
+
return Path.home() / ".melaya-runner" / "rag" / pipeline_name / "store"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _resolve_manifest_path(pipeline_name: str) -> Path:
|
|
73
|
+
return _resolve_store_path(pipeline_name).parent / ".ingest.json"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _ingest(args) -> int:
|
|
77
|
+
folder = Path(args.folder).expanduser()
|
|
78
|
+
if not folder.exists() or not folder.is_dir():
|
|
79
|
+
_emit("error", reason=f"folder not found or not a directory: {folder}")
|
|
80
|
+
return 3
|
|
81
|
+
|
|
82
|
+
# Build the facade-equivalent: pick the embedder, instantiate Qdrant.
|
|
83
|
+
# We import lazily so a missing dep produces a clear error message.
|
|
84
|
+
try:
|
|
85
|
+
if args.embedder_provider == "ollama":
|
|
86
|
+
from agentscope.embedding import OllamaTextEmbedding
|
|
87
|
+
# Centralized dimension lookup — same as the server-side facade
|
|
88
|
+
DIMS = {
|
|
89
|
+
"nomic-embed-text": 768, "nomic-embed-text:v1.5": 768,
|
|
90
|
+
"mxbai-embed-large": 1024, "all-minilm": 384,
|
|
91
|
+
"snowflake-arctic-embed": 1024,
|
|
92
|
+
}
|
|
93
|
+
dim = DIMS.get(args.embedder_model.lower())
|
|
94
|
+
if dim is None:
|
|
95
|
+
_emit("error", reason=(
|
|
96
|
+
f"unknown ollama embedding model '{args.embedder_model}' — "
|
|
97
|
+
f"supported: {sorted(DIMS)}"
|
|
98
|
+
))
|
|
99
|
+
return 4
|
|
100
|
+
embedder = OllamaTextEmbedding(
|
|
101
|
+
model_name=args.embedder_model,
|
|
102
|
+
dimensions=dim,
|
|
103
|
+
host=None, # → uses default localhost:11434
|
|
104
|
+
)
|
|
105
|
+
elif args.embedder_provider == "lmstudio":
|
|
106
|
+
from agentscope.embedding import OpenAITextEmbedding
|
|
107
|
+
# LM Studio exposes an OpenAI-compatible /v1; reuse the OpenAI
|
|
108
|
+
# client pointed at its local endpoint. Dim default = 768 (the
|
|
109
|
+
# FE picker should report the real dim per loaded model).
|
|
110
|
+
embedder = OpenAITextEmbedding(
|
|
111
|
+
api_key="lmstudio",
|
|
112
|
+
model_name=args.embedder_model,
|
|
113
|
+
dimensions=768,
|
|
114
|
+
)
|
|
115
|
+
embedder.client.base_url = "http://localhost:1234/v1"
|
|
116
|
+
# LM Studio's /v1/embeddings rejects the OpenAI-specific
|
|
117
|
+
# `dimensions` kwarg (only OpenAI text-embedding-3 supports
|
|
118
|
+
# output-dim reduction). Strip it before each call so the
|
|
119
|
+
# request is shaped exactly as LMS expects.
|
|
120
|
+
_orig_create = embedder.client.embeddings.create
|
|
121
|
+
async def _lms_create(**kw):
|
|
122
|
+
kw.pop("dimensions", None)
|
|
123
|
+
return await _orig_create(**kw)
|
|
124
|
+
embedder.client.embeddings.create = _lms_create # type: ignore[assignment]
|
|
125
|
+
else:
|
|
126
|
+
_emit("error", reason=(
|
|
127
|
+
f"refused embedder provider '{args.embedder_provider}' for Mode B "
|
|
128
|
+
"— only ollama and lmstudio keep documents private"
|
|
129
|
+
))
|
|
130
|
+
return 5
|
|
131
|
+
|
|
132
|
+
from agentscope.rag import QdrantStore, SimpleKnowledge
|
|
133
|
+
store_path = _resolve_store_path(args.pipeline_name)
|
|
134
|
+
store_path.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
store = QdrantStore(
|
|
136
|
+
location=str(store_path),
|
|
137
|
+
collection_name=args.pipeline_name,
|
|
138
|
+
dimensions=embedder.dimensions,
|
|
139
|
+
distance="Cosine",
|
|
140
|
+
)
|
|
141
|
+
kb = SimpleKnowledge(embedding_store=store, embedding_model=embedder)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
_emit("error", reason=f"failed to build KB: {type(exc).__name__}: {exc}")
|
|
144
|
+
return 6
|
|
145
|
+
|
|
146
|
+
# Read the manifest to skip unchanged files
|
|
147
|
+
manifest_path = _resolve_manifest_path(args.pipeline_name)
|
|
148
|
+
if manifest_path.exists():
|
|
149
|
+
try:
|
|
150
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
151
|
+
except Exception:
|
|
152
|
+
manifest = {"files": {}, "embedder": None}
|
|
153
|
+
else:
|
|
154
|
+
manifest = {"files": {}, "embedder": None}
|
|
155
|
+
stored = manifest.get("embedder") or {}
|
|
156
|
+
embedder_changed = (
|
|
157
|
+
stored.get("provider") != args.embedder_provider
|
|
158
|
+
or stored.get("model") != args.embedder_model
|
|
159
|
+
)
|
|
160
|
+
if embedder_changed:
|
|
161
|
+
_emit("embedder_changed", was=stored, now={
|
|
162
|
+
"provider": args.embedder_provider, "model": args.embedder_model,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
started = time.time()
|
|
166
|
+
chunks_added = 0
|
|
167
|
+
files_processed = 0
|
|
168
|
+
skipped = 0
|
|
169
|
+
files_failed = []
|
|
170
|
+
|
|
171
|
+
# Walk the folder — only top-level + one level deep. Going arbitrarily
|
|
172
|
+
# deep would silently include things like venv/.git which would be
|
|
173
|
+
# surprising. If someone needs recursive, they can manually flatten.
|
|
174
|
+
candidates = [p for p in sorted(folder.iterdir()) if p.is_file()]
|
|
175
|
+
candidates += [p for sub in folder.iterdir() if sub.is_dir() and not sub.name.startswith(".")
|
|
176
|
+
for p in sorted(sub.iterdir()) if p.is_file()]
|
|
177
|
+
|
|
178
|
+
total = sum(1 for p in candidates if p.suffix.lower() in SUPPORTED_EXTS)
|
|
179
|
+
_emit("scanned", folder=str(folder), total_supported=total)
|
|
180
|
+
|
|
181
|
+
for f in candidates:
|
|
182
|
+
ext = f.suffix.lower()
|
|
183
|
+
if ext not in SUPPORTED_EXTS:
|
|
184
|
+
continue
|
|
185
|
+
# macOS AppleDouble resource forks (._foo.docx, ._.DS_Store, …) —
|
|
186
|
+
# they're not real documents and every extractor will choke on them.
|
|
187
|
+
if f.name.startswith("._") or f.name == ".DS_Store":
|
|
188
|
+
continue
|
|
189
|
+
try:
|
|
190
|
+
raw = f.read_bytes()
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
files_failed.append({"file": str(f), "error": f"read: {type(exc).__name__}: {exc}"})
|
|
193
|
+
continue
|
|
194
|
+
sha = hashlib.sha256(raw).hexdigest()
|
|
195
|
+
prev = manifest["files"].get(f.name, {})
|
|
196
|
+
if (not embedder_changed) and prev.get("sha256") == sha:
|
|
197
|
+
skipped += 1
|
|
198
|
+
_emit("skipped_sha_clean", file=f.name)
|
|
199
|
+
continue
|
|
200
|
+
reader_cls = _reader_for(ext)
|
|
201
|
+
if reader_cls is None:
|
|
202
|
+
files_failed.append({"file": str(f), "error": "no extractor"})
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
reader = reader_cls()
|
|
206
|
+
docs = await reader(str(f))
|
|
207
|
+
for doc in docs:
|
|
208
|
+
if hasattr(doc.metadata, "doc_id"):
|
|
209
|
+
doc.metadata.doc_id = f.name
|
|
210
|
+
await kb.add_documents(docs)
|
|
211
|
+
chunks_added += len(docs)
|
|
212
|
+
manifest["files"][f.name] = {
|
|
213
|
+
"sha256": sha, "chunks": len(docs),
|
|
214
|
+
"ingested_at": _dt.datetime.utcnow().isoformat() + "Z",
|
|
215
|
+
}
|
|
216
|
+
files_processed += 1
|
|
217
|
+
_emit("file_done", file=f.name, chunks=len(docs))
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
files_failed.append({
|
|
220
|
+
"file": str(f), "error": f"{type(exc).__name__}: {exc}",
|
|
221
|
+
})
|
|
222
|
+
_emit("file_failed", file=f.name, error=str(exc))
|
|
223
|
+
|
|
224
|
+
manifest["embedder"] = {
|
|
225
|
+
"provider": args.embedder_provider, "model": args.embedder_model,
|
|
226
|
+
}
|
|
227
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
229
|
+
|
|
230
|
+
elapsed_ms = int((time.time() - started) * 1000)
|
|
231
|
+
_emit("done",
|
|
232
|
+
files_processed=files_processed,
|
|
233
|
+
chunks_added=chunks_added,
|
|
234
|
+
skipped=skipped,
|
|
235
|
+
embedder_changed=embedder_changed,
|
|
236
|
+
elapsed_ms=elapsed_ms,
|
|
237
|
+
errors=files_failed)
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def main() -> None:
|
|
242
|
+
ap = argparse.ArgumentParser()
|
|
243
|
+
ap.add_argument("--pipeline-name", required=True)
|
|
244
|
+
ap.add_argument("--folder", required=True)
|
|
245
|
+
ap.add_argument("--embedder-provider", required=True, choices=["ollama", "lmstudio"])
|
|
246
|
+
ap.add_argument("--embedder-model", required=True)
|
|
247
|
+
args = ap.parse_args()
|
|
248
|
+
sys.exit(asyncio.run(_ingest(args)))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@melaya/runner",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.39",
|
|
4
4
|
"description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"dist/**/*.js",
|
|
14
14
|
"dist/**/*.d.ts",
|
|
15
|
+
"dist/**/*.py",
|
|
16
|
+
"localRagIngest.py",
|
|
15
17
|
"README.md"
|
|
16
18
|
],
|
|
17
19
|
"scripts": {
|
|
18
|
-
"build": "tsc",
|
|
20
|
+
"build": "tsc && node -e \"require('fs').copyFileSync('localRagIngest.py','dist/localRagIngest.py')\"",
|
|
19
21
|
"prepublishOnly": "npm run build"
|
|
20
22
|
},
|
|
21
23
|
"dependencies": {
|