@tiens.nguyen/gonext-local-worker 1.0.155 → 1.0.157
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/gonext-local-worker.mjs +10 -0
- package/gonext_agent_chat.py +493 -5
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1601,6 +1601,16 @@ async function runAgentChatJob(job) {
|
|
|
1601
1601
|
emailBodyTemplate: payload?.emailBodyTemplate ?? "",
|
|
1602
1602
|
emailFrom: payload?.emailFrom ?? "",
|
|
1603
1603
|
emailAllowList: payload?.emailAllowList ?? "",
|
|
1604
|
+
// RAG tools (download/unzip/index/search over a user's ZIP-at-URL). Only enabled
|
|
1605
|
+
// + registered when ragEnabled + AWS creds present. The worker talks to the user's
|
|
1606
|
+
// OWN S3 bucket directly with these creds (boto3), NOT via presigned URLs.
|
|
1607
|
+
ragEnabled: payload?.ragEnabled ?? false,
|
|
1608
|
+
ragS3Location: payload?.ragS3Location ?? "",
|
|
1609
|
+
ragAwsRegion: payload?.ragAwsRegion ?? "",
|
|
1610
|
+
ragAwsAccessKeyId: payload?.ragAwsAccessKeyId ?? "",
|
|
1611
|
+
ragAwsSecretAccessKey: payload?.ragAwsSecretAccessKey ?? "",
|
|
1612
|
+
ragEmbedModel: payload?.ragEmbedModel ?? "",
|
|
1613
|
+
ragTopK: payload?.ragTopK ?? 6,
|
|
1604
1614
|
});
|
|
1605
1615
|
// 30 min max for an agent run: multi-step ReAct on a 14B/31B with a cold prompt
|
|
1606
1616
|
// cache — or a remote Ollama box on slow GPU cold-loading a big model — can run
|
package/gonext_agent_chat.py
CHANGED
|
@@ -518,7 +518,7 @@ _AGENT_KEYWORDS = re.compile(
|
|
|
518
518
|
r"|api|endpoint|url|http|https"
|
|
519
519
|
r"|external\s+source|external\s+api|external\s+service"
|
|
520
520
|
r"|web\s+service|rest\s+api|rest\s+call"
|
|
521
|
-
r"|download|scrape|crawl"
|
|
521
|
+
r"|download|scrape|crawl|zip|unzip|\.zip|rag|index\s+the|knowledge\s+base|summari[sz]e"
|
|
522
522
|
r"|search|find|look\s*up|lookup|weather|news|latest|current|today|tonight"
|
|
523
523
|
r"|date|time|what\s+day|what\s+time"
|
|
524
524
|
r"|read\s+(?:this\s+|that\s+|the\s+)?(?:page|article|url|link)|open\s+(?:this\s+|that\s+|the\s+)?(?:url|link|page)|contents?\s+of"
|
|
@@ -1080,6 +1080,270 @@ def _derive_pdf_title(doc_text: str) -> str:
|
|
|
1080
1080
|
return "Document"
|
|
1081
1081
|
|
|
1082
1082
|
|
|
1083
|
+
# ============================ RAG (knowledge base) ============================
|
|
1084
|
+
# The agent can download a ZIP-at-URL, unzip it locally, index the text files into a
|
|
1085
|
+
# flat JSON vector store on the USER'S OWN S3 bucket (explicit AWS creds — NOT presigned),
|
|
1086
|
+
# then retrieve relevant chunks to answer questions. Embeddings run on the Ollama coding
|
|
1087
|
+
# server (/api/embed). Everything below is best-effort with hard safety caps.
|
|
1088
|
+
|
|
1089
|
+
_RAG_TEXT_EXTS = {
|
|
1090
|
+
".cs", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".vue", ".svelte",
|
|
1091
|
+
".json", ".jsonc", ".md", ".mdx", ".txt", ".rst", ".py", ".rb", ".go",
|
|
1092
|
+
".rs", ".java", ".kt", ".swift", ".php", ".c", ".h", ".cpp", ".hpp", ".cc",
|
|
1093
|
+
".html", ".htm", ".css", ".scss", ".sass", ".less", ".xml", ".yaml", ".yml",
|
|
1094
|
+
".toml", ".ini", ".cfg", ".conf", ".env", ".sh", ".bash", ".zsh", ".sql",
|
|
1095
|
+
".graphql", ".proto", ".gradle", ".dockerfile", ".tsv", ".csv",
|
|
1096
|
+
}
|
|
1097
|
+
_RAG_SKIP_DIRS = {
|
|
1098
|
+
"node_modules", ".git", ".svn", ".hg", "__pycache__", ".venv", "venv",
|
|
1099
|
+
"dist", "build", ".next", ".nuxt", "out", ".cache", "bin", "obj", ".idea",
|
|
1100
|
+
".gradle", "vendor", "target",
|
|
1101
|
+
}
|
|
1102
|
+
_RAG_MAX_DOWNLOAD_BYTES = 500 * 1024 * 1024 # 500 MB zip cap
|
|
1103
|
+
_RAG_MAX_UNZIP_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB uncompressed (zip-bomb guard)
|
|
1104
|
+
_RAG_MAX_FILES = 20000
|
|
1105
|
+
_RAG_MAX_FILE_BYTES = 2 * 1024 * 1024 # skip individual text files > 2 MB
|
|
1106
|
+
_RAG_CHUNK_CHARS = 3200 # ~800 tokens
|
|
1107
|
+
_RAG_CHUNK_OVERLAP = 400
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _rag_source_key(url: str) -> str:
|
|
1111
|
+
import hashlib
|
|
1112
|
+
return hashlib.sha256((url or "").strip().encode("utf-8")).hexdigest()[:32]
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _rag_gdrive_direct(url: str) -> str:
|
|
1116
|
+
"""Rewrite a Google Drive share link to its direct-download form."""
|
|
1117
|
+
m = re.search(r"drive\.google\.com/file/d/([A-Za-z0-9_-]+)", url or "")
|
|
1118
|
+
if m:
|
|
1119
|
+
return f"https://drive.google.com/uc?export=download&id={m.group(1)}"
|
|
1120
|
+
m = re.search(r"drive\.google\.com/open\?id=([A-Za-z0-9_-]+)", url or "")
|
|
1121
|
+
if m:
|
|
1122
|
+
return f"https://drive.google.com/uc?export=download&id={m.group(1)}"
|
|
1123
|
+
return url
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _rag_assert_safe_url(url: str) -> None:
|
|
1127
|
+
"""SSRF guard: only http/https, and refuse private/link-local/loopback hosts."""
|
|
1128
|
+
import ipaddress
|
|
1129
|
+
import socket
|
|
1130
|
+
from urllib.parse import urlparse
|
|
1131
|
+
p = urlparse(url or "")
|
|
1132
|
+
if p.scheme not in ("http", "https"):
|
|
1133
|
+
raise ValueError("Only http/https URLs are allowed.")
|
|
1134
|
+
host = p.hostname or ""
|
|
1135
|
+
if not host:
|
|
1136
|
+
raise ValueError("URL has no host.")
|
|
1137
|
+
try:
|
|
1138
|
+
infos = socket.getaddrinfo(host, None)
|
|
1139
|
+
except Exception as e: # noqa: BLE001
|
|
1140
|
+
raise ValueError(f"Cannot resolve host {host}: {e}")
|
|
1141
|
+
for info in infos:
|
|
1142
|
+
ip = info[4][0]
|
|
1143
|
+
try:
|
|
1144
|
+
addr = ipaddress.ip_address(ip)
|
|
1145
|
+
except ValueError:
|
|
1146
|
+
continue
|
|
1147
|
+
if (addr.is_private or addr.is_loopback or addr.is_link_local
|
|
1148
|
+
or addr.is_reserved or addr.is_multicast):
|
|
1149
|
+
raise ValueError(f"Refusing to fetch a private/internal address ({ip}).")
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _rag_workdir(source_key: str) -> str:
|
|
1153
|
+
import os
|
|
1154
|
+
base = os.path.join(os.path.expanduser("~"), ".gonext", "rag-work", source_key)
|
|
1155
|
+
os.makedirs(base, exist_ok=True)
|
|
1156
|
+
return base
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _rag_download(url: str, dest_path: str = "") -> str:
|
|
1160
|
+
"""Download a public URL to a local path with SSRF + size guards. Returns the path."""
|
|
1161
|
+
import os
|
|
1162
|
+
src = _rag_gdrive_direct((url or "").strip())
|
|
1163
|
+
_rag_assert_safe_url(src)
|
|
1164
|
+
key = _rag_source_key(url)
|
|
1165
|
+
if not dest_path:
|
|
1166
|
+
name = os.path.basename(src.split("?")[0]) or "download.bin"
|
|
1167
|
+
if not name.lower().endswith(".zip") and "zip" in src.lower():
|
|
1168
|
+
name += ".zip"
|
|
1169
|
+
dest_path = os.path.join(_rag_workdir(key), name)
|
|
1170
|
+
os.makedirs(os.path.dirname(dest_path) or ".", exist_ok=True)
|
|
1171
|
+
req = urllib.request.Request(src, headers={"User-Agent": "gonext-rag/1.0"})
|
|
1172
|
+
total = 0
|
|
1173
|
+
with urllib.request.urlopen(req, timeout=120, context=_ssl_context()) as resp, \
|
|
1174
|
+
open(dest_path, "wb") as out:
|
|
1175
|
+
while True:
|
|
1176
|
+
block = resp.read(1024 * 256)
|
|
1177
|
+
if not block:
|
|
1178
|
+
break
|
|
1179
|
+
total += len(block)
|
|
1180
|
+
if total > _RAG_MAX_DOWNLOAD_BYTES:
|
|
1181
|
+
out.close()
|
|
1182
|
+
os.remove(dest_path)
|
|
1183
|
+
raise ValueError(
|
|
1184
|
+
f"Download exceeds the {_RAG_MAX_DOWNLOAD_BYTES // (1024*1024)} MB cap."
|
|
1185
|
+
)
|
|
1186
|
+
out.write(block)
|
|
1187
|
+
return dest_path
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _rag_unzip(zip_path: str, dest_dir: str = "") -> tuple:
|
|
1191
|
+
"""Extract a local zip with Zip-Slip + zip-bomb guards. Returns (dir, file_list)."""
|
|
1192
|
+
import os
|
|
1193
|
+
import zipfile
|
|
1194
|
+
if not dest_dir:
|
|
1195
|
+
dest_dir = os.path.splitext(zip_path)[0] + "_unzipped"
|
|
1196
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
1197
|
+
dest_root = os.path.realpath(dest_dir)
|
|
1198
|
+
written = 0
|
|
1199
|
+
names = []
|
|
1200
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
1201
|
+
infos = zf.infolist()
|
|
1202
|
+
if len(infos) > _RAG_MAX_FILES:
|
|
1203
|
+
raise ValueError(f"Zip has too many entries (> {_RAG_MAX_FILES}).")
|
|
1204
|
+
total_uncompressed = sum(i.file_size for i in infos)
|
|
1205
|
+
if total_uncompressed > _RAG_MAX_UNZIP_BYTES:
|
|
1206
|
+
raise ValueError("Zip uncompressed size exceeds the safety cap (possible zip bomb).")
|
|
1207
|
+
for info in infos:
|
|
1208
|
+
if info.is_dir():
|
|
1209
|
+
continue
|
|
1210
|
+
target = os.path.realpath(os.path.join(dest_dir, info.filename))
|
|
1211
|
+
# Zip Slip: extracted path must stay within dest_dir.
|
|
1212
|
+
if not (target == dest_root or target.startswith(dest_root + os.sep)):
|
|
1213
|
+
continue
|
|
1214
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
1215
|
+
with zf.open(info) as src, open(target, "wb") as dst:
|
|
1216
|
+
dst.write(src.read())
|
|
1217
|
+
written += 1
|
|
1218
|
+
names.append(info.filename)
|
|
1219
|
+
return dest_dir, names
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _rag_iter_text_files(root: str):
|
|
1223
|
+
"""Yield (abs_path, rel_path, ext) for indexable text files under root."""
|
|
1224
|
+
import os
|
|
1225
|
+
if os.path.isfile(root):
|
|
1226
|
+
yield root, os.path.basename(root), os.path.splitext(root)[1].lower()
|
|
1227
|
+
return
|
|
1228
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
1229
|
+
dirnames[:] = [d for d in dirnames if d not in _RAG_SKIP_DIRS]
|
|
1230
|
+
for fn in filenames:
|
|
1231
|
+
ext = os.path.splitext(fn)[1].lower()
|
|
1232
|
+
base = fn.lower()
|
|
1233
|
+
if ext not in _RAG_TEXT_EXTS and base not in ("dockerfile", "makefile", ".gitignore"):
|
|
1234
|
+
continue
|
|
1235
|
+
abs_path = os.path.join(dirpath, fn)
|
|
1236
|
+
try:
|
|
1237
|
+
if os.path.getsize(abs_path) > _RAG_MAX_FILE_BYTES:
|
|
1238
|
+
continue
|
|
1239
|
+
except OSError:
|
|
1240
|
+
continue
|
|
1241
|
+
yield abs_path, os.path.relpath(abs_path, root), ext
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _rag_chunk_text(text: str, rel_path: str, ext: str):
|
|
1245
|
+
"""Line-aware windowed chunks with overlap. Returns list of {file,ext,start,end,text}."""
|
|
1246
|
+
if not text.strip():
|
|
1247
|
+
return []
|
|
1248
|
+
chunks = []
|
|
1249
|
+
n = len(text)
|
|
1250
|
+
step = max(1, _RAG_CHUNK_CHARS - _RAG_CHUNK_OVERLAP)
|
|
1251
|
+
i = 0
|
|
1252
|
+
while i < n:
|
|
1253
|
+
end = min(n, i + _RAG_CHUNK_CHARS)
|
|
1254
|
+
# Prefer to cut on a newline near the window end.
|
|
1255
|
+
if end < n:
|
|
1256
|
+
nl = text.rfind("\n", i + step, end)
|
|
1257
|
+
if nl > i:
|
|
1258
|
+
end = nl + 1
|
|
1259
|
+
piece = text[i:end].strip()
|
|
1260
|
+
if piece:
|
|
1261
|
+
chunks.append({"file": rel_path, "ext": ext, "start": i, "end": end, "text": piece})
|
|
1262
|
+
if end >= n:
|
|
1263
|
+
break
|
|
1264
|
+
i = end - _RAG_CHUNK_OVERLAP if end - _RAG_CHUNK_OVERLAP > i else end
|
|
1265
|
+
return chunks
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def _ollama_embed(ollama_root: str, model: str, texts: list) -> list:
|
|
1269
|
+
"""Embed a batch of texts via Ollama /api/embed (falls back to /api/embeddings)."""
|
|
1270
|
+
root = (ollama_root or "").rstrip("/")
|
|
1271
|
+
if not root:
|
|
1272
|
+
raise RuntimeError("No Ollama endpoint available for embeddings.")
|
|
1273
|
+
ctx = _ssl_context()
|
|
1274
|
+
# Preferred: batch /api/embed (recent Ollama).
|
|
1275
|
+
try:
|
|
1276
|
+
req = urllib.request.Request(
|
|
1277
|
+
f"{root}/api/embed",
|
|
1278
|
+
data=json.dumps({"model": model, "input": texts}).encode("utf-8"),
|
|
1279
|
+
headers={"Content-Type": "application/json"},
|
|
1280
|
+
method="POST",
|
|
1281
|
+
)
|
|
1282
|
+
with urllib.request.urlopen(req, timeout=300, context=ctx) as resp:
|
|
1283
|
+
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
1284
|
+
embs = data.get("embeddings")
|
|
1285
|
+
if isinstance(embs, list) and embs and isinstance(embs[0], list):
|
|
1286
|
+
return embs
|
|
1287
|
+
except urllib.error.HTTPError as e:
|
|
1288
|
+
if e.code != 404:
|
|
1289
|
+
detail = e.read().decode("utf-8", "replace")[:200]
|
|
1290
|
+
raise RuntimeError(f"Ollama embed failed (HTTP {e.code}): {detail}")
|
|
1291
|
+
except urllib.error.URLError as e:
|
|
1292
|
+
raise RuntimeError(f"Ollama embed unreachable: {getattr(e, 'reason', e)}")
|
|
1293
|
+
# Fallback: per-text /api/embeddings (older Ollama).
|
|
1294
|
+
out = []
|
|
1295
|
+
for t in texts:
|
|
1296
|
+
req = urllib.request.Request(
|
|
1297
|
+
f"{root}/api/embeddings",
|
|
1298
|
+
data=json.dumps({"model": model, "prompt": t}).encode("utf-8"),
|
|
1299
|
+
headers={"Content-Type": "application/json"},
|
|
1300
|
+
method="POST",
|
|
1301
|
+
)
|
|
1302
|
+
with urllib.request.urlopen(req, timeout=120, context=ctx) as resp:
|
|
1303
|
+
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
1304
|
+
out.append(data.get("embedding") or [])
|
|
1305
|
+
return out
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def _cosine(a: list, b: list) -> float:
|
|
1309
|
+
import math
|
|
1310
|
+
if not a or not b or len(a) != len(b):
|
|
1311
|
+
return -1.0
|
|
1312
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
1313
|
+
na = math.sqrt(sum(x * x for x in a))
|
|
1314
|
+
nb = math.sqrt(sum(y * y for y in b))
|
|
1315
|
+
if na == 0 or nb == 0:
|
|
1316
|
+
return -1.0
|
|
1317
|
+
return dot / (na * nb)
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def _rag_parse_location(loc: str):
|
|
1321
|
+
"""Parse an ARN / s3:// URI / bucket[/prefix] into (bucket, prefix)."""
|
|
1322
|
+
s = (loc or "").strip() or "arn:aws:s3:::gonext-rag"
|
|
1323
|
+
rest = s
|
|
1324
|
+
m = re.match(r"^arn:aws:s3:::(.+)$", s, re.I)
|
|
1325
|
+
if m:
|
|
1326
|
+
rest = m.group(1)
|
|
1327
|
+
else:
|
|
1328
|
+
m = re.match(r"^s3://(.+)$", s, re.I)
|
|
1329
|
+
if m:
|
|
1330
|
+
rest = m.group(1)
|
|
1331
|
+
rest = rest.strip("/")
|
|
1332
|
+
if "/" in rest:
|
|
1333
|
+
bucket, prefix = rest.split("/", 1)
|
|
1334
|
+
else:
|
|
1335
|
+
bucket, prefix = rest, ""
|
|
1336
|
+
return bucket, prefix.strip("/")
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _rag_s3_client(region: str, akid: str, secret: str):
|
|
1340
|
+
import boto3 # noqa: PLC0415
|
|
1341
|
+
return boto3.client(
|
|
1342
|
+
"s3", region_name=region or "ap-southeast-1",
|
|
1343
|
+
aws_access_key_id=akid, aws_secret_access_key=secret,
|
|
1344
|
+
)
|
|
1345
|
+
|
|
1346
|
+
|
|
1083
1347
|
def run_agent_chat(cfg):
|
|
1084
1348
|
try:
|
|
1085
1349
|
from smolagents import CodeAgent, OpenAIServerModel, tool
|
|
@@ -1238,6 +1502,66 @@ def run_agent_chat(cfg):
|
|
|
1238
1502
|
# action must never be silently available (same gating as the PDF reader).
|
|
1239
1503
|
_EMAIL_AVAILABLE = bool(email_enabled and email_api_url and email_body_template)
|
|
1240
1504
|
|
|
1505
|
+
# ---- RAG config (download/unzip/index/search over a user's ZIP-at-URL) ----
|
|
1506
|
+
rag_enabled = bool(cfg.get("ragEnabled"))
|
|
1507
|
+
rag_location = (cfg.get("ragS3Location") or "arn:aws:s3:::gonext-rag").strip()
|
|
1508
|
+
rag_region = (cfg.get("ragAwsRegion") or "ap-southeast-1").strip()
|
|
1509
|
+
rag_akid = (cfg.get("ragAwsAccessKeyId") or "").strip()
|
|
1510
|
+
rag_secret = (cfg.get("ragAwsSecretAccessKey") or "").strip()
|
|
1511
|
+
rag_embed_model = (cfg.get("ragEmbedModel") or "nomic-embed-text").strip()
|
|
1512
|
+
try:
|
|
1513
|
+
rag_top_k = int(cfg.get("ragTopK") or 6)
|
|
1514
|
+
except (TypeError, ValueError):
|
|
1515
|
+
rag_top_k = 6
|
|
1516
|
+
rag_top_k = max(1, min(50, rag_top_k))
|
|
1517
|
+
# Embeddings run on the Ollama coding server (same box as the coder). Strip the
|
|
1518
|
+
# OpenAI-compat /v1 suffix to reach the native /api/embed root.
|
|
1519
|
+
rag_ollama_root = re.sub(r"/v1/?$", "", (coding_base_url or agent_base_url or "").rstrip("/"))
|
|
1520
|
+
try:
|
|
1521
|
+
import boto3 as _boto3_probe # noqa: F401,PLC0415
|
|
1522
|
+
_boto3_ok = True
|
|
1523
|
+
except Exception: # noqa: BLE001
|
|
1524
|
+
_boto3_ok = False
|
|
1525
|
+
_RAG_AVAILABLE = bool(rag_enabled and rag_akid and rag_secret and rag_ollama_root and _boto3_ok)
|
|
1526
|
+
if rag_enabled and not _boto3_ok:
|
|
1527
|
+
_log("RAG requested but boto3 is not installed in the worker python — RAG tools disabled")
|
|
1528
|
+
|
|
1529
|
+
def _rag_s3_and_loc():
|
|
1530
|
+
client = _rag_s3_client(rag_region, rag_akid, rag_secret)
|
|
1531
|
+
bucket, prefix = _rag_parse_location(rag_location)
|
|
1532
|
+
return client, bucket, prefix
|
|
1533
|
+
|
|
1534
|
+
def _rag_prefix_for(source_url: str, prefix: str) -> str:
|
|
1535
|
+
key = _rag_source_key(source_url)
|
|
1536
|
+
base = (prefix + "/") if prefix else ""
|
|
1537
|
+
return f"{base}agent-rag/{key}"
|
|
1538
|
+
|
|
1539
|
+
def _rag_load_chunks(client, bucket: str, base_prefix: str) -> list:
|
|
1540
|
+
"""Load all chunk records (across shards) for a source. Returns [] if none."""
|
|
1541
|
+
out = []
|
|
1542
|
+
token = None
|
|
1543
|
+
while True:
|
|
1544
|
+
kw = {"Bucket": bucket, "Prefix": f"{base_prefix}/chunks"}
|
|
1545
|
+
if token:
|
|
1546
|
+
kw["ContinuationToken"] = token
|
|
1547
|
+
resp = client.list_objects_v2(**kw)
|
|
1548
|
+
for obj in resp.get("Contents", []) or []:
|
|
1549
|
+
if not obj["Key"].endswith(".jsonl"):
|
|
1550
|
+
continue
|
|
1551
|
+
body = client.get_object(Bucket=bucket, Key=obj["Key"])["Body"].read()
|
|
1552
|
+
for line in body.decode("utf-8", "replace").splitlines():
|
|
1553
|
+
line = line.strip()
|
|
1554
|
+
if line:
|
|
1555
|
+
try:
|
|
1556
|
+
out.append(json.loads(line))
|
|
1557
|
+
except json.JSONDecodeError:
|
|
1558
|
+
pass
|
|
1559
|
+
if resp.get("IsTruncated"):
|
|
1560
|
+
token = resp.get("NextContinuationToken")
|
|
1561
|
+
else:
|
|
1562
|
+
break
|
|
1563
|
+
return out
|
|
1564
|
+
|
|
1241
1565
|
def _prior_email_preview() -> bool:
|
|
1242
1566
|
"""True if an earlier assistant turn showed a send_email preview awaiting confirm."""
|
|
1243
1567
|
for m in reversed(messages[:last_user_idx]):
|
|
@@ -1384,7 +1708,25 @@ def run_agent_chat(cfg):
|
|
|
1384
1708
|
if _EMAIL_AVAILABLE:
|
|
1385
1709
|
_tool_num += 1
|
|
1386
1710
|
_email_num = _tool_num
|
|
1711
|
+
if _RAG_AVAILABLE:
|
|
1712
|
+
_tool_num += 5 # download_file, unzip_file, rag_index, rag_add, rag_search
|
|
1387
1713
|
_tool_count_word = {6: "SIX", 7: "SEVEN", 8: "EIGHT"}.get(_tool_num, str(_tool_num))
|
|
1714
|
+
_rag_tool_block = (
|
|
1715
|
+
" KNOWLEDGE BASE (RAG) — to SUMMARIZE or ANSWER questions about a ZIP of files at a URL:\n"
|
|
1716
|
+
" - download_file(url) — download a file (e.g. a .zip) to this machine.\n"
|
|
1717
|
+
" - unzip_file(zip_path) — extract a downloaded .zip locally.\n"
|
|
1718
|
+
" - rag_index(path, source_url) — index the unzipped text/code files into the knowledge "
|
|
1719
|
+
"base (embeds to S3). source_url MUST be the ORIGINAL url the user gave.\n"
|
|
1720
|
+
" - rag_add(source_url, text) — add extra info the user provides to that knowledge base.\n"
|
|
1721
|
+
" - rag_search(source_url, query) — retrieve the most relevant chunks to answer a question.\n"
|
|
1722
|
+
) if _RAG_AVAILABLE else ""
|
|
1723
|
+
_rag_choose_line = (
|
|
1724
|
+
"- user gives a URL to a ZIP of files and asks to summarize/analyze/RAG them -> "
|
|
1725
|
+
"download_file(url) THEN unzip_file(zip) THEN rag_index(dir, source_url=url) THEN "
|
|
1726
|
+
"rag_search(source_url=url, query=<the user's question>), THEN answer from the results. "
|
|
1727
|
+
"For FOLLOW-UP questions about the SAME url, just rag_search (do NOT re-download). "
|
|
1728
|
+
"If the user adds information, rag_add(source_url=url, text=...).\n"
|
|
1729
|
+
) if _RAG_AVAILABLE else ""
|
|
1388
1730
|
_pdf_read_tool_line = (
|
|
1389
1731
|
f" {_pdf_num}. extract_text_from_pdf(url) — READ/summarize an EXISTING PDF file "
|
|
1390
1732
|
"at a URL. This does NOT create a PDF (use create_pdf for that).\n"
|
|
@@ -1421,7 +1763,7 @@ def run_agent_chat(cfg):
|
|
|
1421
1763
|
"Use this ONLY when the task explicitly asks for the date or time.\n"
|
|
1422
1764
|
" 6. create_pdf(text, title='') — make a PDF document from text/data and return a "
|
|
1423
1765
|
"download link. Use ONLY when the task asks to create/make/generate/export a PDF.\n"
|
|
1424
|
-
+ _pdf_read_tool_line + _email_tool_line +
|
|
1766
|
+
+ _pdf_read_tool_line + _email_tool_line + _rag_tool_block +
|
|
1425
1767
|
"\n"
|
|
1426
1768
|
"http_request RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
|
|
1427
1769
|
"\n"
|
|
@@ -1445,7 +1787,7 @@ def run_agent_chat(cfg):
|
|
|
1445
1787
|
"- a PDF of info you must LOOK UP first (e.g. 'get the world cup schedule then make a "
|
|
1446
1788
|
"pdf') -> FIRST web_search/fetch_url to gather the real facts, THEN in a later step "
|
|
1447
1789
|
"create_pdf(text=<the gathered facts>, title=...). Never PDF the request wording itself.\n"
|
|
1448
|
-
+ _pdf_read_choose_line + _email_choose_line +
|
|
1790
|
+
+ _pdf_read_choose_line + _email_choose_line + _rag_choose_line +
|
|
1449
1791
|
"- A specific known API/URL was given -> http_request().\n"
|
|
1450
1792
|
"\n"
|
|
1451
1793
|
"RULES:\n"
|
|
@@ -2275,14 +2617,160 @@ def run_agent_chat(cfg):
|
|
|
2275
2617
|
client_kwargs={"timeout": 600.0, "max_retries": 0},
|
|
2276
2618
|
**extra_model_kwargs,
|
|
2277
2619
|
)
|
|
2620
|
+
@tool
|
|
2621
|
+
def download_file(url: str, dest_path: str = "") -> str:
|
|
2622
|
+
"""Download a file (e.g. a .zip) from a public http(s) URL to a local path on this machine. Use this FIRST when the user gives a URL to a zip of files; then unzip_file, then rag_index.
|
|
2623
|
+
|
|
2624
|
+
Args:
|
|
2625
|
+
url: public http(s) URL of the file to download.
|
|
2626
|
+
dest_path: optional local save path; leave empty for an automatic work-dir path.
|
|
2627
|
+
"""
|
|
2628
|
+
_emit({"type": "step", "text": f"Downloading → {url[:80]}"})
|
|
2629
|
+
try:
|
|
2630
|
+
import os
|
|
2631
|
+
path = _rag_download(url, (dest_path or "").strip())
|
|
2632
|
+
return f"Downloaded to {path} ({os.path.getsize(path)} bytes). Next: unzip_file(zip_path='{path}') if it is a zip."
|
|
2633
|
+
except Exception as e: # noqa: BLE001
|
|
2634
|
+
return f"Error: download failed: {type(e).__name__}: {e}"
|
|
2635
|
+
|
|
2636
|
+
@tool
|
|
2637
|
+
def unzip_file(zip_path: str, dest_dir: str = "") -> str:
|
|
2638
|
+
"""Extract a local .zip file into a local directory. Use after download_file. Returns the directory and a short file listing.
|
|
2639
|
+
|
|
2640
|
+
Args:
|
|
2641
|
+
zip_path: local path to the .zip (from download_file).
|
|
2642
|
+
dest_dir: optional target directory; leave empty for an automatic one.
|
|
2643
|
+
"""
|
|
2644
|
+
_emit({"type": "step", "text": "Unzipping…"})
|
|
2645
|
+
try:
|
|
2646
|
+
d, names = _rag_unzip((zip_path or "").strip(), (dest_dir or "").strip())
|
|
2647
|
+
preview = ", ".join(names[:15])
|
|
2648
|
+
more = f" …(+{len(names) - 15} more)" if len(names) > 15 else ""
|
|
2649
|
+
return f"Unzipped {len(names)} files to {d}. Files: {preview}{more}. Next: rag_index(path='{d}', source_url=<the original url>)."
|
|
2650
|
+
except Exception as e: # noqa: BLE001
|
|
2651
|
+
return f"Error: unzip failed: {type(e).__name__}: {e}"
|
|
2652
|
+
|
|
2653
|
+
@tool
|
|
2654
|
+
def rag_index(path: str, source_url: str) -> str:
|
|
2655
|
+
"""Index a local file or directory of TEXT/code files into the knowledge base for source_url (stores embeddings on S3). Use after unzip_file so the files become searchable. source_url MUST be the original URL the user provided.
|
|
2656
|
+
|
|
2657
|
+
Args:
|
|
2658
|
+
path: local file or directory to index (from unzip_file).
|
|
2659
|
+
source_url: the original URL the user gave — used as the knowledge-base key.
|
|
2660
|
+
"""
|
|
2661
|
+
if not _RAG_AVAILABLE:
|
|
2662
|
+
return "Error: RAG is not configured (enable it + add AWS credentials in Settings)."
|
|
2663
|
+
_emit({"type": "step", "text": f"RAG source → {source_url}"})
|
|
2664
|
+
_emit({"type": "step", "text": "Indexing files for RAG…"})
|
|
2665
|
+
try:
|
|
2666
|
+
import time as _t
|
|
2667
|
+
records = []
|
|
2668
|
+
files = 0
|
|
2669
|
+
for abs_path, rel, ext in _rag_iter_text_files((path or "").strip()):
|
|
2670
|
+
try:
|
|
2671
|
+
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
2672
|
+
text = fh.read()
|
|
2673
|
+
except OSError:
|
|
2674
|
+
continue
|
|
2675
|
+
files += 1
|
|
2676
|
+
records.extend(_rag_chunk_text(text, rel, ext))
|
|
2677
|
+
if not records:
|
|
2678
|
+
return "No indexable text files found at that path."
|
|
2679
|
+
for i in range(0, len(records), 64):
|
|
2680
|
+
batch = records[i:i + 64]
|
|
2681
|
+
vecs = _ollama_embed(rag_ollama_root, rag_embed_model, [r["text"] for r in batch])
|
|
2682
|
+
for r, v in zip(batch, vecs):
|
|
2683
|
+
r["embedding"] = v
|
|
2684
|
+
_emit({"type": "step", "text": f"Embedded {min(i + 64, len(records))}/{len(records)} chunks…"})
|
|
2685
|
+
client, bucket, prefix = _rag_s3_and_loc()
|
|
2686
|
+
base = _rag_prefix_for(source_url, prefix)
|
|
2687
|
+
shard = f"{base}/chunks-{int(_t.time())}.jsonl"
|
|
2688
|
+
body = "\n".join(json.dumps(r, ensure_ascii=False) for r in records).encode("utf-8")
|
|
2689
|
+
client.put_object(Bucket=bucket, Key=shard, Body=body, ContentType="application/x-ndjson")
|
|
2690
|
+
manifest = {
|
|
2691
|
+
"sourceUrl": source_url, "embedModel": rag_embed_model,
|
|
2692
|
+
"dims": len(records[0].get("embedding") or []),
|
|
2693
|
+
"files": files, "chunks": len(records),
|
|
2694
|
+
"updatedAt": _t.strftime("%Y-%m-%dT%H:%M:%SZ", _t.gmtime()),
|
|
2695
|
+
}
|
|
2696
|
+
client.put_object(Bucket=bucket, Key=f"{base}/manifest.json",
|
|
2697
|
+
Body=json.dumps(manifest).encode("utf-8"),
|
|
2698
|
+
ContentType="application/json")
|
|
2699
|
+
return (f"Indexed {files} files into {len(records)} chunks for {source_url}. "
|
|
2700
|
+
f"Use rag_search(source_url='{source_url}', query=...) to retrieve, then answer the user.")
|
|
2701
|
+
except Exception as e: # noqa: BLE001
|
|
2702
|
+
return f"Error: indexing failed: {type(e).__name__}: {e}"
|
|
2703
|
+
|
|
2704
|
+
@tool
|
|
2705
|
+
def rag_add(source_url: str, text: str) -> str:
|
|
2706
|
+
"""Add extra text/notes to an EXISTING knowledge base (keyed by source_url). Use when the user gives more information to remember for later questions.
|
|
2707
|
+
|
|
2708
|
+
Args:
|
|
2709
|
+
source_url: the knowledge-base key (the original URL).
|
|
2710
|
+
text: the new information to embed and store.
|
|
2711
|
+
"""
|
|
2712
|
+
if not _RAG_AVAILABLE:
|
|
2713
|
+
return "Error: RAG is not configured."
|
|
2714
|
+
if not (text or "").strip():
|
|
2715
|
+
return "Error: no text to add."
|
|
2716
|
+
_emit({"type": "step", "text": f"RAG source → {source_url}"})
|
|
2717
|
+
_emit({"type": "step", "text": "Adding info to RAG…"})
|
|
2718
|
+
try:
|
|
2719
|
+
import time as _t
|
|
2720
|
+
records = _rag_chunk_text(text, "user-note", ".txt")
|
|
2721
|
+
vecs = _ollama_embed(rag_ollama_root, rag_embed_model, [r["text"] for r in records])
|
|
2722
|
+
for r, v in zip(records, vecs):
|
|
2723
|
+
r["embedding"] = v
|
|
2724
|
+
client, bucket, prefix = _rag_s3_and_loc()
|
|
2725
|
+
base = _rag_prefix_for(source_url, prefix)
|
|
2726
|
+
shard = f"{base}/chunks-add-{int(_t.time())}.jsonl"
|
|
2727
|
+
body = "\n".join(json.dumps(r, ensure_ascii=False) for r in records).encode("utf-8")
|
|
2728
|
+
client.put_object(Bucket=bucket, Key=shard, Body=body, ContentType="application/x-ndjson")
|
|
2729
|
+
return f"Added {len(records)} chunk(s) to the knowledge base for {source_url}."
|
|
2730
|
+
except Exception as e: # noqa: BLE001
|
|
2731
|
+
return f"Error: rag_add failed: {type(e).__name__}: {e}"
|
|
2732
|
+
|
|
2733
|
+
@tool
|
|
2734
|
+
def rag_search(source_url: str, query: str, k: int = 0) -> str:
|
|
2735
|
+
"""Retrieve the most relevant chunks from the knowledge base for source_url. Use to ANSWER questions about the indexed files; write your answer from the returned chunks.
|
|
2736
|
+
|
|
2737
|
+
Args:
|
|
2738
|
+
source_url: the knowledge-base key (the original URL).
|
|
2739
|
+
query: what to look for.
|
|
2740
|
+
k: number of chunks to return (0 = configured default).
|
|
2741
|
+
"""
|
|
2742
|
+
if not _RAG_AVAILABLE:
|
|
2743
|
+
return "Error: RAG is not configured."
|
|
2744
|
+
_emit({"type": "step", "text": f"RAG source → {source_url}"})
|
|
2745
|
+
_emit({"type": "step", "text": f"Searching RAG → {query[:60]}"})
|
|
2746
|
+
try:
|
|
2747
|
+
client, bucket, prefix = _rag_s3_and_loc()
|
|
2748
|
+
base = _rag_prefix_for(source_url, prefix)
|
|
2749
|
+
chunks = _rag_load_chunks(client, bucket, base)
|
|
2750
|
+
if not chunks:
|
|
2751
|
+
return f"No knowledge base found for {source_url}. Run rag_index first."
|
|
2752
|
+
qvec = _ollama_embed(rag_ollama_root, rag_embed_model, [query])[0]
|
|
2753
|
+
scored = sorted(
|
|
2754
|
+
((_cosine(qvec, c.get("embedding") or []), c) for c in chunks),
|
|
2755
|
+
key=lambda x: x[0], reverse=True,
|
|
2756
|
+
)
|
|
2757
|
+
topk = scored[: (k if k and k > 0 else rag_top_k)]
|
|
2758
|
+
parts = [f"[{c.get('file', '?')}] (score {score:.2f})\n{c.get('text', '')}"
|
|
2759
|
+
for score, c in topk]
|
|
2760
|
+
return "\n\n---\n\n".join(parts) if parts else "No matches."
|
|
2761
|
+
except Exception as e: # noqa: BLE001
|
|
2762
|
+
return f"Error: rag_search failed: {type(e).__name__}: {e}"
|
|
2763
|
+
|
|
2278
2764
|
agent_tools = [http_request, web_search, fetch_url, calculate,
|
|
2279
2765
|
get_current_datetime, create_pdf]
|
|
2280
|
-
# Only register the PDF reader / email tools when available (kept in sync
|
|
2281
|
-
# the gated hint entries above so the model never sees a tool it can't run).
|
|
2766
|
+
# Only register the PDF reader / email / RAG tools when available (kept in sync
|
|
2767
|
+
# with the gated hint entries above so the model never sees a tool it can't run).
|
|
2282
2768
|
if _PDF_READ_AVAILABLE:
|
|
2283
2769
|
agent_tools.append(extract_text_from_pdf)
|
|
2284
2770
|
if _EMAIL_AVAILABLE:
|
|
2285
2771
|
agent_tools.append(send_email)
|
|
2772
|
+
if _RAG_AVAILABLE:
|
|
2773
|
+
agent_tools += [download_file, unzip_file, rag_index, rag_add, rag_search]
|
|
2286
2774
|
agent_kwargs = dict(
|
|
2287
2775
|
tools=agent_tools,
|
|
2288
2776
|
model=model,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gonext-local-worker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.157",
|
|
4
4
|
"description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|