@melaya/runner 1.0.49 → 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 +47 -7
- package/dist/localRagIngest.py +233 -2
- package/localRagIngest.py +233 -2
- package/package.json +1 -1
package/dist/connection.js
CHANGED
|
@@ -480,16 +480,25 @@ export async function connect(opts) {
|
|
|
480
480
|
try {
|
|
481
481
|
const prov = (payload.embedderProvider || "").toLowerCase();
|
|
482
482
|
if (prov === "openai") {
|
|
483
|
-
const reason = ("
|
|
484
|
-
"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).");
|
|
485
486
|
console.log(chalk.red(` ✗ rag:ingest refused: ${reason}`));
|
|
486
487
|
socket.emit("rag:ingest-result", {
|
|
487
488
|
session_id: sid, ok: false, error: reason,
|
|
488
489
|
});
|
|
489
490
|
return;
|
|
490
491
|
}
|
|
491
|
-
|
|
492
|
-
|
|
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
|
+
}
|
|
493
502
|
emitProgress("started");
|
|
494
503
|
// Reuse the same venv as runner:run — agentscope.rag + ollama +
|
|
495
504
|
// qdrant-client land here.
|
|
@@ -562,13 +571,44 @@ export async function connect(opts) {
|
|
|
562
571
|
const ragSslEnv = ragCertBundle
|
|
563
572
|
? { SSL_CERT_FILE: ragCertBundle, REQUESTS_CA_BUNDLE: ragCertBundle }
|
|
564
573
|
: {};
|
|
565
|
-
const
|
|
574
|
+
const ingestMode = payload.mode === "cloud_files" ? "cloud_files" : "local_folder";
|
|
575
|
+
const ingestArgs = [
|
|
566
576
|
"-u", stagedScript,
|
|
577
|
+
"--mode", ingestMode,
|
|
567
578
|
"--pipeline-name", payload.pipelineName,
|
|
568
|
-
"--folder", payload.localFolderPath,
|
|
569
579
|
"--embedder-provider", prov,
|
|
570
580
|
"--embedder-model", payload.embedderModel,
|
|
571
|
-
]
|
|
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, {
|
|
572
612
|
env: {
|
|
573
613
|
...process.env,
|
|
574
614
|
PYTHONPATH: sharedDir, // so `from agentscope.rag import …` resolves
|
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/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__":
|