@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.
- package/gonext_agent_chat.py +181 -31
- package/package.json +1 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -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.
|
|
423
|
-
|
|
424
|
-
for
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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 →
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
502
|
-
+
|
|
503
|
-
agreement,
|
|
504
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
579
|
-
if e["snippet"]
|
|
580
|
-
|
|
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
|
|
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
|
|
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 '
|
|
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.
|
|
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",
|