@tiens.nguyen/gonext-local-worker 1.0.275 → 1.0.277
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 +3 -0
- package/gonext_agent_chat.py +157 -24
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1893,6 +1893,9 @@ async function runAgentChatJob(job) {
|
|
|
1893
1893
|
agentModelId: payload?.agentModelId ?? "",
|
|
1894
1894
|
codingBaseURL: payload?.codingBaseURL ?? "",
|
|
1895
1895
|
codingModelId: payload?.codingModelId ?? "",
|
|
1896
|
+
// Optional search model (task #105): synthesizes web_search results. Empty = no synthesis.
|
|
1897
|
+
searchBaseURL: payload?.searchBaseURL ?? "",
|
|
1898
|
+
searchModelId: payload?.searchModelId ?? "",
|
|
1896
1899
|
tools: payload?.tools ?? ["http_request"],
|
|
1897
1900
|
// 0 = "not explicitly set": python defaults to 5, but may auto-raise to 12 when
|
|
1898
1901
|
// workspaces are registered. Passing a literal 5 here would look user-set and
|
package/gonext_agent_chat.py
CHANGED
|
@@ -440,24 +440,35 @@ def _bk_ddg_html(q, timeout=6):
|
|
|
440
440
|
|
|
441
441
|
|
|
442
442
|
def _bk_searxng(q, base, timeout=6):
|
|
443
|
-
"""Self-hosted SearXNG JSON API →
|
|
443
|
+
"""Self-hosted SearXNG JSON API (keyless) → {"results":[{title,snippet,url}], "answers":[str]}.
|
|
444
|
+
`answers` = SearXNG's aggregated zero-click ANSWERS + INFOBOXES (the 'answer box' / knowledge
|
|
445
|
+
panel a human sees on a SERP) — surfaced first so one search often needs no page read (#104)."""
|
|
444
446
|
from urllib.parse import quote
|
|
445
447
|
base = (base or "").strip().rstrip("/")
|
|
446
448
|
if not base:
|
|
447
|
-
return []
|
|
449
|
+
return {"results": [], "answers": []}
|
|
448
450
|
data = _get_json(f"{base}/search?q={quote(q)}&format=json&safesearch=0", timeout=timeout)
|
|
449
|
-
|
|
451
|
+
results, answers = [], []
|
|
450
452
|
if isinstance(data, dict):
|
|
451
453
|
for r in (data.get("results") or []):
|
|
452
454
|
url = (r.get("url") or "").strip()
|
|
453
455
|
if not url:
|
|
454
456
|
continue
|
|
455
|
-
|
|
457
|
+
results.append({
|
|
456
458
|
"title": _search_strip(r.get("title") or url)[:120],
|
|
457
459
|
"snippet": _search_strip(r.get("content") or ""),
|
|
458
460
|
"url": url,
|
|
459
461
|
})
|
|
460
|
-
|
|
462
|
+
for a in (data.get("answers") or []):
|
|
463
|
+
txt = _search_strip(a if isinstance(a, str) else (a.get("answer") or a.get("content") or ""))
|
|
464
|
+
if txt:
|
|
465
|
+
answers.append(txt[:600])
|
|
466
|
+
for ib in (data.get("infoboxes") or []):
|
|
467
|
+
if isinstance(ib, dict):
|
|
468
|
+
txt = _search_strip(ib.get("content") or "")
|
|
469
|
+
if txt:
|
|
470
|
+
answers.append(txt[:600])
|
|
471
|
+
return {"results": results, "answers": answers}
|
|
461
472
|
|
|
462
473
|
|
|
463
474
|
def _bk_wikipedia(q, timeout=6, n=5):
|
|
@@ -497,7 +508,87 @@ def _bk_ddg_ia_summary(q, timeout=6):
|
|
|
497
508
|
return ("", "")
|
|
498
509
|
|
|
499
510
|
|
|
500
|
-
def
|
|
511
|
+
def _condense_for_query(text, query, max_chars=1600):
|
|
512
|
+
"""Keep the QUERY-RELEVANT paragraphs of a page (task #104): split into paragraphs,
|
|
513
|
+
score each by distinct query-term overlap, keep the highest-scoring ones up to max_chars,
|
|
514
|
+
then restore original order. Boilerplate/nav is already gone (via _html_to_text); this
|
|
515
|
+
picks the part that actually answers the query so the read stays small + on-signal."""
|
|
516
|
+
terms = {t for t in re.findall(r"[a-z0-9]{3,}", (query or "").lower())}
|
|
517
|
+
paras = [p.strip() for p in re.split(r"\n\s*\n|\n", text or "") if len(p.strip()) > 20]
|
|
518
|
+
if not paras:
|
|
519
|
+
return (text or "")[:max_chars].strip()
|
|
520
|
+
if not terms:
|
|
521
|
+
return "\n".join(paras)[:max_chars].strip()
|
|
522
|
+
scored = [(sum(1 for t in terms if t in p.lower()), i, p) for i, p in enumerate(paras)]
|
|
523
|
+
chosen, total = [], 0
|
|
524
|
+
for score, i, p in sorted(scored, key=lambda x: (-x[0], x[1])):
|
|
525
|
+
if score == 0 and chosen: # stop once we're into zero-signal paragraphs
|
|
526
|
+
break
|
|
527
|
+
if chosen and total + len(p) > max_chars:
|
|
528
|
+
break
|
|
529
|
+
chosen.append((i, p))
|
|
530
|
+
total += len(p) + 1
|
|
531
|
+
chosen.sort()
|
|
532
|
+
return "\n".join(p for _, p in chosen)[:max_chars].strip()
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _read_pages(urls, query, per_page_chars=1600, per_timeout=5):
|
|
536
|
+
"""Fetch several result pages IN PARALLEL and return {url: condensed_readable_text}.
|
|
537
|
+
The 'read' half of search-and-read (task #104) — reuses _fetch_page_impl + _html_to_text.
|
|
538
|
+
Skips non-HTML/failed pages (the snippet stays as the fallback)."""
|
|
539
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
540
|
+
import time as _t
|
|
541
|
+
|
|
542
|
+
def _read_one(u):
|
|
543
|
+
status, ctype, raw = _fetch_page_impl(u, timeout=per_timeout, max_bytes=300000)
|
|
544
|
+
if not status or status >= 400:
|
|
545
|
+
return u, ""
|
|
546
|
+
if ctype and ("html" not in ctype and "text" not in ctype):
|
|
547
|
+
return u, "" # skip pdf/json/binary — the model can fetch_url those explicitly
|
|
548
|
+
text = _html_to_text(raw.decode("utf-8", errors="replace"), limit=8000)
|
|
549
|
+
return u, _condense_for_query(text, query, per_page_chars)
|
|
550
|
+
|
|
551
|
+
out = {}
|
|
552
|
+
if not urls:
|
|
553
|
+
return out
|
|
554
|
+
with ThreadPoolExecutor(max_workers=len(urls)) as ex:
|
|
555
|
+
futs = {ex.submit(_read_one, u): u for u in urls}
|
|
556
|
+
end = _t.time() + per_timeout + 3
|
|
557
|
+
for fut in list(futs):
|
|
558
|
+
try:
|
|
559
|
+
uu, txt = fut.result(timeout=max(0.0, end - _t.time()))
|
|
560
|
+
if txt:
|
|
561
|
+
out[uu] = txt
|
|
562
|
+
except Exception: # noqa: BLE001
|
|
563
|
+
continue
|
|
564
|
+
return out
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _synthesize_search(query, context, base_url, model_id, api_key="local"):
|
|
568
|
+
"""Search model (task #105): ask a configured OpenAI-compatible model to write a concise,
|
|
569
|
+
cited answer from the already-fetched search results — so the (slow) code model doesn't
|
|
570
|
+
have to read/reason over raw pages. Returns "" on any failure (caller falls back)."""
|
|
571
|
+
from openai import OpenAI
|
|
572
|
+
client = OpenAI(base_url=base_url, api_key=api_key or "local", max_retries=0, timeout=45)
|
|
573
|
+
resp = _chat_create(
|
|
574
|
+
client,
|
|
575
|
+
model=model_id or "default_model",
|
|
576
|
+
messages=[
|
|
577
|
+
{"role": "system", "content": (
|
|
578
|
+
"You are a research assistant. Using ONLY the search results provided, write a "
|
|
579
|
+
"concise, factual answer to the user's query. Cite sources inline as [n] using "
|
|
580
|
+
"their numbers. If the results do not contain the answer, say so plainly — never "
|
|
581
|
+
"invent facts, dates, names, or URLs."
|
|
582
|
+
)},
|
|
583
|
+
{"role": "user", "content": f"Query: {query}\n\nSearch results:\n{context}\n\nAnswer:"},
|
|
584
|
+
],
|
|
585
|
+
max_tokens=700,
|
|
586
|
+
temperature=0.2,
|
|
587
|
+
)
|
|
588
|
+
return (resp.choices[0].message.content or "").strip()
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _web_search_impl(query, max_results=5, searxng_url="", search_base_url="", search_model_id=""):
|
|
501
592
|
"""Parallel keyless web search (task #103): run DuckDuckGo-HTML + SearXNG (if configured)
|
|
502
593
|
+ Wikipedia CONCURRENTLY with a short deadline, merge/dedupe/rank by cross-engine
|
|
503
594
|
agreement, and return a short SUMMARY (when available) + a numbered list of the top
|
|
@@ -512,8 +603,9 @@ def _web_search_impl(query, max_results=5, searxng_url=""):
|
|
|
512
603
|
return "web_search: empty query."
|
|
513
604
|
searxng = (searxng_url or os.environ.get("GONEXT_SEARXNG_URL") or "").strip()
|
|
514
605
|
|
|
515
|
-
# Short TTL cache (identical query within a turn).
|
|
516
|
-
|
|
606
|
+
# Short TTL cache (identical query within a turn). Key includes the search model so a
|
|
607
|
+
# synthesized answer isn't served when the model config changed.
|
|
608
|
+
ckey = (q.lower(), searxng, search_base_url, search_model_id)
|
|
517
609
|
hit = _WEB_SEARCH_CACHE.get(ckey)
|
|
518
610
|
if hit and (_t.time() - hit[0]) < _WEB_SEARCH_TTL:
|
|
519
611
|
return hit[1]
|
|
@@ -529,6 +621,7 @@ def _web_search_impl(query, max_results=5, searxng_url=""):
|
|
|
529
621
|
tasks.append(("summary", lambda: _bk_ddg_ia_summary(q, per_timeout)))
|
|
530
622
|
|
|
531
623
|
results_by = {}
|
|
624
|
+
answers = [] # zero-click 'answer box' text (SearXNG + DDG-IA)
|
|
532
625
|
summary, summary_src = "", ""
|
|
533
626
|
with ThreadPoolExecutor(max_workers=len(tasks)) as ex:
|
|
534
627
|
futs = {ex.submit(fn): name for name, fn in tasks}
|
|
@@ -540,8 +633,13 @@ def _web_search_impl(query, max_results=5, searxng_url=""):
|
|
|
540
633
|
continue
|
|
541
634
|
if name == "summary":
|
|
542
635
|
summary, summary_src = r
|
|
636
|
+
elif name == "searxng":
|
|
637
|
+
results_by["searxng"] = r.get("results", [])
|
|
638
|
+
answers.extend(r.get("answers", []))
|
|
543
639
|
else:
|
|
544
640
|
results_by[name] = r
|
|
641
|
+
if summary: # DDG instant answer is an 'answer box' too
|
|
642
|
+
answers.insert(0, summary + (f" (source: {summary_src})" if summary_src else ""))
|
|
545
643
|
|
|
546
644
|
# Merge + dedupe by normalized URL; rank by cross-engine AGREEMENT then first-seen order.
|
|
547
645
|
merged = {}
|
|
@@ -563,24 +661,55 @@ def _web_search_impl(query, max_results=5, searxng_url=""):
|
|
|
563
661
|
order += 1
|
|
564
662
|
ranked = sorted(merged.values(), key=lambda e: (-e["hits"], e["order"]))[:max_results]
|
|
565
663
|
|
|
566
|
-
if not
|
|
664
|
+
if not answers and not ranked:
|
|
567
665
|
return (
|
|
568
666
|
f"No results found for '{q}'. Tell the user you couldn't find this — "
|
|
569
667
|
"do NOT invent an answer or a URL."
|
|
570
668
|
)
|
|
571
669
|
|
|
670
|
+
# Search-AND-read (task #104): READ the top pages here (in parallel) so the model gets
|
|
671
|
+
# answer-ready content from ONE call and rarely needs a follow-up fetch_url — the big win
|
|
672
|
+
# when the reasoning model is slow (each avoided round-trip saves a full model step).
|
|
673
|
+
read_enabled = (os.environ.get("GONEXT_SEARCH_READ", "1").strip() != "0")
|
|
674
|
+
try:
|
|
675
|
+
read_k = max(0, int(os.environ.get("GONEXT_SEARCH_READ_K", "3")))
|
|
676
|
+
except ValueError:
|
|
677
|
+
read_k = 3
|
|
678
|
+
try:
|
|
679
|
+
page_chars = max(400, int(os.environ.get("GONEXT_SEARCH_PAGE_CHARS", "1600")))
|
|
680
|
+
except ValueError:
|
|
681
|
+
page_chars = 1600
|
|
682
|
+
page_texts = {}
|
|
683
|
+
if read_enabled and read_k and ranked:
|
|
684
|
+
page_texts = _read_pages([e["url"] for e in ranked[:read_k]], q,
|
|
685
|
+
per_page_chars=page_chars, per_timeout=5)
|
|
686
|
+
|
|
572
687
|
parts = []
|
|
573
|
-
if
|
|
574
|
-
parts.append(
|
|
688
|
+
if answers:
|
|
689
|
+
parts.append("Answer:\n" + "\n".join(f"• {a}" for a in answers[:3]))
|
|
575
690
|
if ranked:
|
|
576
|
-
|
|
691
|
+
header = ("Sources — already READ for you (you usually do NOT need fetch_url; "
|
|
692
|
+
"call it only to read a specific page in full):")
|
|
693
|
+
blocks = [header]
|
|
577
694
|
for i, e in enumerate(ranked, 1):
|
|
578
|
-
|
|
579
|
-
if e["snippet"]
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
parts.append("\n".join(lines))
|
|
695
|
+
head = f"[{i}] {e['title']}\n {e['url']}"
|
|
696
|
+
body = page_texts.get(e["url"]) or (e["snippet"][:200] if e["snippet"] else "")
|
|
697
|
+
blocks.append(head + (f"\n{body}" if body else ""))
|
|
698
|
+
parts.append("\n\n".join(blocks))
|
|
583
699
|
out = "\n\n".join(parts)
|
|
700
|
+
|
|
701
|
+
# Search model (task #105): if configured, have it write the answer FROM the results we
|
|
702
|
+
# just read — so the code model gets a clean cited answer, not raw pages. Best-effort:
|
|
703
|
+
# any failure falls back to the read output above.
|
|
704
|
+
if search_base_url and search_model_id and (answers or ranked):
|
|
705
|
+
try:
|
|
706
|
+
synth = _synthesize_search(q, out, search_base_url, search_model_id)
|
|
707
|
+
if synth:
|
|
708
|
+
srcs = "\n".join(f"[{i}] {e['url']}" for i, e in enumerate(ranked, 1))
|
|
709
|
+
out = f"Answer:\n{synth}" + (f"\n\nSources:\n{srcs}" if srcs else "")
|
|
710
|
+
except Exception as e: # noqa: BLE001
|
|
711
|
+
_log(f"search-model synthesis failed ({type(e).__name__}: {e}); using read output")
|
|
712
|
+
|
|
584
713
|
_WEB_SEARCH_CACHE[ckey] = (_t.time(), out)
|
|
585
714
|
return out
|
|
586
715
|
|
|
@@ -4009,13 +4138,13 @@ def run_agent_chat(cfg):
|
|
|
4009
4138
|
|
|
4010
4139
|
@tool
|
|
4011
4140
|
def web_search(query: str) -> str:
|
|
4012
|
-
"""Search for
|
|
4141
|
+
"""Search the web AND read the top results in one call — returns an 'Answer:' (when found) plus the READ CONTENT of the best pages, not just links. Because the pages are already read for you, you normally do NOT need to call fetch_url afterwards — answer or create_pdf directly from what this returns. Only call fetch_url when you must read one specific page in full.
|
|
4013
4142
|
|
|
4014
|
-
Use this INSTEAD of guessing a URL
|
|
4015
|
-
|
|
4143
|
+
Use this INSTEAD of guessing a URL whenever the user asks to 'find' something or asks a
|
|
4144
|
+
factual / current-events question.
|
|
4016
4145
|
|
|
4017
4146
|
Args:
|
|
4018
|
-
query: What to look up, e.g. '
|
|
4147
|
+
query: What to look up, e.g. 'FIFA World Cup 2026 final date' or 'express error handling best practices'.
|
|
4019
4148
|
"""
|
|
4020
4149
|
dup, nudge = _dedup("web_search", query,
|
|
4021
4150
|
"Use the facts you have ALREADY gathered: if the task asks for a "
|
|
@@ -4028,9 +4157,13 @@ def run_agent_chat(cfg):
|
|
|
4028
4157
|
if blocked:
|
|
4029
4158
|
return blocked
|
|
4030
4159
|
_emit({"type": "step", "text": f"Searching the web → {query[:80]}"})
|
|
4031
|
-
# SearXNG URL (task #103)
|
|
4032
|
-
|
|
4033
|
-
|
|
4160
|
+
# SearXNG URL (task #103) + optional search model (task #105) from Settings.
|
|
4161
|
+
result = _web_search_impl(
|
|
4162
|
+
query,
|
|
4163
|
+
searxng_url=(cfg.get("searxngUrl") or "").strip(),
|
|
4164
|
+
search_base_url=(cfg.get("searchBaseURL") or "").strip(),
|
|
4165
|
+
search_model_id=(cfg.get("searchModelId") or "").strip(),
|
|
4166
|
+
)
|
|
4034
4167
|
_log(f"web_search {query[:60]!r} → {result[:80]}")
|
|
4035
4168
|
_last_obs["text"] = result
|
|
4036
4169
|
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.277",
|
|
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",
|