croissantminer / scripts /_agentic_helpers.py
bearda's picture
Update to paper systems
394445e verified
Raw History Blame Contribute Delete
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']}"