@tiens.nguyen/gonext-local-worker 1.0.274 → 1.0.275
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_agent_chat.py +198 -61
- package/package.json +1 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -364,73 +364,206 @@ def _get_json(url, timeout=15):
|
|
|
364
364
|
return None
|
|
365
365
|
|
|
366
366
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
367
|
+
# --- web_search: parallel keyless multi-backend (task #103) ------------------------------
|
|
368
|
+
# The old impl called DuckDuckGo's INSTANT-ANSWER API + Wikipedia sequentially. The IA API
|
|
369
|
+
# isn't a real web SERP (only curated abstracts for known entities), so general queries came
|
|
370
|
+
# back thin/empty and slow (2-3 sequential 15-25s fetches). This runs several KEYLESS
|
|
371
|
+
# backends CONCURRENTLY with short per-backend timeouts and an overall deadline, then merges
|
|
372
|
+
# + dedupes + ranks by cross-engine agreement. Backends: DuckDuckGo HTML (real results),
|
|
373
|
+
# a self-hosted SearXNG metasearch (aggregates Google/Bing/Yahoo/… server-side, keyless — set
|
|
374
|
+
# GONEXT_SEARXNG_URL or cfg.searxngUrl), and Wikipedia (encyclopedic). No API keys.
|
|
375
|
+
_WEB_SEARCH_TTL = 300 # seconds — short cache so repeated queries in a turn don't re-hit net
|
|
376
|
+
_WEB_SEARCH_CACHE: dict = {} # (query_lower, searxng_url) -> (ts, result_text)
|
|
377
|
+
_WEB_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
378
|
+
"(KHTML, like Gecko) Chrome/121.0 Safari/537.36")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _get_text(url, timeout=6, ua=None, headers=None):
|
|
382
|
+
"""GET a URL and return the decoded body text ("" on failure). For scraping SERP HTML."""
|
|
383
|
+
h = {"User-Agent": ua or _WEB_UA,
|
|
384
|
+
"Accept": "text/html,application/json;q=0.9,*/*;q=0.8",
|
|
385
|
+
"Accept-Language": "en-US,en;q=0.9"}
|
|
386
|
+
if headers:
|
|
387
|
+
h.update(headers)
|
|
388
|
+
req = urllib.request.Request(url, method="GET", headers=h)
|
|
389
|
+
try:
|
|
390
|
+
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
|
|
391
|
+
return resp.read(600_000).decode("utf-8", errors="replace")
|
|
392
|
+
except Exception as e: # noqa: BLE001
|
|
393
|
+
_log(f"web_search GET failed {url[:80]}: {e}")
|
|
394
|
+
return ""
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _search_strip(s):
|
|
377
398
|
import html as _html
|
|
378
|
-
|
|
379
|
-
q = (query or "").strip()
|
|
380
|
-
if not q:
|
|
381
|
-
return "web_search: empty query."
|
|
399
|
+
return _html.unescape(re.sub(r"<[^>]+>", "", s or "")).strip()
|
|
382
400
|
|
|
383
|
-
def _strip(s):
|
|
384
|
-
return _html.unescape(re.sub(r"<[^>]+>", "", s or "")).strip()
|
|
385
401
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
402
|
+
def _norm_url(u):
|
|
403
|
+
"""Normalize a URL for dedupe (lower host/scheme, drop fragment + trailing slash)."""
|
|
404
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
405
|
+
u = (u or "").strip()
|
|
406
|
+
if not u:
|
|
407
|
+
return ""
|
|
408
|
+
try:
|
|
409
|
+
s = urlsplit(u)
|
|
410
|
+
return urlunsplit((s.scheme.lower(), s.netloc.lower(), s.path.rstrip("/"), "", ""))
|
|
411
|
+
except Exception: # noqa: BLE001
|
|
412
|
+
return u.rstrip("/")
|
|
390
413
|
|
|
391
|
-
def _add(title, snippet, url):
|
|
392
|
-
url = (url or "").strip()
|
|
393
|
-
if not url or url in seen or len(results) >= max_results:
|
|
394
|
-
return
|
|
395
|
-
seen.add(url)
|
|
396
|
-
results.append((_strip(title) or url, _strip(snippet), url))
|
|
397
414
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
)
|
|
402
|
-
if
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
415
|
+
def _bk_ddg_html(q, timeout=6):
|
|
416
|
+
"""DuckDuckGo HTML endpoint → real web results [{title,snippet,url}] (keyless)."""
|
|
417
|
+
from urllib.parse import quote, unquote
|
|
418
|
+
html_body = _get_text(f"https://html.duckduckgo.com/html/?q={quote(q)}", timeout=timeout)
|
|
419
|
+
if not html_body:
|
|
420
|
+
return []
|
|
421
|
+
out = []
|
|
422
|
+
anchors = re.findall(r'<a\b[^>]*class="result__a"[^>]*>.*?</a>', html_body, re.S)
|
|
423
|
+
snippets = re.findall(r'<a\b[^>]*class="result__snippet"[^>]*>(.*?)</a>', html_body, re.S)
|
|
424
|
+
for i, a in enumerate(anchors):
|
|
425
|
+
hm = re.search(r'href="([^"]*)"', a)
|
|
426
|
+
if not hm:
|
|
427
|
+
continue
|
|
428
|
+
href = hm.group(1)
|
|
429
|
+
um = re.search(r'[?&]uddg=([^&]+)', href)
|
|
430
|
+
real = unquote(um.group(1)) if um else href
|
|
431
|
+
if real.startswith("//"):
|
|
432
|
+
real = "https:" + real
|
|
433
|
+
if not real.startswith("http"):
|
|
434
|
+
continue
|
|
435
|
+
title = _search_strip(a)
|
|
436
|
+
snip = _search_strip(snippets[i]) if i < len(snippets) else ""
|
|
437
|
+
if title:
|
|
438
|
+
out.append({"title": title[:120], "snippet": snip, "url": real})
|
|
439
|
+
return out
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _bk_searxng(q, base, timeout=6):
|
|
443
|
+
"""Self-hosted SearXNG JSON API → aggregated results [{title,snippet,url}] (keyless)."""
|
|
444
|
+
from urllib.parse import quote
|
|
445
|
+
base = (base or "").strip().rstrip("/")
|
|
446
|
+
if not base:
|
|
447
|
+
return []
|
|
448
|
+
data = _get_json(f"{base}/search?q={quote(q)}&format=json&safesearch=0", timeout=timeout)
|
|
449
|
+
out = []
|
|
450
|
+
if isinstance(data, dict):
|
|
451
|
+
for r in (data.get("results") or []):
|
|
452
|
+
url = (r.get("url") or "").strip()
|
|
453
|
+
if not url:
|
|
454
|
+
continue
|
|
455
|
+
out.append({
|
|
456
|
+
"title": _search_strip(r.get("title") or url)[:120],
|
|
457
|
+
"snippet": _search_strip(r.get("content") or ""),
|
|
458
|
+
"url": url,
|
|
459
|
+
})
|
|
460
|
+
return out
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _bk_wikipedia(q, timeout=6, n=5):
|
|
464
|
+
"""Wikipedia full-text search → encyclopedic candidate pages [{title,snippet,url}]."""
|
|
465
|
+
from urllib.parse import quote
|
|
413
466
|
search = _get_json(
|
|
414
467
|
"https://en.wikipedia.org/w/api.php?action=query&list=search"
|
|
415
|
-
f"&srsearch={quote(q)}&srlimit={
|
|
468
|
+
f"&srsearch={quote(q)}&srlimit={n}&format=json",
|
|
469
|
+
timeout=timeout,
|
|
416
470
|
)
|
|
471
|
+
out = []
|
|
417
472
|
try:
|
|
418
473
|
hits = search["query"]["search"]
|
|
419
474
|
except Exception: # noqa: BLE001
|
|
420
475
|
hits = []
|
|
421
476
|
for hit in hits:
|
|
422
477
|
slug = quote(hit.get("title", "").replace(" ", "_"))
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
478
|
+
out.append({
|
|
479
|
+
"title": hit.get("title", ""),
|
|
480
|
+
"snippet": _search_strip(hit.get("snippet", "")),
|
|
481
|
+
"url": f"https://en.wikipedia.org/wiki/{slug}",
|
|
482
|
+
})
|
|
483
|
+
return out
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _bk_ddg_ia_summary(q, timeout=6):
|
|
487
|
+
"""DuckDuckGo Instant Answer — a direct abstract for a well-known entity (summary only)."""
|
|
488
|
+
from urllib.parse import quote
|
|
489
|
+
ddg = _get_json(
|
|
490
|
+
f"https://api.duckduckgo.com/?q={quote(q)}&format=json&no_html=1&skip_disambig=1",
|
|
491
|
+
timeout=timeout,
|
|
492
|
+
)
|
|
493
|
+
if isinstance(ddg, dict):
|
|
494
|
+
abstract = (ddg.get("AbstractText") or "").strip()
|
|
495
|
+
if abstract:
|
|
496
|
+
return (abstract[:1200], (ddg.get("AbstractURL") or "").strip())
|
|
497
|
+
return ("", "")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _web_search_impl(query, max_results=5, searxng_url=""):
|
|
501
|
+
"""Parallel keyless web search (task #103): run DuckDuckGo-HTML + SearXNG (if configured)
|
|
502
|
+
+ Wikipedia CONCURRENTLY with a short deadline, merge/dedupe/rank by cross-engine
|
|
503
|
+
agreement, and return a short SUMMARY (when available) + a numbered list of the top
|
|
504
|
+
PAGES (title — snippet — URL) so the model can fetch_url() the best one. Never fabricates.
|
|
505
|
+
"""
|
|
506
|
+
import os
|
|
507
|
+
import time as _t
|
|
508
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
509
|
+
|
|
510
|
+
q = (query or "").strip()
|
|
511
|
+
if not q:
|
|
512
|
+
return "web_search: empty query."
|
|
513
|
+
searxng = (searxng_url or os.environ.get("GONEXT_SEARXNG_URL") or "").strip()
|
|
514
|
+
|
|
515
|
+
# Short TTL cache (identical query within a turn).
|
|
516
|
+
ckey = (q.lower(), searxng)
|
|
517
|
+
hit = _WEB_SEARCH_CACHE.get(ckey)
|
|
518
|
+
if hit and (_t.time() - hit[0]) < _WEB_SEARCH_TTL:
|
|
519
|
+
return hit[1]
|
|
520
|
+
|
|
521
|
+
per_timeout = 6 # per-backend socket timeout
|
|
522
|
+
overall = 9 # overall wall-clock deadline across all backends
|
|
523
|
+
|
|
524
|
+
# Result-producing backends (run in parallel). Order = tie-break priority in the merge.
|
|
525
|
+
tasks = [("ddg", lambda: _bk_ddg_html(q, per_timeout))]
|
|
526
|
+
if searxng:
|
|
527
|
+
tasks.append(("searxng", lambda: _bk_searxng(q, searxng, per_timeout)))
|
|
528
|
+
tasks.append(("wikipedia", lambda: _bk_wikipedia(q, per_timeout, max_results)))
|
|
529
|
+
tasks.append(("summary", lambda: _bk_ddg_ia_summary(q, per_timeout)))
|
|
530
|
+
|
|
531
|
+
results_by = {}
|
|
532
|
+
summary, summary_src = "", ""
|
|
533
|
+
with ThreadPoolExecutor(max_workers=len(tasks)) as ex:
|
|
534
|
+
futs = {ex.submit(fn): name for name, fn in tasks}
|
|
535
|
+
end = _t.time() + overall
|
|
536
|
+
for fut, name in futs.items():
|
|
537
|
+
try:
|
|
538
|
+
r = fut.result(timeout=max(0.0, end - _t.time()))
|
|
539
|
+
except Exception: # noqa: BLE001 — a slow/failed backend is skipped
|
|
540
|
+
continue
|
|
541
|
+
if name == "summary":
|
|
542
|
+
summary, summary_src = r
|
|
543
|
+
else:
|
|
544
|
+
results_by[name] = r
|
|
545
|
+
|
|
546
|
+
# Merge + dedupe by normalized URL; rank by cross-engine AGREEMENT then first-seen order.
|
|
547
|
+
merged = {}
|
|
548
|
+
order = 0
|
|
549
|
+
for name in ("searxng", "ddg", "wikipedia"):
|
|
550
|
+
for r in results_by.get(name, []):
|
|
551
|
+
nu = _norm_url(r.get("url"))
|
|
552
|
+
if not nu:
|
|
553
|
+
continue
|
|
554
|
+
if nu in merged:
|
|
555
|
+
merged[nu]["hits"] += 1
|
|
556
|
+
if not merged[nu]["snippet"] and r.get("snippet"):
|
|
557
|
+
merged[nu]["snippet"] = r["snippet"]
|
|
558
|
+
else:
|
|
559
|
+
merged[nu] = {
|
|
560
|
+
"title": r.get("title") or r["url"], "snippet": r.get("snippet", ""),
|
|
561
|
+
"url": r["url"], "hits": 1, "order": order,
|
|
562
|
+
}
|
|
563
|
+
order += 1
|
|
564
|
+
ranked = sorted(merged.values(), key=lambda e: (-e["hits"], e["order"]))[:max_results]
|
|
565
|
+
|
|
566
|
+
if not summary and not ranked:
|
|
434
567
|
return (
|
|
435
568
|
f"No results found for '{q}'. Tell the user you couldn't find this — "
|
|
436
569
|
"do NOT invent an answer or a URL."
|
|
@@ -439,15 +572,17 @@ def _web_search_impl(query, max_results=5):
|
|
|
439
572
|
parts = []
|
|
440
573
|
if summary:
|
|
441
574
|
parts.append(summary + (f"\nSource: {summary_src}" if summary_src else ""))
|
|
442
|
-
if
|
|
575
|
+
if ranked:
|
|
443
576
|
lines = ["Top pages (call fetch_url on the most relevant to read it in full):"]
|
|
444
|
-
for i,
|
|
445
|
-
line = f"{i}. {title}"
|
|
446
|
-
if snippet:
|
|
447
|
-
line += f" — {snippet[:160]}"
|
|
448
|
-
lines.append(line + f"\n {url}")
|
|
577
|
+
for i, e in enumerate(ranked, 1):
|
|
578
|
+
line = f"{i}. {e['title']}"
|
|
579
|
+
if e["snippet"]:
|
|
580
|
+
line += f" — {e['snippet'][:160]}"
|
|
581
|
+
lines.append(line + f"\n {e['url']}")
|
|
449
582
|
parts.append("\n".join(lines))
|
|
450
|
-
|
|
583
|
+
out = "\n\n".join(parts)
|
|
584
|
+
_WEB_SEARCH_CACHE[ckey] = (_t.time(), out)
|
|
585
|
+
return out
|
|
451
586
|
|
|
452
587
|
|
|
453
588
|
class _AgentConfigError(RuntimeError):
|
|
@@ -3893,7 +4028,9 @@ def run_agent_chat(cfg):
|
|
|
3893
4028
|
if blocked:
|
|
3894
4029
|
return blocked
|
|
3895
4030
|
_emit({"type": "step", "text": f"Searching the web → {query[:80]}"})
|
|
3896
|
-
|
|
4031
|
+
# SearXNG URL (task #103): from Settings (cfg.searxngUrl) if the API sends it, else
|
|
4032
|
+
# the GONEXT_SEARXNG_URL env fallback inside _web_search_impl. Keyless either way.
|
|
4033
|
+
result = _web_search_impl(query, searxng_url=(cfg.get("searxngUrl") or "").strip())
|
|
3897
4034
|
_log(f"web_search {query[:60]!r} → {result[:80]}")
|
|
3898
4035
|
_last_obs["text"] = result
|
|
3899
4036
|
if result and not result.startswith("Error"):
|
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.275",
|
|
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",
|