@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.
Files changed (2) hide show
  1. package/gonext_agent_chat.py +198 -61
  2. package/package.json +1 -1
@@ -364,73 +364,206 @@ def _get_json(url, timeout=15):
364
364
  return None
365
365
 
366
366
 
367
- def _web_search_impl(query, max_results=5):
368
- """Look up factual info via free no-key JSON APIs (DuckDuckGo + Wikipedia).
369
-
370
- Returns a short SUMMARY (for a direct-fact question) followed by a numbered list
371
- of the top matching PAGES (title snippet URL), so the model can fetch_url() a
372
- real page when a one-line summary can't carry the data it needs (a schedule, a
373
- table, a fixtures list). Returning only a single Wikipedia intro used to trap weak
374
- models in a re-search loop they never saw a candidate URL to open (task #75).
375
- Never fabricatesreturns an honest 'no results' when nothing is found.
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
- from urllib.parse import quote
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
- summary = ""
387
- summary_src = ""
388
- results = [] # (title, snippet, url)
389
- seen = set()
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
- # 1) DuckDuckGo Instant Answer — a direct abstract + related topics (as pages).
399
- ddg = _get_json(
400
- f"https://api.duckduckgo.com/?q={quote(q)}&format=json&no_html=1&skip_disambig=1"
401
- )
402
- if isinstance(ddg, dict):
403
- abstract = (ddg.get("AbstractText") or "").strip()
404
- if abstract:
405
- summary = abstract[:1200]
406
- summary_src = (ddg.get("AbstractURL") or "").strip()
407
- for topic in ddg.get("RelatedTopics") or []:
408
- if isinstance(topic, dict) and topic.get("Text") and topic.get("FirstURL"):
409
- _add(topic["Text"][:80], topic["Text"], topic["FirstURL"])
410
-
411
- # 2) Wikipedia full-text search — SEVERAL candidate pages with snippets, so the
412
- # model can pick the specific one (e.g. a "…knockout stage" fixtures page).
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={max_results}&format=json"
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
- _add(hit.get("title", ""), hit.get("snippet", ""),
424
- f"https://en.wikipedia.org/wiki/{slug}")
425
- # If no direct abstract, use the best-matching page's summary as the answer.
426
- if not summary and hits:
427
- slug = quote(hits[0]["title"].replace(" ", "_"))
428
- s = _get_json("https://en.wikipedia.org/api/rest_v1/page/summary/" + slug)
429
- if isinstance(s, dict):
430
- summary = (s.get("extract") or "").strip()[:1200]
431
- summary_src = f"https://en.wikipedia.org/wiki/{slug}"
432
-
433
- if not summary and not results:
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 results:
575
+ if ranked:
443
576
  lines = ["Top pages (call fetch_url on the most relevant to read it in full):"]
444
- for i, (title, snippet, url) in enumerate(results, 1):
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
- return "\n\n".join(parts)
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
- result = _web_search_impl(query)
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.274",
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",