from __future__ import annotations import fnmatch import html import json import os import re import socket import time from functools import lru_cache from html.parser import HTMLParser from pathlib import Path from urllib.error import HTTPError, URLError from urllib.parse import parse_qs, urlparse from urllib.request import Request, urlopen _MAX_READ_CHARS = 4000 _MAX_GREP_MATCHES = 20 _MAX_GLOB_MATCHES = 50 _MAX_ORACLE_PAGE_CHARS = 24000 _MAX_ORACLE_CONTEXT_CHARS = 80000 DEFAULT_USER_AGENT = ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/135.0 Safari/537.36" ) DEFAULT_CUSTOM_SEARCH_URL = "http://apisix.westus2.cloudapp.azure.com/search_tool/search" DEFAULT_CUSTOM_SEARCH_AUTH_ENV = "OFFICEQA_CUSTOM_SEARCH_AUTH" DEFAULT_CUSTOM_SEARCH_PROVIDER = "duckduckgo" DEFAULT_CUSTOM_SEARCH_MAX_RESULTS = 4 DEFAULT_CUSTOM_SEARCH_TIMEOUT = 20 DEFAULT_CUSTOM_SEARCH_MAX_RETRIES = 4 DEFAULT_CUSTOM_SEARCH_INITIAL_BACKOFF_SECONDS = 1.0 def _normalize_data_dirs(data_dirs: list[str] | tuple[str, ...] | str | None, project_root: Path) -> list[str]: if data_dirs is None: return [] if isinstance(data_dirs, str): items = [part.strip() for chunk in data_dirs.split(os.pathsep) for part in chunk.split(",")] else: items = [str(item).strip() for item in data_dirs] resolved: list[str] = [] for item in items: if not item: continue path = Path(item).expanduser() if not path.is_absolute(): path = project_root / path resolved.append(str(path)) return resolved def resolve_docs_roots(data_dirs: list[str] | tuple[str, ...] | str | None = None) -> list[str]: project_root = Path(__file__).resolve().parents[3] env_value = os.environ.get("OFFICEQA_DOCS_DIR", "").strip() candidates = _normalize_data_dirs(data_dirs, project_root) candidates.extend(_normalize_data_dirs(env_value, project_root)) candidates.extend([ str(project_root / "data" / "officeqa_docs_official"), str(project_root / "data" / "officeqa_smoke_docs"), os.path.expanduser("~/officeqa-sparse/treasury_bulletins_parsed"), os.path.expanduser("~/officeqa/treasury_bulletins_parsed"), ]) roots: list[str] = [] seen: set[str] = set() for candidate in candidates: path = Path(candidate).expanduser() if not path.is_dir(): continue transformed = path / "transformed" resolved = str((transformed if transformed.is_dir() else path).resolve()) if resolved in seen: continue seen.add(resolved) roots.append(resolved) if not roots: raise FileNotFoundError("OfficeQA docs directory not found. Set OFFICEQA_DOCS_DIR or env.data_dirs.") return roots def _is_allowed(path: str, allowed_roots: list[str], allowed_files: list[str]) -> bool: try: resolved = str(Path(path).resolve()) except FileNotFoundError: return False if not any(resolved.startswith(root + os.sep) or resolved == root for root in allowed_roots): return False if not allowed_files: return True base = os.path.basename(resolved) return base in allowed_files def resolve_candidate_files(source_files: list[str], allowed_roots: list[str]) -> list[str]: resolved: list[str] = [] seen: set[str] = set() for root in allowed_roots: for dirpath, _, filenames in os.walk(root): for filename in filenames: if source_files and filename not in source_files: continue full = str(Path(dirpath, filename).resolve()) if full in seen: continue seen.add(full) resolved.append(full) return resolved def _as_list(value: object) -> list[str]: if value is None: return [] if isinstance(value, list): return [str(item).strip() for item in value if str(item).strip()] text = str(value).strip() if not text: return [] try: loaded = json.loads(text) except json.JSONDecodeError: loaded = None if isinstance(loaded, list): return [str(item).strip() for item in loaded if str(item).strip()] if "\n" in text: return [part.strip() for part in text.splitlines() if part.strip()] return [text] def _extract_page_number(source_doc: str) -> int | None: text = str(source_doc or "").strip() if not text: return None parsed = urlparse(text) query = parse_qs(parsed.query) for key in ("page", "pagenum", "page_id"): for raw_value in query.get(key, []): try: return int(str(raw_value).strip()) except ValueError: continue match = re.search(r"(?:[?&]|^)page=(\d+)", text) if match: return int(match.group(1)) return None def _iter_oracle_refs(source_files: object, source_docs: object) -> list[tuple[str, int, str]]: files = _as_list(source_files) docs = _as_list(source_docs) refs: list[tuple[str, int, str]] = [] seen: set[tuple[str, int, str]] = set() if not files or not docs: return refs for index, source_doc in enumerate(docs): page_number = _extract_page_number(source_doc) if page_number is None: continue if index < len(files): source_file = files[index] elif len(files) == 1: source_file = files[0] else: continue key = (source_file, page_number, source_doc) if key in seen: continue seen.add(key) refs.append(key) return refs def _parsed_root_candidates(docs_roots: list[str]) -> list[Path]: candidates: list[Path] = [] seen: set[str] = set() for root in docs_roots: path = Path(root).expanduser() for candidate in ( path, path.parent, path / "treasury_bulletins_parsed", path.parent / "treasury_bulletins_parsed", ): resolved = str(candidate.resolve()) if candidate.exists() else str(candidate) if resolved in seen: continue seen.add(resolved) candidates.append(candidate) return candidates def _locate_parsed_json(source_file: str, docs_roots: list[str]) -> Path | None: source_path = Path(str(source_file).strip()) stem = source_path.stem if source_path.suffix else source_path.name if not stem: return None candidate_names = [stem + ".json"] if source_path.suffix == ".json": candidate_names.insert(0, source_path.name) for root in _parsed_root_candidates(docs_roots): for name in candidate_names: path = root / "jsons" / name if path.is_file(): return path return None class _TableMarkdownParser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) self.rows: list[list[str]] = [] self._row: list[str] | None = None self._cell: list[str] | None = None def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: if tag.lower() == "tr": self._row = [] elif tag.lower() in {"td", "th"} and self._row is not None: self._cell = [] def handle_data(self, data: str) -> None: if self._cell is not None: self._cell.append(data) def handle_endtag(self, tag: str) -> None: normalized_tag = tag.lower() if normalized_tag in {"td", "th"} and self._cell is not None and self._row is not None: cell = re.sub(r"\s+", " ", "".join(self._cell)).strip() self._row.append(cell) self._cell = None elif normalized_tag == "tr" and self._row is not None: if any(cell for cell in self._row): self.rows.append(self._row) self._row = None self._cell = None def _escape_markdown_cell(value: str) -> str: return str(value).replace("\n", " ").replace("|", "\\|").strip() def _html_table_to_markdown(raw_html: str) -> str: parser = _TableMarkdownParser() try: parser.feed(raw_html) except Exception: # noqa: BLE001 parser.rows = [] rows = parser.rows if not rows: text = re.sub(r"(?is)<[^>]+>", " ", raw_html) return re.sub(r"\s+", " ", html.unescape(text)).strip() width = max(len(row) for row in rows) normalized_rows = [row + [""] * (width - len(row)) for row in rows] header = normalized_rows[0] body = normalized_rows[1:] lines = [ "| " + " | ".join(_escape_markdown_cell(cell) for cell in header) + " |", "| " + " | ".join(["---"] * width) + " |", ] lines.extend("| " + " | ".join(_escape_markdown_cell(cell) for cell in row) + " |" for row in body) return "\n".join(lines) def _render_parsed_content(content: str) -> str: text = content.strip() if not text: return "" if " set[int]: page_ids: set[int] = set() bbox = element.get("bbox") if not isinstance(bbox, list): return page_ids for box in bbox: if not isinstance(box, dict): continue raw_page_id = box.get("page_id") try: page_ids.add(int(raw_page_id)) except (TypeError, ValueError): continue return page_ids @lru_cache(maxsize=256) def _load_parsed_elements(json_path: str) -> tuple[dict, ...]: with open(json_path, encoding="utf-8") as f: payload = json.load(f) document = payload.get("document") if isinstance(payload, dict) else {} elements = document.get("elements") if isinstance(document, dict) else [] if not isinstance(elements, list): return () return tuple(element for element in elements if isinstance(element, dict)) @lru_cache(maxsize=2048) def _render_parsed_page(json_path: str, page_number: int) -> str: rendered: list[str] = [] for element in _load_parsed_elements(json_path): if page_number not in _element_page_ids(element): continue content = element.get("content") if not isinstance(content, str) or not content.strip(): continue section = _render_parsed_content(content) if section: rendered.append(section) return "\n\n".join(rendered).strip() def build_oracle_parsed_pages_context( source_files: object, source_docs: object, docs_roots: list[str], *, max_page_chars: int = _MAX_ORACLE_PAGE_CHARS, max_total_chars: int = _MAX_ORACLE_CONTEXT_CHARS, evidence_note: str = "Treat it as primary document evidence and combine it with custom web search results when useful.", ) -> str: """Render oracle parsed OfficeQA pages referenced by source_docs/source_files.""" refs = _iter_oracle_refs(source_files, source_docs) if not refs: return "" blocks: list[str] = [] total_chars = 0 seen_pages: set[tuple[str, int]] = set() for source_file, page_number, source_doc in refs: json_path = _locate_parsed_json(source_file, docs_roots) if json_path is None: continue page_key = (str(json_path), page_number) if page_key in seen_pages: continue seen_pages.add(page_key) page_text = _render_parsed_page(str(json_path), page_number) if not page_text: continue if len(page_text) > max_page_chars: omitted = len(page_text) - max_page_chars page_text = page_text[:max_page_chars].rstrip() + f"\n\n[... {omitted} characters omitted from this parsed page ...]" block = ( f"### {source_file} page {page_number}\n" f"Source URL: {source_doc}\n\n" f"{page_text}" ) if total_chars + len(block) > max_total_chars: remaining = max_total_chars - total_chars if remaining <= 0: break block = block[:remaining].rstrip() + "\n\n[... oracle parsed page context truncated ...]" blocks.append(block) break blocks.append(block) total_chars += len(block) if not blocks: return "" return ( "The following content is pre-parsed from the oracle OfficeQA source page(s). " f"{evidence_note.strip()}\n\n" + "\n\n".join(blocks) ) def _extract_search_items(payload: object) -> list[dict]: if isinstance(payload, list): return [item for item in payload if isinstance(item, dict)] if not isinstance(payload, dict): return [] candidate_keys = ( "results", "items", "data", "organic", "organic_results", "search_results", "webPages", "value", ) for key in candidate_keys: value = payload.get(key) if isinstance(value, list): return [item for item in value if isinstance(item, dict)] if isinstance(value, dict): nested = _extract_search_items(value) if nested: return nested return [] def _normalize_search_item(item: dict, index: int) -> str: title = str( item.get("title") or item.get("name") or item.get("headline") or item.get("source") or f"Result {index}" ).strip() url = str( item.get("url") or item.get("link") or item.get("href") or item.get("display_url") or "" ).strip() snippet = str( item.get("snippet") or item.get("description") or item.get("body") or item.get("text") or item.get("content") or "" ).strip() lines = [f"[{index}] {title}"] if url: lines.append(f"URL: {url}") if snippet: lines.append(f"Snippet: {snippet}") return "\n".join(lines) def _format_search_payload(query: str, payload: object) -> str: items = _extract_search_items(payload) header = f"Query: {query}" if not items: body = json.dumps(payload, ensure_ascii=False) if payload else "[no results]" return f"{header}\n{body}" rendered = [_normalize_search_item(item, index) for index, item in enumerate(items, start=1)] return f"{header}\n\n" + "\n\n".join(rendered) def _is_retryable_search_http_error(status_code: int) -> bool: return status_code in {408, 429} or status_code >= 500 def custom_search( query: str, *, api_url: str = DEFAULT_CUSTOM_SEARCH_URL, auth_token: str | None = None, auth_env: str = DEFAULT_CUSTOM_SEARCH_AUTH_ENV, provider: str = DEFAULT_CUSTOM_SEARCH_PROVIDER, max_num_results: int = DEFAULT_CUSTOM_SEARCH_MAX_RESULTS, timeout: int = DEFAULT_CUSTOM_SEARCH_TIMEOUT, max_retries: int = DEFAULT_CUSTOM_SEARCH_MAX_RETRIES, initial_backoff_seconds: float = DEFAULT_CUSTOM_SEARCH_INITIAL_BACKOFF_SECONDS, ) -> str: query = str(query or "").strip() if not query: raise ValueError("custom_search query must be non-empty") token = str(auth_token or os.environ.get(auth_env, "")).strip() if not token: raise ValueError(f"custom_search auth token missing; set {auth_env}") payload = json.dumps( { "query": query, "max_num_results": int(max_num_results), "provider": provider, }, ensure_ascii=False, ).encode("utf-8") req = Request( api_url, data=payload, headers={ "Authorization": token, "Content-Type": "application/json", "User-Agent": DEFAULT_USER_AGENT, }, method="POST", ) attempts = max(1, int(max_retries) + 1) last_error: RuntimeError | None = None for attempt in range(1, attempts + 1): try: with urlopen(req, timeout=timeout) as response: raw_body = response.read().decode("utf-8", errors="ignore") break except HTTPError as exc: detail = exc.read().decode("utf-8", errors="ignore") last_error = RuntimeError(f"custom_search HTTP {exc.code}: {detail[:1000]}") if attempt >= attempts or not _is_retryable_search_http_error(exc.code): raise last_error from exc except (URLError, TimeoutError, socket.timeout) as exc: last_error = RuntimeError(f"custom_search connection error: {exc}") if attempt >= attempts: raise last_error from exc backoff_seconds = max(0.0, float(initial_backoff_seconds)) * (2 ** (attempt - 1)) if backoff_seconds > 0: time.sleep(backoff_seconds) else: raise last_error or RuntimeError("custom_search failed without a captured error") try: parsed = json.loads(raw_body) except json.JSONDecodeError: return f"Query: {query}\n\n{raw_body.strip() or '[empty response]'}" return _format_search_payload(query, parsed) def run_tool(name: str, arguments: dict, *, allowed_roots: list[str], allowed_files: list[str]) -> tuple[str, str]: if name == "glob": pattern = str(arguments.get("pattern") or "*") matches: list[str] = [] for root in allowed_roots: for dirpath, _, filenames in os.walk(root): for filename in filenames: if allowed_files and filename not in allowed_files: continue rel = os.path.relpath(os.path.join(dirpath, filename), root) if fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(filename, pattern): matches.append(os.path.join(dirpath, filename)) if len(matches) >= _MAX_GLOB_MATCHES: break if len(matches) >= _MAX_GLOB_MATCHES: break return f"glob(pattern={pattern!r})", "\n".join(matches) if matches else "[no matches]" if name == "read": path = str(arguments.get("path") or "") if not path: return "read(path='')", "[read error: missing path]" if not _is_allowed(path, allowed_roots, allowed_files): return f"read(path={path!r})", "[read error: path not allowed]" start = max(int(arguments.get("start") or 1), 1) limit = max(int(arguments.get("limit") or 80), 1) with open(path, encoding="utf-8") as f: lines = f.readlines() excerpt = "".join(lines[start - 1:start - 1 + limit]) return f"read(path={path!r}, start={start}, limit={limit})", excerpt[:_MAX_READ_CHARS] or "[empty file]" if name == "grep": pattern = str(arguments.get("pattern") or "").lower() path = str(arguments.get("path") or "") if not pattern or not path: return f"grep(pattern={pattern!r}, path={path!r})", "[grep error: missing pattern or path]" if not _is_allowed(path, allowed_roots, allowed_files): return f"grep(pattern={pattern!r}, path={path!r})", "[grep error: path not allowed]" matches: list[str] = [] with open(path, encoding="utf-8") as f: for idx, line in enumerate(f, start=1): if pattern in line.lower(): matches.append(f"{idx}: {line.rstrip()}") if len(matches) >= _MAX_GREP_MATCHES: break return f"grep(pattern={pattern!r}, path={path!r})", "\n".join(matches) if matches else "[no matches]" return name, f"[tool error: unknown tool {name}]"