@melaya/runner 1.0.48 → 1.0.50
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 +68 -7
- package/dist/localRagIngest.py +233 -2
- package/dist/pythonEnv.d.ts +1 -0
- package/dist/pythonEnv.js +59 -0
- package/localRagIngest.py +233 -2
- package/package.json +1 -1
package/dist/connection.js
CHANGED
|
@@ -229,11 +229,24 @@ export async function connect(opts) {
|
|
|
229
229
|
if (k.startsWith("MEL_MODEL_"))
|
|
230
230
|
delete inherited[k];
|
|
231
231
|
}
|
|
232
|
+
// certifi CA-bundle path resolved at venv setup. Set as both
|
|
233
|
+
// SSL_CERT_FILE (urllib / openssl) and REQUESTS_CA_BUNDLE (requests
|
|
234
|
+
// library) so EVERY HTTPS-using tool — gmail_send, slack_post_text,
|
|
235
|
+
// anthropic SDK, openai SDK, web_search, etc. — verifies against
|
|
236
|
+
// certifi's bundle rather than the platform default. Without this,
|
|
237
|
+
// macOS Python crashes with `[SSL: CERTIFICATE_VERIFY_FAILED]` on
|
|
238
|
+
// any cloud call (run eb1b8f67df56491b hit exactly this for
|
|
239
|
+
// gmail_send → api.googleapis.com).
|
|
240
|
+
const certBundle = (await import("./pythonEnv.js")).getCertBundlePath();
|
|
241
|
+
const sslEnv = certBundle
|
|
242
|
+
? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle }
|
|
243
|
+
: {};
|
|
232
244
|
const env = {
|
|
233
245
|
...inherited,
|
|
234
246
|
PYTHONPATH: sharedDir,
|
|
235
247
|
PYTHONIOENCODING: "utf-8",
|
|
236
248
|
PYTHONUTF8: "1",
|
|
249
|
+
...sslEnv,
|
|
237
250
|
MEL_RUN_ID: payload.runId,
|
|
238
251
|
// Point agentscope hooks at the REAL server (not the browser's Vite URL).
|
|
239
252
|
// Convert wss://api.melaya.org → https://api.melaya.org so pushMessage,
|
|
@@ -467,16 +480,25 @@ export async function connect(opts) {
|
|
|
467
480
|
try {
|
|
468
481
|
const prov = (payload.embedderProvider || "").toLowerCase();
|
|
469
482
|
if (prov === "openai") {
|
|
470
|
-
const reason = ("
|
|
471
|
-
"Pick ollama or lmstudio
|
|
483
|
+
const reason = ("Runner-side ingest refuses cloud embedders. " +
|
|
484
|
+
"Pick ollama or lmstudio (the whole point of routing through " +
|
|
485
|
+
"the runner is to use a local model).");
|
|
472
486
|
console.log(chalk.red(` ✗ rag:ingest refused: ${reason}`));
|
|
473
487
|
socket.emit("rag:ingest-result", {
|
|
474
488
|
session_id: sid, ok: false, error: reason,
|
|
475
489
|
});
|
|
476
490
|
return;
|
|
477
491
|
}
|
|
478
|
-
|
|
479
|
-
|
|
492
|
+
const _isModeC = payload.mode === "cloud_files";
|
|
493
|
+
if (_isModeC) {
|
|
494
|
+
console.log(chalk.hex("#7C6FF0")(`\n ☁️→💻 RAG cloud-files ingest (Mode C): ${payload.pipelineName} ` +
|
|
495
|
+
`via ${prov}:${payload.embedderModel}\n` +
|
|
496
|
+
` (files fetched from ${opts.serverUrl}, embedded locally, vectors pushed back)\n`));
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
console.log(chalk.hex("#10B981")(`\n 🔒 RAG local-folder ingest (Mode B): ${payload.pipelineName} ` +
|
|
500
|
+
`← ${payload.localFolderPath} via ${prov}:${payload.embedderModel}\n`));
|
|
501
|
+
}
|
|
480
502
|
emitProgress("started");
|
|
481
503
|
// Reuse the same venv as runner:run — agentscope.rag + ollama +
|
|
482
504
|
// qdrant-client land here.
|
|
@@ -542,16 +564,55 @@ export async function connect(opts) {
|
|
|
542
564
|
];
|
|
543
565
|
const { existsSync: _exists2 } = await import("fs");
|
|
544
566
|
const bundledNltkData = candidateNltkPaths.find((p) => _exists2(p)) || "";
|
|
545
|
-
|
|
567
|
+
// Resolve certifi bundle for HTTPS verification (same rationale as
|
|
568
|
+
// the runner:run spawn — embedder providers like OpenAI hit
|
|
569
|
+
// api.openai.com over TLS and crash on macOS without certifi).
|
|
570
|
+
const ragCertBundle = (await import("./pythonEnv.js")).getCertBundlePath();
|
|
571
|
+
const ragSslEnv = ragCertBundle
|
|
572
|
+
? { SSL_CERT_FILE: ragCertBundle, REQUESTS_CA_BUNDLE: ragCertBundle }
|
|
573
|
+
: {};
|
|
574
|
+
const ingestMode = payload.mode === "cloud_files" ? "cloud_files" : "local_folder";
|
|
575
|
+
const ingestArgs = [
|
|
546
576
|
"-u", stagedScript,
|
|
577
|
+
"--mode", ingestMode,
|
|
547
578
|
"--pipeline-name", payload.pipelineName,
|
|
548
|
-
"--folder", payload.localFolderPath,
|
|
549
579
|
"--embedder-provider", prov,
|
|
550
580
|
"--embedder-model", payload.embedderModel,
|
|
551
|
-
]
|
|
581
|
+
];
|
|
582
|
+
if (ingestMode === "cloud_files") {
|
|
583
|
+
// Server bundled file bytes into the event. Unpack them to a
|
|
584
|
+
// temp dir + a small manifest JSON so the Python script can
|
|
585
|
+
// walk them with the same code path as Mode B reads from disk.
|
|
586
|
+
const filesDir = join(workDir, "cloud-files");
|
|
587
|
+
mkdirSync(filesDir, { recursive: true });
|
|
588
|
+
const { writeFileSync } = await import("fs");
|
|
589
|
+
const filesArr = payload.cloudFiles || [];
|
|
590
|
+
for (const f of filesArr) {
|
|
591
|
+
try {
|
|
592
|
+
writeFileSync(join(filesDir, f.name), Buffer.from(f.contentB64, "base64"));
|
|
593
|
+
}
|
|
594
|
+
catch (e) {
|
|
595
|
+
console.log(chalk.red(` [rag] failed to unpack ${f.name}: ${e?.message || e}`));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const manifestPath = join(workDir, "cloud-manifest.json");
|
|
599
|
+
writeFileSync(manifestPath, JSON.stringify({
|
|
600
|
+
files: filesArr.map(f => ({ name: f.name, sha256: f.sha256, ext: f.ext })),
|
|
601
|
+
stored_embedder: payload.cloudManifest?.stored_embedder || {},
|
|
602
|
+
previously_ingested: payload.cloudManifest?.previously_ingested || {},
|
|
603
|
+
}, null, 2));
|
|
604
|
+
ingestArgs.push("--files-dir", filesDir);
|
|
605
|
+
ingestArgs.push("--manifest-json", manifestPath);
|
|
606
|
+
console.log(chalk.gray(` [rag] unpacked ${filesArr.length} cloud file(s) → ${filesDir}`));
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
ingestArgs.push("--folder", payload.localFolderPath || "");
|
|
610
|
+
}
|
|
611
|
+
const proc = spawn(env.pythonPath, ingestArgs, {
|
|
552
612
|
env: {
|
|
553
613
|
...process.env,
|
|
554
614
|
PYTHONPATH: sharedDir, // so `from agentscope.rag import …` resolves
|
|
615
|
+
...ragSslEnv,
|
|
555
616
|
...(bundledNltkData ? { NLTK_DATA: bundledNltkData } : {}),
|
|
556
617
|
},
|
|
557
618
|
cwd: workDir,
|
package/dist/localRagIngest.py
CHANGED
|
@@ -331,14 +331,245 @@ async def _ingest(args) -> int:
|
|
|
331
331
|
return 0
|
|
332
332
|
|
|
333
333
|
|
|
334
|
+
def _build_embedder(provider: str, model: str):
|
|
335
|
+
"""Embedder factory shared between Mode B (local folder) and Mode C
|
|
336
|
+
(cloud files via HTTP). Returns the agentscope embedder instance +
|
|
337
|
+
a probe error message string when something is misconfigured (empty
|
|
338
|
+
string means OK)."""
|
|
339
|
+
if provider == "ollama":
|
|
340
|
+
from agentscope.embedding import OllamaTextEmbedding
|
|
341
|
+
DIMS = {
|
|
342
|
+
"nomic-embed-text": 768, "nomic-embed-text:v1.5": 768,
|
|
343
|
+
"mxbai-embed-large": 1024, "all-minilm": 384,
|
|
344
|
+
"snowflake-arctic-embed": 1024,
|
|
345
|
+
}
|
|
346
|
+
dim = DIMS.get(model.lower())
|
|
347
|
+
if dim is None:
|
|
348
|
+
return None, (
|
|
349
|
+
f"unknown ollama embedding model '{model}' — supported: {sorted(DIMS)}"
|
|
350
|
+
)
|
|
351
|
+
return OllamaTextEmbedding(
|
|
352
|
+
model_name=model, dimensions=dim, host=None,
|
|
353
|
+
), ""
|
|
354
|
+
if provider == "lmstudio":
|
|
355
|
+
from agentscope.embedding import OpenAITextEmbedding
|
|
356
|
+
embedder = OpenAITextEmbedding(
|
|
357
|
+
api_key="lmstudio", model_name=model, dimensions=768,
|
|
358
|
+
base_url="http://localhost:1234/v1",
|
|
359
|
+
)
|
|
360
|
+
# Strip OpenAI-specific `dimensions` kwarg that LMS rejects
|
|
361
|
+
_orig_create = embedder.client.embeddings.create
|
|
362
|
+
async def _lms_create(**kw):
|
|
363
|
+
kw.pop("dimensions", None)
|
|
364
|
+
return await _orig_create(**kw)
|
|
365
|
+
embedder.client.embeddings.create = _lms_create # type: ignore[assignment]
|
|
366
|
+
return embedder, ""
|
|
367
|
+
return None, f"unsupported embedder provider '{provider}'"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
async def _ingest_cloud_files(args) -> int:
|
|
371
|
+
"""Mode C ingest.
|
|
372
|
+
|
|
373
|
+
Files originate on prod but the runner does the embedding so the
|
|
374
|
+
operator can use a free local model (ollama / lmstudio) without
|
|
375
|
+
needing an OpenAI key. Both the file CONTENTS and the resulting
|
|
376
|
+
(chunks, vectors) tuples flow over Socket.IO end-to-end:
|
|
377
|
+
|
|
378
|
+
• The server (tRPC bridge in runnerNamespace.ts) reads the files
|
|
379
|
+
from prod's <pipeline_dir>/rag/retrieval/, base64-encodes them,
|
|
380
|
+
and bakes them into the `rag:ingest` event payload. The runner
|
|
381
|
+
connection.ts unpacks them into ``<tmp>/files-dir/`` and
|
|
382
|
+
passes ``--files-dir <tmp> --manifest-json <tmp>/manifest.json``
|
|
383
|
+
here.
|
|
384
|
+
• For each file we parse + chunk + embed locally, then emit a
|
|
385
|
+
``progress: {"kind":"upsert_chunks", ...}`` line to stdout. The
|
|
386
|
+
runner forwards that line as a ``rag:ingest-progress`` Socket.IO
|
|
387
|
+
event; the server's progress handler recognises the
|
|
388
|
+
``upsert_chunks`` kind and POSTs the payload to
|
|
389
|
+
``http://127.0.0.1:8766/pipelines/{name}/docs/retrieval/upsert``
|
|
390
|
+
— the loopback-only builder API that owns the QdrantStore.
|
|
391
|
+
• Nothing on the wire ever needs to leave the user's machine
|
|
392
|
+
via HTTP. The whole transport is the already-authenticated
|
|
393
|
+
Socket.IO channel, which means we don't have to expose any
|
|
394
|
+
builder endpoints publicly.
|
|
395
|
+
"""
|
|
396
|
+
files_dir = Path(args.files_dir)
|
|
397
|
+
if not files_dir.exists() or not files_dir.is_dir():
|
|
398
|
+
_emit("error", reason=f"files-dir not found: {files_dir}")
|
|
399
|
+
return 3
|
|
400
|
+
|
|
401
|
+
embedder, err = _build_embedder(args.embedder_provider, args.embedder_model)
|
|
402
|
+
if not embedder:
|
|
403
|
+
_emit("error", reason=err)
|
|
404
|
+
return 4
|
|
405
|
+
|
|
406
|
+
manifest: dict = {}
|
|
407
|
+
if args.manifest_json:
|
|
408
|
+
try:
|
|
409
|
+
manifest = json.loads(Path(args.manifest_json).read_text(encoding="utf-8"))
|
|
410
|
+
except Exception as exc:
|
|
411
|
+
_emit("warning", reason=f"manifest read failed: {exc}")
|
|
412
|
+
files: list[dict] = manifest.get("files") or []
|
|
413
|
+
stored_embedder = manifest.get("stored_embedder") or {}
|
|
414
|
+
previously_ingested = manifest.get("previously_ingested") or {}
|
|
415
|
+
embedder_changed = (
|
|
416
|
+
stored_embedder.get("provider") != args.embedder_provider
|
|
417
|
+
or stored_embedder.get("model") != args.embedder_model
|
|
418
|
+
)
|
|
419
|
+
_emit("started", mode="cloud_files", pipeline=args.pipeline_name,
|
|
420
|
+
embedder_changed=embedder_changed, total_supported=len(files))
|
|
421
|
+
|
|
422
|
+
started = time.time()
|
|
423
|
+
chunks_added_total = 0
|
|
424
|
+
files_processed = 0
|
|
425
|
+
skipped = 0
|
|
426
|
+
files_failed: list[dict] = []
|
|
427
|
+
|
|
428
|
+
embedder_payload = {
|
|
429
|
+
"provider": args.embedder_provider,
|
|
430
|
+
"model": args.embedder_model,
|
|
431
|
+
"dimensions": embedder.dimensions,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
first_emit = True
|
|
435
|
+
for entry in files:
|
|
436
|
+
name = entry["name"]
|
|
437
|
+
sha = entry["sha256"]
|
|
438
|
+
ext = (entry.get("ext") or "").lower()
|
|
439
|
+
if ext not in SUPPORTED_EXTS:
|
|
440
|
+
files_failed.append({"file": name, "error": f"unsupported ext '{ext}'"})
|
|
441
|
+
_emit("file_failed", file=name, error=f"unsupported ext '{ext}'")
|
|
442
|
+
continue
|
|
443
|
+
if not embedder_changed and previously_ingested.get(name, {}).get("sha256") == sha:
|
|
444
|
+
skipped += 1
|
|
445
|
+
_emit("skipped_sha_clean", file=name)
|
|
446
|
+
continue
|
|
447
|
+
tmp_path = files_dir / name
|
|
448
|
+
if not tmp_path.is_file():
|
|
449
|
+
files_failed.append({"file": name, "error": "file missing from cloud-files dir"})
|
|
450
|
+
_emit("file_failed", file=name, error="missing locally")
|
|
451
|
+
continue
|
|
452
|
+
reader_cls = _reader_for(ext)
|
|
453
|
+
if reader_cls is None:
|
|
454
|
+
files_failed.append({"file": name, "error": "no extractor"})
|
|
455
|
+
_emit("file_failed", file=name, error="no extractor")
|
|
456
|
+
continue
|
|
457
|
+
try:
|
|
458
|
+
reader = reader_cls()
|
|
459
|
+
docs = await reader(str(tmp_path))
|
|
460
|
+
for i, doc in enumerate(docs):
|
|
461
|
+
if hasattr(doc.metadata, "doc_id"):
|
|
462
|
+
doc.metadata.doc_id = name
|
|
463
|
+
if hasattr(doc.metadata, "chunk_id"):
|
|
464
|
+
doc.metadata.chunk_id = i
|
|
465
|
+
texts: list[str] = []
|
|
466
|
+
for doc in docs:
|
|
467
|
+
content = getattr(doc.metadata, "content", None)
|
|
468
|
+
text = (
|
|
469
|
+
(content or {}).get("text", "")
|
|
470
|
+
if isinstance(content, dict)
|
|
471
|
+
else getattr(content, "text", "")
|
|
472
|
+
)
|
|
473
|
+
if text:
|
|
474
|
+
texts.append(text)
|
|
475
|
+
if not texts:
|
|
476
|
+
files_processed += 1
|
|
477
|
+
_emit("file_done", file=name, chunks=0)
|
|
478
|
+
_emit("upsert_chunks",
|
|
479
|
+
embedder=embedder_payload,
|
|
480
|
+
chunks=[],
|
|
481
|
+
files_done=[{"name": name, "sha256": sha, "chunks": 0}],
|
|
482
|
+
embedder_changed=embedder_changed and first_emit,
|
|
483
|
+
finalize=False)
|
|
484
|
+
first_emit = False
|
|
485
|
+
continue
|
|
486
|
+
vectors_resp = await embedder(text=texts)
|
|
487
|
+
vectors = [eb.embedding for eb in vectors_resp.embeddings]
|
|
488
|
+
if len(vectors) != len(texts):
|
|
489
|
+
raise RuntimeError(
|
|
490
|
+
f"embedder returned {len(vectors)} vectors for {len(texts)} chunks"
|
|
491
|
+
)
|
|
492
|
+
upsert_chunks = [
|
|
493
|
+
{
|
|
494
|
+
"text": text,
|
|
495
|
+
"vector": [float(v) for v in vec],
|
|
496
|
+
"metadata": {
|
|
497
|
+
"doc_id": name,
|
|
498
|
+
"chunk_id": i,
|
|
499
|
+
"total_chunks": len(texts),
|
|
500
|
+
},
|
|
501
|
+
}
|
|
502
|
+
for i, (text, vec) in enumerate(zip(texts, vectors))
|
|
503
|
+
]
|
|
504
|
+
# Emit one upsert event per file. Server forwards to builder
|
|
505
|
+
# /upsert. For small per-file payloads this is well below the
|
|
506
|
+
# Socket.IO default 1 MB/event limit (60-doc corpora ≈ 6 MB
|
|
507
|
+
# spread across ~60 events).
|
|
508
|
+
_emit("upsert_chunks",
|
|
509
|
+
embedder=embedder_payload,
|
|
510
|
+
chunks=upsert_chunks,
|
|
511
|
+
files_done=[{"name": name, "sha256": sha, "chunks": len(texts)}],
|
|
512
|
+
embedder_changed=embedder_changed and first_emit,
|
|
513
|
+
finalize=False)
|
|
514
|
+
first_emit = False
|
|
515
|
+
chunks_added_total += len(texts)
|
|
516
|
+
files_processed += 1
|
|
517
|
+
_emit("file_done", file=name, chunks=len(texts))
|
|
518
|
+
except Exception as exc:
|
|
519
|
+
files_failed.append({
|
|
520
|
+
"file": name, "error": f"{type(exc).__name__}: {exc}",
|
|
521
|
+
})
|
|
522
|
+
_emit("file_failed", file=name, error=str(exc))
|
|
523
|
+
|
|
524
|
+
# Finalize: tells the server to stamp the embedder identity into the
|
|
525
|
+
# manifest. Sent as one final, empty-chunk upsert event.
|
|
526
|
+
_emit("upsert_chunks",
|
|
527
|
+
embedder=embedder_payload,
|
|
528
|
+
chunks=[],
|
|
529
|
+
files_done=[],
|
|
530
|
+
embedder_changed=False,
|
|
531
|
+
finalize=True)
|
|
532
|
+
|
|
533
|
+
elapsed_ms = int((time.time() - started) * 1000)
|
|
534
|
+
_emit("done",
|
|
535
|
+
mode="cloud_files",
|
|
536
|
+
files_processed=files_processed,
|
|
537
|
+
chunks_added=chunks_added_total,
|
|
538
|
+
skipped=skipped,
|
|
539
|
+
embedder_changed=embedder_changed,
|
|
540
|
+
elapsed_ms=elapsed_ms,
|
|
541
|
+
errors=files_failed)
|
|
542
|
+
return 0
|
|
543
|
+
|
|
544
|
+
|
|
334
545
|
def main() -> None:
|
|
335
546
|
ap = argparse.ArgumentParser()
|
|
547
|
+
ap.add_argument("--mode", choices=["local_folder", "cloud_files"],
|
|
548
|
+
default="local_folder")
|
|
336
549
|
ap.add_argument("--pipeline-name", required=True)
|
|
337
|
-
ap.add_argument("--folder",
|
|
550
|
+
ap.add_argument("--folder", default="")
|
|
338
551
|
ap.add_argument("--embedder-provider", required=True, choices=["ollama", "lmstudio"])
|
|
339
552
|
ap.add_argument("--embedder-model", required=True)
|
|
553
|
+
# Mode C only (cloud_files): files were base64-encoded by the
|
|
554
|
+
# server into the rag:ingest payload and the runner's connection.ts
|
|
555
|
+
# unpacked them to disk at --files-dir. --manifest-json points at
|
|
556
|
+
# a small JSON manifest (file list + sha + previously-ingested map
|
|
557
|
+
# so we can skip unchanged files).
|
|
558
|
+
ap.add_argument("--files-dir", default="",
|
|
559
|
+
help="Mode C only — directory containing the unpacked file payload.")
|
|
560
|
+
ap.add_argument("--manifest-json", default="",
|
|
561
|
+
help="Mode C only — path to manifest JSON (file list, prior ingest, embedder).")
|
|
340
562
|
args = ap.parse_args()
|
|
341
|
-
|
|
563
|
+
if args.mode == "cloud_files":
|
|
564
|
+
if not args.files_dir or not args.manifest_json:
|
|
565
|
+
_emit("error", reason="cloud_files mode requires --files-dir + --manifest-json")
|
|
566
|
+
sys.exit(2)
|
|
567
|
+
sys.exit(asyncio.run(_ingest_cloud_files(args)))
|
|
568
|
+
else:
|
|
569
|
+
if not args.folder:
|
|
570
|
+
_emit("error", reason="local_folder mode requires --folder")
|
|
571
|
+
sys.exit(2)
|
|
572
|
+
sys.exit(asyncio.run(_ingest(args)))
|
|
342
573
|
|
|
343
574
|
|
|
344
575
|
if __name__ == "__main__":
|
package/dist/pythonEnv.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* shared-bundle version. Re-bootstraps when the bundle version changes.
|
|
11
11
|
*/
|
|
12
12
|
export declare function venvPython(): string;
|
|
13
|
+
export declare function getCertBundlePath(): string;
|
|
13
14
|
export declare function ensurePythonEnv(systemPython: string, expectedVersion: string, onProgress?: (msg: string) => void): Promise<{
|
|
14
15
|
ok: boolean;
|
|
15
16
|
pythonPath: string;
|
package/dist/pythonEnv.js
CHANGED
|
@@ -119,12 +119,69 @@ const PIP_DEPS = [
|
|
|
119
119
|
// "pandas is not installed". Numpy is already in PIP_DEPS (line 57) so
|
|
120
120
|
// the wheel install is fast.
|
|
121
121
|
"pandas",
|
|
122
|
+
// certifi — provides the CA root bundle that tools like gmail_send /
|
|
123
|
+
// any other HTTPS API rely on for cert verification. On macOS Python
|
|
124
|
+
// 3.x doesn't read the system keychain by default; without certifi +
|
|
125
|
+
// SSL_CERT_FILE pointed at its bundle, every Gmail / Slack / Anthropic
|
|
126
|
+
// / etc. API call raises SSL: CERTIFICATE_VERIFY_FAILED (run
|
|
127
|
+
// eb1b8f67df56491b hit exactly this — gmail_send dispatched but
|
|
128
|
+
// urllib couldn't verify api.googleapis.com). The runner exports
|
|
129
|
+
// SSL_CERT_FILE + REQUESTS_CA_BUNDLE pointing here when spawning
|
|
130
|
+
// pipeline Python — see getCertBundlePath() + spawn env merging in
|
|
131
|
+
// connection.ts.
|
|
132
|
+
"certifi",
|
|
122
133
|
];
|
|
123
134
|
export function venvPython() {
|
|
124
135
|
return platform() === "win32"
|
|
125
136
|
? join(VENV_DIR, "Scripts", "python.exe")
|
|
126
137
|
: join(VENV_DIR, "bin", "python");
|
|
127
138
|
}
|
|
139
|
+
// ── certifi CA-bundle path resolution + cache ────────────────────────────
|
|
140
|
+
// Set as SSL_CERT_FILE + REQUESTS_CA_BUNDLE when spawning pipeline Python
|
|
141
|
+
// so that ANY HTTPS-using tool (gmail_send, slack_post_text, anthropic /
|
|
142
|
+
// openai SDKs, requests, urllib, etc.) verifies certs against certifi's
|
|
143
|
+
// bundle rather than the platform default. On macOS specifically, the
|
|
144
|
+
// system Python doesn't read the keychain — without this, every API call
|
|
145
|
+
// crashes with `[SSL: CERTIFICATE_VERIFY_FAILED]`.
|
|
146
|
+
//
|
|
147
|
+
// We cache the path in ~/.melaya-runner/cert-bundle-path so we don't pay
|
|
148
|
+
// a Python startup roundtrip on every spawn. The cache is invalidated
|
|
149
|
+
// when the venv is rebuilt (the file lives outside VENV_DIR but is
|
|
150
|
+
// re-written every ensurePythonEnv).
|
|
151
|
+
const CERT_PATH_CACHE = join(CACHE_DIR, "cert-bundle-path");
|
|
152
|
+
export function getCertBundlePath() {
|
|
153
|
+
try {
|
|
154
|
+
if (existsSync(CERT_PATH_CACHE)) {
|
|
155
|
+
const cached = readFileSync(CERT_PATH_CACHE, "utf-8").trim();
|
|
156
|
+
if (cached && existsSync(cached))
|
|
157
|
+
return cached;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch { /* fall through to resolution */ }
|
|
161
|
+
return "";
|
|
162
|
+
}
|
|
163
|
+
async function _resolveAndCacheCertBundle(onProgress) {
|
|
164
|
+
if (!existsSync(venvPython()))
|
|
165
|
+
return;
|
|
166
|
+
// Resolve certifi.where() — fast subprocess (<300 ms typical).
|
|
167
|
+
let resolved = "";
|
|
168
|
+
await runProc(venvPython(), ["-c", "import certifi, sys; sys.stdout.write(certifi.where())"], (line) => {
|
|
169
|
+
if (line && existsSync(line.trim()))
|
|
170
|
+
resolved = line.trim();
|
|
171
|
+
});
|
|
172
|
+
if (resolved) {
|
|
173
|
+
try {
|
|
174
|
+
writeFileSync(CERT_PATH_CACHE, resolved + "\n", "utf-8");
|
|
175
|
+
onProgress(`cert bundle: ${resolved}`);
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
onProgress(`(warn: failed to cache cert bundle path: ${e?.message})`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
onProgress("(warn: certifi.where() did not resolve a valid path — HTTPS calls may fail with cert verify errors)");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
128
185
|
// The marker is keyed on sharedVersion + a hash of PIP_DEPS so changes
|
|
129
186
|
// to either invalidate the venv. Without the PIP_DEPS half, a runner
|
|
130
187
|
// update that adds a dep (e.g. scrapling[fetchers] in 1.0.32) would
|
|
@@ -226,6 +283,7 @@ export async function ensurePythonEnv(systemPython, expectedVersion, onProgress
|
|
|
226
283
|
// says we're up to date. This is what previously broke for boxes
|
|
227
284
|
// that upgraded the runner without invalidating the marker.
|
|
228
285
|
await ensureNltkData(onProgress);
|
|
286
|
+
await _resolveAndCacheCertBundle(onProgress);
|
|
229
287
|
return { ok: true, pythonPath: venvPython() };
|
|
230
288
|
}
|
|
231
289
|
if (!existsSync(AGENTSCOPE)) {
|
|
@@ -298,6 +356,7 @@ export async function ensurePythonEnv(systemPython, expectedVersion, onProgress
|
|
|
298
356
|
// Centralised in `ensureNltkData()` so the success/failure surface is
|
|
299
357
|
// identical whether the venv is fresh or already valid.
|
|
300
358
|
await ensureNltkData(onProgress);
|
|
359
|
+
await _resolveAndCacheCertBundle(onProgress);
|
|
301
360
|
writeFileSync(VENV_MARK, venvMarkerValue(expectedVersion), "utf-8");
|
|
302
361
|
// sanity: confirm shortuuid + agentscope (via PYTHONPATH) resolve now.
|
|
303
362
|
// Probe must mirror the spawn env so PYTHONPATH=CACHE_DIR points at
|
package/localRagIngest.py
CHANGED
|
@@ -331,14 +331,245 @@ async def _ingest(args) -> int:
|
|
|
331
331
|
return 0
|
|
332
332
|
|
|
333
333
|
|
|
334
|
+
def _build_embedder(provider: str, model: str):
|
|
335
|
+
"""Embedder factory shared between Mode B (local folder) and Mode C
|
|
336
|
+
(cloud files via HTTP). Returns the agentscope embedder instance +
|
|
337
|
+
a probe error message string when something is misconfigured (empty
|
|
338
|
+
string means OK)."""
|
|
339
|
+
if provider == "ollama":
|
|
340
|
+
from agentscope.embedding import OllamaTextEmbedding
|
|
341
|
+
DIMS = {
|
|
342
|
+
"nomic-embed-text": 768, "nomic-embed-text:v1.5": 768,
|
|
343
|
+
"mxbai-embed-large": 1024, "all-minilm": 384,
|
|
344
|
+
"snowflake-arctic-embed": 1024,
|
|
345
|
+
}
|
|
346
|
+
dim = DIMS.get(model.lower())
|
|
347
|
+
if dim is None:
|
|
348
|
+
return None, (
|
|
349
|
+
f"unknown ollama embedding model '{model}' — supported: {sorted(DIMS)}"
|
|
350
|
+
)
|
|
351
|
+
return OllamaTextEmbedding(
|
|
352
|
+
model_name=model, dimensions=dim, host=None,
|
|
353
|
+
), ""
|
|
354
|
+
if provider == "lmstudio":
|
|
355
|
+
from agentscope.embedding import OpenAITextEmbedding
|
|
356
|
+
embedder = OpenAITextEmbedding(
|
|
357
|
+
api_key="lmstudio", model_name=model, dimensions=768,
|
|
358
|
+
base_url="http://localhost:1234/v1",
|
|
359
|
+
)
|
|
360
|
+
# Strip OpenAI-specific `dimensions` kwarg that LMS rejects
|
|
361
|
+
_orig_create = embedder.client.embeddings.create
|
|
362
|
+
async def _lms_create(**kw):
|
|
363
|
+
kw.pop("dimensions", None)
|
|
364
|
+
return await _orig_create(**kw)
|
|
365
|
+
embedder.client.embeddings.create = _lms_create # type: ignore[assignment]
|
|
366
|
+
return embedder, ""
|
|
367
|
+
return None, f"unsupported embedder provider '{provider}'"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
async def _ingest_cloud_files(args) -> int:
|
|
371
|
+
"""Mode C ingest.
|
|
372
|
+
|
|
373
|
+
Files originate on prod but the runner does the embedding so the
|
|
374
|
+
operator can use a free local model (ollama / lmstudio) without
|
|
375
|
+
needing an OpenAI key. Both the file CONTENTS and the resulting
|
|
376
|
+
(chunks, vectors) tuples flow over Socket.IO end-to-end:
|
|
377
|
+
|
|
378
|
+
• The server (tRPC bridge in runnerNamespace.ts) reads the files
|
|
379
|
+
from prod's <pipeline_dir>/rag/retrieval/, base64-encodes them,
|
|
380
|
+
and bakes them into the `rag:ingest` event payload. The runner
|
|
381
|
+
connection.ts unpacks them into ``<tmp>/files-dir/`` and
|
|
382
|
+
passes ``--files-dir <tmp> --manifest-json <tmp>/manifest.json``
|
|
383
|
+
here.
|
|
384
|
+
• For each file we parse + chunk + embed locally, then emit a
|
|
385
|
+
``progress: {"kind":"upsert_chunks", ...}`` line to stdout. The
|
|
386
|
+
runner forwards that line as a ``rag:ingest-progress`` Socket.IO
|
|
387
|
+
event; the server's progress handler recognises the
|
|
388
|
+
``upsert_chunks`` kind and POSTs the payload to
|
|
389
|
+
``http://127.0.0.1:8766/pipelines/{name}/docs/retrieval/upsert``
|
|
390
|
+
— the loopback-only builder API that owns the QdrantStore.
|
|
391
|
+
• Nothing on the wire ever needs to leave the user's machine
|
|
392
|
+
via HTTP. The whole transport is the already-authenticated
|
|
393
|
+
Socket.IO channel, which means we don't have to expose any
|
|
394
|
+
builder endpoints publicly.
|
|
395
|
+
"""
|
|
396
|
+
files_dir = Path(args.files_dir)
|
|
397
|
+
if not files_dir.exists() or not files_dir.is_dir():
|
|
398
|
+
_emit("error", reason=f"files-dir not found: {files_dir}")
|
|
399
|
+
return 3
|
|
400
|
+
|
|
401
|
+
embedder, err = _build_embedder(args.embedder_provider, args.embedder_model)
|
|
402
|
+
if not embedder:
|
|
403
|
+
_emit("error", reason=err)
|
|
404
|
+
return 4
|
|
405
|
+
|
|
406
|
+
manifest: dict = {}
|
|
407
|
+
if args.manifest_json:
|
|
408
|
+
try:
|
|
409
|
+
manifest = json.loads(Path(args.manifest_json).read_text(encoding="utf-8"))
|
|
410
|
+
except Exception as exc:
|
|
411
|
+
_emit("warning", reason=f"manifest read failed: {exc}")
|
|
412
|
+
files: list[dict] = manifest.get("files") or []
|
|
413
|
+
stored_embedder = manifest.get("stored_embedder") or {}
|
|
414
|
+
previously_ingested = manifest.get("previously_ingested") or {}
|
|
415
|
+
embedder_changed = (
|
|
416
|
+
stored_embedder.get("provider") != args.embedder_provider
|
|
417
|
+
or stored_embedder.get("model") != args.embedder_model
|
|
418
|
+
)
|
|
419
|
+
_emit("started", mode="cloud_files", pipeline=args.pipeline_name,
|
|
420
|
+
embedder_changed=embedder_changed, total_supported=len(files))
|
|
421
|
+
|
|
422
|
+
started = time.time()
|
|
423
|
+
chunks_added_total = 0
|
|
424
|
+
files_processed = 0
|
|
425
|
+
skipped = 0
|
|
426
|
+
files_failed: list[dict] = []
|
|
427
|
+
|
|
428
|
+
embedder_payload = {
|
|
429
|
+
"provider": args.embedder_provider,
|
|
430
|
+
"model": args.embedder_model,
|
|
431
|
+
"dimensions": embedder.dimensions,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
first_emit = True
|
|
435
|
+
for entry in files:
|
|
436
|
+
name = entry["name"]
|
|
437
|
+
sha = entry["sha256"]
|
|
438
|
+
ext = (entry.get("ext") or "").lower()
|
|
439
|
+
if ext not in SUPPORTED_EXTS:
|
|
440
|
+
files_failed.append({"file": name, "error": f"unsupported ext '{ext}'"})
|
|
441
|
+
_emit("file_failed", file=name, error=f"unsupported ext '{ext}'")
|
|
442
|
+
continue
|
|
443
|
+
if not embedder_changed and previously_ingested.get(name, {}).get("sha256") == sha:
|
|
444
|
+
skipped += 1
|
|
445
|
+
_emit("skipped_sha_clean", file=name)
|
|
446
|
+
continue
|
|
447
|
+
tmp_path = files_dir / name
|
|
448
|
+
if not tmp_path.is_file():
|
|
449
|
+
files_failed.append({"file": name, "error": "file missing from cloud-files dir"})
|
|
450
|
+
_emit("file_failed", file=name, error="missing locally")
|
|
451
|
+
continue
|
|
452
|
+
reader_cls = _reader_for(ext)
|
|
453
|
+
if reader_cls is None:
|
|
454
|
+
files_failed.append({"file": name, "error": "no extractor"})
|
|
455
|
+
_emit("file_failed", file=name, error="no extractor")
|
|
456
|
+
continue
|
|
457
|
+
try:
|
|
458
|
+
reader = reader_cls()
|
|
459
|
+
docs = await reader(str(tmp_path))
|
|
460
|
+
for i, doc in enumerate(docs):
|
|
461
|
+
if hasattr(doc.metadata, "doc_id"):
|
|
462
|
+
doc.metadata.doc_id = name
|
|
463
|
+
if hasattr(doc.metadata, "chunk_id"):
|
|
464
|
+
doc.metadata.chunk_id = i
|
|
465
|
+
texts: list[str] = []
|
|
466
|
+
for doc in docs:
|
|
467
|
+
content = getattr(doc.metadata, "content", None)
|
|
468
|
+
text = (
|
|
469
|
+
(content or {}).get("text", "")
|
|
470
|
+
if isinstance(content, dict)
|
|
471
|
+
else getattr(content, "text", "")
|
|
472
|
+
)
|
|
473
|
+
if text:
|
|
474
|
+
texts.append(text)
|
|
475
|
+
if not texts:
|
|
476
|
+
files_processed += 1
|
|
477
|
+
_emit("file_done", file=name, chunks=0)
|
|
478
|
+
_emit("upsert_chunks",
|
|
479
|
+
embedder=embedder_payload,
|
|
480
|
+
chunks=[],
|
|
481
|
+
files_done=[{"name": name, "sha256": sha, "chunks": 0}],
|
|
482
|
+
embedder_changed=embedder_changed and first_emit,
|
|
483
|
+
finalize=False)
|
|
484
|
+
first_emit = False
|
|
485
|
+
continue
|
|
486
|
+
vectors_resp = await embedder(text=texts)
|
|
487
|
+
vectors = [eb.embedding for eb in vectors_resp.embeddings]
|
|
488
|
+
if len(vectors) != len(texts):
|
|
489
|
+
raise RuntimeError(
|
|
490
|
+
f"embedder returned {len(vectors)} vectors for {len(texts)} chunks"
|
|
491
|
+
)
|
|
492
|
+
upsert_chunks = [
|
|
493
|
+
{
|
|
494
|
+
"text": text,
|
|
495
|
+
"vector": [float(v) for v in vec],
|
|
496
|
+
"metadata": {
|
|
497
|
+
"doc_id": name,
|
|
498
|
+
"chunk_id": i,
|
|
499
|
+
"total_chunks": len(texts),
|
|
500
|
+
},
|
|
501
|
+
}
|
|
502
|
+
for i, (text, vec) in enumerate(zip(texts, vectors))
|
|
503
|
+
]
|
|
504
|
+
# Emit one upsert event per file. Server forwards to builder
|
|
505
|
+
# /upsert. For small per-file payloads this is well below the
|
|
506
|
+
# Socket.IO default 1 MB/event limit (60-doc corpora ≈ 6 MB
|
|
507
|
+
# spread across ~60 events).
|
|
508
|
+
_emit("upsert_chunks",
|
|
509
|
+
embedder=embedder_payload,
|
|
510
|
+
chunks=upsert_chunks,
|
|
511
|
+
files_done=[{"name": name, "sha256": sha, "chunks": len(texts)}],
|
|
512
|
+
embedder_changed=embedder_changed and first_emit,
|
|
513
|
+
finalize=False)
|
|
514
|
+
first_emit = False
|
|
515
|
+
chunks_added_total += len(texts)
|
|
516
|
+
files_processed += 1
|
|
517
|
+
_emit("file_done", file=name, chunks=len(texts))
|
|
518
|
+
except Exception as exc:
|
|
519
|
+
files_failed.append({
|
|
520
|
+
"file": name, "error": f"{type(exc).__name__}: {exc}",
|
|
521
|
+
})
|
|
522
|
+
_emit("file_failed", file=name, error=str(exc))
|
|
523
|
+
|
|
524
|
+
# Finalize: tells the server to stamp the embedder identity into the
|
|
525
|
+
# manifest. Sent as one final, empty-chunk upsert event.
|
|
526
|
+
_emit("upsert_chunks",
|
|
527
|
+
embedder=embedder_payload,
|
|
528
|
+
chunks=[],
|
|
529
|
+
files_done=[],
|
|
530
|
+
embedder_changed=False,
|
|
531
|
+
finalize=True)
|
|
532
|
+
|
|
533
|
+
elapsed_ms = int((time.time() - started) * 1000)
|
|
534
|
+
_emit("done",
|
|
535
|
+
mode="cloud_files",
|
|
536
|
+
files_processed=files_processed,
|
|
537
|
+
chunks_added=chunks_added_total,
|
|
538
|
+
skipped=skipped,
|
|
539
|
+
embedder_changed=embedder_changed,
|
|
540
|
+
elapsed_ms=elapsed_ms,
|
|
541
|
+
errors=files_failed)
|
|
542
|
+
return 0
|
|
543
|
+
|
|
544
|
+
|
|
334
545
|
def main() -> None:
|
|
335
546
|
ap = argparse.ArgumentParser()
|
|
547
|
+
ap.add_argument("--mode", choices=["local_folder", "cloud_files"],
|
|
548
|
+
default="local_folder")
|
|
336
549
|
ap.add_argument("--pipeline-name", required=True)
|
|
337
|
-
ap.add_argument("--folder",
|
|
550
|
+
ap.add_argument("--folder", default="")
|
|
338
551
|
ap.add_argument("--embedder-provider", required=True, choices=["ollama", "lmstudio"])
|
|
339
552
|
ap.add_argument("--embedder-model", required=True)
|
|
553
|
+
# Mode C only (cloud_files): files were base64-encoded by the
|
|
554
|
+
# server into the rag:ingest payload and the runner's connection.ts
|
|
555
|
+
# unpacked them to disk at --files-dir. --manifest-json points at
|
|
556
|
+
# a small JSON manifest (file list + sha + previously-ingested map
|
|
557
|
+
# so we can skip unchanged files).
|
|
558
|
+
ap.add_argument("--files-dir", default="",
|
|
559
|
+
help="Mode C only — directory containing the unpacked file payload.")
|
|
560
|
+
ap.add_argument("--manifest-json", default="",
|
|
561
|
+
help="Mode C only — path to manifest JSON (file list, prior ingest, embedder).")
|
|
340
562
|
args = ap.parse_args()
|
|
341
|
-
|
|
563
|
+
if args.mode == "cloud_files":
|
|
564
|
+
if not args.files_dir or not args.manifest_json:
|
|
565
|
+
_emit("error", reason="cloud_files mode requires --files-dir + --manifest-json")
|
|
566
|
+
sys.exit(2)
|
|
567
|
+
sys.exit(asyncio.run(_ingest_cloud_files(args)))
|
|
568
|
+
else:
|
|
569
|
+
if not args.folder:
|
|
570
|
+
_emit("error", reason="local_folder mode requires --folder")
|
|
571
|
+
sys.exit(2)
|
|
572
|
+
sys.exit(asyncio.run(_ingest(args)))
|
|
342
573
|
|
|
343
574
|
|
|
344
575
|
if __name__ == "__main__":
|