@tiens.nguyen/gonext-local-worker 1.0.280 → 1.0.281

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 +181 -31
  2. package/package.json +1 -1
@@ -413,15 +413,23 @@ def _norm_url(u):
413
413
 
414
414
 
415
415
  def _bk_ddg_html(q, timeout=6):
416
- """DuckDuckGo HTML endpoint → real web results [{title,snippet,url}] (keyless)."""
416
+ """DuckDuckGo HTML endpoint → real web results [{title,snippet,url}] (keyless).
417
+
418
+ Parses each result as a UNIT (task #104 fix): for every result__a anchor we take the
419
+ snippet that belongs to IT — the result__snippet appearing before the NEXT anchor —
420
+ instead of zipping two independent global lists. The old zip silently misaligned
421
+ snippets onto the wrong title/URL the moment any result lacked a snippet (ads, news,
422
+ or snippet-less rows), so the model read evidence attached to the wrong page.
423
+ """
417
424
  from urllib.parse import quote, unquote
418
425
  html_body = _get_text(f"https://html.duckduckgo.com/html/?q={quote(q)}", timeout=timeout)
419
426
  if not html_body:
420
427
  return []
421
428
  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):
429
+ anchors = list(re.finditer(
430
+ r'<a\b[^>]*class="[^"]*result__a[^"]*"[^>]*>.*?</a>', html_body, re.S))
431
+ for idx, am in enumerate(anchors):
432
+ a = am.group(0)
425
433
  hm = re.search(r'href="([^"]*)"', a)
426
434
  if not hm:
427
435
  continue
@@ -433,31 +441,52 @@ def _bk_ddg_html(q, timeout=6):
433
441
  if not real.startswith("http"):
434
442
  continue
435
443
  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})
444
+ if not title:
445
+ continue
446
+ # Snippet = the result__snippet that lies BETWEEN this anchor and the next one
447
+ # (i.e. inside this result's block). Missing → "" with no shift onto the next row.
448
+ seg_end = anchors[idx + 1].start() if idx + 1 < len(anchors) else len(html_body)
449
+ seg = html_body[am.end():seg_end]
450
+ sm = re.search(
451
+ r'<a\b[^>]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>', seg, re.S)
452
+ snip = _search_strip(sm.group(1)) if sm else ""
453
+ out.append({"title": title[:120], "snippet": snip, "url": real})
439
454
  return out
440
455
 
441
456
 
442
457
  def _bk_searxng(q, base, timeout=6):
443
- """Self-hosted SearXNG JSON API → aggregated results [{title,snippet,url}] (keyless)."""
458
+ """Self-hosted SearXNG JSON API → {results:[{title,snippet,url}], answers:[str]} (keyless).
459
+
460
+ 'answers' + 'infoboxes' are SearXNG's aggregated SERP answer box / knowledge panel — a
461
+ DIRECT answer we can surface without reading any page (task #104). Returns the dict shape
462
+ so _web_search_impl can promote answers into the 'Answer:' block."""
444
463
  from urllib.parse import quote
445
464
  base = (base or "").strip().rstrip("/")
446
465
  if not base:
447
- return []
466
+ return {"results": [], "answers": []}
448
467
  data = _get_json(f"{base}/search?q={quote(q)}&format=json&safesearch=0", timeout=timeout)
449
- out = []
468
+ results, answers = [], []
450
469
  if isinstance(data, dict):
451
470
  for r in (data.get("results") or []):
452
471
  url = (r.get("url") or "").strip()
453
472
  if not url:
454
473
  continue
455
- out.append({
474
+ results.append({
456
475
  "title": _search_strip(r.get("title") or url)[:120],
457
476
  "snippet": _search_strip(r.get("content") or ""),
458
477
  "url": url,
459
478
  })
460
- return out
479
+ # 'answers' entries are plain strings on some versions, {answer: ...} dicts on others.
480
+ for a in (data.get("answers") or []):
481
+ txt = _search_strip(str((a.get("answer") if isinstance(a, dict) else a) or ""))
482
+ if txt:
483
+ answers.append(txt)
484
+ for ib in (data.get("infoboxes") or []):
485
+ if isinstance(ib, dict):
486
+ txt = _search_strip(str(ib.get("content") or ""))
487
+ if txt:
488
+ answers.append(txt)
489
+ return {"results": results, "answers": answers}
461
490
 
462
491
 
463
492
  def _bk_wikipedia(q, timeout=6, n=5):
@@ -497,11 +526,92 @@ def _bk_ddg_ia_summary(q, timeout=6):
497
526
  return ("", "")
498
527
 
499
528
 
529
+ def _condense_for_query(text, query, budget=1500):
530
+ """Keep the query-relevant + STRUCTURAL lines of a page's text, bounded to `budget`
531
+ chars, in original order (task #104). Structural lines — table rows (which _html_to_text
532
+ renders with ' | '), list items, or lines carrying a year / clock time — are ALWAYS kept
533
+ even at zero prose-score, so schedules / fixtures / specs survive instead of being
534
+ summarized away (the flatten-to-summary failure). Model-free: pure text selection."""
535
+ if not text:
536
+ return ""
537
+ terms = {w for w in re.findall(r"\w+", (query or "").lower()) if len(w) > 2}
538
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
539
+ picked = []
540
+ for ln in lines:
541
+ low = ln.lower()
542
+ score = sum(1 for t in terms if t in low)
543
+ structural = (
544
+ "|" in ln
545
+ or bool(re.match(r"^([-*•]|\d+[.)])\s", ln))
546
+ or bool(re.search(r"\b(19|20)\d{2}\b", ln))
547
+ or bool(re.search(r"\d{1,2}:\d{2}", ln))
548
+ )
549
+ if score > 0 or structural:
550
+ picked.append(ln)
551
+ if not picked: # nothing matched — fall back to the page lead so we return SOMETHING
552
+ picked = lines[:20]
553
+ kept, total = [], 0
554
+ for ln in picked:
555
+ if total + len(ln) + 1 > budget:
556
+ continue
557
+ kept.append(ln)
558
+ total += len(ln) + 1
559
+ return "\n".join(kept)
560
+
561
+
562
+ def _read_pages(ranked, query, per_page_chars=1500, per_timeout=5, overall=6):
563
+ """Read the given result pages IN PARALLEL → {url: condensed_text} (task #104).
564
+
565
+ Model-free: HTTP GET (_fetch_page_impl) + _html_to_text + _condense_for_query. Each URL
566
+ is SSRF-guarded (_rag_assert_safe_url) BEFORE fetching — these come from an untrusted
567
+ SERP, and this reads them automatically (not a URL the model explicitly chose), so an
568
+ entry pointing at an internal/link-local host must never be fetched. PDFs/binaries and
569
+ any per-page failure are skipped (the caller falls back to that result's snippet)."""
570
+ from concurrent.futures import ThreadPoolExecutor, as_completed
571
+ import time as _t
572
+ if not ranked:
573
+ return {}
574
+
575
+ def _read_one(url):
576
+ try:
577
+ _rag_assert_safe_url(url)
578
+ except Exception: # noqa: BLE001 — SSRF/scheme refusal → skip this page
579
+ return url, ""
580
+ status, ctype, raw = _fetch_page_impl(url, timeout=per_timeout, max_bytes=300000)
581
+ if status is None or (isinstance(status, int) and status >= 400):
582
+ return url, ""
583
+ if "application/pdf" in ctype or raw[:5].lstrip().startswith(b"%PDF"):
584
+ return url, "" # binary — snippet fallback (fetch_url can't read PDFs either)
585
+ text = _html_to_text(raw.decode("utf-8", errors="replace"), limit=per_page_chars * 3)
586
+ return url, _condense_for_query(text, query, per_page_chars)
587
+
588
+ out = {}
589
+ # Manual shutdown (not `with`): a slow page must not hold the whole search past `overall`
590
+ # via shutdown(wait=True). We collect what finishes within the deadline, then return.
591
+ ex = ThreadPoolExecutor(max_workers=len(ranked))
592
+ futs = {ex.submit(_read_one, e["url"]): e["url"] for e in ranked}
593
+ end = _t.time() + overall
594
+ try:
595
+ for fut in as_completed(futs, timeout=overall):
596
+ try:
597
+ url, txt = fut.result(timeout=max(0.0, end - _t.time()))
598
+ except Exception: # noqa: BLE001
599
+ continue
600
+ if txt:
601
+ out[url] = txt
602
+ except Exception: # noqa: BLE001 — overall deadline hit; return what finished
603
+ pass
604
+ finally:
605
+ ex.shutdown(wait=False, cancel_futures=True)
606
+ return out
607
+
608
+
500
609
  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.
610
+ """Keyless web search that READS for the model (tasks #103 + #104): run DuckDuckGo-HTML
611
+ + SearXNG (if configured) + Wikipedia CONCURRENTLY, merge/dedupe/rank by cross-engine
612
+ agreement, surface any SERP answer box, then (unless GONEXT_SEARCH_READ=0) READ the top
613
+ pages in parallel and return answer-ready condensed content so the model usually does
614
+ NOT need a slow fetch_url round-trip. Model-free; never fabricates.
505
615
  """
506
616
  import os
507
617
  import time as _t
@@ -519,20 +629,31 @@ def _web_search_impl(query, max_results=5, searxng_url=""):
519
629
  return hit[1]
520
630
 
521
631
  per_timeout = 6 # per-backend socket timeout
522
- overall = 9 # overall wall-clock deadline across all backends
632
+ overall = 5 # overall deadline; a straggler is abandoned (shutdown is non-blocking)
523
633
 
524
634
  # Result-producing backends (run in parallel). Order = tie-break priority in the merge.
525
635
  tasks = [("ddg", lambda: _bk_ddg_html(q, per_timeout))]
526
636
  if searxng:
637
+ # SearXNG aggregates Wikipedia (and everything else) server-side, so the standalone
638
+ # Wikipedia backend is redundant here — and its full-text API is the slow straggler
639
+ # (~7s) that otherwise dominates the whole search. Only pay for it as a keyless
640
+ # fallback when SearXNG isn't configured.
527
641
  tasks.append(("searxng", lambda: _bk_searxng(q, searxng, per_timeout)))
528
- tasks.append(("wikipedia", lambda: _bk_wikipedia(q, per_timeout, max_results)))
642
+ else:
643
+ tasks.append(("wikipedia", lambda: _bk_wikipedia(q, per_timeout, max_results)))
529
644
  tasks.append(("summary", lambda: _bk_ddg_ia_summary(q, per_timeout)))
530
645
 
531
646
  results_by = {}
647
+ answers = [] # #104: direct answer-box text (SearXNG answers/infoboxes)
532
648
  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
649
+ # NOT a `with` block: the context manager's shutdown(wait=True) would block on straggler
650
+ # threads PAST the deadline (a slow SearXNG/engine could add 10s+). We read results up to
651
+ # `overall`, then shutdown(wait=False) so the function returns on time; leftover backend
652
+ # threads finish harmlessly in the background.
653
+ ex = ThreadPoolExecutor(max_workers=len(tasks))
654
+ futs = {ex.submit(fn): name for name, fn in tasks}
655
+ end = _t.time() + overall
656
+ try:
536
657
  for fut, name in futs.items():
537
658
  try:
538
659
  r = fut.result(timeout=max(0.0, end - _t.time()))
@@ -540,8 +661,13 @@ def _web_search_impl(query, max_results=5, searxng_url=""):
540
661
  continue
541
662
  if name == "summary":
542
663
  summary, summary_src = r
664
+ elif name == "searxng": # dict shape: {results, answers}
665
+ results_by[name] = r.get("results", [])
666
+ answers.extend(r.get("answers", []))
543
667
  else:
544
668
  results_by[name] = r
669
+ finally:
670
+ ex.shutdown(wait=False, cancel_futures=True)
545
671
 
546
672
  # Merge + dedupe by normalized URL; rank by cross-engine AGREEMENT then first-seen order.
547
673
  merged = {}
@@ -563,22 +689,43 @@ def _web_search_impl(query, max_results=5, searxng_url=""):
563
689
  order += 1
564
690
  ranked = sorted(merged.values(), key=lambda e: (-e["hits"], e["order"]))[:max_results]
565
691
 
566
- if not summary and not ranked:
692
+ if not summary and not ranked and not answers:
567
693
  return (
568
694
  f"No results found for '{q}'. Tell the user you couldn't find this — "
569
695
  "do NOT invent an answer or a URL."
570
696
  )
571
697
 
698
+ # #104 read step: unless disabled, READ the top pages inline so the model gets
699
+ # answer-ready content in ONE call (no slow fetch_url round-trips). GONEXT_SEARCH_READ=0
700
+ # is the kill switch → link+snippet behaviour of #103.
701
+ def _env_int(name, default):
702
+ try:
703
+ return max(1, int(os.environ.get(name, "").strip()))
704
+ except (ValueError, TypeError):
705
+ return default
706
+ read_on = os.environ.get("GONEXT_SEARCH_READ", "1").strip().lower() not in ("0", "false", "no")
707
+ read_k = _env_int("GONEXT_SEARCH_READ_K", 3)
708
+ page_chars = _env_int("GONEXT_SEARCH_PAGE_CHARS", 1500)
709
+ page_texts = _read_pages(ranked[:read_k], q, page_chars) if (read_on and ranked) else {}
710
+
572
711
  parts = []
712
+ # Answer box(es) first — a direct answer often needs no page read at all.
713
+ answer_lines = [f"• {a[:600]}" for a in answers[:3]]
573
714
  if summary:
574
- parts.append(summary + (f"\nSource: {summary_src}" if summary_src else ""))
715
+ answer_lines.append(f"• {summary[:600]}" + (f" (source: {summary_src})" if summary_src else ""))
716
+ if answer_lines:
717
+ parts.append("Answer:\n" + "\n".join(answer_lines))
718
+
575
719
  if ranked:
576
- lines = ["Top pages (call fetch_url on the most relevant to read it in full):"]
720
+ if page_texts:
721
+ lines = ["Sources — already READ for you (you usually do NOT need fetch_url; "
722
+ "call it only to read a specific page in full):"]
723
+ else:
724
+ lines = ["Top pages (call fetch_url on the most relevant to read it in full):"]
577
725
  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']}")
726
+ head = f"[{i}] {e['title']}\n {e['url']}"
727
+ body = page_texts.get(e["url"]) or (e["snippet"][:200] if e["snippet"] else "")
728
+ lines.append(head + (f"\n{body}" if body else ""))
582
729
  parts.append("\n".join(lines))
583
730
  out = "\n\n".join(parts)
584
731
  _WEB_SEARCH_CACHE[ckey] = (_t.time(), out)
@@ -4009,13 +4156,16 @@ def run_agent_chat(cfg):
4009
4156
 
4010
4157
  @tool
4011
4158
  def web_search(query: str) -> str:
4012
- """Search for factual or encyclopedic information using free no-key sources.
4159
+ """Search the web AND read the top pages for you, in one call.
4013
4160
 
4014
4161
  Use this INSTEAD of guessing a URL when the user asks to 'find' something or asks a
4015
- general-knowledge question. Returns a short summary and a source URL.
4162
+ general-knowledge question. Returns any direct answer box plus the top sources with
4163
+ their CONTENT ALREADY READ (HTML stripped, tables kept) — so you can usually answer
4164
+ or call final_answer/create_pdf straight away WITHOUT calling fetch_url again. Only
4165
+ call fetch_url when you specifically need a single page in full.
4016
4166
 
4017
4167
  Args:
4018
- query: What to look up, e.g. 'capital of France' or 'productivity day-to-day method'.
4168
+ query: What to look up, e.g. 'capital of France' or 'world cup 2026 schedule'.
4019
4169
  """
4020
4170
  dup, nudge = _dedup("web_search", query,
4021
4171
  "Use the facts you have ALREADY gathered: if the task asks for a "
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.280",
3
+ "version": "1.0.281",
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",