Spaces:
Sleeping
Sleeping
Download scripts/_agentic_helpers.py from bearda/croissantminer: direct link, hf CLI and curl.
- Browser
- Download file 15.9 kB
-
https://huggingface.co/spaces/bearda/croissantminer/resolve/main/scripts/_agentic_helpers.py
- Command line
-
hf download hf://spaces/bearda/croissantminer/scripts/_agentic_helpers.py
-
curl -L -o _agentic_helpers.py https://huggingface.co/spaces/bearda/croissantminer/resolve/main/scripts/_agentic_helpers.py
15.9 kB
| """Shared helpers for agentic pipelines (agentic_v2.py, agentic_lev.py). | |
| Keeps provider abstraction, prompt hashing, _meta provenance, PDF loading, | |
| and canonical output writing in one place so the two pipelines stay aligned | |
| with the single-call baseline in scripts/experiments/model_comparison/. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import os | |
| import re | |
| import subprocess | |
| import sys | |
| import time | |
| from datetime import datetime | |
| from pathlib import Path | |
| from typing import Any | |
| ROOT = Path(__file__).resolve().parent.parent | |
| if str(ROOT) not in sys.path: | |
| sys.path.insert(0, str(ROOT)) | |
| from config import SYSTEM_PROMPT # noqa: E402 | |
| from validation.validate_extraction import CANONICAL_FIELDS, validate_extraction # noqa: E402 | |
| # ββ Provenance constants (computed once at import) βββββββββββββββββ | |
| PROMPT_HASH = hashlib.sha256(SYSTEM_PROMPT.encode()).hexdigest()[:16] | |
| try: | |
| GIT_COMMIT = subprocess.check_output( | |
| ["git", "rev-parse", "--short", "HEAD"], | |
| stderr=subprocess.DEVNULL, | |
| cwd=str(ROOT), | |
| ).decode().strip() | |
| except Exception: | |
| GIT_COMMIT = "unknown" | |
| # ββ Backbone catalogue βββββββββββββββββββββββββββββββββββββββββββββ | |
| # Each entry is self-contained so the calling script only needs the key. | |
| # `output_slug` is used to build data/extractions/agentic_{v2,lev}_<slug>/. | |
| MODELS: dict[str, dict[str, Any]] = { | |
| "sonnet-4-5": { | |
| "name": "Claude Sonnet 4.5 (gold)", | |
| "model_id": "claude-sonnet-4-5-20250929", | |
| "provider": "anthropic", | |
| "output_slug": "sonnet_4_5", | |
| "input_price_per_mtok": 3.0, | |
| "output_price_per_mtok": 15.0, | |
| }, | |
| "gpt-5.4": { | |
| "name": "GPT-5.4 full", | |
| "model_id": "gpt-5.4-2026-03-05", | |
| "provider": "openai", | |
| "output_slug": "gpt5_4_full", | |
| "max_tokens_param": "max_completion_tokens", | |
| "input_price_per_mtok": 1.25, | |
| "output_price_per_mtok": 10.0, | |
| }, | |
| "gpt-5.4-mini": { | |
| "name": "GPT-5.4 mini", | |
| "model_id": "gpt-5.4-mini-2026-03-17", | |
| "provider": "openai", | |
| "output_slug": "gpt5_4_mini", | |
| "max_tokens_param": "max_completion_tokens", | |
| "input_price_per_mtok": 0.15, | |
| "output_price_per_mtok": 0.60, | |
| }, | |
| "sonnet-4-6": { | |
| "name": "Claude Sonnet 4.6", | |
| "model_id": "claude-sonnet-4-6", | |
| "provider": "anthropic", | |
| "output_slug": "sonnet_4_6", | |
| "input_price_per_mtok": 3.0, | |
| "output_price_per_mtok": 15.0, | |
| }, | |
| "gemini-3.1-pro": { | |
| "name": "Gemini 3.1 Pro Preview", | |
| "model_id": "gemini-3.1-pro-preview", | |
| "provider": "google", | |
| "output_slug": "gemini_3_1_pro", | |
| "input_price_per_mtok": 1.25, | |
| "output_price_per_mtok": 10.0, | |
| }, | |
| "llama-4-scout": { | |
| "name": "Llama 4 Scout 17B-16E (vLLM)", | |
| "model_id": "meta-llama/Llama-4-Scout-17B-16E-Instruct", | |
| "provider": "local_vllm", | |
| "output_slug": "llama_4_scout", | |
| "base_url": os.getenv("VLLM_BASE_URL", "http://localhost:8000/v1"), | |
| "api_key": os.getenv("VLLM_API_KEY", "EMPTY"), | |
| "input_price_per_mtok": 0.0, | |
| "output_price_per_mtok": 0.0, | |
| }, | |
| } | |
| def resolve_backbone(key: str) -> dict[str, Any]: | |
| if key not in MODELS: | |
| raise SystemExit( | |
| f"Unknown backbone '{key}'. Available: {sorted(MODELS)}" | |
| ) | |
| return MODELS[key] | |
| # ββ Text / JSON utilities βββββββββββββββββββββββββββββββββββββββββ | |
| def sanitize_utf8(text: str) -> str: | |
| """Drop lone UTF-16 surrogates PyPDF2 occasionally emits.""" | |
| return text.encode("utf-8", errors="replace").decode("utf-8") | |
| def parse_json_response(raw_text: str) -> dict: | |
| text = raw_text.strip() | |
| if "```json" in text: | |
| text = text.split("```json", 1)[1].split("```", 1)[0].strip() | |
| elif text.startswith("```"): | |
| text = text[3:] | |
| if text.endswith("```"): | |
| text = text[:-3] | |
| first = text.find("{") | |
| last = text.rfind("}") | |
| if first != -1 and last != -1: | |
| text = text[first:last + 1] | |
| # Strip unescaped ASCII control characters (JSON spec disallows them | |
| # inside strings). Models occasionally emit raw \x00-\x1f bytes, | |
| # which json.loads rejects. Whitelist \t, \n, \r since those are | |
| # valid JSON whitespace at structural positions. | |
| text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text) | |
| return json.loads(text.strip()) | |
| _PREFIX_MAP = { | |
| "sc:name": "name", "sc:description": "description", "sc:url": "url", | |
| "sc:license": "license", "sc:creator": "creator", "sc:publisher": "publisher", | |
| "sc:datePublished": "datePublished", "sc:inLanguage": "inLanguage", | |
| "cr:citeAs": "citeAs", "cr:isLiveDataset": "isLiveDataset", | |
| "sc:citeAs": "citeAs", "sc:isLiveDataset": "isLiveDataset", | |
| "cr:name": "name", "cr:description": "description", | |
| } | |
| def normalize_field_names(metadata: dict) -> dict: | |
| """Strip sc:/cr: prefixes the RAI fields don't carry.""" | |
| return {_PREFIX_MAP.get(k, k): v for k, v in metadata.items()} | |
| def fill_canonical(metadata: dict) -> dict: | |
| """Ensure all 30 canonical keys exist (null where missing).""" | |
| for field in CANONICAL_FIELDS: | |
| metadata.setdefault(field, None) | |
| return metadata | |
| # ββ PDF loading βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_paper_text(ds_id: str) -> str | None: | |
| """Load + clean + sanitize paper text. Mirrors extract_all_models.py.""" | |
| from croissantminer.pdf.reader import extract_text_from_pdf as _extract | |
| from croissantminer.pdf.processor import clean_text as _clean | |
| raw_dir = ROOT / "data" / "raw" | |
| candidates = [ | |
| raw_dir / f"{ds_id}.pdf", | |
| raw_dir / f"{ds_id.replace('_', '/')}.pdf", | |
| ] | |
| links_path = ROOT / "data" / "paper_links.json" | |
| if links_path.exists(): | |
| with open(links_path) as f: | |
| links = json.load(f) | |
| match = re.search(r"(\d{4}\.\d{4,5})", links.get(ds_id, "")) | |
| if match: | |
| arxiv_id = match.group(1) | |
| for suffix in ("", "v1", "v2", "v3", "v4", "v5"): | |
| candidates.append(raw_dir / f"{arxiv_id}{suffix}.pdf") | |
| for path in candidates: | |
| if path.exists(): | |
| return sanitize_utf8(_clean(_extract(str(path)))) | |
| return None | |
| def strip_references(text: str) -> str: | |
| """Cut off the references section (anywhere past the paper midpoint).""" | |
| patterns = ( | |
| r"\n\s*References\s*\n", | |
| r"\n\s*REFERENCES\s*\n", | |
| r"\n\s*Bibliography\s*\n", | |
| ) | |
| for pattern in patterns: | |
| match = re.search(pattern, text) | |
| if match and match.start() > len(text) * 0.5: | |
| return text[: match.start()] | |
| return text | |
| # ββ Provider-agnostic LLM call ββββββββββββββββββββββββββββββββββββ | |
| def _call_anthropic(cfg, system_prompt, user_content, max_tokens): | |
| import anthropic | |
| client = anthropic.Anthropic() | |
| kwargs = { | |
| "model": cfg["model_id"], | |
| "max_tokens": max_tokens, | |
| "system": system_prompt, | |
| "messages": [{"role": "user", "content": user_content}], | |
| } | |
| if not cfg.get("skip_temperature"): | |
| kwargs["temperature"] = 0.0 | |
| response = client.messages.create(**kwargs) | |
| raw = "" | |
| for block in response.content: | |
| if getattr(block, "type", None) == "text": | |
| raw += block.text | |
| elif hasattr(block, "text"): | |
| raw += block.text | |
| usage = { | |
| "input_tokens": response.usage.input_tokens, | |
| "output_tokens": response.usage.output_tokens, | |
| } | |
| return raw, usage | |
| def _call_openai(cfg, system_prompt, user_content, max_tokens, base_url=None, api_key=None): | |
| from openai import OpenAI | |
| kwargs = {} | |
| if base_url: | |
| kwargs["base_url"] = base_url | |
| if api_key: | |
| kwargs["api_key"] = api_key | |
| client = OpenAI(**kwargs) | |
| params = { | |
| "model": cfg["model_id"], | |
| "temperature": 0.0, | |
| "messages": [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_content}, | |
| ], | |
| } | |
| params[cfg.get("max_tokens_param", "max_completion_tokens")] = max_tokens | |
| response = client.chat.completions.create(**params) | |
| raw = response.choices[0].message.content or "" | |
| usage = { | |
| "input_tokens": response.usage.prompt_tokens, | |
| "output_tokens": response.usage.completion_tokens, | |
| } | |
| return raw, usage | |
| _OPENROUTER_MODEL_MAP = { | |
| "gemini-3.1-pro-preview": "google/gemini-3.1-pro-preview", | |
| "gemini-2.5-pro": "google/gemini-2.5-pro", | |
| } | |
| def _call_via_openrouter(cfg, system_prompt, user_content, max_tokens): | |
| """OpenAI-compatible call against OpenRouter, used to bypass Google's | |
| 250-RPD AI-Studio cap on Gemini 3.1 Pro preview. Same model, different | |
| routing pool. Pricier per token but no daily request cap.""" | |
| from openai import OpenAI | |
| api_key = os.environ["OPENROUTER_API_KEY"] | |
| model_id = _OPENROUTER_MODEL_MAP.get(cfg["model_id"], cfg["model_id"]) | |
| client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=api_key) | |
| response = client.chat.completions.create( | |
| model=model_id, | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_content}, | |
| ], | |
| temperature=0.0, | |
| max_tokens=max_tokens, | |
| ) | |
| raw = response.choices[0].message.content or "" | |
| usage = { | |
| "input_tokens": response.usage.prompt_tokens, | |
| "output_tokens": response.usage.completion_tokens, | |
| } | |
| return raw, usage | |
| def _call_google(cfg, system_prompt, user_content, max_tokens): | |
| import requests | |
| # 2026-05-28: route via OpenRouter when OPENROUTER_API_KEY is set, to | |
| # bypass Google AI Studio's 250 RPD cap on gemini-3.1-pro. Same model, | |
| # OpenAI-compatible endpoint; ~60% pricier per token than direct. | |
| if os.getenv("OPENROUTER_API_KEY"): | |
| return _call_via_openrouter(cfg, system_prompt, user_content, max_tokens) | |
| api_key = os.getenv("GEMINI_API_KEY") | |
| url = f"https://generativelanguage.googleapis.com/v1beta/models/{cfg['model_id']}:generateContent" | |
| gen_config = {"temperature": 0.0, "maxOutputTokens": max_tokens} | |
| # Gemini 3.x burns most output budget on internal reasoning before | |
| # emitting JSON, truncating extraction mid-field. Set thinkingLevel | |
| # to "low" so the budget goes to the actual JSON. Gemini 2.5 (and | |
| # earlier) reject thinkingLevel, so apply only to 3.x. | |
| if cfg.get("model_id", "").startswith("gemini-3"): | |
| gen_config["thinkingConfig"] = {"thinkingLevel": "low"} | |
| payload = { | |
| "contents": [{"parts": [{"text": user_content}]}], | |
| "systemInstruction": {"parts": [{"text": system_prompt}]}, | |
| "generationConfig": gen_config, | |
| } | |
| resp = requests.post(url, params={"key": api_key}, json=payload, timeout=180) | |
| if resp.status_code != 200: | |
| raise RuntimeError(f"Gemini API error {resp.status_code}: {resp.text[:300]}") | |
| data = resp.json() | |
| try: | |
| raw = data["candidates"][0]["content"]["parts"][0]["text"] | |
| except (KeyError, IndexError): | |
| raise RuntimeError(f"Unexpected Gemini response: {json.dumps(data)[:300]}") | |
| usage_meta = data.get("usageMetadata", {}) | |
| usage = { | |
| "input_tokens": usage_meta.get("promptTokenCount", 0), | |
| "output_tokens": usage_meta.get("candidatesTokenCount", 0), | |
| } | |
| return raw, usage | |
| def call_llm( | |
| cfg: dict, | |
| system_prompt: str, | |
| user_content: str, | |
| max_tokens: int = 8192, | |
| retries: int = 3, | |
| ) -> tuple[str, dict]: | |
| """Provider-agnostic call with retry on rate limits / 5xx. | |
| Returns (raw_text, usage). Parsing is left to the caller so each phase | |
| can handle its own schema. | |
| """ | |
| provider = cfg["provider"] | |
| backoffs = [5, 15, 45] | |
| last_exc: Exception | None = None | |
| for attempt in range(retries): | |
| try: | |
| if provider == "anthropic": | |
| return _call_anthropic(cfg, system_prompt, user_content, max_tokens) | |
| if provider == "openai": | |
| return _call_openai(cfg, system_prompt, user_content, max_tokens) | |
| if provider == "google": | |
| return _call_google(cfg, system_prompt, user_content, max_tokens) | |
| if provider == "local_vllm": | |
| return _call_openai( | |
| cfg, system_prompt, user_content, max_tokens, | |
| base_url=cfg.get("base_url"), | |
| api_key=cfg.get("api_key") or "EMPTY", | |
| ) | |
| raise ValueError(f"Unknown provider: {provider}") | |
| except Exception as exc: # noqa: BLE001 β retry on anything transient | |
| last_exc = exc | |
| msg = str(exc).lower() | |
| retryable = ( | |
| "rate" in msg or "429" in msg or "timeout" in msg | |
| or str(getattr(exc, "status_code", "")).startswith("5") or "overloaded" in msg | |
| ) | |
| if attempt == retries - 1 or not retryable: | |
| raise | |
| wait = backoffs[attempt] if attempt < len(backoffs) else 60 | |
| time.sleep(wait) | |
| raise last_exc # unreachable but keeps type-checkers quiet | |
| def estimate_cost(cfg: dict, usage: dict, batch_discount: bool = False) -> float: | |
| mult = 0.5 if batch_discount else 1.0 | |
| inp = usage.get("input_tokens", 0) / 1e6 * cfg["input_price_per_mtok"] * mult | |
| out = usage.get("output_tokens", 0) / 1e6 * cfg["output_price_per_mtok"] * mult | |
| return round(inp + out, 4) | |
| # ββ Canonical output ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def meta_block( | |
| cfg: dict, | |
| script: str, | |
| mode: str, | |
| paper_set: str = "102_dev_test_split", | |
| extra: dict | None = None, | |
| ) -> dict: | |
| block = { | |
| "parser": "pypdf2", | |
| "parser_version": "3.0.1", | |
| "prompt_sha256_prefix": PROMPT_HASH, | |
| "prompt_chars": len(SYSTEM_PROMPT), | |
| "model_id": cfg["model_id"], | |
| "provider": cfg["provider"], | |
| "temperature": None if cfg.get("skip_temperature") else 0.0, | |
| "timestamp": datetime.utcnow().isoformat() + "Z", | |
| "git_commit": GIT_COMMIT, | |
| "paper_set": paper_set, | |
| "script": script, | |
| "mode": mode, | |
| } | |
| if extra: | |
| block.update(extra) | |
| return block | |
| def write_canonical_output( | |
| out_dir: Path, | |
| ds_id: str, | |
| extraction: dict, | |
| usage: dict, | |
| cfg: dict, | |
| meta: dict, | |
| ) -> Path: | |
| """Write one paper's extraction in the canonical {dataset_id, model, | |
| extraction, usage, valid, _meta} schema. Returns the output path.""" | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| safe_id = ds_id.replace("/", "__") | |
| path = out_dir / f"{safe_id}.json" | |
| extraction = normalize_field_names(extraction) | |
| extraction = fill_canonical(extraction) | |
| is_valid, errors = validate_extraction(extraction, ds_id) | |
| payload = { | |
| "dataset_id": ds_id, | |
| "model": cfg["model_id"], | |
| "extraction": extraction, | |
| "usage": usage, | |
| "valid": is_valid, | |
| "_meta": meta, | |
| } | |
| if not is_valid: | |
| payload["validation_errors"] = errors | |
| with open(path, "w") as f: | |
| json.dump(payload, f, indent=2, ensure_ascii=False) | |
| return path | |
| def output_dir_for(pipeline: str, backbone_key: str) -> Path: | |
| """Canonical output directory per pipeline/backbone pair.""" | |
| cfg = resolve_backbone(backbone_key) | |
| return ROOT / "data" / "extractions" / f"agentic_{pipeline}_{cfg['output_slug']}" | |