mediarag 0.1.14__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.
- mediarag/__init__.py +140 -0
- mediarag/adapters/__init__.py +0 -0
- mediarag/adapters/dropbox +48 -0
- mediarag/adapters/obsidian +53 -0
- mediarag/adapters/pcloud +42 -0
- mediarag/adapters/raindrop +64 -0
- mediarag/adapters/rclone +68 -0
- mediarag/core/engine.py +238 -0
- mediarag/core/ingest.py +387 -0
- mediarag/core/search.py +49 -0
- mediarag/core/store.py +221 -0
- mediarag/core/tools.py +265 -0
- mediarag/core/urlsafe.py +73 -0
- mediarag/mcp.json +9 -0
- mediarag/mediarag-config.example.yaml +4 -0
- mediarag/plugin.json +20 -0
- mediarag/plugin.yaml +29 -0
- mediarag/requirements.txt +10 -0
- mediarag/scripts/fetch_model.py +16 -0
- mediarag/scripts/mcp_launch.py +104 -0
- mediarag/scripts/mcp_server.py +139 -0
- mediarag-0.1.14.dist-info/METADATA +229 -0
- mediarag-0.1.14.dist-info/RECORD +27 -0
- mediarag-0.1.14.dist-info/WHEEL +5 -0
- mediarag-0.1.14.dist-info/entry_points.txt +2 -0
- mediarag-0.1.14.dist-info/licenses/LICENSE +21 -0
- mediarag-0.1.14.dist-info/top_level.txt +1 -0
mediarag/__init__.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""MediaRAG plugin entry — agent-plugins 1.0, loaded as hermes_plugins.<slug>.
|
|
2
|
+
|
|
3
|
+
register(ctx) is the real loader contract: ctx.register_tool(name, toolset,
|
|
4
|
+
schema={"type":"object","parameters":…}, handler, description). Handlers
|
|
5
|
+
absorb dispatch kwargs themselves (**_x — Hermes passes task_id + extras).
|
|
6
|
+
|
|
7
|
+
Env contract: register() checks that onnxruntime, numpy, and transformers
|
|
8
|
+
import, and refuses to register tools when they do not. Install them with
|
|
9
|
+
`pip install -r requirements.txt`. The model snapshot is loaded from the
|
|
10
|
+
Hugging Face cache and is not downloaded on startup.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
_PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
18
|
+
|
|
19
|
+
def _config_root() -> str:
|
|
20
|
+
"""Corpus root from mediarag-config.yaml in the plugin dir (named config, rag-config.yaml pattern).
|
|
21
|
+
# ponytail: flat `root:` line parse — no yaml dependency on the stdio spawn path;
|
|
22
|
+
# switch to yaml.safe_load if the config ever grows beyond one key.
|
|
23
|
+
"""
|
|
24
|
+
path = os.path.join(_PLUGIN_DIR, "mediarag-config.yaml")
|
|
25
|
+
try:
|
|
26
|
+
with open(path, encoding="utf-8") as fh:
|
|
27
|
+
for line in fh:
|
|
28
|
+
if line.strip().startswith("root:"):
|
|
29
|
+
return line.split(":", 1)[1].strip().strip("'\"")
|
|
30
|
+
except OSError:
|
|
31
|
+
pass
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
TOOL_ROOT = _config_root() or os.environ.get("MEDIARAG_ROOT") or os.path.expanduser("~/.mediarag")
|
|
36
|
+
|
|
37
|
+
PYVER = f"python{sys.version_info.major}.{sys.version_info.minor}"
|
|
38
|
+
|
|
39
|
+
BOOTSTRAP_HINT = (
|
|
40
|
+
"MediaRAG engine deps missing — pip install -r requirements.txt "
|
|
41
|
+
"from the plugin directory"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _engine_importable() -> bool:
|
|
46
|
+
try:
|
|
47
|
+
import onnxruntime # noqa: F401
|
|
48
|
+
import numpy # noqa: F401
|
|
49
|
+
import transformers # noqa: F401
|
|
50
|
+
# The processor chain constructs a video processor needing BOTH torch and
|
|
51
|
+
# torchvision; without them every embed raises ImportError at call time
|
|
52
|
+
# while the plugin still advertises five working tools.
|
|
53
|
+
import torch # noqa: F401
|
|
54
|
+
import torchvision # noqa: F401
|
|
55
|
+
return True
|
|
56
|
+
except Exception:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def register(ctx) -> None:
|
|
61
|
+
"""MediaRAG native tool registration (Hermes loader contract)."""
|
|
62
|
+
# Deps arrive with the plugin install (python_dependencies / requirements.txt).
|
|
63
|
+
# Nothing here edits sys.path or the process environment: this runs inside the
|
|
64
|
+
# Hermes gateway, where either would leak into every other plugin.
|
|
65
|
+
if not _engine_importable():
|
|
66
|
+
print(f"[mediarag] {BOOTSTRAP_HINT}")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
import json as _json
|
|
70
|
+
|
|
71
|
+
from .core import tools as _tools
|
|
72
|
+
|
|
73
|
+
def _json_result(handler):
|
|
74
|
+
"""Registry dispatch contract: handler receives ONE positional arg (the
|
|
75
|
+
args dict, task_id-style extras included) and returns a str (dict
|
|
76
|
+
results are rejected — the workspace plugin's lifetime bug)."""
|
|
77
|
+
def wrapped(args=None, **kwargs):
|
|
78
|
+
a = dict(args or {})
|
|
79
|
+
a.update(kwargs)
|
|
80
|
+
return _json.dumps(handler(**a))
|
|
81
|
+
return wrapped
|
|
82
|
+
|
|
83
|
+
def reg(name, desc, handler, params, required):
|
|
84
|
+
ctx.register_tool(
|
|
85
|
+
name=name,
|
|
86
|
+
toolset="mediarag",
|
|
87
|
+
schema={"type": "object", "parameters": {
|
|
88
|
+
"type": "object", "properties": params, "required": required}},
|
|
89
|
+
handler=_json_result(handler),
|
|
90
|
+
check_fn=lambda: True,
|
|
91
|
+
description=desc,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
reg(
|
|
95
|
+
"mediarag_search",
|
|
96
|
+
"Search documents and media (hybrid: semantic + keyword/BM25): text, images, "
|
|
97
|
+
"audio. Ranked hits with snippets, scores, spans, and image thumbnails.",
|
|
98
|
+
_tools.tool_search,
|
|
99
|
+
{
|
|
100
|
+
"query": {"type": "string", "description": "natural-language query (or keyword terms)"},
|
|
101
|
+
"k": {"type": "integer", "default": 5},
|
|
102
|
+
"kind": {"type": "string", "enum": ["text", "image", "audio"]},
|
|
103
|
+
"include_thumbnails": {"type": "boolean", "default": True},
|
|
104
|
+
},
|
|
105
|
+
["query"],
|
|
106
|
+
)
|
|
107
|
+
reg(
|
|
108
|
+
"mediarag_add_note",
|
|
109
|
+
"Store a text note into the MediaRAG corpus (searchable afterwards).",
|
|
110
|
+
_tools.tool_add_note,
|
|
111
|
+
{"text": {"type": "string", "description": "note text"},
|
|
112
|
+
},
|
|
113
|
+
["text"],
|
|
114
|
+
)
|
|
115
|
+
reg(
|
|
116
|
+
"mediarag_ingest_dir",
|
|
117
|
+
"Bulk-index a directory of documents and media (pdf, office, notes, "
|
|
118
|
+
"subtitles, images, audio). Per-file errors are collected, never fatal.",
|
|
119
|
+
_tools.tool_ingest_dir,
|
|
120
|
+
{"path": {"type": "string"},
|
|
121
|
+
},
|
|
122
|
+
["path"],
|
|
123
|
+
)
|
|
124
|
+
reg(
|
|
125
|
+
"mediarag_add_source",
|
|
126
|
+
"Add one source: local file/dir, http(s) URL, or adapter scheme URI "
|
|
127
|
+
"(raindrop://, pcloud://, obsidian://, rclone://backend:path).",
|
|
128
|
+
_tools.tool_add_source,
|
|
129
|
+
{"source": {"type": "string"},
|
|
130
|
+
},
|
|
131
|
+
["source"],
|
|
132
|
+
)
|
|
133
|
+
reg(
|
|
134
|
+
"mediarag_status",
|
|
135
|
+
"MediaRAG corpus status: row counts by kind, corpus root, adapters.",
|
|
136
|
+
_tools.tool_status,
|
|
137
|
+
{},
|
|
138
|
+
[],
|
|
139
|
+
)
|
|
140
|
+
print(f"[mediarag] 5 tools registered (corpus root: {TOOL_ROOT})")
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MediaRAG source adapter: dropbox://<path-in-dropbox> — Dropbox assets via the
|
|
3
|
+
desktop client's local folder (or any local mirror).
|
|
4
|
+
|
|
5
|
+
Env: DROPBOX_DIR (default: "~/Dropbox"). Item shape: {"uri", "path"} — the
|
|
6
|
+
indexer decodes/embeds real files itself, so this adapter deliberately does not
|
|
7
|
+
carry its own extension list: `ing.index_file` knows which formats it can read
|
|
8
|
+
(pdf/epub/docx via pandoc, pdf via pdftotext, images, audio) and returns an
|
|
9
|
+
`unsupported_ext` skip for the rest. Duplicating that list here is how Dropbox
|
|
10
|
+
PDFs would go missing without a word.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
# Dropbox's own bookkeeping, plus the usual VCS/mount debris. Anything else is
|
|
17
|
+
# the indexer's call.
|
|
18
|
+
SKIP_DIRS = {".dropbox", ".dropbox.cache", ".git", ".obsidian", ".trash"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main():
|
|
22
|
+
if len(sys.argv) != 3 or sys.argv[1] != "fetch":
|
|
23
|
+
print("usage: dropbox fetch <path-in-dropbox> (e.g. dropbox://Papers)", file=sys.stderr)
|
|
24
|
+
return 2
|
|
25
|
+
base = os.path.expanduser(os.environ.get("DROPBOX_DIR", "~/Dropbox"))
|
|
26
|
+
sub = sys.argv[2].lstrip("/")
|
|
27
|
+
root = os.path.join(base, sub)
|
|
28
|
+
if not os.path.isdir(root):
|
|
29
|
+
print(f"not a directory: {root} (set DROPBOX_DIR if your Dropbox lives elsewhere)",
|
|
30
|
+
file=sys.stderr)
|
|
31
|
+
return 1
|
|
32
|
+
total = 0
|
|
33
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
34
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".") and d not in SKIP_DIRS]
|
|
35
|
+
for fn in sorted(filenames):
|
|
36
|
+
if fn.startswith(".") or fn.endswith(".dropbox.attr"):
|
|
37
|
+
continue
|
|
38
|
+
p = os.path.join(dirpath, fn)
|
|
39
|
+
print(json.dumps({"uri": f"dropbox://{os.path.relpath(p, base)}", "path": p},
|
|
40
|
+
ensure_ascii=False))
|
|
41
|
+
total += 1
|
|
42
|
+
print(f"emitted {total} files under {sub or '/'} (indexer decides what it can read)",
|
|
43
|
+
file=sys.stderr)
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
sys.exit(main())
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MediaRAG source adapter: obsidian://<vault-path> — an Obsidian vault.
|
|
3
|
+
|
|
4
|
+
Emits {"uri", "path"} items for indexable files under the vault, skipping
|
|
5
|
+
Obsidian's own config/noise (.obsidian, .trash, .DS_Store, hidden dirs, .tmp).
|
|
6
|
+
Vault resolution order for the arg (the part after obsidian://):
|
|
7
|
+
1. absolute or ~-expanded path as given
|
|
8
|
+
2. $OBSIDIAN_VAULT + arg
|
|
9
|
+
Env: OBSIDIAN_VAULT (optional default vault). Indexable extensions = the server's.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
SKIP_DIRS = {".obsidian", ".trash", ".git", ".smart-env", ".DS_Store"}
|
|
16
|
+
SKIP_EXT = {".tmp", ".bak", ".partial"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main():
|
|
20
|
+
if len(sys.argv) != 3 or sys.argv[1] != "fetch":
|
|
21
|
+
print("usage: obsidian fetch <vault-path> (e.g. obsidian://~/notes/vault)", file=sys.stderr)
|
|
22
|
+
return 2
|
|
23
|
+
sub = sys.argv[2]
|
|
24
|
+
candidates = []
|
|
25
|
+
if sub.startswith("/"):
|
|
26
|
+
candidates.append(sub)
|
|
27
|
+
if sub.startswith("~"):
|
|
28
|
+
candidates.append(os.path.expanduser(sub))
|
|
29
|
+
default = os.environ.get("OBSIDIAN_VAULT", "")
|
|
30
|
+
if default:
|
|
31
|
+
candidates.append(os.path.join(os.path.expanduser(default), sub.lstrip("/")))
|
|
32
|
+
root = next((c for c in candidates if os.path.isdir(c)), None)
|
|
33
|
+
if root is None:
|
|
34
|
+
print(f"vault not found: tried {candidates} (set OBSIDIAN_VAULT or pass an absolute path)",
|
|
35
|
+
file=sys.stderr)
|
|
36
|
+
return 1
|
|
37
|
+
total = 0
|
|
38
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
39
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".") and d not in SKIP_DIRS]
|
|
40
|
+
for fn in sorted(filenames):
|
|
41
|
+
base, ext = os.path.splitext(fn)
|
|
42
|
+
if fn.startswith(".") or fn.endswith(".tmp") or ext.lower() in SKIP_EXT:
|
|
43
|
+
continue
|
|
44
|
+
p = os.path.join(dirpath, fn)
|
|
45
|
+
rel = os.path.relpath(p, root)
|
|
46
|
+
print(json.dumps({"uri": f"obsidian://{rel}", "path": p}, ensure_ascii=False))
|
|
47
|
+
total += 1
|
|
48
|
+
print(f"emitted {total} indexable files from vault {root}", file=sys.stderr)
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
sys.exit(main())
|
mediarag/adapters/pcloud
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MediaRAG source adapter: pcloud://<path-in-drive> — pCloud assets via the
|
|
3
|
+
rclone FUSE mount (or any local mount point).
|
|
4
|
+
|
|
5
|
+
Env: PCLOUD_MOUNT (default: "~/pCloud Drive"). Item shape: {"uri", "path"} —
|
|
6
|
+
the indexer decodes/embeds real files itself. Every file is emitted; the indexer
|
|
7
|
+
classifies extensions itself and reports unsupported ones as skips.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
if len(sys.argv) != 3 or sys.argv[1] != "fetch":
|
|
16
|
+
print("usage: pcloud fetch <path-inside-drive> (e.g. pcloud://Shared/wiki)", file=sys.stderr)
|
|
17
|
+
return 2
|
|
18
|
+
mount = os.environ.get("PCLOUD_MOUNT", "~/pCloud Drive")
|
|
19
|
+
mount = os.path.expanduser(mount)
|
|
20
|
+
sub = sys.argv[2]
|
|
21
|
+
if sub.startswith("/"): # accept both "Shard/x" and "/Shared/x"
|
|
22
|
+
sub = sub.lstrip("/")
|
|
23
|
+
root = os.path.join(mount, sub)
|
|
24
|
+
if not os.path.isdir(root):
|
|
25
|
+
print(f"not a directory: {root} (set PCLOUD_MOUNT if your mount differs)", file=sys.stderr)
|
|
26
|
+
return 1
|
|
27
|
+
total = 0
|
|
28
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
29
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".") and d != ".corpus"]
|
|
30
|
+
for fn in sorted(filenames):
|
|
31
|
+
if fn.startswith("."):
|
|
32
|
+
continue
|
|
33
|
+
p = os.path.join(dirpath, fn)
|
|
34
|
+
print(json.dumps({"uri": f"pcloud://{os.path.relpath(p, mount)}", "path": p},
|
|
35
|
+
ensure_ascii=False))
|
|
36
|
+
total += 1
|
|
37
|
+
print(f"emitted {total} indexable files under {sub}", file=sys.stderr)
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
sys.exit(main())
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MediaRAG source adapter: raindrop://bookmarks — Raindrop.io bookmarks as text items.
|
|
3
|
+
|
|
4
|
+
Env: RAINDROP_API_TOKEN (required). Stdout = one JSON line per bookmark.
|
|
5
|
+
Stdlib only; no dependencies. Community adapters follow this shape.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_token():
|
|
14
|
+
return os.environ.get("RAINDROP_API_TOKEN", "").strip()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main():
|
|
18
|
+
if len(sys.argv) != 3 or sys.argv[1] != "fetch":
|
|
19
|
+
print("usage: raindrop fetch <what> (e.g. raindrop://bookmarks)", file=sys.stderr)
|
|
20
|
+
return 2
|
|
21
|
+
token = resolve_token()
|
|
22
|
+
if not token:
|
|
23
|
+
print("set RAINDROP_API_TOKEN in the server environment", file=sys.stderr)
|
|
24
|
+
return 1
|
|
25
|
+
total = 0
|
|
26
|
+
page = -1 # raindrop pagination is 0-indexed; loop pre-increments
|
|
27
|
+
while True:
|
|
28
|
+
page += 1
|
|
29
|
+
url = f"https://api.raindrop.io/rest/v1/raindrops/0?perpage=50&page={page}"
|
|
30
|
+
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
|
|
31
|
+
try:
|
|
32
|
+
resp = json.load(urllib.request.urlopen(req, timeout=30))
|
|
33
|
+
except Exception as e:
|
|
34
|
+
print(f"raindrop API page {page} failed: {e}", file=sys.stderr)
|
|
35
|
+
return 1
|
|
36
|
+
if resp.get("result") not in (0, True):
|
|
37
|
+
print(f"raindrop API error on page {page}: {resp.get('error')}", file=sys.stderr)
|
|
38
|
+
return 1
|
|
39
|
+
items = resp.get("items") or []
|
|
40
|
+
for d in items:
|
|
41
|
+
title = (d.get("title") or "").strip() or "(untitled)"
|
|
42
|
+
parts = [f"# {title}"]
|
|
43
|
+
if d.get("excerpt"):
|
|
44
|
+
parts.append(f"Excerpt: {d['excerpt']}")
|
|
45
|
+
if d.get("tags"):
|
|
46
|
+
parts.append("Tags: " + ", ".join(d["tags"]))
|
|
47
|
+
if d.get("note"):
|
|
48
|
+
parts.append(f"Note: {d['note']}")
|
|
49
|
+
if d.get("link"):
|
|
50
|
+
parts.append(f"URL: {d['link']}")
|
|
51
|
+
text = "\n\n".join(parts).strip()
|
|
52
|
+
if not text:
|
|
53
|
+
continue
|
|
54
|
+
item = {"uri": f"raindrop://{d['_id']}", "title": title, "text": text}
|
|
55
|
+
print(json.dumps(item, ensure_ascii=False))
|
|
56
|
+
total += 1
|
|
57
|
+
if len(items) < 50:
|
|
58
|
+
break
|
|
59
|
+
print(f"emitted {total} bookmarks", file=sys.stderr)
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
sys.exit(main())
|
mediarag/adapters/rclone
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MediaRAG source adapter: rclone://<remote>:<path> — ANY rclone-configured
|
|
3
|
+
backend (Google Drive, OneDrive, S3, WebDAV, Dropbox, SFTP, …) in one adapter.
|
|
4
|
+
|
|
5
|
+
Mechanism: `rclone copy` the subtree into a persistent local cache, then emit
|
|
6
|
+
{"uri", "path"} items for indexable files (the server's native indexer decodes
|
|
7
|
+
and embeds them itself). The cache is persistent (default ~/.cache/mediarag/
|
|
8
|
+
rclone/<remote>/<path>) so thumbnails and re-indexing keep working afterwards.
|
|
9
|
+
|
|
10
|
+
Env: RCLONE_ADAPTER_CACHE (default ~/.cache/mediarag/rclone). rclone credentials
|
|
11
|
+
come from the server's rclone environment (config file / env), same as any
|
|
12
|
+
rclone invocation.
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
SKIP_DIRS = {".obsidian", ".trash", ".git"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main():
|
|
23
|
+
if len(sys.argv) != 3 or sys.argv[1] != "fetch":
|
|
24
|
+
print("usage: rclone fetch \"<remote>:<path>\" (e.g. rclone://gdrive:/notes)", file=sys.stderr)
|
|
25
|
+
return 2
|
|
26
|
+
target = sys.argv[2]
|
|
27
|
+
if ":" not in target:
|
|
28
|
+
print('target must be "<remote>:<path>", e.g. "gdrive:/notes"', file=sys.stderr)
|
|
29
|
+
return 1
|
|
30
|
+
remote, _, remote_path = target.partition(":")
|
|
31
|
+
# remote is joined into the cache path below, so it must be a bare remote
|
|
32
|
+
# name: "x/../../etc:y" would otherwise escape the cache directory.
|
|
33
|
+
if not remote or remote.startswith(".") or any(c in remote for c in ("/", "\\")):
|
|
34
|
+
print(f"invalid remote name {remote!r}: expected a bare rclone remote", file=sys.stderr)
|
|
35
|
+
return 1
|
|
36
|
+
remote_path = "/" + remote_path.lstrip("/")
|
|
37
|
+
cache = os.environ.get("RCLONE_ADAPTER_CACHE",
|
|
38
|
+
os.path.expanduser("~/.cache/mediarag/rclone"))
|
|
39
|
+
dest = os.path.join(cache, remote, remote_path.lstrip("/"))
|
|
40
|
+
os.makedirs(dest, exist_ok=True)
|
|
41
|
+
# rclone copy: parallel, respects remotes' own config/env; exits non-zero on
|
|
42
|
+
# partial failure with a summary on stderr.
|
|
43
|
+
cp = subprocess.run(["rclone", "copy", f"{remote}:{remote_path}", dest,
|
|
44
|
+
"--transfers", "8", "--checkers", "8"],
|
|
45
|
+
capture_output=True, text=True)
|
|
46
|
+
for line in cp.stderr.strip().splitlines()[-3:]:
|
|
47
|
+
print(f"rclone: {line}", file=sys.stderr)
|
|
48
|
+
if cp.returncode != 0:
|
|
49
|
+
print(f"rclone copy failed (exit {cp.returncode})", file=sys.stderr)
|
|
50
|
+
return 1
|
|
51
|
+
total = 0
|
|
52
|
+
for dirpath, dirnames, filenames in os.walk(dest):
|
|
53
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".") and d not in SKIP_DIRS]
|
|
54
|
+
for fn in sorted(filenames):
|
|
55
|
+
if fn.startswith("."):
|
|
56
|
+
continue
|
|
57
|
+
p = os.path.join(dirpath, fn)
|
|
58
|
+
rel = os.path.relpath(p, os.path.join(cache, remote))
|
|
59
|
+
print(json.dumps({"uri": f"rclone://{remote}/{rel}", "path": p},
|
|
60
|
+
ensure_ascii=False))
|
|
61
|
+
total += 1
|
|
62
|
+
print(f"emitted {total} indexable files from {remote}:{remote_path} (cache {dest})",
|
|
63
|
+
file=sys.stderr)
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
sys.exit(main())
|
mediarag/core/engine.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""MediaRAG plugin engine: EG2 towers via the HF processor + onnxruntime.
|
|
2
|
+
|
|
3
|
+
Mirrors scripts/export_golden.py exactly (the validated reference stack).
|
|
4
|
+
Parity gate: tests/bench_engine.py — cosine >= 0.999 vs goldens, all towers.
|
|
5
|
+
|
|
6
|
+
Memory law (live-verified): variable-shape audio/image workloads grow ort
|
|
7
|
+
arenas unbounded with the default memory pattern — every session sets
|
|
8
|
+
enable_mem_pattern=False (12 GB + 27 GB swap observed without it).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import glob
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
import wave
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import fcntl
|
|
21
|
+
except ImportError: # Windows: no flock; materialize is single-process there
|
|
22
|
+
fcntl = None
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import onnxruntime as ort
|
|
26
|
+
|
|
27
|
+
from transformers import EmbeddingGemma2Processor # noqa: E402
|
|
28
|
+
from PIL import Image # noqa: E402
|
|
29
|
+
|
|
30
|
+
QUERY_PREFIX = "task: search result | query: "
|
|
31
|
+
DOC_PREFIX = "title: none | text: "
|
|
32
|
+
REPO = "onnx-community/embeddinggemma-2-ONNX"
|
|
33
|
+
MODEL_FILES = {
|
|
34
|
+
"trunk": "model_q4.onnx",
|
|
35
|
+
"vision": "vision_encoder_q4.onnx",
|
|
36
|
+
"audio": "audio_encoder_q4.onnx",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_REAL = os.path.expanduser("~/.cache/mediarag/onnx-real")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _external_escapes(onnx_dir: str) -> bool:
|
|
44
|
+
"""onnxruntime resolves the model symlink, then requires external data
|
|
45
|
+
inside that directory. huggingface_hub shards blobs into blobs/ab/ vs
|
|
46
|
+
blobs/cd/, so the data file 'escapes'. A flat blobs/ dir does not.
|
|
47
|
+
"""
|
|
48
|
+
for name in MODEL_FILES.values():
|
|
49
|
+
graph = os.path.join(onnx_dir, name)
|
|
50
|
+
data = graph + "_data"
|
|
51
|
+
if not (os.path.exists(graph) and os.path.exists(data)):
|
|
52
|
+
continue
|
|
53
|
+
gdir = os.path.dirname(os.path.realpath(graph))
|
|
54
|
+
try:
|
|
55
|
+
if os.path.commonpath([gdir, os.path.realpath(data)]) != gdir:
|
|
56
|
+
return True
|
|
57
|
+
except ValueError:
|
|
58
|
+
return True
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _fresh(dst: str, src: str) -> bool:
|
|
63
|
+
stamp = os.path.join(dst, ".src")
|
|
64
|
+
if not os.path.isfile(stamp):
|
|
65
|
+
return False
|
|
66
|
+
with open(stamp) as f:
|
|
67
|
+
if f.read() != src:
|
|
68
|
+
return False
|
|
69
|
+
for name in MODEL_FILES.values():
|
|
70
|
+
for fn in (name, name + "_data"):
|
|
71
|
+
sp = os.path.join(src, fn)
|
|
72
|
+
dp = os.path.join(dst, fn)
|
|
73
|
+
if not os.path.lexists(sp):
|
|
74
|
+
continue
|
|
75
|
+
if os.path.islink(dp) or not os.path.isfile(dp):
|
|
76
|
+
return False
|
|
77
|
+
if os.path.getsize(dp) != os.path.getsize(sp):
|
|
78
|
+
return False
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _materialize(src: str, dest: str | None = None) -> str:
|
|
83
|
+
"""Copy the q4 towers to real files beside each other. ~480MB, once per snapshot.
|
|
84
|
+
ponytail: flock, not a lock manager. A crash mid-copy leaves a .tmp that the
|
|
85
|
+
next call deletes and rewrites.
|
|
86
|
+
"""
|
|
87
|
+
dst = dest or _REAL
|
|
88
|
+
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
89
|
+
fh = open(dst + ".lock", "a")
|
|
90
|
+
try:
|
|
91
|
+
if fcntl is not None:
|
|
92
|
+
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
93
|
+
if _fresh(dst, src):
|
|
94
|
+
return dst
|
|
95
|
+
tmp = dst + ".tmp"
|
|
96
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
97
|
+
os.makedirs(tmp)
|
|
98
|
+
for name in MODEL_FILES.values():
|
|
99
|
+
for fn in (name, name + "_data"):
|
|
100
|
+
sp = os.path.join(src, fn)
|
|
101
|
+
if os.path.lexists(sp):
|
|
102
|
+
shutil.copyfile(sp, os.path.join(tmp, fn)) # follows the symlink
|
|
103
|
+
with open(os.path.join(tmp, ".src"), "w") as f:
|
|
104
|
+
f.write(src)
|
|
105
|
+
if os.path.isdir(dst):
|
|
106
|
+
shutil.rmtree(dst)
|
|
107
|
+
os.rename(tmp, dst)
|
|
108
|
+
return dst
|
|
109
|
+
finally:
|
|
110
|
+
if fcntl is not None:
|
|
111
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|
|
112
|
+
fh.close()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def snap_dir() -> str:
|
|
116
|
+
hits = glob.glob(
|
|
117
|
+
os.path.expanduser(
|
|
118
|
+
"~/.cache/huggingface/hub/models--onnx-community--embeddinggemma-2-ONNX/snapshots/*/onnx"
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
if not hits:
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
"embedding model not downloaded — snapshot_download "
|
|
124
|
+
"onnx-community/embeddinggemma-2-ONNX (onnx/*_q4.onnx + *_q4.onnx_data)"
|
|
125
|
+
)
|
|
126
|
+
src = sorted(hits)[-1]
|
|
127
|
+
return _materialize(src) if _external_escapes(src) else src
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _sess(model_path: str) -> ort.InferenceSession:
|
|
131
|
+
so = ort.SessionOptions()
|
|
132
|
+
so.enable_mem_pattern = False # memory law
|
|
133
|
+
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
|
134
|
+
so.intra_op_num_threads = 4
|
|
135
|
+
return ort.InferenceSession(model_path, so, providers=["CPUExecutionProvider"])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@lru_cache(maxsize=1)
|
|
139
|
+
def _processor():
|
|
140
|
+
return EmbeddingGemma2Processor.from_pretrained(REPO, local_files_only=True)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@lru_cache(maxsize=4)
|
|
144
|
+
def _trunk() -> ort.InferenceSession:
|
|
145
|
+
return _sess(os.path.join(snap_dir(), MODEL_FILES["trunk"]))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@lru_cache(maxsize=2)
|
|
149
|
+
def _vis() -> ort.InferenceSession:
|
|
150
|
+
return _sess(os.path.join(snap_dir(), MODEL_FILES["vision"]))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@lru_cache(maxsize=2)
|
|
154
|
+
def _aud() -> ort.InferenceSession:
|
|
155
|
+
return _sess(os.path.join(snap_dir(), MODEL_FILES["audio"]))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
Z = np.zeros((0, 512), np.float32)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
MAX_TOK = 1024 # ponytail: EG2 trunk is quadratic in sequence length — an unbounded
|
|
162
|
+
# doc chunk (e.g. one 80KB paragraph) OOMs the host; 1024 tokens ≈ 4KB text, plenty for retrieval.
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _cap_tokens(feeds: dict) -> dict:
|
|
166
|
+
"""Slice input_ids/attention_mask to MAX_TOK (trust-boundary guard: tool inputs
|
|
167
|
+
like add_note/adapter text are unbounded; the trunk is not)."""
|
|
168
|
+
for k in ("input_ids", "attention_mask"):
|
|
169
|
+
if k in feeds and feeds[k].shape[-1] > MAX_TOK:
|
|
170
|
+
feeds[k] = feeds[k][..., :MAX_TOK]
|
|
171
|
+
return feeds
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _finish(feeds: dict) -> np.ndarray:
|
|
175
|
+
v = np.asarray(_trunk().run(None, feeds)[1], dtype=np.float32).reshape(-1) # [1] = sentence_embedding
|
|
176
|
+
return v / np.linalg.norm(v)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def embed_query(q: str) -> np.ndarray:
|
|
180
|
+
q = (QUERY_PREFIX + q.strip()).strip()
|
|
181
|
+
p = _processor()(text=[q], return_tensors="np")
|
|
182
|
+
return _finish(_cap_tokens({"input_ids": p["input_ids"].astype(np.int64),
|
|
183
|
+
"attention_mask": p["attention_mask"].astype(np.int64),
|
|
184
|
+
"image_features": Z, "video_features": Z, "audio_features": Z}))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def embed_doc(raw: str) -> np.ndarray:
|
|
188
|
+
raw = (DOC_PREFIX + raw.strip()).strip()
|
|
189
|
+
if raw == DOC_PREFIX.strip():
|
|
190
|
+
raise ValueError("empty text chunk")
|
|
191
|
+
p = _processor()(text=[raw], return_tensors="np")
|
|
192
|
+
return _finish(_cap_tokens({"input_ids": p["input_ids"].astype(np.int64),
|
|
193
|
+
"attention_mask": p["attention_mask"].astype(np.int64),
|
|
194
|
+
"image_features": Z, "video_features": Z, "audio_features": Z}))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def embed_plain(raw: str) -> np.ndarray:
|
|
198
|
+
"""verbatim text, no prompt template (caption/label lane)."""
|
|
199
|
+
raw = raw.strip()
|
|
200
|
+
if not raw:
|
|
201
|
+
raise ValueError("empty text chunk")
|
|
202
|
+
p = _processor()(text=[raw], return_tensors="np")
|
|
203
|
+
return _finish(_cap_tokens({"input_ids": p["input_ids"].astype(np.int64),
|
|
204
|
+
"attention_mask": p["attention_mask"].astype(np.int64),
|
|
205
|
+
"image_features": Z, "video_features": Z, "audio_features": Z}))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def embed_image(path: str) -> np.ndarray:
|
|
209
|
+
img = Image.open(path).convert("RGB")
|
|
210
|
+
# ponytail: oversized images blow up the soft-token patch grid → onnxruntime
|
|
211
|
+
# arena abort (observed: 14 TB single-buffer request kills the process).
|
|
212
|
+
# Cap at 64 MP (8192^2): EG2 resizes to ≤ 560 px patches anyway — above this
|
|
213
|
+
# cap the pixels are pure waste. Upgrade path: pre-resize instead of reject.
|
|
214
|
+
if img.width * img.height > 64_000_000 or img.width > 16_384 or img.height > 16_384:
|
|
215
|
+
raise ValueError(f"image too large to embed: {img.size}")
|
|
216
|
+
p = _processor()(images=[img], return_tensors="np")
|
|
217
|
+
enc = _vis().run(None, {"pixel_values": p["pixel_values"],
|
|
218
|
+
"pixel_position_ids": p["image_position_ids"].astype(np.int64)})[0]
|
|
219
|
+
n = int((p["input_ids"][0] == _processor().image_token_id).sum())
|
|
220
|
+
assert n == enc.shape[0], f"image token mismatch: {n} != {enc.shape[0]}"
|
|
221
|
+
return _finish({"input_ids": p["input_ids"].astype(np.int64),
|
|
222
|
+
"attention_mask": p["attention_mask"].astype(np.int64),
|
|
223
|
+
"image_features": enc.astype(np.float32),
|
|
224
|
+
"video_features": Z, "audio_features": Z})
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def embed_audio(path: str) -> np.ndarray:
|
|
228
|
+
with wave.open(path, "rb") as w:
|
|
229
|
+
a = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0
|
|
230
|
+
p = _processor()(audio=[a], sampling_rate=16000, return_tensors="np")
|
|
231
|
+
enc = _aud().run(None, {"input_features": p["input_features"].astype(np.float32),
|
|
232
|
+
"input_features_mask": p["input_features_mask"]})[0]
|
|
233
|
+
n = int((p["input_ids"][0] == _processor().audio_token_id).sum())
|
|
234
|
+
assert n == enc.shape[0], f"audio token mismatch: {n} != {enc.shape[0]}"
|
|
235
|
+
return _finish({"input_ids": p["input_ids"].astype(np.int64),
|
|
236
|
+
"attention_mask": p["attention_mask"].astype(np.int64),
|
|
237
|
+
"image_features": Z, "video_features": Z,
|
|
238
|
+
"audio_features": enc.astype(np.float32)})
|