Spaces:
Sleeping
Sleeping
Download scripts/agentic_v2.py from bearda/croissantminer: direct link, hf CLI and curl.
- Browser
- Download file 41.1 kB
-
https://huggingface.co/spaces/bearda/croissantminer/resolve/main/scripts/agentic_v2.py
- Command line
-
hf download hf://spaces/bearda/croissantminer/scripts/agentic_v2.py
-
curl -L -o agentic_v2.py https://huggingface.co/spaces/bearda/croissantminer/resolve/main/scripts/agentic_v2.py
41.1 kB
| #!/usr/bin/env python3 | |
| """CroissantMiner Agentic V2 pipeline. | |
| Phases: | |
| 0. PDF parse (PyPDF2 + canonical clean_text + UTF-8 sanitize). | |
| 1. Load Gemini Flash triage (from data/agentic/phase1/<ds_id>.json). | |
| 2. Full-context extraction that also asks for evidence quotes per field. | |
| 2.5 Self-correction pass over fields that are null but triage says present. | |
| 3. Cross-document enrichment (HuggingFace + Semantic Scholar) for gaps. | |
| 4. Schema validation + confidence scoring. | |
| 5. Write canonical output to data/extractions/agentic_v2_<backbone_slug>/. | |
| Output schema (canonical): | |
| {"dataset_id", "model", "extraction" (flat 30 fields), "usage", "valid", | |
| "_meta": {provenance, pipeline="agentic_v2", extraction_details: {field: | |
| {confidence, status, source, evidence}}, paper_stats, ...}} | |
| Usage: | |
| python scripts/agentic_v2.py --backbone sonnet-4-5 | |
| python scripts/agentic_v2.py --backbone gpt-5.4 --dev-only | |
| python scripts/agentic_v2.py --backbone sonnet-4-5 --batch # Anthropic only | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import re | |
| import time | |
| import urllib.parse | |
| import urllib.request | |
| from pathlib import Path | |
| from dotenv import load_dotenv | |
| from _agentic_helpers import ( | |
| ROOT, | |
| SYSTEM_PROMPT, | |
| CANONICAL_FIELDS, | |
| call_llm, | |
| estimate_cost, | |
| fill_canonical, | |
| get_paper_text, | |
| meta_block, | |
| normalize_field_names, | |
| output_dir_for, | |
| parse_json_response, | |
| resolve_backbone, | |
| strip_references, | |
| ) | |
| load_dotenv(ROOT / ".env") | |
| MAX_PAPER_CHARS = 200_000 | |
| MAX_TOKENS_EXTRACT = 16384 # was 8192; bumped to clear Gemini 3.x truncation failures (2026-04-30) | |
| MAX_TOKENS_CORRECT = 8192 # was 4096; bumped proportionally | |
| SCRIPT_NAME = "scripts/agentic_v2.py" | |
| PHASE1_DIR = ROOT / "data" / "agentic" / "phase1" | |
| with open(ROOT / "data" / "agentic" / "dev_test_split.json") as f: | |
| SPLIT = json.load(f) | |
| # ββ Field groups (used by triage + self-correction) βββββββββββββββ | |
| EASY_FIELDS = [ | |
| "name", "description", "url", "license", "creator", | |
| "publisher", "datePublished", "inLanguage", "citeAs", "isLiveDataset", | |
| ] | |
| HARD_FIELDS = [ | |
| "rai:dataCollection", "rai:dataCollectionType", "rai:dataCollectionMissingData", | |
| "rai:dataCollectionRawData", "rai:dataCollectionTimeframe", | |
| "rai:dataImputationProtocol", "rai:dataManipulationProtocol", | |
| "rai:dataPreprocessingProtocol", "rai:dataAnnotationProtocol", | |
| "rai:dataAnnotationPlatform", "rai:dataAnnotationAnalysis", | |
| "rai:annotationsPerItem", "rai:annotatorDemographics", | |
| "rai:machineAnnotationTools", "rai:dataReleaseMaintenancePlan", | |
| "rai:personalSensitiveInformation", "rai:dataSocialImpact", | |
| "rai:dataBiases", "rai:dataLimitations", "rai:dataUseCases", | |
| ] | |
| ALL_FIELDS = EASY_FIELDS + HARD_FIELDS | |
| TRIAGE_GROUPS = { | |
| "G3_collection": [ | |
| "rai:dataCollection", "rai:dataCollectionType", | |
| "rai:dataCollectionMissingData", "rai:dataCollectionRawData", | |
| "rai:dataCollectionTimeframe", | |
| ], | |
| "G4_annotation": [ | |
| "rai:dataAnnotationProtocol", "rai:dataAnnotationPlatform", | |
| "rai:dataAnnotationAnalysis", "rai:annotationsPerItem", | |
| "rai:annotatorDemographics", "rai:machineAnnotationTools", | |
| ], | |
| "G5_rai": [ | |
| "rai:dataBiases", "rai:dataLimitations", "rai:dataSocialImpact", | |
| "rai:personalSensitiveInformation", "rai:dataUseCases", | |
| "rai:dataReleaseMaintenancePlan", | |
| ], | |
| "G6_processing": [ | |
| "rai:dataImputationProtocol", "rai:dataManipulationProtocol", | |
| "rai:dataPreprocessingProtocol", | |
| ], | |
| } | |
| # ββ V2-specific prompt (asks for {value, evidence} per field) βββββ | |
| USER_PROMPT_WITH_EVIDENCE = '''Extract metadata from the following academic paper by matching text to the field descriptions above. | |
| For each non-null field, also provide a brief "evidence" quote: the key sentence(s) from the paper that support your extraction. This helps verify accuracy. | |
| Your output must conform exactly to the following schema. For each field, provide "value" and "evidence": | |
| SCHEMA: | |
| { | |
| "name": {"value": "string", "evidence": "quote from paper"}, | |
| "description": {"value": "string", "evidence": "quote from paper"}, | |
| "url": {"value": "string", "evidence": "quote from paper"}, | |
| "license": {"value": "string or null", "evidence": "quote or null"}, | |
| "creator": {"value": "string", "evidence": "quote from paper"}, | |
| "publisher": {"value": "string or null", "evidence": "quote or null"}, | |
| "datePublished": {"value": "string (YYYY or YYYY-MM-DD)", "evidence": "quote from paper"}, | |
| "inLanguage": {"value": "string (ISO codes)", "evidence": "quote or null"}, | |
| "citeAs": {"value": "string or null", "evidence": "quote or null"}, | |
| "isLiveDataset": {"value": "Yes/No or null", "evidence": "quote or null"}, | |
| "rai:dataCollection": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:dataCollectionType": {"value": "string (from recommended values) or null", "evidence": "quote or null"}, | |
| "rai:dataCollectionMissingData": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:dataCollectionRawData": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:dataCollectionTimeframe": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:dataImputationProtocol": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:dataManipulationProtocol": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:dataPreprocessingProtocol": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:dataAnnotationProtocol": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:dataAnnotationPlatform": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:dataAnnotationAnalysis": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:annotationsPerItem": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:annotatorDemographics": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:machineAnnotationTools": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:dataReleaseMaintenancePlan": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:personalSensitiveInformation": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:dataSocialImpact": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:dataBiases": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:dataLimitations": {"value": "string or null", "evidence": "quote or null"}, | |
| "rai:dataUseCases": {"value": "string or null", "evidence": "quote or null"} | |
| } | |
| For null fields, use: {"value": null, "evidence": null} | |
| PAPER TEXT: | |
| %s | |
| Return ONLY valid JSON matching the schema above. No markdown, no explanations.''' | |
| CORRECTION_PROMPT_TEMPLATE = """You previously extracted metadata from this paper but returned null for the following fields: | |
| {null_fields} | |
| The paper likely discusses these topics based on keyword analysis. Please re-read the paper carefully and try to extract these specific fields. If the information is genuinely not in the paper, return null. | |
| Return ONLY valid JSON with just these keys: {field_keys} | |
| PAPER TEXT: | |
| {paper_text}""" | |
| # ββ Prompt variant v2: anti-null-bias with few-shot null examples β | |
| # Phase 3 Fix A from the 2026-05-02 V2/LEV iteration plan. | |
| # Targets failure mode F1 (null-gold hallucination, ~70% of round-0 score=3 cells). | |
| USER_PROMPT_WITH_EVIDENCE_V2_PREFIX = '''CRITICAL β DEFAULT TO NULL WHEN UNCERTAIN. | |
| Many Croissant RAI fields are rarely documented in dataset papers. The | |
| following fields are MOST OFTEN null and you should default to null | |
| unless the paper has an explicit discussion you can quote: | |
| - rai:dataImputationProtocol (most papers have no missing data) | |
| - rai:personalSensitiveInformation (only if paper explicitly lists collected attributes) | |
| - rai:dataAnnotationPlatform (only if a specific platform is named: MTurk, Label Studio, etc.) | |
| - rai:dataReleaseMaintenancePlan (only if paper discusses versioning or update cadence) | |
| - rai:annotationsPerItem (only if a specific count is stated) | |
| - rai:annotatorDemographics (only if demographics are explicitly described) | |
| - rai:dataSocialImpact (only if a discussion of social implications exists) | |
| - rai:machineAnnotationTools (only if specific software tools are named) | |
| - rai:dataManipulationProtocol (only if explicit post-preprocessing transforms are described) | |
| RULES: | |
| 1. DO NOT generate plausible-sounding content based on what a similar | |
| paper might say. If THIS paper does not discuss it, return null. | |
| 2. DO NOT infer from related content. A paper discussing biases does | |
| NOT automatically document rai:personalSensitiveInformation. | |
| 3. EVERY non-null extraction MUST have an evidence quote that is a | |
| near-verbatim sentence from the paper. If you cannot quote a | |
| sentence supporting your extraction, the value should be null. | |
| EXAMPLES: | |
| EXAMPLE A β paper does NOT discuss the field, so the answer is null: | |
| Paper text: "We crowdsourced 10K questions and verified accuracy..." | |
| Field: rai:dataAnnotationPlatform | |
| CORRECT output: {"value": null, "evidence": null} | |
| WRONG output: {"value": "Amazon Mechanical Turk", "evidence": "..."} | |
| (wrong because paper says "crowdsourced" but never names MTurk) | |
| EXAMPLE B β paper DOES discuss the field, so extract with evidence: | |
| Paper text: "Annotations were collected via Amazon Mechanical Turk with 3 | |
| workers per item. Each worker had >97%% HIT acceptance rate." | |
| Field: rai:dataAnnotationPlatform | |
| CORRECT output: {"value": "Amazon Mechanical Turk", "evidence": | |
| "Annotations were collected via Amazon Mechanical Turk..."} | |
| EXAMPLE C β paper has no missing data, so dataImputationProtocol is null: | |
| Paper text: "Our dataset contains 50K complete examples, no | |
| preprocessing was needed beyond normalization." | |
| Field: rai:dataImputationProtocol | |
| CORRECT output: {"value": null, "evidence": null} | |
| WRONG output: extracting "data was filled using mean imputation" | |
| (would be hallucinated; paper says nothing about imputation) | |
| Now extract metadata from the paper below, applying these rules strictly. | |
| ''' | |
| USER_PROMPT_WITH_EVIDENCE_V2 = USER_PROMPT_WITH_EVIDENCE_V2_PREFIX + USER_PROMPT_WITH_EVIDENCE | |
| # Prompt variant v5: v4 (Fix A + Fix C) + Fix D (multi-aspect emphasis). | |
| # Phase 3 iteration #4 of the V2/LEV plan, on top of accepted v4. | |
| USER_PROMPT_WITH_EVIDENCE_V5_PREFIX = '''ADDITIONAL GUIDANCE FOR MULTI-ASPECT FIELDS | |
| Several Croissant fields capture MULTIPLE aspects of a single concept. | |
| When the paper discusses multiple aspects, be EXHAUSTIVE and list them ALL, | |
| not just the primary or most prominent one. | |
| - rai:dataLimitations: capture ALL discussed limitations | |
| (temporal, geographic, quality, scope, generalization, exclusion of specific | |
| cases, modality, etc.). If the paper mentions multiple types of | |
| limitations, include ALL of them. | |
| - rai:dataBiases: capture ALL discussed biases (selection, demographic, | |
| contamination, source-medium, stylistic, evaluation, etc.). | |
| - rai:dataUseCases: list ALL stated use cases (training, testing, validation, | |
| fine-tuning, benchmarking, evaluation). | |
| - rai:dataCollectionType: list ALL applicable types from the controlled | |
| vocabulary. Many datasets combine 2 or 3 types (e.g., "Web Scraping" + | |
| "Manual Human Curator"). Pick ALL that apply. | |
| Format: use " | " separator OR continuous text covering all aspects. | |
| Do not omit aspects to keep the answer brief. | |
| ''' | |
| USER_PROMPT_WITH_EVIDENCE_V5 = ( | |
| USER_PROMPT_WITH_EVIDENCE_V2_PREFIX | |
| + USER_PROMPT_WITH_EVIDENCE_V5_PREFIX | |
| + USER_PROMPT_WITH_EVIDENCE | |
| ) | |
| LICENSE_MAP = { | |
| "cc-by-4.0": "CC-BY-4.0", "cc by 4.0": "CC-BY-4.0", | |
| "cc-by-sa-4.0": "CC-BY-SA-4.0", "cc by-sa 4.0": "CC-BY-SA-4.0", | |
| "cc-by-nc-4.0": "CC-BY-NC-4.0", "cc by-nc 4.0": "CC-BY-NC-4.0", | |
| "cc-by-nc-sa-4.0": "CC-BY-NC-SA-4.0", | |
| "cc0-1.0": "CC0-1.0", "cc0": "CC0-1.0", "public domain": "CC0-1.0", | |
| "mit": "MIT", "mit license": "MIT", | |
| "apache-2.0": "Apache-2.0", "apache 2.0": "Apache-2.0", | |
| } | |
| # ββ Phase 0: PDF text βββββββββββββββββββββββββββββββββββββββββββββ | |
| def extract_paper_text(ds_id: str) -> str | None: | |
| text = get_paper_text(ds_id) | |
| if text is None: | |
| return None | |
| text = strip_references(text).strip() | |
| if len(text) > MAX_PAPER_CHARS: | |
| text = text[:MAX_PAPER_CHARS] | |
| return text | |
| # ββ Phase 1: triage βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_triage(ds_id: str) -> dict: | |
| path = PHASE1_DIR / f"{ds_id}.json" | |
| if not path.exists(): | |
| return {} | |
| with open(path) as f: | |
| return json.load(f).get("triage", {}) | |
| # ββ Phase 2: extract + evidence βββββββββββββββββββββββββββββββββββ | |
| def extract_phase2(cfg, paper_text, with_evidence=True, prompt_variant="v1"): | |
| if with_evidence: | |
| # v2 / v4 / v6 use the anti-null-bias prompt prefix. | |
| # v5 adds multi-aspect enumeration on top of v2's prefix. | |
| # v6 uses v4's prompt but stricter post-hoc verification (in process_paper). | |
| if prompt_variant == "v5": | |
| user_content = USER_PROMPT_WITH_EVIDENCE_V5 % paper_text | |
| elif prompt_variant in ("v2", "v4", "v6"): | |
| user_content = USER_PROMPT_WITH_EVIDENCE_V2 % paper_text | |
| else: | |
| user_content = USER_PROMPT_WITH_EVIDENCE % paper_text | |
| raw, usage = call_llm(cfg, SYSTEM_PROMPT, user_content, MAX_TOKENS_EXTRACT) | |
| parsed = parse_json_response(raw) | |
| extraction: dict = {} | |
| evidence: dict = {} | |
| for field in ALL_FIELDS: | |
| entry = parsed.get(field, {}) | |
| if isinstance(entry, dict): | |
| extraction[field] = entry.get("value") | |
| evidence[field] = entry.get("evidence") | |
| else: | |
| extraction[field] = entry | |
| evidence[field] = None | |
| return extraction, evidence, usage | |
| # Ablation baseline: reuse single-pass user prompt (flat schema) | |
| from config import USER_PROMPT_TEMPLATE | |
| user_content = USER_PROMPT_TEMPLATE % paper_text | |
| raw, usage = call_llm(cfg, SYSTEM_PROMPT, user_content, MAX_TOKENS_EXTRACT) | |
| parsed = normalize_field_names(parse_json_response(raw)) | |
| return parsed, {f: None for f in ALL_FIELDS}, usage | |
| # ββ Phase 2.5: self-correction ββββββββββββββββββββββββββββββββββββ | |
| def _is_null(val) -> bool: | |
| if val is None: | |
| return True | |
| if isinstance(val, str) and val.strip().lower() in ("", "null", "none"): | |
| return True | |
| return False | |
| def find_null_but_expected(extraction: dict, triage: dict) -> list[str]: | |
| missing = [] | |
| for field in ALL_FIELDS: | |
| if not _is_null(extraction.get(field)): | |
| continue | |
| if field in EASY_FIELDS: | |
| missing.append(field) | |
| continue | |
| for group_name, group_fields in TRIAGE_GROUPS.items(): | |
| if field not in group_fields: | |
| continue | |
| info = triage.get(group_name, {}) | |
| if info.get("presence") in ("likely", "certain"): | |
| missing.append(field) | |
| break | |
| return missing | |
| def self_correct(cfg, paper_text, extraction, triage): | |
| null_fields = find_null_but_expected(extraction, triage) | |
| if not null_fields: | |
| return extraction, 0, {"input_tokens": 0, "output_tokens": 0} | |
| prompt = CORRECTION_PROMPT_TEMPLATE.format( | |
| null_fields="\n".join(f"- {f}" for f in null_fields), | |
| field_keys=", ".join(null_fields), | |
| paper_text=paper_text, | |
| ) | |
| try: | |
| raw, usage = call_llm(cfg, SYSTEM_PROMPT, prompt, MAX_TOKENS_CORRECT) | |
| corrected = parse_json_response(raw) | |
| except Exception as e: | |
| print(f" Self-correction failed: {str(e)[:80]}") | |
| return extraction, 0, {"input_tokens": 0, "output_tokens": 0} | |
| applied = 0 | |
| for field in null_fields: | |
| val = corrected.get(field) | |
| if not _is_null(val): | |
| extraction[field] = val | |
| applied += 1 | |
| return extraction, applied, usage | |
| # ββ Phase 3: cross-document enrichment ββββββββββββββββββββββββββββ | |
| def _enrich_huggingface(ds_id: str, extraction: dict) -> dict: | |
| enrichments: dict = {} | |
| hf_id = ds_id.replace("_", "/", 1) | |
| try: | |
| url = f"https://huggingface.co/api/datasets/{hf_id}" | |
| req = urllib.request.Request(url, headers={"User-Agent": "CroissantMiner/2.0"}) | |
| resp = urllib.request.urlopen(req, timeout=10) | |
| data = json.loads(resp.read()) | |
| card = data.get("cardData", {}) or {} | |
| if _is_null(extraction.get("license")): | |
| lic = card.get("license") | |
| if lic: | |
| enrichments["license"] = lic | |
| if _is_null(extraction.get("url")): | |
| enrichments["url"] = f"https://huggingface.co/datasets/{hf_id}" | |
| if _is_null(extraction.get("inLanguage")): | |
| lang = card.get("language") | |
| if lang: | |
| enrichments["inLanguage"] = ", ".join(lang) if isinstance(lang, list) else str(lang) | |
| except Exception: | |
| pass | |
| return enrichments | |
| def _enrich_semantic_scholar(ds_id: str, extraction: dict) -> dict: | |
| if not _is_null(extraction.get("citeAs")): | |
| return {} | |
| name = extraction.get("name") or ds_id | |
| try: | |
| q = urllib.parse.quote(str(name)) | |
| url = (f"https://api.semanticscholar.org/graph/v1/paper/search" | |
| f"?query={q}&limit=1&fields=title,citationStyles") | |
| req = urllib.request.Request(url, headers={"User-Agent": "CroissantMiner/2.0"}) | |
| resp = urllib.request.urlopen(req, timeout=10) | |
| data = json.loads(resp.read()) | |
| papers = data.get("data", []) | |
| if papers: | |
| bibtex = papers[0].get("citationStyles", {}).get("bibtex", "") | |
| if bibtex: | |
| return {"citeAs": bibtex} | |
| except Exception: | |
| pass | |
| return {} | |
| def run_enrichment(ds_id: str, extraction: dict) -> tuple[dict, dict]: | |
| enrichments: dict = {} | |
| enrichments.update(_enrich_huggingface(ds_id, extraction)) | |
| enrichments.update(_enrich_semantic_scholar(ds_id, extraction)) | |
| for field, val in enrichments.items(): | |
| extraction[field] = val | |
| return extraction, enrichments | |
| # ββ Phase 4: validation + confidence ββββββββββββββββββββββββββββββ | |
| def validate_and_score(extraction, evidence, enrichments, triage) -> dict: | |
| details: dict = {} | |
| for field in ALL_FIELDS: | |
| val = extraction.get(field) | |
| if isinstance(val, str) and val.strip().lower() in ( | |
| "", "null", "none", "n/a", "not applicable", | |
| "[null]", "[null - not found in paper]", | |
| ): | |
| val = None | |
| if isinstance(val, list): | |
| val = ", ".join(str(x) for x in val) | |
| if field == "license" and val: | |
| norm = LICENSE_MAP.get(str(val).strip().lower()) | |
| if norm: | |
| val = norm | |
| if field == "url" and val and not re.match(r"https?://", str(val)): | |
| val = f"https://{val}" | |
| extraction[field] = val | |
| if val is None: | |
| details[field] = {"confidence": 0.0, "status": "NULL", "source": "absent", "evidence": None} | |
| continue | |
| confidence = 0.8 | |
| source = "extraction" | |
| field_ev = evidence.get(field) | |
| has_ev = field_ev is not None and str(field_ev).strip() not in ("", "null", "None") | |
| if has_ev: | |
| confidence = min(confidence + 0.15, 1.0) | |
| if field in enrichments: | |
| source = "enrichment" | |
| confidence = 0.7 | |
| if field in HARD_FIELDS: | |
| for group_name, group_fields in TRIAGE_GROUPS.items(): | |
| if field in group_fields: | |
| if triage.get(group_name, {}).get("presence") == "unlikely": | |
| confidence = min(confidence, 0.5) | |
| break | |
| status = ("VERIFIED" if confidence >= 0.7 | |
| else "UNCERTAIN" if confidence >= 0.4 | |
| else "LOW_CONFIDENCE") | |
| details[field] = { | |
| "confidence": round(confidence, 2), | |
| "status": status, | |
| "source": source, | |
| "evidence": field_ev if has_ev else None, | |
| } | |
| return details | |
| # ββ Phase 5: save canonical output ββββββββββββββββββββββββββββββββ | |
| def _paper_stats(details: dict) -> dict: | |
| extracted = sum(1 for d in details.values() if d["status"] != "NULL") | |
| verified = sum(1 for d in details.values() if d["status"] == "VERIFIED") | |
| uncertain = sum(1 for d in details.values() if d["status"] == "UNCERTAIN") | |
| ev = sum(1 for d in details.values() if d.get("evidence")) | |
| confs = [d["confidence"] for d in details.values() if d["status"] != "NULL"] | |
| avg = sum(confs) / len(confs) if confs else 0.0 | |
| return { | |
| "fields_extracted": extracted, | |
| "fields_null": 30 - extracted, | |
| "fields_verified": verified, | |
| "fields_uncertain": uncertain, | |
| "evidence_count": ev, | |
| "evidence_coverage": round(ev / max(extracted, 1) * 100, 1), | |
| "avg_confidence": round(avg, 3), | |
| } | |
| def save_paper(out_dir: Path, ds_id: str, cfg, extraction, details, usage, | |
| mode: str, extra_meta: dict) -> dict: | |
| stats = _paper_stats(details) | |
| extra = { | |
| "pipeline": "agentic_v2", | |
| "extraction_details": details, | |
| "paper_stats": stats, | |
| "cost_usd": estimate_cost(cfg, usage, batch_discount=(mode == "batch")), | |
| "split": "dev" if ds_id in SPLIT["dev"] else "test", | |
| } | |
| extra.update(extra_meta) | |
| meta = meta_block(cfg, SCRIPT_NAME, mode, extra=extra) | |
| from _agentic_helpers import write_canonical_output | |
| write_canonical_output(out_dir, ds_id, extraction, usage, cfg, meta) | |
| return stats | |
| # ββ Sequential processing βββββββββββββββββββββββββββββββββββββββββ | |
| def verify_evidence_grounding(extraction: dict, evidence: dict, paper_text: str, | |
| min_match_len: int = 30) -> tuple[dict, dict, int]: | |
| """Phase 3 Fix C: null RAI prose extractions whose evidence quote does | |
| not appear in the paper text. Restricted to LONG_TEXT_RAI_FIELDS β the | |
| fields where the failure-mode taxonomy showed hallucination dominates. | |
| Match rule: lowercase + collapse whitespace; require at least one | |
| min_match_len-char contiguous substring of the evidence quote to appear | |
| in the normalized paper. This catches verbatim and near-verbatim | |
| quotes; hallucinated quotes (which the model confabulates rather than | |
| pulls from the paper) typically fail. | |
| Tier 1 fields are not verified here β their gold values are short and | |
| enforced by the Tier 1 canonicalizer at scoring time. | |
| """ | |
| from evaluation.field_metrics import LONG_TEXT_RAI_FIELDS | |
| if not paper_text: | |
| return extraction, evidence, 0 | |
| norm_paper = re.sub(r"\s+", " ", paper_text.lower()) | |
| nullified = 0 | |
| for field in LONG_TEXT_RAI_FIELDS: | |
| v = extraction.get(field) | |
| if v is None or (isinstance(v, str) and not v.strip()): | |
| continue # already null | |
| ev = evidence.get(field) if evidence else None | |
| if not ev or not isinstance(ev, str): | |
| # No evidence quote: cannot verify. Default behaviour is to | |
| # null the extraction since this fix's purpose is "if you | |
| # cannot ground it in the paper, do not keep it." | |
| extraction[field] = None | |
| if evidence: | |
| evidence[field] = None | |
| nullified += 1 | |
| continue | |
| norm_ev = re.sub(r"\s+", " ", ev.lower()).strip() | |
| if len(norm_ev) < min_match_len: | |
| # Quote too short to require a substring match β keep as-is. | |
| continue | |
| matched = any( | |
| norm_ev[i:i + min_match_len] in norm_paper | |
| for i in range(len(norm_ev) - min_match_len + 1) | |
| ) | |
| if not matched: | |
| extraction[field] = None | |
| if evidence: | |
| evidence[field] = None | |
| nullified += 1 | |
| return extraction, evidence, nullified | |
| def process_paper(cfg, ds_id: str, args, out_dir: Path) -> dict | None: | |
| t0 = time.time() | |
| paper_text = extract_paper_text(ds_id) | |
| if not paper_text: | |
| print(f" SKIP {ds_id}: no PDF") | |
| return None | |
| triage = load_triage(ds_id) | |
| usage = {"input_tokens": 0, "output_tokens": 0} | |
| with_evidence = not args.no_verification | |
| prompt_variant = getattr(args, "prompt_variant", "v1") | |
| extraction, evidence, ext_usage = extract_phase2(cfg, paper_text, with_evidence, | |
| prompt_variant=prompt_variant) | |
| usage["input_tokens"] += ext_usage["input_tokens"] | |
| usage["output_tokens"] += ext_usage["output_tokens"] | |
| corrections = 0 | |
| if not args.no_correction: | |
| extraction, corrections, cusage = self_correct(cfg, paper_text, extraction, triage) | |
| usage["input_tokens"] += cusage["input_tokens"] | |
| usage["output_tokens"] += cusage["output_tokens"] | |
| enrichments: dict = {} | |
| if not args.no_enrichment: | |
| extraction, enrichments = run_enrichment(ds_id, extraction) | |
| # Phase 3 Fix C: evidence-grounding verification (variants v3, v4, v5, v6). | |
| # v6 uses a stricter 50-char substring match (vs 30 in v3/v4/v5) to null | |
| # more borderline cases more aggressively. | |
| nullified_by_verify = 0 | |
| pv = getattr(args, "prompt_variant", "v1") | |
| if pv in ("v3", "v4", "v5"): | |
| extraction, evidence, nullified_by_verify = verify_evidence_grounding( | |
| extraction, evidence, paper_text, min_match_len=30) | |
| elif pv == "v6": | |
| # Same prompt as v4 (A+C compound) but stricter post-hoc verification. | |
| extraction, evidence, nullified_by_verify = verify_evidence_grounding( | |
| extraction, evidence, paper_text, min_match_len=50) | |
| if args.no_verification: | |
| details = { | |
| f: {"confidence": 0.8 if extraction.get(f) is not None else 0.0, | |
| "status": "EXTRACTED" if extraction.get(f) is not None else "NULL", | |
| "source": "extraction", "evidence": None} | |
| for f in ALL_FIELDS | |
| } | |
| else: | |
| details = validate_and_score(extraction, evidence, enrichments, triage) | |
| extra_meta = { | |
| "corrections_applied": corrections, | |
| "enrichments": list(enrichments.keys()), | |
| "elapsed_seconds": round(time.time() - t0, 1), | |
| "flags": { | |
| "correction": not args.no_correction, | |
| "enrichment": not args.no_enrichment, | |
| "verification": not args.no_verification, | |
| }, | |
| "prompt_variant": getattr(args, "prompt_variant", "v1"), | |
| "evidence_verify_nullified": nullified_by_verify, | |
| } | |
| return save_paper(out_dir, ds_id, cfg, extraction, details, usage, "realtime", extra_meta) | |
| def run_sequential(cfg, papers, args, out_dir): | |
| ok = fail = total_extracted = total_verified = 0 | |
| total_usage = {"input_tokens": 0, "output_tokens": 0} | |
| for i, ds_id in enumerate(papers): | |
| safe = ds_id.replace("/", "__") | |
| out_path = out_dir / f"{safe}.json" | |
| if out_path.exists() and not args.paper: | |
| ok += 1 | |
| if (i + 1) % 25 == 0: | |
| print(f" [{i+1}/{len(papers)}] {ok} done (skipping existing)") | |
| continue | |
| try: | |
| stats = process_paper(cfg, ds_id, args, out_dir) | |
| if stats is None: | |
| fail += 1 | |
| continue | |
| ok += 1 | |
| total_extracted += stats["fields_extracted"] | |
| total_verified += stats["fields_verified"] | |
| with open(out_path) as f: | |
| saved = json.load(f) | |
| total_usage["input_tokens"] += saved["usage"]["input_tokens"] | |
| total_usage["output_tokens"] += saved["usage"]["output_tokens"] | |
| print(f" [{i+1}/{len(papers)}] {ds_id}: " | |
| f"{stats['fields_extracted']}/30 extracted, " | |
| f"{stats['fields_verified']} verified, " | |
| f"${saved['_meta']['cost_usd']:.3f}, " | |
| f"{saved['_meta']['elapsed_seconds']:.0f}s") | |
| except Exception as e: | |
| print(f" [{i+1}/{len(papers)}] {ds_id}: FAILED ({str(e)[:80]})") | |
| fail += 1 | |
| time.sleep(0.4) | |
| total_cost = estimate_cost(cfg, total_usage, batch_discount=False) | |
| total_fields = ok * 30 | |
| print(f"\n{'=' * 70}") | |
| print(f"SUMMARY β V2 sequential ({cfg['name']})") | |
| print(f"{'=' * 70}") | |
| print(f"Papers: {ok} OK, {fail} failed") | |
| print(f"Fields: {total_extracted}/{total_fields} " | |
| f"({total_extracted / max(total_fields, 1) * 100:.1f}%)") | |
| print(f"Verified: {total_verified}/{total_fields}") | |
| print(f"Tokens: {total_usage['input_tokens']:,} in, " | |
| f"{total_usage['output_tokens']:,} out") | |
| print(f"Cost: ${total_cost:.2f}") | |
| print(f"Output: {out_dir}/") | |
| summary = { | |
| "backbone": cfg["model_id"], | |
| "papers_ok": ok, "papers_failed": fail, | |
| "total_extracted": total_extracted, | |
| "total_verified": total_verified, | |
| "fill_rate": round(total_extracted / max(total_fields, 1) * 100, 1), | |
| "usage": total_usage, "cost_usd": total_cost, | |
| } | |
| with open(out_dir / "_summary.json", "w") as f: | |
| json.dump(summary, f, indent=2) | |
| # ββ Anthropic-only batch mode βββββββββββββββββββββββββββββββββββββ | |
| def _require_anthropic(cfg): | |
| if cfg["provider"] != "anthropic": | |
| raise SystemExit( | |
| f"Batch mode currently supports provider=anthropic only " | |
| f"(backbone '{cfg['model_id']}' uses provider={cfg['provider']}). " | |
| f"Run without --batch for sequential execution." | |
| ) | |
| def _sanitize_custom_id(ds_id: str) -> str: | |
| return re.sub(r"[^a-zA-Z0-9_-]", "_", ds_id.replace("/", "__"))[:64] | |
| def run_batch(cfg, papers, args, out_dir): | |
| _require_anthropic(cfg) | |
| import anthropic | |
| from anthropic.types.message_create_params import MessageCreateParamsNonStreaming | |
| from anthropic.types.messages.batch_create_params import Request | |
| client = anthropic.Anthropic() | |
| wave_dir = out_dir / "_batch" | |
| wave_dir.mkdir(parents=True, exist_ok=True) | |
| # ββ Wave 1: base extraction ββ | |
| wave1_path = wave_dir / "wave1.json" | |
| if wave1_path.exists(): | |
| wave1 = json.loads(wave1_path.read_text()) | |
| else: | |
| requests = [] | |
| id_map: dict = {} | |
| for ds_id in papers: | |
| safe = ds_id.replace("/", "__") | |
| if (out_dir / f"{safe}.json").exists(): | |
| continue | |
| text = extract_paper_text(ds_id) | |
| if not text: | |
| print(f" skip (no PDF): {ds_id}") | |
| continue | |
| cid = _sanitize_custom_id(ds_id) | |
| id_map[cid] = ds_id | |
| requests.append(Request( | |
| custom_id=cid, | |
| params=MessageCreateParamsNonStreaming( | |
| model=cfg["model_id"], | |
| max_tokens=MAX_TOKENS_EXTRACT, | |
| temperature=0.0, | |
| system=SYSTEM_PROMPT, | |
| messages=[{"role": "user", "content": USER_PROMPT_WITH_EVIDENCE % text}], | |
| ), | |
| )) | |
| if not requests: | |
| print(" No papers to submit for Wave 1.") | |
| return | |
| batch = client.messages.batches.create(requests=requests) | |
| wave1 = {"batch_id": batch.id, "id_map": id_map, | |
| "submitted_at": time.strftime("%Y-%m-%d %H:%M:%S")} | |
| wave1_path.write_text(json.dumps(wave1, indent=2)) | |
| print(f" Wave 1 submitted: {batch.id} ({len(requests)} requests)") | |
| # Poll Wave 1 | |
| while True: | |
| info = client.messages.batches.retrieve(wave1["batch_id"]) | |
| counts = info.request_counts | |
| print(f" Wave 1 {info.processing_status}: " | |
| f"proc={counts.processing} ok={counts.succeeded} err={counts.errored}") | |
| if info.processing_status == "ended": | |
| break | |
| time.sleep(30) | |
| # Collect Wave 1 parsed results | |
| parsed: dict = {} | |
| for result in client.messages.batches.results(wave1["batch_id"]): | |
| ds_id = wave1["id_map"].get(result.custom_id, result.custom_id) | |
| if result.result.type != "succeeded": | |
| print(f" Wave 1 FAILED {ds_id}: {result.result.type}") | |
| continue | |
| msg = result.result.message | |
| raw = "".join(getattr(b, "text", "") for b in msg.content) | |
| usage = {"input_tokens": msg.usage.input_tokens, | |
| "output_tokens": msg.usage.output_tokens} | |
| try: | |
| data = parse_json_response(raw) | |
| except Exception as exc: | |
| print(f" Wave 1 parse failed {ds_id}: {str(exc)[:60]}") | |
| continue | |
| extraction, evidence = {}, {} | |
| for field in ALL_FIELDS: | |
| entry = data.get(field, {}) | |
| if isinstance(entry, dict): | |
| extraction[field] = entry.get("value") | |
| evidence[field] = entry.get("evidence") | |
| else: | |
| extraction[field] = entry | |
| evidence[field] = None | |
| parsed[ds_id] = {"extraction": extraction, "evidence": evidence, "usage": usage} | |
| print(f" Wave 1 parsed: {len(parsed)} papers") | |
| # ββ Wave 2: corrections batch (optional) ββ | |
| wave2_path = wave_dir / "wave2.json" | |
| corrections: dict = {} | |
| if not args.no_correction: | |
| need: dict = {} | |
| for ds_id, rec in parsed.items(): | |
| triage = load_triage(ds_id) | |
| nulls = find_null_but_expected(rec["extraction"], triage) | |
| if nulls: | |
| need[ds_id] = nulls | |
| print(f" Papers needing corrections: {len(need)}/{len(parsed)}") | |
| if need and not wave2_path.exists(): | |
| reqs = [] | |
| id_map2: dict = {} | |
| for ds_id, nulls in need.items(): | |
| text = extract_paper_text(ds_id) | |
| if not text: | |
| continue | |
| prompt = CORRECTION_PROMPT_TEMPLATE.format( | |
| null_fields="\n".join(f"- {f}" for f in nulls), | |
| field_keys=", ".join(nulls), | |
| paper_text=text, | |
| ) | |
| cid = _sanitize_custom_id(ds_id) | |
| id_map2[cid] = ds_id | |
| reqs.append(Request( | |
| custom_id=cid, | |
| params=MessageCreateParamsNonStreaming( | |
| model=cfg["model_id"], | |
| max_tokens=MAX_TOKENS_CORRECT, | |
| temperature=0.0, | |
| system=SYSTEM_PROMPT, | |
| messages=[{"role": "user", "content": prompt}], | |
| ), | |
| )) | |
| if reqs: | |
| batch = client.messages.batches.create(requests=reqs) | |
| wave2 = {"batch_id": batch.id, "id_map": id_map2, "need": need, | |
| "submitted_at": time.strftime("%Y-%m-%d %H:%M:%S")} | |
| wave2_path.write_text(json.dumps(wave2, indent=2)) | |
| print(f" Wave 2 submitted: {batch.id} ({len(reqs)} requests)") | |
| if wave2_path.exists(): | |
| wave2 = json.loads(wave2_path.read_text()) | |
| while True: | |
| info = client.messages.batches.retrieve(wave2["batch_id"]) | |
| counts = info.request_counts | |
| print(f" Wave 2 {info.processing_status}: " | |
| f"proc={counts.processing} ok={counts.succeeded} err={counts.errored}") | |
| if info.processing_status == "ended": | |
| break | |
| time.sleep(30) | |
| for result in client.messages.batches.results(wave2["batch_id"]): | |
| ds_id = wave2["id_map"].get(result.custom_id, result.custom_id) | |
| if result.result.type != "succeeded": | |
| print(f" Wave 2 FAILED {ds_id}") | |
| continue | |
| msg = result.result.message | |
| raw = "".join(getattr(b, "text", "") for b in msg.content) | |
| usage = {"input_tokens": msg.usage.input_tokens, | |
| "output_tokens": msg.usage.output_tokens} | |
| try: | |
| corrected = parse_json_response(raw) | |
| except Exception: | |
| continue | |
| corrections[ds_id] = {"corrected": corrected, | |
| "nulls": wave2["need"].get(ds_id, []), | |
| "usage": usage} | |
| # ββ Apply corrections + enrichment + validation + save ββ | |
| ok = 0 | |
| for ds_id, rec in parsed.items(): | |
| extraction = rec["extraction"] | |
| evidence = rec["evidence"] | |
| usage = dict(rec["usage"]) | |
| applied = 0 | |
| if ds_id in corrections: | |
| c = corrections[ds_id] | |
| usage["input_tokens"] += c["usage"]["input_tokens"] | |
| usage["output_tokens"] += c["usage"]["output_tokens"] | |
| for field in c["nulls"]: | |
| val = c["corrected"].get(field) | |
| if not _is_null(val): | |
| extraction[field] = val | |
| applied += 1 | |
| enrichments: dict = {} | |
| if not args.no_enrichment: | |
| extraction, enrichments = run_enrichment(ds_id, extraction) | |
| triage = load_triage(ds_id) | |
| details = validate_and_score(extraction, evidence, enrichments, triage) | |
| extra_meta = { | |
| "corrections_applied": applied, | |
| "enrichments": list(enrichments.keys()), | |
| "flags": { | |
| "correction": not args.no_correction, | |
| "enrichment": not args.no_enrichment, | |
| "verification": True, | |
| }, | |
| } | |
| save_paper(out_dir, ds_id, cfg, extraction, details, usage, "batch", extra_meta) | |
| ok += 1 | |
| print(f" Batch pipeline done: {ok} papers written to {out_dir}") | |
| # ββ CLI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main(): | |
| p = argparse.ArgumentParser(description="CroissantMiner Agentic V2 pipeline") | |
| p.add_argument("--backbone", default="sonnet-4-5", | |
| help="Backbone key from scripts/_agentic_helpers.py::MODELS") | |
| p.add_argument("--dev-only", action="store_true", help="Process only the 15 dev papers") | |
| p.add_argument("--paper", type=str, help="Process a single paper by dataset_id") | |
| p.add_argument("--batch", action="store_true", | |
| help="Use Anthropic batch API (sonnet-4-5 only)") | |
| p.add_argument("--no-enrichment", action="store_true", help="Ablation: skip Phase 3") | |
| p.add_argument("--no-verification", action="store_true", help="Ablation: skip Phase 4") | |
| p.add_argument("--no-correction", action="store_true", help="Ablation: skip Phase 2.5") | |
| p.add_argument("--prompt-variant", default="v1", choices=["v1", "v2", "v3", "v4", "v5", "v6"], | |
| help="v1=round-0; v2=Fix A; v3=Fix C; v4=A+C (accepted +0.052); v5=A+C+D; v6=A+C with stricter 50-char evidence verification.") | |
| args = p.parse_args() | |
| if not any(PHASE1_DIR.glob("*.json")): | |
| raise SystemExit("Triage + Critique reads the stored Gemini 2.5 Flash triage in data/agentic/phase1/, " | |
| "which is missing (it ships with the repository; rebuild it with scripts/agentic_phase1.py).") | |
| cfg = resolve_backbone(args.backbone) | |
| out_dir = output_dir_for("v2", args.backbone) | |
| if args.prompt_variant != "v1": | |
| out_dir = out_dir.parent / f"{out_dir.name}_{args.prompt_variant}" | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| if args.paper: | |
| papers = [args.paper] | |
| elif args.dev_only: | |
| papers = sorted(SPLIT["dev"]) | |
| else: | |
| papers = sorted(SPLIT["dev"] + SPLIT["test"]) | |
| print(f"{'=' * 70}") | |
| print(f"CROISSANTMINER AGENTIC V2 β backbone={args.backbone} ({cfg['name']})") | |
| print(f"Papers: {len(papers)} Output: {out_dir}") | |
| print(f"Flags: correction={'ON' if not args.no_correction else 'OFF'}, " | |
| f"enrichment={'ON' if not args.no_enrichment else 'OFF'}, " | |
| f"verification={'ON' if not args.no_verification else 'OFF'}") | |
| print(f"{'=' * 70}") | |
| if args.batch: | |
| run_batch(cfg, papers, args, out_dir) | |
| else: | |
| run_sequential(cfg, papers, args, out_dir) | |
| if __name__ == "__main__": | |
| main() | |