@tiens.nguyen/gonext-local-worker 1.0.238 → 1.0.239
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 +92 -33
- package/package.json +1 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -131,6 +131,12 @@ def _html_to_text(html_text, limit=3000):
|
|
|
131
131
|
# Main menu / Donate / Create account…" before any article text, which both starves
|
|
132
132
|
# the model of real content and bloats the per-step context (OOM risk on local MLX).
|
|
133
133
|
text = re.sub(r"(?is)<(nav|header|footer|aside|form|button|menu)\b.*?</\1>", " ", text)
|
|
134
|
+
# Preserve TABLE structure before stripping tags: a cell boundary becomes " | "
|
|
135
|
+
# and a row becomes a newline, so a schedule/fixtures table survives as readable
|
|
136
|
+
# pipe-delimited rows instead of collapsing into a wall of words — a "make a table"
|
|
137
|
+
# task (task #75) is unanswerable if the source table is flattened on the way in.
|
|
138
|
+
text = re.sub(r"(?i)</(td|th)>", " | ", text)
|
|
139
|
+
text = re.sub(r"(?i)</tr>", "\n", text)
|
|
134
140
|
# Turn block-ending tags into newlines so document structure survives stripping.
|
|
135
141
|
text = re.sub(r"(?i)<(br|/p|/div|/li|/tr|/h[1-6]|/section|/article)\s*>", "\n", text)
|
|
136
142
|
# Remove all remaining tags.
|
|
@@ -358,60 +364,90 @@ def _get_json(url, timeout=15):
|
|
|
358
364
|
return None
|
|
359
365
|
|
|
360
366
|
|
|
361
|
-
def _web_search_impl(query):
|
|
367
|
+
def _web_search_impl(query, max_results=5):
|
|
362
368
|
"""Look up factual info via free no-key JSON APIs (DuckDuckGo + Wikipedia).
|
|
363
369
|
|
|
364
|
-
Returns a short
|
|
365
|
-
|
|
366
|
-
|
|
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 fabricates — returns an honest 'no results' when nothing is found.
|
|
367
376
|
"""
|
|
377
|
+
import html as _html
|
|
368
378
|
from urllib.parse import quote
|
|
369
379
|
q = (query or "").strip()
|
|
370
380
|
if not q:
|
|
371
381
|
return "web_search: empty query."
|
|
372
382
|
|
|
373
|
-
|
|
383
|
+
def _strip(s):
|
|
384
|
+
return _html.unescape(re.sub(r"<[^>]+>", "", s or "")).strip()
|
|
385
|
+
|
|
386
|
+
summary = ""
|
|
387
|
+
summary_src = ""
|
|
388
|
+
results = [] # (title, snippet, url)
|
|
389
|
+
seen = set()
|
|
390
|
+
|
|
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
|
+
|
|
398
|
+
# 1) DuckDuckGo Instant Answer — a direct abstract + related topics (as pages).
|
|
374
399
|
ddg = _get_json(
|
|
375
400
|
f"https://api.duckduckgo.com/?q={quote(q)}&format=json&no_html=1&skip_disambig=1"
|
|
376
401
|
)
|
|
377
402
|
if isinstance(ddg, dict):
|
|
378
403
|
abstract = (ddg.get("AbstractText") or "").strip()
|
|
379
404
|
if abstract:
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
# No abstract — use the first related topic that has text.
|
|
405
|
+
summary = abstract[:1200]
|
|
406
|
+
summary_src = (ddg.get("AbstractURL") or "").strip()
|
|
383
407
|
for topic in ddg.get("RelatedTopics") or []:
|
|
384
|
-
if isinstance(topic, dict) and topic.get("Text"):
|
|
385
|
-
|
|
386
|
-
text = topic["Text"][:1500]
|
|
387
|
-
return f"{text}\nSource: {src}" if src else text
|
|
408
|
+
if isinstance(topic, dict) and topic.get("Text") and topic.get("FirstURL"):
|
|
409
|
+
_add(topic["Text"][:80], topic["Text"], topic["FirstURL"])
|
|
388
410
|
|
|
389
|
-
# 2) Wikipedia
|
|
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).
|
|
390
413
|
search = _get_json(
|
|
391
414
|
"https://en.wikipedia.org/w/api.php?action=query&list=search"
|
|
392
|
-
f"&srsearch={quote(q)}&format=json
|
|
415
|
+
f"&srsearch={quote(q)}&srlimit={max_results}&format=json"
|
|
393
416
|
)
|
|
394
|
-
title = ""
|
|
395
417
|
try:
|
|
396
|
-
|
|
418
|
+
hits = search["query"]["search"]
|
|
397
419
|
except Exception: # noqa: BLE001
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
slug = quote(title.replace(" ", "_"))
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
420
|
+
hits = []
|
|
421
|
+
for hit in hits:
|
|
422
|
+
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:
|
|
434
|
+
return (
|
|
435
|
+
f"No results found for '{q}'. Tell the user you couldn't find this — "
|
|
436
|
+
"do NOT invent an answer or a URL."
|
|
437
|
+
)
|
|
410
438
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
439
|
+
parts = []
|
|
440
|
+
if summary:
|
|
441
|
+
parts.append(summary + (f"\nSource: {summary_src}" if summary_src else ""))
|
|
442
|
+
if results:
|
|
443
|
+
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}")
|
|
449
|
+
parts.append("\n".join(lines))
|
|
450
|
+
return "\n\n".join(parts)
|
|
415
451
|
|
|
416
452
|
|
|
417
453
|
class _AgentConfigError(RuntimeError):
|
|
@@ -3510,7 +3546,12 @@ def run_agent_chat(cfg):
|
|
|
3510
3546
|
_log(f"fetch_url {url} → PDF, cannot read")
|
|
3511
3547
|
_last_obs["text"] = msg
|
|
3512
3548
|
return msg
|
|
3513
|
-
|
|
3549
|
+
# 8000, not the 3000 default: a research page's real content (fixture tables,
|
|
3550
|
+
# data) sits well past the lead, so the old cap returned only the intro/nav and
|
|
3551
|
+
# starved the model (task #75). Older large observations are trimmed by the #63
|
|
3552
|
+
# step_callback, so the extra size only costs context for the latest 2 steps.
|
|
3553
|
+
text = (_html_to_text(raw.decode("utf-8", errors="replace"), limit=8000)
|
|
3554
|
+
or "(page had no readable text)")
|
|
3514
3555
|
out = f"{url}\n{text}"
|
|
3515
3556
|
_log(f"fetch_url {url} → {len(text)} chars")
|
|
3516
3557
|
_last_obs["text"] = out
|
|
@@ -3654,6 +3695,24 @@ def run_agent_chat(cfg):
|
|
|
3654
3695
|
"text": f"PDF already created → {prev['title']}.pdf (reusing link)"})
|
|
3655
3696
|
_last_obs["text"] = prev["msg"]
|
|
3656
3697
|
return prev["msg"]
|
|
3698
|
+
|
|
3699
|
+
# Refuse a placeholder skeleton instead of shipping a confident-but-empty PDF
|
|
3700
|
+
# (task #75): a table of 'TBD vs TBD' rows means the agent never found the real
|
|
3701
|
+
# data. 3+ placeholder cells is the fingerprint of that failure and virtually
|
|
3702
|
+
# never appears in a genuine user document. Steer the model to answer honestly
|
|
3703
|
+
# rather than render a green "✅ ready" over an empty table.
|
|
3704
|
+
_placeholders = len(re.findall(
|
|
3705
|
+
r"\b(?:TBD|TBA|TBC|N/?A)\b|\?\?\?|—\s*(?:vs\.?)?\s*—", text or "", re.I))
|
|
3706
|
+
if _placeholders >= 3:
|
|
3707
|
+
msg = (
|
|
3708
|
+
f"This document is mostly placeholders ({_placeholders} TBD/TBA cells) — "
|
|
3709
|
+
"the real data was never found, so the PDF would be empty. Do NOT retry "
|
|
3710
|
+
"create_pdf. Call final_answer to tell the user honestly that you could "
|
|
3711
|
+
f"not find the actual {doc_title} data to fill the table."
|
|
3712
|
+
)
|
|
3713
|
+
_log(f"create_pdf refused: {_placeholders} placeholder cells")
|
|
3714
|
+
_last_obs["text"] = msg
|
|
3715
|
+
return msg
|
|
3657
3716
|
# 1) Format the raw text into clean Markdown (extra model call, intentional).
|
|
3658
3717
|
# CHAT model, not the coder — see the fast-path call site for why (task #74).
|
|
3659
3718
|
_emit({"type": "step", "text": "Formatting document…"})
|
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.239",
|
|
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",
|