Download tiny_gdn/web_search.py from kerzgrr/Tercet-R-1.1: direct link, hf CLI and curl.
- Browser
- Download file 14.9 kB
-
https://huggingface.co/kerzgrr/Tercet-R-1.1/resolve/main/tiny_gdn/web_search.py
- Command line
-
hf download hf://kerzgrr/Tercet-R-1.1/tiny_gdn/web_search.py
-
curl -L -o web_search.py https://huggingface.co/kerzgrr/Tercet-R-1.1/resolve/main/tiny_gdn/web_search.py
14.9 kB
| """Execute Nemotron Agentic-v2 `web-search` the way Tercet-R stage-3 SFT saw it. | |
| `nvidia/Nemotron-SFT-Agentic-v2` `data/search.jsonl` (packed as | |
| `agentic_v2_search`) declares a single tool named `web-search` whose | |
| observations are compact Tavily Search JSON: | |
| {"query", "follow_up_questions", "answer", "images", "results", | |
| "response_time", "request_id"} | |
| Each `results[]` item is `{url, title, content, score, raw_content}` | |
| (about ten hits, with an LLM `answer` when Tavily supplies one). The chat | |
| template then prefixes that JSON with `<|tool_response|>`. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import time | |
| from collections.abc import Mapping, Sequence | |
| from html import unescape | |
| from html.parser import HTMLParser | |
| from typing import Any | |
| from urllib.error import HTTPError, URLError | |
| from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse | |
| from urllib.request import Request, urlopen | |
| NEMOTRON_WEB_SEARCH_TOOL: dict[str, Any] = { | |
| "type": "function", | |
| "function": { | |
| "name": "web-search", | |
| "description": "Search the web for a query.", | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "query": { | |
| "type": "string", | |
| "description": "Search query.", | |
| } | |
| }, | |
| "required": ["query"], | |
| }, | |
| }, | |
| } | |
| WEB_SEARCH_TOOL_NAME = "web-search" | |
| DEFAULT_MAX_RESULTS = 10 | |
| TAVILY_SEARCH_URL = "https://api.tavily.com/search" | |
| DUCKDUCKGO_HTML_URL = "https://html.duckduckgo.com/html/" | |
| WIKIPEDIA_API_URL = "https://en.wikipedia.org/w/api.php" | |
| USER_AGENT = ( | |
| "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " | |
| "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" | |
| ) | |
| REQUEST_TIMEOUT_SECONDS = 30.0 | |
| def is_web_search_tool_name(name: str) -> bool: | |
| return name.strip().lower().replace("_", "-") == WEB_SEARCH_TOOL_NAME | |
| def query_from_arguments(arguments: Mapping[str, Any] | None) -> str: | |
| if not arguments: | |
| return "" | |
| raw = arguments.get("query") | |
| if isinstance(raw, str): | |
| return raw.strip() | |
| if raw is None: | |
| return "" | |
| return str(raw).strip() | |
| def dumps_search_payload(payload: Mapping[str, Any]) -> str: | |
| return json.dumps(canonical_search_payload(payload), ensure_ascii=False) | |
| def canonical_search_payload(payload: Mapping[str, Any]) -> dict[str, Any]: | |
| results_raw = payload.get("results") | |
| results: list[dict[str, Any]] = [] | |
| if isinstance(results_raw, Sequence) and not isinstance(results_raw, (str, bytes)): | |
| for item in results_raw: | |
| if isinstance(item, Mapping): | |
| results.append(_canonical_result(item)) | |
| images_raw = payload.get("images") | |
| images: list[Any] = [] | |
| if isinstance(images_raw, Sequence) and not isinstance(images_raw, (str, bytes)): | |
| images = list(images_raw) | |
| return { | |
| "query": str(payload.get("query") or ""), | |
| "follow_up_questions": payload.get("follow_up_questions"), | |
| "answer": payload.get("answer"), | |
| "images": images, | |
| "results": results, | |
| "response_time": payload.get("response_time"), | |
| "request_id": payload.get("request_id"), | |
| } | |
| def _canonical_result(item: Mapping[str, Any]) -> dict[str, Any]: | |
| if item.get("error") and not item.get("url") and not item.get("title"): | |
| return {"error": str(item["error"])} | |
| result: dict[str, Any] = { | |
| "url": str(item.get("url") or ""), | |
| "title": str(item.get("title") or ""), | |
| "content": str(item.get("content") or ""), | |
| "score": _as_score(item.get("score")), | |
| "raw_content": item.get("raw_content"), | |
| } | |
| if item.get("error"): | |
| result["error"] = str(item["error"]) | |
| return result | |
| def _as_score(value: Any) -> float | None: | |
| if value is None or value == "": | |
| return None | |
| try: | |
| return float(value) | |
| except (TypeError, ValueError): | |
| return None | |
| def search_web( | |
| query: str, | |
| *, | |
| max_results: int = DEFAULT_MAX_RESULTS, | |
| api_key: str | None = None, | |
| ) -> str: | |
| """Return a Tavily-shaped JSON string for one `web-search` observation.""" | |
| cleaned = query.strip() | |
| started = time.perf_counter() | |
| if not cleaned: | |
| return dumps_search_payload( | |
| _empty_payload( | |
| query, | |
| error="Search query was empty", | |
| response_time=time.perf_counter() - started, | |
| ) | |
| ) | |
| key = (api_key if api_key is not None else os.environ.get("TAVILY_API_KEY", "")).strip() | |
| if key: | |
| try: | |
| return dumps_search_payload( | |
| _search_tavily(cleaned, api_key=key, max_results=max_results) | |
| ) | |
| except Exception as error: | |
| fallback_error = f"Tavily search failed: {error}" | |
| try: | |
| return dumps_search_payload( | |
| _search_without_tavily( | |
| cleaned, | |
| max_results=max_results, | |
| fallback_error=fallback_error, | |
| ) | |
| ) | |
| except Exception: | |
| return dumps_search_payload( | |
| _empty_payload( | |
| cleaned, | |
| error=fallback_error, | |
| response_time=time.perf_counter() - started, | |
| ) | |
| ) | |
| try: | |
| return dumps_search_payload( | |
| _search_without_tavily(cleaned, max_results=max_results) | |
| ) | |
| except Exception as error: | |
| return dumps_search_payload( | |
| _empty_payload( | |
| cleaned, | |
| error=str(error), | |
| response_time=time.perf_counter() - started, | |
| ) | |
| ) | |
| def _empty_payload( | |
| query: str, | |
| *, | |
| error: str, | |
| response_time: float | None, | |
| ) -> dict[str, Any]: | |
| return { | |
| "query": query, | |
| "follow_up_questions": None, | |
| "answer": None, | |
| "images": [], | |
| "results": [{"error": error}], | |
| "response_time": response_time, | |
| "request_id": None, | |
| } | |
| def _search_tavily( | |
| query: str, | |
| *, | |
| api_key: str, | |
| max_results: int, | |
| ) -> dict[str, Any]: | |
| body = json.dumps( | |
| { | |
| "query": query, | |
| "max_results": max(1, min(int(max_results), 20)), | |
| "include_answer": True, | |
| "include_raw_content": False, | |
| "include_images": False, | |
| "search_depth": "basic", | |
| } | |
| ).encode("utf-8") | |
| payload = _read_json( | |
| TAVILY_SEARCH_URL, | |
| data=body, | |
| headers={ | |
| "Authorization": f"Bearer {api_key}", | |
| "Content-Type": "application/json", | |
| "Accept": "application/json", | |
| }, | |
| method="POST", | |
| ) | |
| if not isinstance(payload, dict): | |
| raise RuntimeError("Tavily returned a non-object payload") | |
| payload.setdefault("query", query) | |
| return payload | |
| def _search_without_tavily( | |
| query: str, | |
| *, | |
| max_results: int, | |
| fallback_error: str | None = None, | |
| ) -> dict[str, Any]: | |
| started = time.perf_counter() | |
| results: list[dict[str, Any]] = [] | |
| errors: list[str] = [] | |
| if fallback_error: | |
| errors.append(fallback_error) | |
| try: | |
| results.extend(_search_duckduckgo_html(query, max_results=max_results)) | |
| except Exception as error: | |
| errors.append(f"DuckDuckGo search failed: {error}") | |
| if len(results) < max_results: | |
| try: | |
| wiki = _search_wikipedia(query, max_results=max_results) | |
| seen = {item["url"] for item in results} | |
| for item in wiki: | |
| if item["url"] in seen: | |
| continue | |
| results.append(item) | |
| if len(results) >= max_results: | |
| break | |
| except Exception as error: | |
| errors.append(f"Wikipedia search failed: {error}") | |
| elapsed = time.perf_counter() - started | |
| if not results: | |
| message = "; ".join(errors) if errors else "Search returned no results" | |
| return _empty_payload(query, error=message, response_time=elapsed) | |
| clipped = results[: max(1, min(int(max_results), 20))] | |
| for index, item in enumerate(clipped): | |
| if item.get("score") is None: | |
| item["score"] = round(max(0.05, 1.0 - (index * 0.07)), 8) | |
| item.setdefault("raw_content", None) | |
| answer = clipped[0].get("content") if clipped else None | |
| if isinstance(answer, str) and not answer.strip(): | |
| answer = None | |
| return { | |
| "query": query, | |
| "follow_up_questions": None, | |
| "answer": answer, | |
| "images": [], | |
| "results": clipped, | |
| "response_time": elapsed, | |
| "request_id": None, | |
| } | |
| def _search_duckduckgo_html(query: str, *, max_results: int) -> list[dict[str, Any]]: | |
| encoded = urlencode({"q": query, "kl": "us-en"}) | |
| html = _read_text( | |
| DUCKDUCKGO_HTML_URL, | |
| data=encoded.encode("utf-8"), | |
| headers={ | |
| "Content-Type": "application/x-www-form-urlencoded", | |
| "Accept": "text/html", | |
| }, | |
| method="POST", | |
| ) | |
| parser = _DuckDuckGoParser() | |
| parser.feed(html) | |
| parser.close() | |
| results: list[dict[str, Any]] = [] | |
| seen: set[str] = set() | |
| for item in parser.results: | |
| url = item["url"] | |
| if not url or url in seen: | |
| continue | |
| seen.add(url) | |
| results.append( | |
| { | |
| "url": url, | |
| "title": item["title"], | |
| "content": item["content"], | |
| "score": None, | |
| "raw_content": None, | |
| } | |
| ) | |
| if len(results) >= max_results: | |
| break | |
| return results | |
| def _search_wikipedia(query: str, *, max_results: int) -> list[dict[str, Any]]: | |
| params = urlencode( | |
| { | |
| "action": "query", | |
| "list": "search", | |
| "srsearch": query, | |
| "srlimit": max(1, min(int(max_results), 10)), | |
| "srprop": "snippet", | |
| "format": "json", | |
| "utf8": "1", | |
| } | |
| ) | |
| payload = _read_json( | |
| f"{WIKIPEDIA_API_URL}?{params}", | |
| headers={"Accept": "application/json"}, | |
| method="GET", | |
| ) | |
| if not isinstance(payload, dict): | |
| return [] | |
| query_block = payload.get("query") | |
| hits = query_block.get("search") if isinstance(query_block, dict) else None | |
| if not isinstance(hits, list): | |
| return [] | |
| results: list[dict[str, Any]] = [] | |
| for hit in hits: | |
| if not isinstance(hit, dict): | |
| continue | |
| title = str(hit.get("title") or "").strip() | |
| if not title: | |
| continue | |
| snippet = unescape(_strip_tags(str(hit.get("snippet") or ""))).strip() | |
| results.append( | |
| { | |
| "url": f"https://en.wikipedia.org/wiki/{quote(title.replace(' ', '_'))}", | |
| "title": title, | |
| "content": snippet, | |
| "score": None, | |
| "raw_content": None, | |
| } | |
| ) | |
| return results | |
| def unwrap_ddg_url(href: str) -> str: | |
| raw = href.strip() | |
| if raw.startswith("//"): | |
| raw = f"https:{raw}" | |
| parsed = urlparse(raw) | |
| host = parsed.netloc.lower() | |
| if "duckduckgo.com" in host: | |
| values = parse_qs(parsed.query).get("uddg") or [] | |
| if values: | |
| return unquote(values[0]) | |
| return raw | |
| def _strip_tags(value: str) -> str: | |
| return _TagStripper.strip(value) | |
| class _TagStripper(HTMLParser): | |
| def __init__(self) -> None: | |
| super().__init__() | |
| self.chunks: list[str] = [] | |
| def handle_data(self, data: str) -> None: | |
| self.chunks.append(data) | |
| def strip(cls, value: str) -> str: | |
| parser = cls() | |
| parser.feed(value) | |
| parser.close() | |
| return "".join(parser.chunks) | |
| class _DuckDuckGoParser(HTMLParser): | |
| def __init__(self) -> None: | |
| super().__init__() | |
| self.results: list[dict[str, str]] = [] | |
| self._in_title = False | |
| self._in_snippet = False | |
| self._href = "" | |
| self._buffer: list[str] = [] | |
| self._pending: dict[str, str] | None = None | |
| def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: | |
| classes = {item for item in (dict(attrs).get("class") or "").split() if item} | |
| if tag == "a" and "result__a" in classes: | |
| self._flush_pending() | |
| self._in_title = True | |
| self._href = dict(attrs).get("href") or "" | |
| self._buffer = [] | |
| return | |
| if tag in {"a", "div"} and "result__snippet" in classes: | |
| self._in_snippet = True | |
| self._buffer = [] | |
| def handle_endtag(self, tag: str) -> None: | |
| if tag == "a" and self._in_title: | |
| title = unescape("".join(self._buffer)).strip() | |
| url = unwrap_ddg_url(self._href) | |
| self._pending = {"title": title, "url": url, "content": ""} | |
| self._in_title = False | |
| self._buffer = [] | |
| return | |
| if self._in_snippet and tag in {"a", "div"}: | |
| snippet = unescape("".join(self._buffer)).strip() | |
| if self._pending is not None: | |
| self._pending["content"] = snippet | |
| self.results.append(self._pending) | |
| self._pending = None | |
| self._in_snippet = False | |
| self._buffer = [] | |
| def handle_data(self, data: str) -> None: | |
| if self._in_title or self._in_snippet: | |
| self._buffer.append(data) | |
| def close(self) -> None: | |
| self._flush_pending() | |
| super().close() | |
| def _flush_pending(self) -> None: | |
| if self._pending is None: | |
| return | |
| self.results.append(self._pending) | |
| self._pending = None | |
| def _read_json( | |
| url: str, | |
| *, | |
| data: bytes | None = None, | |
| headers: Mapping[str, str], | |
| method: str, | |
| ) -> Any: | |
| text = _read_text(url, data=data, headers=headers, method=method) | |
| return json.loads(text) | |
| def _read_text( | |
| url: str, | |
| *, | |
| data: bytes | None = None, | |
| headers: Mapping[str, str], | |
| method: str, | |
| ) -> str: | |
| merged = {"User-Agent": USER_AGENT, **dict(headers)} | |
| request = Request(url, data=data, headers=merged, method=method) | |
| try: | |
| with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response: | |
| raw = response.read() | |
| charset = response.headers.get_content_charset() or "utf-8" | |
| except HTTPError as error: | |
| detail = error.read().decode("utf-8", errors="replace") | |
| raise RuntimeError( | |
| f"{error.code} {error.reason} from {url}: {detail[:500]}" | |
| ) from error | |
| except URLError as error: | |
| raise RuntimeError(f"Could not reach {url}: {error.reason}") from error | |
| return raw.decode(charset, errors="replace") | |